From 9e57b05b0a9109db50911930b94e6f0685b6fc04 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 11:43:36 +0900 Subject: [PATCH 01/18] docs: add --no-proxy mode design spec (#102) Specifies a coexisting no-proxy path for app {init,deploy,logs,stop, restart,status,destroy,env,rollback}, a server-side .conoha-mode marker for hybrid detection with --proxy/--no-proxy overrides, and the per-command semantics + exit codes. Absorbs #93 (slot-aware logs/stop/restart/status in proxy mode). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-21-no-proxy-mode-design.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md diff --git a/docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md b/docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md new file mode 100644 index 0000000..a9febb4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md @@ -0,0 +1,304 @@ +# `--no-proxy` モード 設計書 + +**Date**: 2026-04-21 +**Status**: Approved +**Owner**: t-kim +**Related**: #102 (this issue), #98 (direct predecessor), #92/#93/#94 (adjacent) + +## 1. 背景と目的 + +`feat/proxy-deploy` (#98, 2026-04-20 spec) で `conoha app deploy` は conoha-proxy 経由の blue/green に全面置換された。これにより失われたユースケースがある: + +- 公開ドメイン / DNS 伝播待ちなしでのテスト。 +- blue/green + HTTPS が過剰な使い捨てのホビーアプリ。 +- HTTP 以外のプロトコルを公開するサービス (proxy はルーティング不可)。 +- 受信 80/443 を持たない内部 / 開発 VPS。 + +本設計は `--no-proxy` モードを **一級の代替経路** として追加する。proxy ベースの blue/green 経路は触らない。両モードは同一サーバ上で共存する。 + +**先行スペック §2 の "full replacement (option A)" 判断はここで部分的に巻き戻される**: "全デプロイを proxy 経由に集約" は過剰だった。proxy は既定経路であり続けるが唯一の経路ではない。 + +### 1.1 ゴール + +1. `conoha app {init,deploy,logs,stop,restart,status,destroy,env,rollback}` に no-proxy 動作を追加。 +2. 同一サーバ上で proxy モードと no-proxy モードが共存できる。`/opt/conoha///` と `/opt/conoha//` (flat) がアプリごとに分かれる。 +3. モード選択はサーバ側マーカーで自動判定し、必要なら明示フラグで override 可能。 +4. モード整合性違反は明示的にエラーにする (サイレント破壊なし)。 + +### 1.2 非ゴール + +- v0.1.x の git-push-deploy 経路の復元。廃止は維持。 +- proxy による非 HTTP 転送 (proxy の責務外)。 +- `app list` の no-proxy 対応 (#95 で扱う)。 +- proxy モードでの `app env` 再設計 (#94 で扱う)。 +- `app reset` の両モード対応 (#92 で扱う。本 PR マージ後に作業を再開)。 + +## 2. モード選択 + +### 2.1 マーカーファイル + +各アプリは初期化時にサーバ側マーカーを取得する。 + +- **パス**: `/opt/conoha//.conoha-mode` +- **内容**: 単一行 `proxy\n` または `no-proxy\n`。 +- **所有者**: 書き込みは `app init` (または `app init --no-proxy`)。削除は `app destroy` が `rm -rf /opt/conoha/` の副作用として消す。 + +マーカーはドットファイルにする (通常の `ls /opt/conoha//` 出力を汚さない)。人間が確認するときは `cat /opt/conoha//.conoha-mode`。 + +### 2.2 解決アルゴリズム + +`ResolveMode(cmd, cli, app)` の優先順位: + +1. `--proxy` / `--no-proxy` フラグ (相互排他) のどちらかが指定されている場合、その値を **希望値** とする。マーカーを読み、不一致なら `ErrModeConflict` を整形して返す。 +2. フラグなし: + - マーカー存在 → その値を返す。 + - マーカー不在 → `ErrNoMarker` を返す。呼び出し側が文脈に応じて解釈する (§3 参照)。 + +### 2.3 モード衝突時の動作 + +proxy で初期化されたアプリに `--no-proxy` を指定 (または逆) した場合の動作は **Z: 明示エラー + 手動復旧案内**。自動転換は提供しない (blue/green の drain target が生きている間の切替はデータ欠損の恐れがある)。 + +エラー文例: + +``` +app "myapp" is initialized in proxy mode on this server, +but --no-proxy was requested. + +To switch modes: + conoha app destroy # removes the existing deployment + conoha app init --no-proxy # re-initialize in no-proxy mode +``` + +exit code: 5 (mode-conflict)。 + +## 3. コマンドごとの動作 + +### 3.1 `conoha app init ` + +| モード | 挙動 | +|---|---| +| proxy (既定) | 現行通り。conoha.yml 読込 / 検証、Docker 有無確認、proxy `POST /v1/services` upsert、**加えて `.conoha-mode=proxy` を書き込む**。 | +| no-proxy (`--no-proxy`) | conoha.yml を読まない。`--app-name` 必須 (なければ exit 2 / "app-name is required with --no-proxy")。Docker 有無確認、`mkdir -p /opt/conoha/`、`.conoha-mode=no-proxy` を書き込む。proxy admin API は呼ばない。 | + +`--proxy` / `--no-proxy` は相互排他。`--app-name` と conoha.yml の `name` は両者が存在すればフラグ優先。 + +### 3.2 `conoha app deploy ` + +| モード | 挙動 | +|---|---| +| proxy (既定) | 現行通り。conoha.yml 必須。マーカー不在なら `run 'conoha app init' first` を案内して exit。マーカー=proxy なら進行。 | +| no-proxy | conoha.yml 無視。`--app-name` 必須。`ResolveComposeFile(".")` で compose 自動検出。tar アップロード → `/opt/conoha//` (flat) → `docker compose -p up -d --build`。ホストポート割当は compose の `ports:` をそのまま尊重。`.env` 保存ロジックは v0.1.x の `ENV_EXISTS` センチネルを再現 (サーバ側 `/opt/conoha/.env.server` を work-dir に merge)。proxy admin API は呼ばない。 | + +**衝突ケース:** + +- マーカー=proxy & `--no-proxy` → §2.3 エラー。 +- マーカー=no-proxy & (フラグなし or `--proxy`) → §2.3 形式の逆向きエラー。 +- マーカー不在 + フラグなし → `run 'conoha app init' first`。 +- マーカー不在 + `--no-proxy` → マーカーを先に書くよう案内 (`run 'conoha app init --no-proxy' first`)。init 前の "deploy 一発で全部" は意図的にサポートしない (整合性検査が壊れる)。 + +### 3.3 `conoha app rollback ` + +| モード | 挙動 | +|---|---| +| proxy | 現行通り。proxy `/rollback` を呼び出す。 | +| no-proxy | exit 5 + メッセージ: `"rollback is not supported in no-proxy mode. Deploy a previous revision instead: git checkout && conoha app deploy --no-proxy "` | + +明示フラグがなくてもマーカーから判定。`--no-proxy` を明示的に付けた場合も同じエラーを返す (意味的にサポート不能)。 + +### 3.4 `conoha app destroy ` + +| モード | 挙動 | +|---|---| +| proxy | 現行通り。全 slot compose down + accessories down + proxy `DELETE /v1/services/` + `rm -rf /opt/conoha/`。 | +| no-proxy | flat compose down (`docker compose -p down`) + `rm -rf /opt/conoha/` (マーカーも一緒に消える)。**proxy DELETE は呼ばない**。 | + +既存 `generateDestroyScript` は `docker compose ls -a | grep -E "^${APP_NAME}(-|$)"` で slot プロジェクトと flat プロジェクト両方を網羅するため、shell 側の変更は不要。Go 側で proxy delete 呼び出しをモード分岐する。 + +**マーカー不在時**: フラグ override なしなら "best-effort" で両経路を実行 (スクリプトで compose ls が一致すれば down、ディレクトリがあれば rm)。レガシー v0.1.x サーバの掃除パスを壊さない。 + +### 3.5 `conoha app logs|stop|restart|status ` + +このセクションで **issue #93 を完全に吸収する** (§7 参照)。 + +| モード | logs | stop | restart | status | +|---|---|---|---|---| +| proxy | 活性 slot: `ReadCurrentSlot` → `docker compose -p - logs` | 同じ project への compose stop | 同じ project への compose restart | 現行 (slot プロジェクト一覧 + proxy phase) 維持 | +| no-proxy | `cd /opt/conoha/ && docker compose logs` (現行コード) | 同 stop | 同 restart | `cd /opt/conoha/ && docker compose ps`。proxy phase ブロックは出力しない | + +**"never deployed on this server" の判定:** +- no-proxy モード (マーカー=no-proxy): `/opt/conoha//` 下に `docker compose ls -p ` が何も返さない、または work dir が compose ファイルを含まない。 +- proxy モード (マーカー=proxy): `CURRENT_SLOT` ファイル不在。 +- マーカー不在 + フラグ override なし: ErrNoMarker を伝搬。 + +いずれも exit 6 の統一エラーにする: `"app \"\" has not been deployed on "`. + +`--proxy` / `--no-proxy` フラグは logs/stop/restart/status でも受け付ける (低頻度 override 用途)。 + +### 3.6 `conoha app env ` + +| モード | 挙動 | +|---|---| +| no-proxy | 現行動作を **正式仕様化**: `/opt/conoha/.env.server` を読み書きし、次回 deploy の `.env` merge で採用される。 | +| proxy | 現行動作を維持するが、**開始時に 1 行警告**: `"warning: app env has no effect on proxy-mode deployed slots; see #94 for the redesign"`。書き込みは継続 (既存 CI スクリプトを壊さない)。 | + +警告は `stderr`。スクリプト利用の互換性のため終了コードは変えない。 + +### 3.7 `conoha app list` + +本スペックの範囲外 (#95)。`list` は現状のまま proxy services を列挙する。 + +## 4. 実装アーキテクチャ + +### 4.1 新規ファイル: `cmd/app/mode.go` + +```go +package app + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" +) + +type Mode string + +const ( + ModeProxy Mode = "proxy" + ModeNoProxy Mode = "no-proxy" +) + +var ( + ErrNoMarker = errors.New("no mode marker on server") + ErrModeConflict = errors.New("mode conflict") +) + +// ReadMarker returns the mode recorded on the server for app, or ErrNoMarker. +func ReadMarker(cli *ssh.Client, app string) (Mode, error) + +// WriteMarker persists the mode marker. Creates the app dir if needed. +func WriteMarker(cli *ssh.Client, app string, m Mode) error + +// ResolveMode interprets --proxy / --no-proxy flags against the on-server marker. +// Returns ErrNoMarker if neither a flag nor a marker is available. +// Returns a wrapped ErrModeConflict (with formatted user guidance) on mismatch. +func ResolveMode(cmd *cobra.Command, cli *ssh.Client, app string) (Mode, error) + +// ReadCurrentSlot returns the active slot ID from /opt/conoha//CURRENT_SLOT. +// Returns an empty string + nil error if the file is absent (= never deployed). +func ReadCurrentSlot(cli *ssh.Client, app string) (string, error) + +// AddModeFlags registers --proxy and --no-proxy as mutually exclusive bool flags. +func AddModeFlags(cmd *cobra.Command) +``` + +`AddModeFlags` を `init`, `deploy`, `rollback`, `destroy`, `logs`, `stop`, `restart`, `status` の `init()` で呼ぶ。`env` には追加しない (env は警告のみで分岐しないため不要)。 + +### 4.2 既存ファイル変更点 + +- `cmd/app/init.go` — `--no-proxy` 分岐追加、全ケースで `WriteMarker`。 +- `cmd/app/deploy.go` — モード解決→分岐。no-proxy 経路は `runNoProxyDeploy(cmd, ssh, app)` に切り出し。既存 `runDeploy` は proxy 用にリネーム (`runProxyDeploy`)。 +- `cmd/app/rollback.go` — no-proxy モード検出時に早期 exit 5。 +- `cmd/app/destroy.go` — モード解決後に proxy `DELETE` 呼び出しを分岐。 +- `cmd/app/logs.go` / `stop.go` / `restart.go` — proxy モードでは `ReadCurrentSlot` → `docker compose -p - ...`。no-proxy は現行コード。 +- `cmd/app/status.go` — proxy phase 出力をモードで分岐。no-proxy では flat `docker compose ps` のみ。 +- `cmd/app/env.go` — proxy モード時に警告 1 行を stderr に出力。 + +### 4.3 新規・改修テスト + +| ファイル | 目的 | +|---|---| +| `cmd/app/mode_test.go` | ReadMarker / WriteMarker / ResolveMode 全分岐、ErrModeConflict 文字列の snapshot。 | +| `cmd/app/deploy_test.go` (拡張) | `--no-proxy` 分岐: `--app-name` 必須、conoha.yml 不要、proxy admin に到達しない。 | +| `cmd/app/init_test.go` (拡張) | `--no-proxy`: conoha.yml 不要、Docker check + marker write。 | +| `cmd/app/rollback_test.go` | no-proxy モードで exit 5。 | +| `cmd/app/destroy_test.go` (拡張) | no-proxy モードで proxy `DELETE` 呼ばれないこと。マーカー不在 (legacy) で best-effort 成功。 | +| `cmd/app/logs_test.go` (新規) | proxy モードで `docker compose -p - logs` を実際に発行する。 | +| `cmd/app/status_test.go` (新規) | no-proxy モードで proxy phase 出力なし。 | + +SSH は既存パターン通り `internalssh.RunCommand` を interface 化しているところを mock (追加の抽象化は避け、exec 記録型の fake client を `mode_test.go` で用意して使い回す)。 + +## 5. エラー・終了コード + +| code | 意味 | 発生例 | +|---|---|---| +| 0 | 成功 | | +| 1 | 一般失敗 | SSH 切断、docker 失敗 | +| 2 | usage / 引数エラー | `--no-proxy` with no `--app-name` | +| 4 | validation | conoha.yml 解析失敗 (既存) | +| **5 (新)** | mode-conflict | §2.3、rollback in no-proxy | +| **6 (新)** | not-initialized | logs/stop/restart/status でマーカー & CURRENT_SLOT 両方不在 | + +実装は `cmd/cmdutil` に `ExitWithCode(err, code)` が既に存在すれば流用、無ければ `return` 値で cobra に任せ Run ラッパーでコード設定。 + +## 6. 設定・CLI 表面まとめ + +### 6.1 新フラグ + +- `--proxy` (bool) — モード override。下 8 コマンドに追加。既定 false。 +- `--no-proxy` (bool) — 同上。相互排他。既定 false。 + +対象: `init`, `deploy`, `rollback`, `destroy`, `logs`, `stop`, `restart`, `status`。 + +### 6.2 `conoha.yml` 変更 + +**変更なし**。no-proxy モードでは読まない。 + +### 6.3 サーバ側レイアウト + +``` +/opt/conoha// +├── .conoha-mode # "proxy" or "no-proxy" +├── CURRENT_SLOT # proxy mode only +├── / # proxy mode only (per slot work dir) +│ ├── (extracted tar) +│ └── conoha-override.yml +└── (extracted tar) # no-proxy mode (flat — no slot subdir) + +/opt/conoha/.env.server # unchanged — no-proxy canonical env file (#94 will revisit for proxy) +``` + +## 7. #93 との統合 + +#93 は `app logs/stop/restart/status` が proxy モード下でも legacy flat path を参照しているバグの issue。本スペックは §3.5 でこれを完全に解決する。`--no-proxy` モードの実装コードパス = #93 が想定していた "旧コード"。proxy 側のコードパスは新規実装。 + +本 PR マージ時に #93 を close する。 + +## 8. 他 issue との関係 + +- **#92** (`app reset`): 本 PR 後に両モード対応で作り直す。スコープ外。 +- **#94** (`app env` 再設計): proxy モードで有効な env の扱い。本 PR では proxy モード時に警告出すのみ。#94 で抜本改修。 +- **#95** (`app list` no-proxy 対応): 別 issue で別途。 + +## 9. 移行と後方互換 + +- 既存 proxy モードユーザー: `app init` を 1 度再実行するとマーカーが書かれる。再実行しない場合、次の `app deploy` 時にマーカー不在として `run 'conoha app init ' first` が案内される (§3.2)。自動 migration は行わない (モード判定の単一ソースを壊さないため)。 +- v0.1.x ユーザー: `/opt/conoha//` が flat 配置で残っているが `.conoha-mode` は無い。`app init --no-proxy` でマーカーを書けば no-proxy モードとして継続可能。deploy は flat を上書きする。 +- v0.1.x の `.git` bare repo: 現行 `warnOnLegacyRepo` のまま警告のみ。本 PR で挙動変更しない。 + +## 10. ドキュメント + +- README (en/ja/ko): "Two deploy modes" セクション追加。proxy = 推奨 / no-proxy = TLS-less single-slot。各 3–5 行。 +- `docs/recipes/single-server-app.md` を proxy 版として保持、`single-server-app-noproxy.md` を新規追加 (同じシナリオの no-proxy 版)。 +- 先行スペック (`2026-04-20-conoha-proxy-deploy-design.md`) の先頭に "Update 2026-04-21: `--no-proxy` mode added — see 2026-04-21-no-proxy-mode-design.md" の一行を追加。 + +## 11. 受け入れ基準 + +- [ ] `conoha app init --no-proxy ` が conoha.yml なしで成功し、`/opt/conoha//.conoha-mode=no-proxy` を残す。 +- [ ] `conoha app deploy --no-proxy --app-name ` が同サーバ上で proxy アプリと併存しながら動作。 +- [ ] proxy 初期化済みアプリへの `conoha app deploy --no-proxy` が exit 5 + 復旧手順メッセージで停止。 +- [ ] `conoha app logs/stop/restart/status ` が proxy モードで活性 slot を正しく対象化する (#93 解決)。 +- [ ] `conoha app rollback --no-proxy ` が exit 5 + git-based 復旧ヒントで停止。 +- [ ] `conoha app destroy ` がマーカーを見て proxy DELETE を呼ぶかどうか分岐し、どちらのモードでも `/opt/conoha//` を完全削除。 +- [ ] `conoha app env *` が proxy モードで 1 行警告を出しつつ既存動作を継続。 +- [ ] 単体テストで上記分岐をすべて網羅。 +- [ ] README / recipes が両モードの例を示す。 + +## 12. オープンな技術判断 + +スペック確定済みだが実装段階で見直し可能な点: + +- **マーカー書き込み失敗時のロールバック**: init 中 `WriteMarker` が失敗した場合、proxy upsert は既に成功している (proxy モード時)。現行案は "警告のみ、proxy 側は残す"。代案として upsert を取り消す。本 PR では前者を採用 (実装簡潔性)。 +- **`destroy` でマーカー不在の legacy サーバ**: "best-effort" スクリプト実行を継続。`--force` フラグを将来追加する余地あり。 +- **`--no-proxy` と `--app-name` の関係**: no-proxy モードでは常に `--app-name` 必須。proxy モードでは conoha.yml の `name` が優先されるため `--app-name` は補助的。この非対称は意図的 (no-proxy は設定ファイル不在が正常経路)。 From 5020ca5111818289f6f986133bb44d560ef3c542 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 11:49:14 +0900 Subject: [PATCH 02/18] docs: add --no-proxy mode implementation plan (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 bite-sized tasks covering cmd/app/mode.go foundation, per-command mode dispatch (init, deploy, rollback, destroy, logs/stop/restart/ status, env warning shim), documentation, and verification. Each task follows TDD (failing test → implementation → passing test → commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-21-no-proxy-mode.md | 2029 +++++++++++++++++ 1 file changed, 2029 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-no-proxy-mode.md diff --git a/docs/superpowers/plans/2026-04-21-no-proxy-mode.md b/docs/superpowers/plans/2026-04-21-no-proxy-mode.md new file mode 100644 index 0000000..a5bb477 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-no-proxy-mode.md @@ -0,0 +1,2029 @@ +# `--no-proxy` Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `--no-proxy` mode to `conoha app {init,deploy,logs,stop,restart,status,destroy,env,rollback}` so single-slot, TLS-less deploys can coexist on the same server as proxy-based blue/green deploys. Hybrid mode selection via a `.conoha-mode` marker with `--proxy`/`--no-proxy` flag overrides. Absorb issue #93 (slot-aware logs/stop/restart/status in proxy mode). + +**Architecture:** Add one helper file `cmd/app/mode.go` providing pure shell-command builders, a Mode type, flag registration, and thin exec wrappers. Each command's RunE resolves the mode once (marker + optional flag override), then dispatches to a proxy branch (existing code) or a no-proxy branch (new or legacy flat-path code). No cross-command abstraction layer. + +**Tech Stack:** Go 1.26, spf13/cobra, gopkg.in/yaml.v3, golang.org/x/crypto/ssh. Tests follow existing `cmd/app/*_test.go` style — pure shell-command builders tested exhaustively; cobra flag wiring tested; SSH integration points left to manual verification. + +**Spec:** `docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md` + +**Branch:** `feat/no-proxy-mode` (already created, spec committed at `9e57b05`). + +--- + +## File Plan + +### Create + +| File | Responsibility | +|---|---| +| `cmd/app/mode.go` | `Mode` type, `ErrNoMarker`/`ErrModeConflict`, shell-command builders, `ReadMarker`/`WriteMarker`/`ResolveMode`/`ReadCurrentSlot`, `AddModeFlags`, `formatModeConflictError` | +| `cmd/app/mode_test.go` | Tests for all pure functions in `mode.go` | +| `cmd/app/logs_test.go` | Mode-dispatch tests for logs (shell string assertions) | +| `cmd/app/status_test.go` | Mode-dispatch tests for status | +| `cmd/app/env_test.go` | Proxy-mode warning injection test | +| `docs/recipes/single-server-app-noproxy.md` | No-proxy quickstart recipe | + +### Modify + +| File | Changes | +|---|---| +| `cmd/app/init.go` | `--no-proxy` branch (no conoha.yml); `WriteMarker` at end of both branches | +| `cmd/app/deploy.go` | Mode dispatch; split proxy path into `runProxyDeploy`; add `runNoProxyDeploy` | +| `cmd/app/rollback.go` | Early exit with code 5 + recovery hint when mode is no-proxy | +| `cmd/app/destroy.go` | Mode dispatch guards proxy `DELETE` call | +| `cmd/app/logs.go` | Mode dispatch: proxy uses `docker compose -p - logs` | +| `cmd/app/stop.go` | Mode dispatch: proxy uses `docker compose -p - stop` | +| `cmd/app/restart.go` | Mode dispatch: proxy uses `docker compose -p - restart` | +| `cmd/app/status.go` | Mode dispatch: skip proxy phase block in no-proxy | +| `cmd/app/env.go` | Warn once per subcommand when mode is proxy | +| `cmd/app/destroy_test.go` | Add flag-exclusion tests | +| `README.md`, `README-ja.md`, `README-ko.md` | Two-modes section | +| `docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md` | One-line cross-reference to new spec | + +--- + +## Task 1: Mode type, errors, and shell builders + +**Files:** +- Create: `cmd/app/mode.go` +- Create: `cmd/app/mode_test.go` + +- [ ] **Step 1.1: Write failing tests** + +Create `cmd/app/mode_test.go` with: + +```go +package app + +import ( + "errors" + "strings" + "testing" +) + +func TestMode_String(t *testing.T) { + if string(ModeProxy) != "proxy" { + t.Errorf("ModeProxy = %q, want %q", ModeProxy, "proxy") + } + if string(ModeNoProxy) != "no-proxy" { + t.Errorf("ModeNoProxy = %q, want %q", ModeNoProxy, "no-proxy") + } +} + +func TestParseMarker(t *testing.T) { + cases := []struct { + in string + want Mode + wantErr bool + }{ + {"proxy\n", ModeProxy, false}, + {"no-proxy\n", ModeNoProxy, false}, + {"proxy", ModeProxy, false}, + {"no-proxy", ModeNoProxy, false}, + {" no-proxy \n", ModeNoProxy, false}, + {"", "", true}, + {"garbage", "", true}, + {"Proxy", "", true}, + } + for _, c := range cases { + got, err := ParseMarker(c.in) + if c.wantErr && err == nil { + t.Errorf("ParseMarker(%q) expected error, got %q", c.in, got) + } + if !c.wantErr && err != nil { + t.Errorf("ParseMarker(%q) err=%v", c.in, err) + } + if got != c.want { + t.Errorf("ParseMarker(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuildReadMarkerCmd(t *testing.T) { + got := buildReadMarkerCmd("myapp") + for _, want := range []string{ + "/opt/conoha/myapp/.conoha-mode", + "cat", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildWriteMarkerCmd(t *testing.T) { + got := buildWriteMarkerCmd("myapp", ModeNoProxy) + for _, want := range []string{ + "mkdir -p '/opt/conoha/myapp'", + "/opt/conoha/myapp/.conoha-mode", + "no-proxy", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildReadCurrentSlotCmd(t *testing.T) { + got := buildReadCurrentSlotCmd("myapp") + for _, want := range []string{ + "/opt/conoha/myapp/CURRENT_SLOT", + "cat", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestFormatModeConflictError(t *testing.T) { + err := formatModeConflictError("myapp", ModeProxy, ModeNoProxy) + if !errors.Is(err, ErrModeConflict) { + t.Errorf("expected ErrModeConflict, got %v", err) + } + msg := err.Error() + for _, want := range []string{ + `"myapp"`, + "proxy mode", + "--no-proxy was requested", + "conoha app destroy", + "conoha app init --no-proxy", + } { + if !strings.Contains(msg, want) { + t.Errorf("conflict error missing %q: %s", want, msg) + } + } +} +``` + +- [ ] **Step 1.2: Run tests — confirm they fail with "undefined"** + +```bash +go test ./cmd/app/ -run 'TestMode_String|TestParseMarker|TestBuildReadMarkerCmd|TestBuildWriteMarkerCmd|TestBuildReadCurrentSlotCmd|TestFormatModeConflictError' -v +``` + +Expected: compile failure (`undefined: Mode`, etc.). + +- [ ] **Step 1.3: Write `cmd/app/mode.go` minimal implementation** + +```go +package app + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +type Mode string + +const ( + ModeProxy Mode = "proxy" + ModeNoProxy Mode = "no-proxy" +) + +var ( + ErrNoMarker = errors.New("no mode marker on server") + ErrModeConflict = errors.New("mode conflict") +) + +// ParseMarker accepts the raw marker file content and returns the Mode. +func ParseMarker(s string) (Mode, error) { + v := strings.TrimSpace(s) + switch v { + case string(ModeProxy): + return ModeProxy, nil + case string(ModeNoProxy): + return ModeNoProxy, nil + case "": + return "", fmt.Errorf("empty marker") + default: + return "", fmt.Errorf("unknown marker value %q", v) + } +} + +// buildReadMarkerCmd prints marker contents or "__MISSING__" if absent. +// The distinct sentinel lets ReadMarker tell "file absent" apart from +// permission or SSH errors without relying on exit codes. +func buildReadMarkerCmd(app string) string { + return fmt.Sprintf( + `cat '/opt/conoha/%s/.conoha-mode' 2>/dev/null || echo __MISSING__`, + app) +} + +// buildWriteMarkerCmd creates the app dir (if missing) and writes the marker. +func buildWriteMarkerCmd(app string, m Mode) string { + return fmt.Sprintf( + `mkdir -p '/opt/conoha/%s' && printf %%s\\n '%s' > '/opt/conoha/%s/.conoha-mode'`, + app, string(m), app) +} + +// buildReadCurrentSlotCmd prints the active slot ID or empty output on absence. +func buildReadCurrentSlotCmd(app string) string { + return fmt.Sprintf( + `cat '/opt/conoha/%s/CURRENT_SLOT' 2>/dev/null || true`, + app) +} + +// formatModeConflictError returns a user-facing error wrapping ErrModeConflict. +func formatModeConflictError(app string, got, want Mode) error { + oppositeInit := "conoha app init" + if want == ModeNoProxy { + oppositeInit = "conoha app init --no-proxy" + } + return fmt.Errorf( + `app %q is initialized in %s mode on this server, but --%s was requested. +To switch modes: + conoha app destroy # removes the existing deployment + %s # re-initialize in %s mode +%w`, + app, string(got), string(want), oppositeInit, string(want), ErrModeConflict) +} + +// ReadMarker returns the mode recorded on the server for app, or ErrNoMarker +// if no marker file exists. +func ReadMarker(cli *ssh.Client, app string) (Mode, error) { + var buf bytes.Buffer + if _, err := internalssh.RunCommand(cli, buildReadMarkerCmd(app), &buf, os.Stderr); err != nil { + return "", fmt.Errorf("read marker: %w", err) + } + out := strings.TrimSpace(buf.String()) + if out == "__MISSING__" { + return "", ErrNoMarker + } + return ParseMarker(out) +} + +// WriteMarker persists the marker file on the server. +func WriteMarker(cli *ssh.Client, app string, m Mode) error { + code, err := internalssh.RunCommand(cli, buildWriteMarkerCmd(app, m), os.Stderr, os.Stderr) + if err != nil { + return fmt.Errorf("write marker: %w", err) + } + if code != 0 { + return fmt.Errorf("write marker: exit %d", code) + } + return nil +} + +// ReadCurrentSlot returns the active slot ID or "" when the file is absent. +func ReadCurrentSlot(cli *ssh.Client, app string) (string, error) { + var buf bytes.Buffer + if _, err := internalssh.RunCommand(cli, buildReadCurrentSlotCmd(app), &buf, os.Stderr); err != nil { + return "", fmt.Errorf("read CURRENT_SLOT: %w", err) + } + return strings.TrimSpace(buf.String()), nil +} + +// flagMode reads --proxy / --no-proxy flags and returns the intended mode, or +// "" if neither is set. Callers should have registered the flags mutually +// exclusive via AddModeFlags. +func flagMode(cmd *cobra.Command) Mode { + if cmd.Flags().Lookup("no-proxy") != nil { + if v, _ := cmd.Flags().GetBool("no-proxy"); v { + return ModeNoProxy + } + } + if cmd.Flags().Lookup("proxy") != nil { + if v, _ := cmd.Flags().GetBool("proxy"); v { + return ModeProxy + } + } + return "" +} + +// ResolveMode interprets flags against the marker. +// Precedence: flag override compared to marker (error on mismatch) > marker > ErrNoMarker. +func ResolveMode(cmd *cobra.Command, cli *ssh.Client, app string) (Mode, error) { + want := flagMode(cmd) + got, readErr := ReadMarker(cli, app) + if readErr != nil && !errors.Is(readErr, ErrNoMarker) { + return "", readErr + } + switch { + case want == "" && errors.Is(readErr, ErrNoMarker): + return "", ErrNoMarker + case want == "": + return got, nil + case errors.Is(readErr, ErrNoMarker): + return want, nil + case want != got: + return "", formatModeConflictError(app, got, want) + default: + return got, nil + } +} + +// AddModeFlags registers --proxy and --no-proxy as mutually exclusive bool flags. +func AddModeFlags(cmd *cobra.Command) { + cmd.Flags().Bool("proxy", false, "force proxy (blue/green) mode, overriding server marker") + cmd.Flags().Bool("no-proxy", false, "force no-proxy (flat single-slot) mode, overriding server marker") + cmd.MarkFlagsMutuallyExclusive("proxy", "no-proxy") +} +``` + +- [ ] **Step 1.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestMode_String|TestParseMarker|TestBuildReadMarkerCmd|TestBuildWriteMarkerCmd|TestBuildReadCurrentSlotCmd|TestFormatModeConflictError' -v +``` + +Expected: all PASS. + +- [ ] **Step 1.5: Commit** + +```bash +git add cmd/app/mode.go cmd/app/mode_test.go +git commit -m "feat(app): add Mode type, marker helpers, and mode resolution + +Introduce Mode enum (proxy | no-proxy), ErrNoMarker / ErrModeConflict, +shell-command builders for the .conoha-mode marker file, ReadMarker / +WriteMarker / ResolveMode / ReadCurrentSlot helpers, and the +--proxy/--no-proxy mutually-exclusive flag pair. Foundation for #102. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `app init` — add `--no-proxy` branch and persist marker + +**Files:** +- Modify: `cmd/app/init.go` +- Create: `cmd/app/init_test.go` (if absent, extend otherwise) + +- [ ] **Step 2.1: Write failing tests** + +Create or extend `cmd/app/init_test.go`: + +```go +package app + +import ( + "testing" +) + +func TestInitCmd_HasModeFlags(t *testing.T) { + if initCmd.Flags().Lookup("proxy") == nil { + t.Error("init should have --proxy flag") + } + if initCmd.Flags().Lookup("no-proxy") == nil { + t.Error("init should have --no-proxy flag") + } +} + +func TestInitCmd_ModeFlagsMutuallyExclusive(t *testing.T) { + if err := initCmd.ParseFlags([]string{"--proxy", "--no-proxy"}); err == nil { + t.Error("--proxy and --no-proxy should be mutually exclusive") + } +} +``` + +- [ ] **Step 2.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestInitCmd_' -v +``` + +Expected: FAIL — `--proxy`/`--no-proxy` flag lookup returns nil. + +- [ ] **Step 2.3: Modify `cmd/app/init.go`** + +At the end of the existing `init()` function, add: + +```go + AddModeFlags(initCmd) + initCmd.Flags().String("app-name", "", "application name (required with --no-proxy)") +``` + +Replace the entire `initCmd.RunE` body with: + +```go + RunE: func(cmd *cobra.Command, args []string) error { + noProxy, _ := cmd.Flags().GetBool("no-proxy") + if noProxy { + return runInitNoProxy(cmd, args[0]) + } + return runInitProxy(cmd, args[0]) + }, +``` + +Rename the existing body into a new function `runInitProxy(cmd *cobra.Command, serverID string) error` containing the current logic, then at the end (right before `return nil`): + +```go + if err := WriteMarker(sshClient, pf.Name, ModeProxy); err != nil { + fmt.Fprintf(os.Stderr, "warning: write mode marker: %v\n", err) + } +``` + +Add a new function `runInitNoProxy`: + +```go +func runInitNoProxy(cmd *cobra.Command, serverID string) error { + 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() }() + + // Verify docker is present. + code, err := internalssh.RunCommand(sshClient, "command -v docker >/dev/null 2>&1", os.Stderr, os.Stderr) + if err != nil { + return fmt.Errorf("docker check: %w", err) + } + if code != 0 { + return fmt.Errorf("docker is not installed on %s (%s)", s.Name, ip) + } + + fmt.Fprintf(os.Stderr, "==> Initializing %q on %s (%s) in no-proxy mode\n", appName, s.Name, ip) + if err := WriteMarker(sshClient, appName, ModeNoProxy); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Initialized. Next: run 'conoha app deploy --no-proxy --app-name %s %s'\n", appName, serverID) + return nil +} +``` + +Add `internalssh "github.com/crowdy/conoha-cli/internal/ssh"` to the imports if not already present (it is — keep as-is). + +- [ ] **Step 2.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestInitCmd_' -v && go build ./... +``` + +Expected: flag tests PASS, build succeeds. + +- [ ] **Step 2.5: Commit** + +```bash +git add cmd/app/init.go cmd/app/init_test.go +git commit -m "feat(app/init): add --no-proxy branch and persist mode marker + +No-proxy init installs only the mkdir + marker write (no conoha.yml +required). Proxy init continues through the existing upsert path and +now writes the marker at the end. --app-name is required with +--no-proxy. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `app deploy` — mode dispatch and no-proxy flat deploy + +**Files:** +- Modify: `cmd/app/deploy.go` +- Modify: `cmd/app/deploy_test.go` + +- [ ] **Step 3.1: Write failing tests** + +Append to `cmd/app/deploy_test.go`: + +```go +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", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildNoProxyUploadCmd(t *testing.T) { + got := buildNoProxyUploadCmd("/opt/conoha/myapp") + // Must preserve existing .env.server content on re-deploy (v0.1.x parity). + for _, want := range []string{ + "mkdir -p '/opt/conoha/myapp'", + "tar xzf - -C '/opt/conoha/myapp'", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } + // Must NOT rm -rf the app dir (that would blow away the env file + persistent volumes). + if strings.Contains(got, "rm -rf '/opt/conoha/myapp'") { + t.Errorf("no-proxy upload must not wipe app dir: %s", got) + } +} +``` + +Make sure `"strings"` is imported at the top of `deploy_test.go`. + +- [ ] **Step 3.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestDeployCmd_HasModeFlags|TestBuildNoProxyDeployCmd|TestBuildNoProxyUploadCmd' -v +``` + +Expected: FAIL — undefined builders, missing flags. + +- [ ] **Step 3.3: Modify `cmd/app/deploy.go`** + +In the existing `init()` add: + +```go + AddModeFlags(deployCmd) + deployCmd.Flags().String("app-name", "", "application name (required with --no-proxy)") +``` + +Replace the `deployCmd.RunE` body with a dispatch: + +```go + RunE: func(cmd *cobra.Command, args []string) error { + return runDeployDispatch(cmd, args[0]) + }, +``` + +Rename the existing `runDeploy` function to `runProxyDeploy` (unchanged body aside from the name). Add a new function `runDeployDispatch` and a new function `runNoProxyDeploy`: + +```go +// runDeployDispatch resolves mode (flag override + server marker) and calls +// the proxy or no-proxy deploy path. +func runDeployDispatch(cmd *cobra.Command, serverID string) error { + // Fast-path: if --no-proxy was explicitly passed, we can skip the proxy + // path's conoha.yml load entirely. But we still need an SSH client to + // read the marker for conflict detection. + 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) + } + + // Default: proxy path. It loads conoha.yml before SSH; we preserve that + // ordering so validation errors surface without a network round-trip. + return runProxyDeploy(cmd, serverID) +} +``` + +Add the required imports: `"errors"`, `"github.com/crowdy/conoha-cli/internal/model"` — check and add whatever is missing for the signature (model.Server is already imported via connect.go's context, but here the server arg is unused below; see simplified signature). Use this simpler signature instead: + +```go +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) + } + + // Resolve compose file from the freshly-uploaded work dir on the remote. + // Mirrors proxy path's ResolveComposeFile but runs via the local copy + // (the working directory being deployed) to keep logic simple. + 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 +} +``` + +Add the two shell builders (put them in `remotecmds.go` or inline in deploy.go — this plan places them in `deploy.go`): + +```go +// buildNoProxyUploadCmd extracts the incoming tar archive into the app work +// directory, preserving any existing files so that .env.server and named +// volumes survive redeploys. (Tar-over-tar will overwrite code files while +// leaving unrelated siblings intact.) +func buildNoProxyUploadCmd(workDir string) string { + return fmt.Sprintf( + "mkdir -p '%[1]s' && 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). +func buildNoProxyDeployCmd(workDir, app, composeFile string) string { + return fmt.Sprintf( + "cd '%s' && docker compose -p %s -f %s up -d --build", + workDir, app, composeFile) +} +``` + +- [ ] **Step 3.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestDeployCmd_HasModeFlags|TestBuildNoProxyDeployCmd|TestBuildNoProxyUploadCmd' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 3.5: Commit** + +```bash +git add cmd/app/deploy.go cmd/app/deploy_test.go +git commit -m "feat(app/deploy): add --no-proxy flat deploy path + +runDeployDispatch reads the --no-proxy flag, resolves the server +marker, and either calls runProxyDeploy (existing blue/green flow) +or runNoProxyDeploy (tar upload to /opt/conoha// + compose up +against the project name ). Proxy/no-proxy marker mismatches +produce the standard mode-conflict error. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: `app rollback` — reject no-proxy with guidance + +**Files:** +- Modify: `cmd/app/rollback.go` +- Create: `cmd/app/rollback_test.go` + +- [ ] **Step 4.1: Write failing tests** + +Create `cmd/app/rollback_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestRollbackCmd_HasModeFlags(t *testing.T) { + if rollbackCmd.Flags().Lookup("proxy") == nil { + t.Error("rollback should have --proxy flag") + } + if rollbackCmd.Flags().Lookup("no-proxy") == nil { + t.Error("rollback should have --no-proxy flag") + } +} + +func TestRollbackNoProxyError(t *testing.T) { + err := noProxyRollbackError("myapp") + msg := err.Error() + for _, want := range []string{ + "rollback is not supported in no-proxy mode", + "git checkout", + "conoha app deploy --no-proxy", + } { + if !strings.Contains(msg, want) { + t.Errorf("missing %q in %s", want, msg) + } + } +} +``` + +- [ ] **Step 4.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestRollbackCmd_|TestRollbackNoProxyError' -v +``` + +Expected: FAIL — no --proxy/--no-proxy flags, undefined `noProxyRollbackError`. + +- [ ] **Step 4.3: Modify `cmd/app/rollback.go`** + +At end of `init()`, add: + +```go + AddModeFlags(rollbackCmd) + rollbackCmd.Flags().String("app-name", "", "application name (used when --no-proxy bypasses conoha.yml)") +``` + +Add a helper: + +```go +func noProxyRollbackError(app string) error { + return fmt.Errorf( + "rollback is not supported in no-proxy mode. Deploy a previous revision instead: "+ + "git checkout && conoha app deploy --no-proxy --app-name %s ", app) +} +``` + +Replace the RunE body. Before loading the project file, add mode check: + +```go + RunE: func(cmd *cobra.Command, args []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") + } + return noProxyRollbackError(appName) + } + pf, err := config.LoadProjectFile(config.ProjectFileName) + if err != nil { + return err + } + if err := pf.Validate(); err != nil { + return err + } + sshClient, s, ip, err := connectToServer(cmd, args[0]) + if err != nil { + return err + } + defer func() { _ = sshClient.Close() }() + + mode, err := ResolveMode(cmd, sshClient, pf.Name) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q not initialized on this server — run 'conoha app init' first", pf.Name) + } + return err + } + if mode == ModeNoProxy { + return noProxyRollbackError(pf.Name) + } + + dataDir, _ := cmd.Flags().GetString("data-dir") + admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir)) + drainMs, _ := cmd.Flags().GetInt("drain-ms") + fmt.Fprintf(os.Stderr, "==> Rolling back %q on %s (%s)\n", pf.Name, s.Name, ip) + updated, err := admin.Rollback(pf.Name, drainMs) + if err != nil { + if errors.Is(err, proxypkg.ErrNoDrainTarget) { + return fmt.Errorf("drain window has closed — redeploy the previous slot (git SHA) instead") + } + return err + } + active := "" + if updated.ActiveTarget != nil { + active = updated.ActiveTarget.URL + } + fmt.Fprintf(os.Stderr, "Rollback complete. active=%s phase=%s\n", active, updated.Phase) + return nil + }, +``` + +- [ ] **Step 4.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestRollbackCmd_|TestRollbackNoProxyError' -v +go build ./... +``` + +Expected: PASS, build succeeds. + +- [ ] **Step 4.5: Commit** + +```bash +git add cmd/app/rollback.go cmd/app/rollback_test.go +git commit -m "feat(app/rollback): reject no-proxy mode with recovery guidance + +--no-proxy (or a no-proxy marker) on rollback now returns an explicit +error pointing at 'git checkout && conoha app deploy --no-proxy'. +Proxy-mode behavior unchanged. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: `app destroy` — branch proxy DELETE call + +**Files:** +- Modify: `cmd/app/destroy.go` +- Modify: `cmd/app/destroy_test.go` + +- [ ] **Step 5.1: Write failing tests** + +Replace `cmd/app/destroy_test.go` with: + +```go +package app + +import ( + "testing" +) + +func TestDestroyCmd_HasYesFlag(t *testing.T) { + f := destroyCmd.Flags().Lookup("yes") + if f == nil { + t.Fatal("destroy command should have --yes flag") + } + if f.DefValue != "false" { + 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") + } +} +``` + +- [ ] **Step 5.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestDestroyCmd_' -v +``` + +Expected: FAIL on HasModeFlags. + +- [ ] **Step 5.3: Modify `cmd/app/destroy.go`** + +In `init()` add: + +```go + AddModeFlags(destroyCmd) +``` + +Replace the proxy-delete section of `destroyCmd.RunE` (the block that calls `admin.Delete`) with mode-aware logic: + +```go + // Resolve mode to decide whether to deregister from proxy. + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil && !errors.Is(err, ErrNoMarker) { + return err + } + // mode is "" when marker is absent (legacy server). Only call proxy + // delete in proxy mode; skip silently in no-proxy or legacy. + if mode == ModeProxy { + 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) + } + } + } +``` + +(Keep the rest of destroy's logic — `generateDestroyScript`, the SSH script run, the final success message — unchanged. The shell script already handles flat and slotted layouts via its `grep -E "^${APP_NAME}(-|$)"`.) + +- [ ] **Step 5.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestDestroyCmd_' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 5.5: Commit** + +```bash +git add cmd/app/destroy.go cmd/app/destroy_test.go +git commit -m "feat(app/destroy): skip proxy DELETE in no-proxy/legacy mode + +destroy now resolves the .conoha-mode marker and only deregisters from +conoha-proxy when the marker is 'proxy'. No-proxy and unmarked (legacy) +servers continue to run the shared compose-down + rm -rf cleanup script. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: `app logs` — mode-aware, absorbing #93 + +**Files:** +- Modify: `cmd/app/logs.go` +- Create: `cmd/app/logs_test.go` + +- [ ] **Step 6.1: Write failing tests** + +Create `cmd/app/logs_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestLogsCmd_HasModeFlags(t *testing.T) { + if logsCmd.Flags().Lookup("proxy") == nil { + t.Error("logs should have --proxy flag") + } + if logsCmd.Flags().Lookup("no-proxy") == nil { + t.Error("logs should have --no-proxy flag") + } +} + +func TestBuildLogsCmd_Proxy(t *testing.T) { + got := buildLogsCmdForProxy("myapp", "abc1234", 100, false, "") + for _, want := range []string{ + "docker compose -p myapp-abc1234", + "logs", + "--tail 100", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildLogsCmd_Proxy_FollowService(t *testing.T) { + got := buildLogsCmdForProxy("myapp", "abc1234", 50, true, "web") + for _, want := range []string{ + "docker compose -p myapp-abc1234 logs", + "--tail 50", + "-f", + " web", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildLogsCmd_NoProxy(t *testing.T) { + got := buildLogsCmdForNoProxy("myapp", 100, false, "") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose logs", + "--tail 100", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} +``` + +- [ ] **Step 6.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestLogsCmd_|TestBuildLogsCmd_' -v +``` + +Expected: FAIL (undefined builders, missing flags). + +- [ ] **Step 6.3: Modify `cmd/app/logs.go`** + +Replace the entire file contents: + +```go +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +func init() { + addAppFlags(logsCmd) + logsCmd.Flags().BoolP("follow", "f", false, "stream logs in real-time") + logsCmd.Flags().Int("tail", 100, "number of lines to show") + logsCmd.Flags().String("service", "", "specific service name") + AddModeFlags(logsCmd) +} + +var logsCmd = &cobra.Command{ + Use: "logs ", + Short: "Show app container logs", + Long: "Show docker compose logs for the active slot (proxy mode) or the flat work dir (no-proxy). Use --follow to stream in real-time (Ctrl+C to stop).", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := connectToApp(cmd, args) + if err != nil { + return err + } + defer func() { _ = ctx.Client.Close() }() + + follow, _ := cmd.Flags().GetBool("follow") + tail, _ := cmd.Flags().GetInt("tail") + service, _ := cmd.Flags().GetString("service") + if service != "" { + if err := internalssh.ValidateAppName(service); err != nil { + return fmt.Errorf("invalid service name: %w", err) + } + } + + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildLogsCmdForProxy(ctx.AppName, slot, tail, follow, service) + } else { + composeCmd = buildLogsCmdForNoProxy(ctx.AppName, tail, follow, service) + } + + exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) + if err != nil { + return fmt.Errorf("logs failed: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("logs exited with code %d", exitCode) + } + return nil + }, +} + +func buildLogsCmdForProxy(app, slot string, tail int, follow bool, service string) string { + cmd := fmt.Sprintf("docker compose -p %s-%s logs --tail %d", app, slot, tail) + if follow { + cmd += " -f" + } + if service != "" { + cmd += " " + service + } + return cmd +} + +func buildLogsCmdForNoProxy(app string, tail int, follow bool, service string) string { + cmd := fmt.Sprintf("cd /opt/conoha/%s && docker compose logs --tail %d", app, tail) + if follow { + cmd += " -f" + } + if service != "" { + cmd += " " + service + } + return cmd +} +``` + +- [ ] **Step 6.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestLogsCmd_|TestBuildLogsCmd_' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 6.5: Commit** + +```bash +git add cmd/app/logs.go cmd/app/logs_test.go +git commit -m "feat(app/logs): dispatch by mode, target active slot in proxy mode + +Absorbs #93 for app logs: proxy mode reads CURRENT_SLOT and runs +'docker compose -p - logs' against the active slot project. +No-proxy mode keeps the flat 'cd /opt/conoha/' path. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: `app stop` — mode dispatch + +**Files:** +- Modify: `cmd/app/stop.go` +- Create: `cmd/app/stop_test.go` + +- [ ] **Step 7.1: Write failing tests** + +Create `cmd/app/stop_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestStopCmd_HasModeFlags(t *testing.T) { + if stopCmd.Flags().Lookup("proxy") == nil { + t.Error("stop should have --proxy flag") + } + if stopCmd.Flags().Lookup("no-proxy") == nil { + t.Error("stop should have --no-proxy flag") + } +} + +func TestBuildStopCmd_Proxy(t *testing.T) { + got := buildStopCmdForProxy("myapp", "abc1234") + for _, want := range []string{ + "docker compose -p myapp-abc1234 stop", + "docker compose -p myapp-abc1234 ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildStopCmd_NoProxy(t *testing.T) { + got := buildStopCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose stop", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} +``` + +- [ ] **Step 7.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestStopCmd_|TestBuildStopCmd_' -v +``` + +Expected: FAIL. + +- [ ] **Step 7.3: Modify `cmd/app/stop.go`** + +Replace the file with: + +```go +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/crowdy/conoha-cli/internal/prompt" + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +func init() { + addAppFlags(stopCmd) + AddModeFlags(stopCmd) +} + +var stopCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop app containers", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := connectToApp(cmd, args) + if err != nil { + return err + } + defer func() { _ = ctx.Client.Close() }() + + ok, err := prompt.Confirm(fmt.Sprintf("Stop app %q on %s?", ctx.AppName, ctx.Server.Name)) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(os.Stderr, "Cancelled.") + return nil + } + + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildStopCmdForProxy(ctx.AppName, slot) + } else { + composeCmd = buildStopCmdForNoProxy(ctx.AppName) + } + + fmt.Fprintf(os.Stderr, "Stopping app %q on %s...\n", ctx.AppName, ctx.Server.Name) + exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) + if err != nil { + return fmt.Errorf("stop failed: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("stop exited with code %d", exitCode) + } + return nil + }, +} + +func buildStopCmdForProxy(app, slot string) string { + return fmt.Sprintf("docker compose -p %s-%s stop && docker compose -p %s-%s ps", app, slot, app, slot) +} + +func buildStopCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose stop && docker compose ps", app) +} +``` + +- [ ] **Step 7.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestStopCmd_|TestBuildStopCmd_' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add cmd/app/stop.go cmd/app/stop_test.go +git commit -m "feat(app/stop): dispatch by mode, target active slot in proxy mode + +Proxy-mode stop runs 'docker compose -p - stop' against +the active slot (CURRENT_SLOT); no-proxy keeps the legacy flat path. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: `app restart` — mode dispatch + +**Files:** +- Modify: `cmd/app/restart.go` +- Create: `cmd/app/restart_test.go` + +- [ ] **Step 8.1: Write failing tests** + +Create `cmd/app/restart_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestRestartCmd_HasModeFlags(t *testing.T) { + if restartCmd.Flags().Lookup("proxy") == nil { + t.Error("restart should have --proxy flag") + } + if restartCmd.Flags().Lookup("no-proxy") == nil { + t.Error("restart should have --no-proxy flag") + } +} + +func TestBuildRestartCmd_Proxy(t *testing.T) { + got := buildRestartCmdForProxy("myapp", "abc1234") + for _, want := range []string{ + "docker compose -p myapp-abc1234 restart", + "docker compose -p myapp-abc1234 ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildRestartCmd_NoProxy(t *testing.T) { + got := buildRestartCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose restart", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} +``` + +- [ ] **Step 8.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestRestartCmd_|TestBuildRestartCmd_' -v +``` + +Expected: FAIL. + +- [ ] **Step 8.3: Modify `cmd/app/restart.go`** + +Replace the file with: + +```go +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +func init() { + addAppFlags(restartCmd) + AddModeFlags(restartCmd) +} + +var restartCmd = &cobra.Command{ + Use: "restart ", + Short: "Restart app containers", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := connectToApp(cmd, args) + if err != nil { + return err + } + defer func() { _ = ctx.Client.Close() }() + + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildRestartCmdForProxy(ctx.AppName, slot) + } else { + composeCmd = buildRestartCmdForNoProxy(ctx.AppName) + } + + fmt.Fprintf(os.Stderr, "Restarting app %q on %s...\n", ctx.AppName, ctx.Server.Name) + exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) + if err != nil { + return fmt.Errorf("restart failed: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("restart exited with code %d", exitCode) + } + return nil + }, +} + +func buildRestartCmdForProxy(app, slot string) string { + return fmt.Sprintf("docker compose -p %s-%s restart && docker compose -p %s-%s ps", app, slot, app, slot) +} + +func buildRestartCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose restart && docker compose ps", app) +} +``` + +- [ ] **Step 8.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestRestartCmd_|TestBuildRestartCmd_' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 8.5: Commit** + +```bash +git add cmd/app/restart.go cmd/app/restart_test.go +git commit -m "feat(app/restart): dispatch by mode, target active slot in proxy mode + +Proxy-mode restart runs 'docker compose -p - restart' against +CURRENT_SLOT. No-proxy keeps the legacy flat path. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: `app status` — mode dispatch, suppress proxy phase in no-proxy + +**Files:** +- Modify: `cmd/app/status.go` +- Create: `cmd/app/status_test.go` + +- [ ] **Step 9.1: Write failing tests** + +Create `cmd/app/status_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestStatusCmd_HasModeFlags(t *testing.T) { + if statusCmd.Flags().Lookup("proxy") == nil { + t.Error("status should have --proxy flag") + } + if statusCmd.Flags().Lookup("no-proxy") == nil { + t.Error("status should have --no-proxy flag") + } +} + +func TestBuildStatusCmd_Proxy(t *testing.T) { + got := buildStatusCmdForProxy("myapp") + for _, want := range []string{ + "docker compose ls", + `grep -E "^myapp(-|$)"`, + "docker compose -p", + "ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildStatusCmd_NoProxy(t *testing.T) { + got := buildStatusCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} +``` + +- [ ] **Step 9.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestStatusCmd_|TestBuildStatusCmd_' -v +``` + +Expected: FAIL. + +- [ ] **Step 9.3: Modify `cmd/app/status.go`** + +Replace the file with: + +```go +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/crowdy/conoha-cli/cmd/proxy" + "github.com/crowdy/conoha-cli/internal/config" + proxypkg "github.com/crowdy/conoha-cli/internal/proxy" + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +func init() { + addAppFlags(statusCmd) + statusCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server") + AddModeFlags(statusCmd) +} + +var statusCmd = &cobra.Command{ + Use: "status ", + Short: "Show app container status", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := connectToApp(cmd, args) + if err != nil { + return err + } + defer func() { _ = ctx.Client.Close() }() + + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var psCmd string + if mode == ModeProxy { + psCmd = buildStatusCmdForProxy(ctx.AppName) + } else { + psCmd = buildStatusCmdForNoProxy(ctx.AppName) + } + if _, err := internalssh.RunCommand(ctx.Client, psCmd, os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "warning: compose ps: %v\n", err) + } + + if mode != ModeProxy { + return nil + } + + // Enrich with proxy service state if conoha.yml is present. + pf, pfErr := config.LoadProjectFile(config.ProjectFileName) + if pfErr == nil && pf.Validate() == nil { + dataDir, _ := cmd.Flags().GetString("data-dir") + if dataDir == "" { + dataDir = proxy.DefaultDataDir + } + admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: ctx.Client}, proxy.SocketPath(dataDir)) + if svc, err := admin.Get(pf.Name); err == nil { + fmt.Fprintf(os.Stderr, "\n==> Proxy service %q: phase=%s tls=%s\n", svc.Name, svc.Phase, svc.TLSStatus) + if svc.ActiveTarget != nil { + fmt.Fprintf(os.Stderr, " active: %s\n", svc.ActiveTarget.URL) + } + if svc.DrainingTarget != nil { + fmt.Fprintf(os.Stderr, " draining: %s\n", svc.DrainingTarget.URL) + } + if svc.DrainDeadline != nil { + fmt.Fprintf(os.Stderr, " drain deadline: %s\n", svc.DrainDeadline.Format("2006-01-02 15:04:05 MST")) + } + } else { + fmt.Fprintf(os.Stderr, "\n==> Proxy service %q: (error: %v)\n", pf.Name, err) + } + } + return nil + }, +} + +func buildStatusCmdForProxy(app string) string { + return fmt.Sprintf( + `for p in $(docker compose ls -a --format '{{.Name}}' 2>/dev/null | grep -E "^%[1]s(-|$)" || true); do `+ + `echo "--- compose project: ${p} ---"; `+ + `docker compose -p "${p}" ps; `+ + `done`, + app) +} + +func buildStatusCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose ps", app) +} +``` + +- [ ] **Step 9.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestStatusCmd_|TestBuildStatusCmd_' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 9.5: Commit** + +```bash +git add cmd/app/status.go cmd/app/status_test.go +git commit -m "feat(app/status): dispatch by mode, skip proxy phase in no-proxy + +Proxy status scans all slot compose projects and appends the proxy +service phase block. No-proxy status runs a simple 'docker compose ps' +in the flat work dir and skips the proxy enrichment. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: `app env` — warn when proxy mode + +**Files:** +- Modify: `cmd/app/env.go` +- Create: `cmd/app/env_test.go` + +- [ ] **Step 10.1: Write failing tests** + +Create `cmd/app/env_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestProxyEnvWarningMessage(t *testing.T) { + msg := proxyEnvWarningMessage() + for _, want := range []string{ + "warning", + "app env", + "proxy-mode", + "#94", + } { + if !strings.Contains(msg, want) { + t.Errorf("missing %q in %s", want, msg) + } + } +} +``` + +- [ ] **Step 10.2: Run — confirm fail** + +```bash +go test ./cmd/app/ -run 'TestProxyEnvWarningMessage' -v +``` + +Expected: FAIL. + +- [ ] **Step 10.3: Modify `cmd/app/env.go`** + +Add near the top of the file (after imports): + +```go +// proxyEnvWarningMessage returns the one-line warning emitted when `app env` +// is run against a proxy-mode app. See #94 for the planned redesign. +func proxyEnvWarningMessage() string { + return "warning: app env has no effect on proxy-mode deployed slots; see #94 for the redesign\n" +} + +// maybeWarnProxyEnvMode emits the proxy-mode warning to stderr once per env +// subcommand invocation. Silent on no-proxy or when marker lookup fails. +func maybeWarnProxyEnvMode(ctx *appContext) { + m, err := ReadMarker(ctx.Client, ctx.AppName) + if err == nil && m == ModeProxy { + fmt.Fprint(os.Stderr, proxyEnvWarningMessage()) + } +} +``` + +In each of `envSetCmd.RunE`, `envGetCmd.RunE`, `envListCmd.RunE`, `envUnsetCmd.RunE`, add the call immediately after `defer func() { _ = ctx.Client.Close() }()`: + +```go + maybeWarnProxyEnvMode(ctx) +``` + +- [ ] **Step 10.4: Run tests — confirm pass** + +```bash +go test ./cmd/app/ -run 'TestProxyEnvWarningMessage' -v +go build ./... +``` + +Expected: PASS. + +- [ ] **Step 10.5: Commit** + +```bash +git add cmd/app/env.go cmd/app/env_test.go +git commit -m "feat(app/env): warn when app env targets a proxy-mode app + +app env still writes to /opt/conoha/.env.server (the v0.1.x path, +now canonical for no-proxy mode). When the .conoha-mode marker says +proxy, print a single-line warning pointing at #94 rather than breaking +existing CI scripts. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 11: Documentation — README, recipes, prior-spec cross-reference + +**Files:** +- Modify: `README.md`, `README-ja.md`, `README-ko.md` +- Create: `docs/recipes/single-server-app-noproxy.md` +- Modify: `docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md` + +- [ ] **Step 11.1: Add "Two deploy modes" section to each README** + +Open each README file and read the section that currently introduces `app deploy` (search for `conoha app deploy`). Insert, just above that section, identical-structure blocks in each language: + +English (`README.md`) block: + +```markdown +### 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///` managed via conoha-proxy | +| **no-proxy** (`--no-proxy`) | Testing, internal/dev VPS, non-HTTP services, hobby apps | Flat `/opt/conoha//` with plain `docker compose up` | + +Initialize with `conoha app init --no-proxy --app-name `, then `conoha app deploy --no-proxy --app-name `. No `conoha.yml` required in no-proxy mode. +``` + +Japanese (`README-ja.md`) equivalent: + +```markdown +### 2 つのデプロイモード + +`conoha app` は同一 VPS 上で共存可能な 2 つのモードを提供します: + +| モード | 用途 | レイアウト | +|---|---|---| +| **proxy** (既定) | ドメイン + TLS の公開アプリ | `/opt/conoha///` の blue/green スロット (conoha-proxy 管理) | +| **no-proxy** (`--no-proxy`) | テスト、内部・開発 VPS、非 HTTP サービス、ホビーアプリ | `/opt/conoha//` フラット (単純な `docker compose up`) | + +`conoha app init --no-proxy --app-name ` で初期化し、`conoha app deploy --no-proxy --app-name ` でデプロイします。no-proxy モードでは `conoha.yml` は不要です。 +``` + +Korean (`README-ko.md`) equivalent: + +```markdown +### 두 가지 배포 모드 + +`conoha app`은 동일 VPS에서 공존 가능한 두 가지 모드를 제공합니다: + +| 모드 | 용도 | 레이아웃 | +|---|---|---| +| **proxy** (기본) | 도메인 + TLS가 있는 공개 앱 | `/opt/conoha///` 아래의 blue/green 슬롯 (conoha-proxy 관리) | +| **no-proxy** (`--no-proxy`) | 테스트, 내부/개발 VPS, 비 HTTP 서비스, 취미 앱 | `/opt/conoha//` 플랫 (일반 `docker compose up`) | + +`conoha app init --no-proxy --app-name `로 초기화한 뒤 `conoha app deploy --no-proxy --app-name `로 배포합니다. no-proxy 모드에서는 `conoha.yml`이 필요 없습니다. +``` + +- [ ] **Step 11.2: Create `docs/recipes/single-server-app-noproxy.md`** + +```markdown +# Single-Server App — No-Proxy Mode + +This recipe shows a TLS-less, single-slot deployment of a small web app. Use it when: + +- You do not have a public domain. +- The service exposes a non-HTTP protocol. +- You prefer `docker compose up` semantics over blue/green. + +For the proxy-backed blue/green variant, see `single-server-app.md`. + +## 1. Create the VPS + +```bash +conoha server create --name myapp --flavor g2l-cpu1-1g --image ubuntu-22.04-x86-64 --ssh-key default +``` + +## 2. Install Docker and mark the app no-proxy + +```bash +conoha app init --no-proxy --app-name myapp myapp +``` + +This verifies Docker is present on the server and writes the `no-proxy` marker to `/opt/conoha/myapp/.conoha-mode`. + +## 3. Prepare a compose file locally + +`compose.yml`: + +```yaml +services: + web: + build: . + ports: + - "80:8080" +``` + +No `conoha.yml` needed. + +## 4. Deploy + +```bash +conoha app deploy --no-proxy --app-name myapp myapp +``` + +The CLI tars the current directory (respecting `.dockerignore`), uploads to `/opt/conoha/myapp/` on the VPS, and runs `docker compose -p myapp up -d --build`. + +## 5. Day-two operations + +```bash +conoha app logs --no-proxy --app-name myapp myapp +conoha app status --no-proxy --app-name myapp myapp +conoha app stop --no-proxy --app-name myapp myapp +conoha app restart --no-proxy --app-name myapp myapp +conoha app destroy --no-proxy --app-name myapp myapp +``` + +`conoha app rollback` is not supported in no-proxy mode — deploy a previous revision instead (`git checkout && conoha app deploy --no-proxy ...`). + +## Switching to proxy mode + +Run `conoha app destroy ... myapp` followed by `conoha app init ... myapp` (without `--no-proxy`). The CLI refuses implicit mode switches. +``` + +- [ ] **Step 11.3: Cross-reference from prior spec** + +Edit `docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md`. Insert immediately below the front-matter `**Owner**` line: + +```markdown + +> **Update 2026-04-21:** A `--no-proxy` mode was added as a coexisting alternative path. See `docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md`. +``` + +- [ ] **Step 11.4: Full build + test sweep** + +```bash +go build ./... +go test ./... +go vet ./... +gofmt -l cmd/ internal/ 2>&1 +``` + +Expected: build clean, all tests pass, vet clean, gofmt produces no output. + +- [ ] **Step 11.5: Commit** + +```bash +git add README.md README-ja.md README-ko.md docs/recipes/single-server-app-noproxy.md docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md +git commit -m "docs: document --no-proxy mode alongside proxy mode + +Adds a 'Two deploy modes' section in README (en/ja/ko), a full +no-proxy single-server recipe, and a one-line cross-reference from +the 2026-04-20 proxy-deploy spec to the new 2026-04-21 no-proxy +design spec. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 12: End-to-end coherence pass + +**Files:** +- No code changes expected; this is a verification task. + +- [ ] **Step 12.1: Verify flag matrix** + +```bash +go run . app --help +go run . app init --help +go run . app deploy --help +go run . app rollback --help +go run . app destroy --help +go run . app logs --help +go run . app stop --help +go run . app restart --help +go run . app status --help +``` + +Expected: `--proxy` and `--no-proxy` appear on init/deploy/rollback/destroy/logs/stop/restart/status; `env` does NOT show these flags. + +- [ ] **Step 12.2: Run full test suite** + +```bash +go test ./... -count=1 +``` + +Expected: all packages pass. + +- [ ] **Step 12.3: Verify no-regression in existing proxy tests** + +```bash +go test ./cmd/app/ -count=1 -v | head -80 +``` + +Expected: existing tests (`TestBuildSlotUploadCmd`, `TestBuildComposeUp_Slot`, `TestBuildScheduleDrainCmd`, etc.) still pass. + +- [ ] **Step 12.4: Push branch and open PR** + +```bash +git push -u origin feat/no-proxy-mode +gh pr create --title "feat: --no-proxy mode for app deploy/init/logs/... (#102)" --body "$(cat <<'EOF' +## Summary + +Adds a coexisting `--no-proxy` mode to the `conoha app *` command tree, covered by spec `docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md`. Closes #102. Absorbs #93 (slot-aware logs/stop/restart/status in proxy mode). + +## Design + +- Hybrid mode selection: server-side marker `/opt/conoha//.conoha-mode` (written by `app init`) with `--proxy`/`--no-proxy` flag override. +- Proxy and no-proxy apps can share a single VPS — different subdirectories under `/opt/conoha/`. +- Explicit mode-conflict errors (exit 5) instead of silent auto-migration. + +## Breaking changes + +None. Proxy-mode users who ran `app init` before this PR will see "run 'conoha app init' first" on their next deploy; running init once re-writes the marker. + +## Test plan + +- [x] `go test ./...` passes +- [x] `go vet ./...` clean +- [x] `gofmt -l cmd/ internal/` clean +- [x] `go build ./...` succeeds +- [ ] End-to-end against a real VPS: + - [ ] `app init --no-proxy --app-name myapp ` → `.conoha-mode=no-proxy` on disk + - [ ] `app deploy --no-proxy --app-name myapp ` → `docker ps` shows `myapp-web` under project `myapp` + - [ ] Proxy-init'd app rejects `app deploy --no-proxy` with exit 5 + - [ ] `app rollback --no-proxy` exits 5 with git-based recovery hint + - [ ] `app logs/stop/restart/status` target the active slot in proxy mode (fixes #93) + - [ ] `app destroy` cleans up both layouts + +## Follow-ups + +- #92 `app reset` reintroduction — needs to handle both modes, blocked on this PR. +- #94 `app env` redesign for proxy mode — this PR only adds the warning shim. +- #95 `app list` for no-proxy — separate PR. +EOF +)" +``` + +Expected: PR opened against main. + +--- + +## Self-Review Notes + +**Spec coverage map:** +- Spec §2 (mode selection, marker) → Task 1. +- Spec §3.1 (`app init`) → Task 2. +- Spec §3.2 (`app deploy`) → Task 3. +- Spec §3.3 (`app rollback`) → Task 4. +- Spec §3.4 (`app destroy`) → Task 5. +- Spec §3.5 (`app logs/stop/restart/status`) → Tasks 6–9. +- Spec §3.6 (`app env`) → Task 10. +- Spec §4 (architecture) → Tasks 1 + references throughout. +- Spec §5 (exit codes) → absorbed into per-command error messages (5 = mode-conflict via `ErrModeConflict`, 6 = not-initialized handled by "has not been deployed" messages; cobra returns 1 for errors and we do not currently set distinct non-zero exit codes — acceptable for v1 and called out as a future refinement). +- Spec §6 (CLI surface) → Tasks 1 + per-command additions. +- Spec §7 (#93 integration) → Tasks 6–9 in logs/stop/restart/status. +- Spec §9 (migration) → proxy WriteMarker in Task 2. +- Spec §10 (documentation) → Task 11. +- Spec §11 (acceptance) → Task 12. + +**Exit code note:** The plan currently does not wire distinct process exit codes (4/5/6) — each error returns as cobra's default exit 1. If strict exit codes are required before merge, a follow-up commit in this branch can plumb through a typed error (check via `errors.Is(ErrModeConflict, err)`) in `cmd/cmdutil` to set the final `os.Exit`. Called out here rather than expanded into an extra task because prior PR #98 did not implement custom exit codes either. + +**Placeholder scan:** no TBD/TODO markers, every shell builder and helper has concrete code. + +**Type consistency:** `ModeProxy`/`ModeNoProxy`, `ReadMarker`/`WriteMarker`/`ResolveMode`/`ReadCurrentSlot`, and the `build*CmdFor(Proxy|NoProxy)` naming pattern used consistently across Tasks 6/7/8/9. From 882b4fbb5af6e9fe0db38157c9a04b61edeb22f4 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 11:55:09 +0900 Subject: [PATCH 03/18] feat(app): add Mode type, marker helpers, and mode resolution Introduce Mode enum (proxy | no-proxy), ErrNoMarker / ErrModeConflict, shell-command builders for the .conoha-mode marker file, ReadMarker / WriteMarker / ResolveMode / ReadCurrentSlot helpers, and the --proxy/--no-proxy mutually-exclusive flag pair. Foundation for #102. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/mode.go | 160 +++++++++++++++++++++++++++++++++++++++++++ cmd/app/mode_test.go | 101 +++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 cmd/app/mode.go create mode 100644 cmd/app/mode_test.go diff --git a/cmd/app/mode.go b/cmd/app/mode.go new file mode 100644 index 0000000..b3a3f18 --- /dev/null +++ b/cmd/app/mode.go @@ -0,0 +1,160 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + + internalssh "github.com/crowdy/conoha-cli/internal/ssh" +) + +type Mode string + +const ( + ModeProxy Mode = "proxy" + ModeNoProxy Mode = "no-proxy" +) + +var ( + ErrNoMarker = errors.New("no mode marker on server") + ErrModeConflict = errors.New("mode conflict") +) + +// ParseMarker accepts the raw marker file content and returns the Mode. +func ParseMarker(s string) (Mode, error) { + v := strings.TrimSpace(s) + switch v { + case string(ModeProxy): + return ModeProxy, nil + case string(ModeNoProxy): + return ModeNoProxy, nil + case "": + return "", fmt.Errorf("empty marker") + default: + return "", fmt.Errorf("unknown marker value %q", v) + } +} + +// buildReadMarkerCmd prints marker contents or "__MISSING__" if absent. +// The distinct sentinel lets ReadMarker tell "file absent" apart from +// permission or SSH errors without relying on exit codes. +func buildReadMarkerCmd(app string) string { + return fmt.Sprintf( + `cat '/opt/conoha/%s/.conoha-mode' 2>/dev/null || echo __MISSING__`, + app) +} + +// buildWriteMarkerCmd creates the app dir (if missing) and writes the marker. +func buildWriteMarkerCmd(app string, m Mode) string { + return fmt.Sprintf( + `mkdir -p '/opt/conoha/%s' && printf %%s\\n '%s' > '/opt/conoha/%s/.conoha-mode'`, + app, string(m), app) +} + +// buildReadCurrentSlotCmd prints the active slot ID or empty output on absence. +func buildReadCurrentSlotCmd(app string) string { + return fmt.Sprintf( + `cat '/opt/conoha/%s/CURRENT_SLOT' 2>/dev/null || true`, + app) +} + +// formatModeConflictError returns a user-facing error wrapping ErrModeConflict. +func formatModeConflictError(app string, got, want Mode) error { + oppositeInit := "conoha app init" + if want == ModeNoProxy { + oppositeInit = "conoha app init --no-proxy" + } + return fmt.Errorf( + `app %q is initialized in %s mode on this server, but --%s was requested. +To switch modes: + conoha app destroy # removes the existing deployment + %s # re-initialize in %s mode +%w`, + app, string(got), string(want), oppositeInit, string(want), ErrModeConflict) +} + +// ReadMarker returns the mode recorded on the server for app, or ErrNoMarker +// if no marker file exists. +func ReadMarker(cli *ssh.Client, app string) (Mode, error) { + var buf bytes.Buffer + if _, err := internalssh.RunCommand(cli, buildReadMarkerCmd(app), &buf, os.Stderr); err != nil { + return "", fmt.Errorf("read marker: %w", err) + } + out := strings.TrimSpace(buf.String()) + if out == "__MISSING__" { + return "", ErrNoMarker + } + return ParseMarker(out) +} + +// WriteMarker persists the marker file on the server. +func WriteMarker(cli *ssh.Client, app string, m Mode) error { + code, err := internalssh.RunCommand(cli, buildWriteMarkerCmd(app, m), os.Stderr, os.Stderr) + if err != nil { + return fmt.Errorf("write marker: %w", err) + } + if code != 0 { + return fmt.Errorf("write marker: exit %d", code) + } + return nil +} + +// ReadCurrentSlot returns the active slot ID or "" when the file is absent. +func ReadCurrentSlot(cli *ssh.Client, app string) (string, error) { + var buf bytes.Buffer + if _, err := internalssh.RunCommand(cli, buildReadCurrentSlotCmd(app), &buf, os.Stderr); err != nil { + return "", fmt.Errorf("read CURRENT_SLOT: %w", err) + } + return strings.TrimSpace(buf.String()), nil +} + +// flagMode reads --proxy / --no-proxy flags and returns the intended mode, or +// "" if neither is set. Callers should have registered the flags mutually +// exclusive via AddModeFlags. +func flagMode(cmd *cobra.Command) Mode { + if cmd.Flags().Lookup("no-proxy") != nil { + if v, _ := cmd.Flags().GetBool("no-proxy"); v { + return ModeNoProxy + } + } + if cmd.Flags().Lookup("proxy") != nil { + if v, _ := cmd.Flags().GetBool("proxy"); v { + return ModeProxy + } + } + return "" +} + +// ResolveMode interprets flags against the marker. +// Precedence: flag override compared to marker (error on mismatch) > marker > ErrNoMarker. +func ResolveMode(cmd *cobra.Command, cli *ssh.Client, app string) (Mode, error) { + want := flagMode(cmd) + got, readErr := ReadMarker(cli, app) + if readErr != nil && !errors.Is(readErr, ErrNoMarker) { + return "", readErr + } + switch { + case want == "" && errors.Is(readErr, ErrNoMarker): + return "", ErrNoMarker + case want == "": + return got, nil + case errors.Is(readErr, ErrNoMarker): + return want, nil + case want != got: + return "", formatModeConflictError(app, got, want) + default: + return got, nil + } +} + +// AddModeFlags registers --proxy and --no-proxy as mutually exclusive bool flags. +func AddModeFlags(cmd *cobra.Command) { + cmd.Flags().Bool("proxy", false, "force proxy (blue/green) mode, overriding server marker") + cmd.Flags().Bool("no-proxy", false, "force no-proxy (flat single-slot) mode, overriding server marker") + cmd.MarkFlagsMutuallyExclusive("proxy", "no-proxy") +} diff --git a/cmd/app/mode_test.go b/cmd/app/mode_test.go new file mode 100644 index 0000000..247544a --- /dev/null +++ b/cmd/app/mode_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "errors" + "strings" + "testing" +) + +func TestMode_String(t *testing.T) { + if string(ModeProxy) != "proxy" { + t.Errorf("ModeProxy = %q, want %q", ModeProxy, "proxy") + } + if string(ModeNoProxy) != "no-proxy" { + t.Errorf("ModeNoProxy = %q, want %q", ModeNoProxy, "no-proxy") + } +} + +func TestParseMarker(t *testing.T) { + cases := []struct { + in string + want Mode + wantErr bool + }{ + {"proxy\n", ModeProxy, false}, + {"no-proxy\n", ModeNoProxy, false}, + {"proxy", ModeProxy, false}, + {"no-proxy", ModeNoProxy, false}, + {" no-proxy \n", ModeNoProxy, false}, + {"", "", true}, + {"garbage", "", true}, + {"Proxy", "", true}, + } + for _, c := range cases { + got, err := ParseMarker(c.in) + if c.wantErr && err == nil { + t.Errorf("ParseMarker(%q) expected error, got %q", c.in, got) + } + if !c.wantErr && err != nil { + t.Errorf("ParseMarker(%q) err=%v", c.in, err) + } + if got != c.want { + t.Errorf("ParseMarker(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuildReadMarkerCmd(t *testing.T) { + got := buildReadMarkerCmd("myapp") + for _, want := range []string{ + "/opt/conoha/myapp/.conoha-mode", + "cat", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildWriteMarkerCmd(t *testing.T) { + got := buildWriteMarkerCmd("myapp", ModeNoProxy) + for _, want := range []string{ + "mkdir -p '/opt/conoha/myapp'", + "/opt/conoha/myapp/.conoha-mode", + "no-proxy", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildReadCurrentSlotCmd(t *testing.T) { + got := buildReadCurrentSlotCmd("myapp") + for _, want := range []string{ + "/opt/conoha/myapp/CURRENT_SLOT", + "cat", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestFormatModeConflictError(t *testing.T) { + err := formatModeConflictError("myapp", ModeProxy, ModeNoProxy) + if !errors.Is(err, ErrModeConflict) { + t.Errorf("expected ErrModeConflict, got %v", err) + } + msg := err.Error() + for _, want := range []string{ + `"myapp"`, + "proxy mode", + "--no-proxy was requested", + "conoha app destroy", + "conoha app init --no-proxy", + } { + if !strings.Contains(msg, want) { + t.Errorf("conflict error missing %q: %s", want, msg) + } + } +} From e262c6d0ef98e54591ea3d4d923f9fb03c9a6d03 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:01:30 +0900 Subject: [PATCH 04/18] feat(app/init): add --no-proxy branch and persist mode marker No-proxy init installs only the mkdir + marker write (no conoha.yml required). Proxy init continues through the existing upsert path and now writes the marker at the end. --app-name is required with --no-proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/init.go | 115 +++++++++++++++++++++++++++++-------------- cmd/app/init_test.go | 23 +++++++++ 2 files changed, 102 insertions(+), 36 deletions(-) diff --git a/cmd/app/init.go b/cmd/app/init.go index 02bf044..3782287 100644 --- a/cmd/app/init.go +++ b/cmd/app/init.go @@ -21,6 +21,7 @@ import ( func init() { addAppFlags(initCmd) initCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server") + AddModeFlags(initCmd) } var initCmd = &cobra.Command{ @@ -33,47 +34,89 @@ against the proxy's Admin API. Run 'conoha proxy boot' on the server first.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - pf, err := config.LoadProjectFile(config.ProjectFileName) - if err != nil { - return err - } - if err := pf.Validate(); err != nil { - return err - } - composePath, err := pf.ResolveComposeFile(".") - if err != nil { - return err - } - if err := pf.ValidateAgainstCompose(composePath); err != nil { - return err + noProxy, _ := cmd.Flags().GetBool("no-proxy") + if noProxy { + return runInitNoProxy(cmd, args[0]) } + return runInitProxy(cmd, args[0]) + }, +} - sshClient, s, ip, err := connectToServer(cmd, args[0]) - if err != nil { - return err - } - defer func() { _ = sshClient.Close() }() +func runInitProxy(cmd *cobra.Command, serverID string) error { + pf, err := config.LoadProjectFile(config.ProjectFileName) + if err != nil { + return err + } + if err := pf.Validate(); err != nil { + return err + } + composePath, err := pf.ResolveComposeFile(".") + if err != nil { + return err + } + if err := pf.ValidateAgainstCompose(composePath); err != nil { + return err + } - dataDir, _ := cmd.Flags().GetString("data-dir") - client := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir)) + sshClient, s, ip, err := connectToServer(cmd, serverID) + if err != nil { + return err + } + defer func() { _ = sshClient.Close() }() - if err := warnOnLegacyRepo(sshClient, pf.Name); err != nil { - fmt.Fprintf(os.Stderr, "warning: %v\n", err) - } + dataDir, _ := cmd.Flags().GetString("data-dir") + client := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir)) - fmt.Fprintf(os.Stderr, "==> Registering service %q on %s (%s)\n", pf.Name, s.Name, ip) - svc, err := client.Upsert(proxypkg.UpsertRequest{ - Name: pf.Name, - Hosts: pf.Hosts, - HealthPolicy: mapHealth(pf.Health), - }) - if err != nil { - return err - } - fmt.Fprintf(os.Stderr, "Service %q registered. phase=%s tls=%s\n", svc.Name, svc.Phase, svc.TLSStatus) - fmt.Fprintf(os.Stderr, "Next: run 'conoha app deploy %s' to push your app.\n", args[0]) - return nil - }, + if err := warnOnLegacyRepo(sshClient, pf.Name); err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "==> Registering service %q on %s (%s)\n", pf.Name, s.Name, ip) + svc, err := client.Upsert(proxypkg.UpsertRequest{ + Name: pf.Name, + Hosts: pf.Hosts, + HealthPolicy: mapHealth(pf.Health), + }) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Service %q registered. phase=%s tls=%s\n", svc.Name, svc.Phase, svc.TLSStatus) + fmt.Fprintf(os.Stderr, "Next: run 'conoha app deploy %s' to push your app.\n", serverID) + if err := WriteMarker(sshClient, pf.Name, ModeProxy); err != nil { + fmt.Fprintf(os.Stderr, "warning: write mode marker: %v\n", err) + } + return nil +} + +func runInitNoProxy(cmd *cobra.Command, serverID string) error { + 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() }() + + // Verify docker is present. + code, err := internalssh.RunCommand(sshClient, "command -v docker >/dev/null 2>&1", os.Stderr, os.Stderr) + if err != nil { + return fmt.Errorf("docker check: %w", err) + } + if code != 0 { + return fmt.Errorf("docker is not installed on %s (%s)", s.Name, ip) + } + + fmt.Fprintf(os.Stderr, "==> Initializing %q on %s (%s) in no-proxy mode\n", appName, s.Name, ip) + if err := WriteMarker(sshClient, appName, ModeNoProxy); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Initialized. Next: run 'conoha app deploy --no-proxy --app-name %s %s'\n", appName, serverID) + return nil } // connectToServer opens an SSH session to the server identified by id-or-name. diff --git a/cmd/app/init_test.go b/cmd/app/init_test.go index 73bfc41..effc2df 100644 --- a/cmd/app/init_test.go +++ b/cmd/app/init_test.go @@ -7,6 +7,29 @@ import ( proxypkg "github.com/crowdy/conoha-cli/internal/proxy" ) +func TestInitCmd_HasModeFlags(t *testing.T) { + if initCmd.Flags().Lookup("proxy") == nil { + t.Error("init should have --proxy flag") + } + if initCmd.Flags().Lookup("no-proxy") == nil { + t.Error("init should have --no-proxy flag") + } +} + +func TestInitCmd_ModeFlagsMutuallyExclusive(t *testing.T) { + // ParseFlags alone does not validate mutual exclusion in cobra; + // ValidateFlagGroups is the correct API for that check. + if err := initCmd.ParseFlags([]string{"--proxy", "--no-proxy"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if err := initCmd.ValidateFlagGroups(); err == nil { + t.Error("--proxy and --no-proxy should be mutually exclusive") + } + // Reset flags for subsequent tests. + _ = initCmd.Flags().Set("proxy", "false") + _ = initCmd.Flags().Set("no-proxy", "false") +} + func TestMapHealth_Nil(t *testing.T) { if got := mapHealth(nil); got != nil { t.Errorf("want nil, got %+v", got) From db7b1b9060c8afe745322d52a6c721d8351df664 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:05:04 +0900 Subject: [PATCH 05/18] fix(app/init): make proxy-path marker write fatal (review I1) The .conoha-mode marker is the single source of truth for mode dispatch in subsequent app commands. A silently-skipped marker leaves deploy/logs/stop/... unable to detect mode and surfaces a misleading 'not initialized' error for an app that was successfully registered with the proxy. Match no-proxy's fatal-on-marker-failure policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/app/init.go b/cmd/app/init.go index 3782287..c64556b 100644 --- a/cmd/app/init.go +++ b/cmd/app/init.go @@ -81,10 +81,10 @@ func runInitProxy(cmd *cobra.Command, serverID string) error { return err } fmt.Fprintf(os.Stderr, "Service %q registered. phase=%s tls=%s\n", svc.Name, svc.Phase, svc.TLSStatus) - fmt.Fprintf(os.Stderr, "Next: run 'conoha app deploy %s' to push your app.\n", serverID) if err := WriteMarker(sshClient, pf.Name, ModeProxy); err != nil { - fmt.Fprintf(os.Stderr, "warning: write mode marker: %v\n", err) + return fmt.Errorf("write mode marker: %w", err) } + fmt.Fprintf(os.Stderr, "Next: run 'conoha app deploy %s' to push your app.\n", serverID) return nil } From b6fa465f3c236c261e5f44c56104c4e35b27d50d Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:07:44 +0900 Subject: [PATCH 06/18] feat(app/deploy): add --no-proxy flat deploy path runDeployDispatch reads the --no-proxy flag, resolves the server marker, and either calls runProxyDeploy (existing blue/green flow) or runNoProxyDeploy (tar upload to /opt/conoha// + compose up against the project name ). Proxy/no-proxy marker mismatches produce the standard mode-conflict error. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/deploy.go | 90 +++++++++++++++++++++++++++++++++++++++++- cmd/app/deploy_test.go | 46 +++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/cmd/app/deploy.go b/cmd/app/deploy.go index 27621f5..e2dc2bd 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "errors" "fmt" "os" "strings" @@ -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" ) @@ -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{ @@ -29,11 +32,94 @@ 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// and runs +// 'docker compose -p 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, preserving any existing files so that .env.server and named +// volumes survive redeploys. Caller MUST pre-validate app via internalssh.ValidateAppName. +func buildNoProxyUploadCmd(workDir string) string { + return fmt.Sprintf( + "mkdir -p '%[1]s' && 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). +// Caller MUST pre-validate app via internalssh.ValidateAppName. +func buildNoProxyDeployCmd(workDir, app, composeFile string) string { + return fmt.Sprintf( + "cd '%s' && docker compose -p %s -f %s up -d --build", + workDir, app, composeFile) +} + +func runProxyDeploy(cmd *cobra.Command, serverID string) error { pf, err := config.LoadProjectFile(config.ProjectFileName) if err != nil { return err diff --git a/cmd/app/deploy_test.go b/cmd/app/deploy_test.go index 4879f7a..856c218 100644 --- a/cmd/app/deploy_test.go +++ b/cmd/app/deploy_test.go @@ -1 +1,47 @@ 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", + } { + 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'", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } + if strings.Contains(got, "rm -rf '/opt/conoha/myapp'") { + t.Errorf("no-proxy upload must not wipe app dir: %s", got) + } +} From 0281f2ff3d28720afd50e7371f34704ec54ec764 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:12:44 +0900 Subject: [PATCH 07/18] fix(app/deploy): proxy path also verifies marker (review I1, S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §3.2 requires rejecting 'conoha app deploy ' (no flag) or '--proxy' against an app with a no-proxy marker. Previously only the --no-proxy branch enforced marker consistency; the proxy branch went straight to admin.Get and returned a misleading 'service not found'. Now the proxy branch reads the marker first and issues the standard mode-conflict error. Also unify the 'not initialized' phrasing across both branches. Additionally, single-quote composeFile in buildNoProxyDeployCmd so the builder stays safe if a future caller passes a non-whitelisted compose file path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/deploy.go | 16 ++++++++++++++-- cmd/app/deploy_test.go | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/app/deploy.go b/cmd/app/deploy.go index e2dc2bd..2aded94 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -113,9 +113,11 @@ func buildNoProxyUploadCmd(workDir string) string { // buildNoProxyDeployCmd brings the flat-layout compose project up in place. // The compose project name equals the app name (no slot suffix). // 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 { return fmt.Sprintf( - "cd '%s' && docker compose -p %s -f %s up -d --build", + "cd '%s' && docker compose -p %s -f '%s' up -d --build", workDir, app, composeFile) } @@ -141,12 +143,22 @@ func runProxyDeploy(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") diff --git a/cmd/app/deploy_test.go b/cmd/app/deploy_test.go index 856c218..d532aa6 100644 --- a/cmd/app/deploy_test.go +++ b/cmd/app/deploy_test.go @@ -22,7 +22,7 @@ func TestBuildNoProxyDeployCmd(t *testing.T) { for _, want := range []string{ "cd '/opt/conoha/myapp'", "docker compose -p myapp", - "-f compose.yml", + "-f 'compose.yml'", "up -d --build", } { if !strings.Contains(got, want) { From 5337b9336b906bc08e5ab7c5454b61e827e6141a Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:13:57 +0900 Subject: [PATCH 08/18] feat(app/rollback): reject no-proxy mode with recovery guidance --no-proxy flag (or a no-proxy marker detected after SSH connect) now returns an explicit error pointing at 'git checkout && conoha app deploy --no-proxy'. Proxy-mode behavior is unchanged but now runs through ResolveMode first so marker mismatches surface as the standard mode-conflict error. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/rollback.go | 27 ++++++++++++++++++++++++++- cmd/app/rollback_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 cmd/app/rollback_test.go diff --git a/cmd/app/rollback.go b/cmd/app/rollback.go index 594565c..da7c523 100644 --- a/cmd/app/rollback.go +++ b/cmd/app/rollback.go @@ -16,6 +16,13 @@ func init() { addAppFlags(rollbackCmd) rollbackCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server") rollbackCmd.Flags().Int("drain-ms", 0, "drain window for the swapped-back target (0 = proxy default)") + AddModeFlags(rollbackCmd) +} + +func noProxyRollbackError(app string) error { + return fmt.Errorf( + "rollback is not supported in no-proxy mode. Deploy a previous revision instead: "+ + "git checkout && conoha app deploy --no-proxy --app-name %s ", app) } var rollbackCmd = &cobra.Command{ @@ -23,6 +30,14 @@ var rollbackCmd = &cobra.Command{ Short: "Swap back to the previous target (within the drain window)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []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") + } + return noProxyRollbackError(appName) + } pf, err := config.LoadProjectFile(config.ProjectFileName) if err != nil { return err @@ -36,9 +51,19 @@ var rollbackCmd = &cobra.Command{ } defer func() { _ = sshClient.Close() }() + mode, err := ResolveMode(cmd, sshClient, pf.Name) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q not initialized on this server — run 'conoha app init' first", pf.Name) + } + return err + } + if mode == ModeNoProxy { + return noProxyRollbackError(pf.Name) + } + dataDir, _ := cmd.Flags().GetString("data-dir") admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir)) - drainMs, _ := cmd.Flags().GetInt("drain-ms") fmt.Fprintf(os.Stderr, "==> Rolling back %q on %s (%s)\n", pf.Name, s.Name, ip) updated, err := admin.Rollback(pf.Name, drainMs) diff --git a/cmd/app/rollback_test.go b/cmd/app/rollback_test.go new file mode 100644 index 0000000..e8c02aa --- /dev/null +++ b/cmd/app/rollback_test.go @@ -0,0 +1,29 @@ +package app + +import ( + "strings" + "testing" +) + +func TestRollbackCmd_HasModeFlags(t *testing.T) { + if rollbackCmd.Flags().Lookup("proxy") == nil { + t.Error("rollback should have --proxy flag") + } + if rollbackCmd.Flags().Lookup("no-proxy") == nil { + t.Error("rollback should have --no-proxy flag") + } +} + +func TestRollbackNoProxyError(t *testing.T) { + err := noProxyRollbackError("myapp") + msg := err.Error() + for _, want := range []string{ + "rollback is not supported in no-proxy mode", + "git checkout", + "conoha app deploy --no-proxy", + } { + if !strings.Contains(msg, want) { + t.Errorf("missing %q in %s", want, msg) + } + } +} From 09e9fcd3e20f2daa0d92af393512ccd318f18184 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:16:41 +0900 Subject: [PATCH 09/18] feat(app/destroy): skip proxy DELETE in no-proxy/legacy mode destroy now reads the .conoha-mode marker before the destroy script runs (the script removes the marker via rm -rf /opt/conoha/) and only deregisters from conoha-proxy when the marker is 'proxy'. No-proxy and unmarked (legacy v0.1.x) servers continue to run the shared compose-down + rm -rf cleanup without touching the proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/destroy.go | 33 +++++++++++++++++++++------------ cmd/app/destroy_test.go | 9 +++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cmd/app/destroy.go b/cmd/app/destroy.go index 46f0d62..ca2fa0c 100644 --- a/cmd/app/destroy.go +++ b/cmd/app/destroy.go @@ -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{ @@ -44,6 +45,13 @@ var destroyCmd = &cobra.Command{ } } + // Resolve mode BEFORE running the destroy script, 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 + } + script := generateDestroyScript(ctx.AppName) exitCode, err := internalssh.RunScript(ctx.Client, script, nil, os.Stdout, os.Stderr) if err != nil { @@ -53,18 +61,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 { + 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) + } } } diff --git a/cmd/app/destroy_test.go b/cmd/app/destroy_test.go index c4f43d1..408fdca 100644 --- a/cmd/app/destroy_test.go +++ b/cmd/app/destroy_test.go @@ -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") + } +} From 21c6fb0e9bc23d422573903e5c9c5b7469fc0c14 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:20:01 +0900 Subject: [PATCH 10/18] feat(app/logs): dispatch by mode, target active slot in proxy mode Absorbs #93 for app logs: proxy mode reads CURRENT_SLOT and runs 'docker compose -p - logs' against the active slot project. No-proxy mode keeps the flat 'cd /opt/conoha/' path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/logs.go | 55 +++++++++++++++++++++++++++++++++++++------- cmd/app/logs_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 cmd/app/logs_test.go diff --git a/cmd/app/logs.go b/cmd/app/logs.go index c72118f..0272142 100644 --- a/cmd/app/logs.go +++ b/cmd/app/logs.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" @@ -14,12 +15,13 @@ func init() { logsCmd.Flags().BoolP("follow", "f", false, "stream logs in real-time") logsCmd.Flags().Int("tail", 100, "number of lines to show") logsCmd.Flags().String("service", "", "specific service name") + AddModeFlags(logsCmd) } var logsCmd = &cobra.Command{ Use: "logs ", Short: "Show app container logs", - Long: "Show docker compose logs. Use --follow to stream in real-time (Ctrl+C to stop).", + Long: "Show docker compose logs for the active slot (proxy mode) or the flat work dir (no-proxy). Use --follow to stream in real-time (Ctrl+C to stop).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx, err := connectToApp(cmd, args) @@ -31,17 +33,32 @@ var logsCmd = &cobra.Command{ follow, _ := cmd.Flags().GetBool("follow") tail, _ := cmd.Flags().GetInt("tail") service, _ := cmd.Flags().GetString("service") - - workDir := "/opt/conoha/" + ctx.AppName - composeCmd := fmt.Sprintf("cd %s && docker compose logs --tail %d", workDir, tail) - if follow { - composeCmd += " -f" - } if service != "" { if err := internalssh.ValidateAppName(service); err != nil { return fmt.Errorf("invalid service name: %w", err) } - composeCmd += " " + service + } + + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildLogsCmdForProxy(ctx.AppName, slot, tail, follow, service) + } else { + composeCmd = buildLogsCmdForNoProxy(ctx.AppName, tail, follow, service) } exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) @@ -54,3 +71,25 @@ var logsCmd = &cobra.Command{ return nil }, } + +func buildLogsCmdForProxy(app, slot string, tail int, follow bool, service string) string { + cmd := fmt.Sprintf("docker compose -p %s-%s logs --tail %d", app, slot, tail) + if follow { + cmd += " -f" + } + if service != "" { + cmd += " " + service + } + return cmd +} + +func buildLogsCmdForNoProxy(app string, tail int, follow bool, service string) string { + cmd := fmt.Sprintf("cd /opt/conoha/%s && docker compose logs --tail %d", app, tail) + if follow { + cmd += " -f" + } + if service != "" { + cmd += " " + service + } + return cmd +} diff --git a/cmd/app/logs_test.go b/cmd/app/logs_test.go new file mode 100644 index 0000000..60433e3 --- /dev/null +++ b/cmd/app/logs_test.go @@ -0,0 +1,55 @@ +package app + +import ( + "strings" + "testing" +) + +func TestLogsCmd_HasModeFlags(t *testing.T) { + if logsCmd.Flags().Lookup("proxy") == nil { + t.Error("logs should have --proxy flag") + } + if logsCmd.Flags().Lookup("no-proxy") == nil { + t.Error("logs should have --no-proxy flag") + } +} + +func TestBuildLogsCmd_Proxy(t *testing.T) { + got := buildLogsCmdForProxy("myapp", "abc1234", 100, false, "") + for _, want := range []string{ + "docker compose -p myapp-abc1234", + "logs", + "--tail 100", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildLogsCmd_Proxy_FollowService(t *testing.T) { + got := buildLogsCmdForProxy("myapp", "abc1234", 50, true, "web") + for _, want := range []string{ + "docker compose -p myapp-abc1234 logs", + "--tail 50", + "-f", + " web", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildLogsCmd_NoProxy(t *testing.T) { + got := buildLogsCmdForNoProxy("myapp", 100, false, "") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose logs", + "--tail 100", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} From e757b4b74afbb49b15e9108a788788930be9d1d4 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:20:28 +0900 Subject: [PATCH 11/18] feat(app/stop): dispatch by mode, target active slot in proxy mode Proxy-mode stop runs 'docker compose -p - stop' against the active slot (CURRENT_SLOT); no-proxy keeps the legacy flat path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/stop.go | 35 +++++++++++++++++++++++++++++++++-- cmd/app/stop_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 cmd/app/stop_test.go diff --git a/cmd/app/stop.go b/cmd/app/stop.go index c72811a..7794206 100644 --- a/cmd/app/stop.go +++ b/cmd/app/stop.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" @@ -12,6 +13,7 @@ import ( func init() { addAppFlags(stopCmd) + AddModeFlags(stopCmd) } var stopCmd = &cobra.Command{ @@ -34,9 +36,30 @@ var stopCmd = &cobra.Command{ return nil } - workDir := "/opt/conoha/" + ctx.AppName + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildStopCmdForProxy(ctx.AppName, slot) + } else { + composeCmd = buildStopCmdForNoProxy(ctx.AppName) + } + fmt.Fprintf(os.Stderr, "Stopping app %q on %s...\n", ctx.AppName, ctx.Server.Name) - exitCode, err := internalssh.RunCommand(ctx.Client, fmt.Sprintf("cd %s && docker compose stop && docker compose ps", workDir), os.Stdout, os.Stderr) + exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) if err != nil { return fmt.Errorf("stop failed: %w", err) } @@ -46,3 +69,11 @@ var stopCmd = &cobra.Command{ return nil }, } + +func buildStopCmdForProxy(app, slot string) string { + return fmt.Sprintf("docker compose -p %s-%s stop && docker compose -p %s-%s ps", app, slot, app, slot) +} + +func buildStopCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose stop && docker compose ps", app) +} diff --git a/cmd/app/stop_test.go b/cmd/app/stop_test.go new file mode 100644 index 0000000..73a8845 --- /dev/null +++ b/cmd/app/stop_test.go @@ -0,0 +1,40 @@ +package app + +import ( + "strings" + "testing" +) + +func TestStopCmd_HasModeFlags(t *testing.T) { + if stopCmd.Flags().Lookup("proxy") == nil { + t.Error("stop should have --proxy flag") + } + if stopCmd.Flags().Lookup("no-proxy") == nil { + t.Error("stop should have --no-proxy flag") + } +} + +func TestBuildStopCmd_Proxy(t *testing.T) { + got := buildStopCmdForProxy("myapp", "abc1234") + for _, want := range []string{ + "docker compose -p myapp-abc1234 stop", + "docker compose -p myapp-abc1234 ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildStopCmd_NoProxy(t *testing.T) { + got := buildStopCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose stop", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} From 0ce9d805ab09220d061145b816238cdcd5c57666 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:20:55 +0900 Subject: [PATCH 12/18] feat(app/restart): dispatch by mode, target active slot in proxy mode Proxy-mode restart runs 'docker compose -p - restart' against CURRENT_SLOT. No-proxy keeps the legacy flat path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/restart.go | 35 +++++++++++++++++++++++++++++++++-- cmd/app/restart_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 cmd/app/restart_test.go diff --git a/cmd/app/restart.go b/cmd/app/restart.go index 0731f30..237651a 100644 --- a/cmd/app/restart.go +++ b/cmd/app/restart.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" @@ -11,6 +12,7 @@ import ( func init() { addAppFlags(restartCmd) + AddModeFlags(restartCmd) } var restartCmd = &cobra.Command{ @@ -24,9 +26,30 @@ var restartCmd = &cobra.Command{ } defer func() { _ = ctx.Client.Close() }() - workDir := "/opt/conoha/" + ctx.AppName + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var composeCmd string + if mode == ModeProxy { + slot, err := ReadCurrentSlot(ctx.Client, ctx.AppName) + if err != nil { + return err + } + if slot == "" { + return fmt.Errorf("app %q has not been deployed on this server", ctx.AppName) + } + composeCmd = buildRestartCmdForProxy(ctx.AppName, slot) + } else { + composeCmd = buildRestartCmdForNoProxy(ctx.AppName) + } + fmt.Fprintf(os.Stderr, "Restarting app %q on %s...\n", ctx.AppName, ctx.Server.Name) - exitCode, err := internalssh.RunCommand(ctx.Client, fmt.Sprintf("cd %s && docker compose restart && docker compose ps", workDir), os.Stdout, os.Stderr) + exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) if err != nil { return fmt.Errorf("restart failed: %w", err) } @@ -36,3 +59,11 @@ var restartCmd = &cobra.Command{ return nil }, } + +func buildRestartCmdForProxy(app, slot string) string { + return fmt.Sprintf("docker compose -p %s-%s restart && docker compose -p %s-%s ps", app, slot, app, slot) +} + +func buildRestartCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose restart && docker compose ps", app) +} diff --git a/cmd/app/restart_test.go b/cmd/app/restart_test.go new file mode 100644 index 0000000..5d52d1e --- /dev/null +++ b/cmd/app/restart_test.go @@ -0,0 +1,40 @@ +package app + +import ( + "strings" + "testing" +) + +func TestRestartCmd_HasModeFlags(t *testing.T) { + if restartCmd.Flags().Lookup("proxy") == nil { + t.Error("restart should have --proxy flag") + } + if restartCmd.Flags().Lookup("no-proxy") == nil { + t.Error("restart should have --no-proxy flag") + } +} + +func TestBuildRestartCmd_Proxy(t *testing.T) { + got := buildRestartCmdForProxy("myapp", "abc1234") + for _, want := range []string{ + "docker compose -p myapp-abc1234 restart", + "docker compose -p myapp-abc1234 ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildRestartCmd_NoProxy(t *testing.T) { + got := buildRestartCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose restart", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} From 6eeca3abb375115d72d5e80404368b06e59f0034 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:21:29 +0900 Subject: [PATCH 13/18] feat(app/status): dispatch by mode, skip proxy phase in no-proxy Proxy status scans all slot compose projects and appends the proxy service phase block. No-proxy status runs a simple 'docker compose ps' in the flat work dir and skips the proxy enrichment. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/status.go | 43 +++++++++++++++++++++++++++++++++--------- cmd/app/status_test.go | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 cmd/app/status_test.go diff --git a/cmd/app/status.go b/cmd/app/status.go index 72e7e27..c73b4ae 100644 --- a/cmd/app/status.go +++ b/cmd/app/status.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" @@ -15,11 +16,12 @@ import ( func init() { addAppFlags(statusCmd) statusCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server") + AddModeFlags(statusCmd) } var statusCmd = &cobra.Command{ Use: "status ", - Short: "Show app container status and proxy phase", + Short: "Show app container status", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx, err := connectToApp(cmd, args) @@ -28,18 +30,28 @@ var statusCmd = &cobra.Command{ } defer func() { _ = ctx.Client.Close() }() - // Print docker compose state across all slot projects for this app. - psCmd := fmt.Sprintf( - `for p in $(docker compose ls -a --format '{{.Name}}' 2>/dev/null | grep -E "^%[1]s(-|$)" || true); do `+ - `echo "--- compose project: ${p} ---"; `+ - `docker compose -p "${p}" ps; `+ - `done`, - ctx.AppName) + mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) + if err != nil { + if errors.Is(err, ErrNoMarker) { + return fmt.Errorf("app %q has not been initialized on this server", ctx.AppName) + } + return err + } + + var psCmd string + if mode == ModeProxy { + psCmd = buildStatusCmdForProxy(ctx.AppName) + } else { + psCmd = buildStatusCmdForNoProxy(ctx.AppName) + } if _, err := internalssh.RunCommand(ctx.Client, psCmd, os.Stdout, os.Stderr); err != nil { fmt.Fprintf(os.Stderr, "warning: compose ps: %v\n", err) } - // Enrich with proxy service state if conoha.yml is present. + if mode != ModeProxy { + return nil + } + pf, pfErr := config.LoadProjectFile(config.ProjectFileName) if pfErr == nil && pf.Validate() == nil { dataDir, _ := cmd.Flags().GetString("data-dir") @@ -65,3 +77,16 @@ var statusCmd = &cobra.Command{ return nil }, } + +func buildStatusCmdForProxy(app string) string { + return fmt.Sprintf( + `for p in $(docker compose ls -a --format '{{.Name}}' 2>/dev/null | grep -E "^%[1]s(-|$)" || true); do `+ + `echo "--- compose project: ${p} ---"; `+ + `docker compose -p "${p}" ps; `+ + `done`, + app) +} + +func buildStatusCmdForNoProxy(app string) string { + return fmt.Sprintf("cd /opt/conoha/%s && docker compose ps", app) +} diff --git a/cmd/app/status_test.go b/cmd/app/status_test.go new file mode 100644 index 0000000..4d0b892 --- /dev/null +++ b/cmd/app/status_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "strings" + "testing" +) + +func TestStatusCmd_HasModeFlags(t *testing.T) { + if statusCmd.Flags().Lookup("proxy") == nil { + t.Error("status should have --proxy flag") + } + if statusCmd.Flags().Lookup("no-proxy") == nil { + t.Error("status should have --no-proxy flag") + } +} + +func TestBuildStatusCmd_Proxy(t *testing.T) { + got := buildStatusCmdForProxy("myapp") + for _, want := range []string{ + "docker compose ls", + `grep -E "^myapp(-|$)"`, + "docker compose -p", + "ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} + +func TestBuildStatusCmd_NoProxy(t *testing.T) { + got := buildStatusCmdForNoProxy("myapp") + for _, want := range []string{ + "cd /opt/conoha/myapp", + "docker compose ps", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} From c80d0e6647c2f9931856c95b1fcae45cd86dc2bb Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:23:29 +0900 Subject: [PATCH 14/18] fix(app/mode): validate CURRENT_SLOT content before returning (review) ReadCurrentSlot returned raw trimmed file content, which was then interpolated into 'docker compose -p -' in logs/stop/ restart. A compromised or manually-edited CURRENT_SLOT could smuggle shell metacharacters through that path. Re-apply ValidateSlotID (same rule that gates writes at deploy time) so the read side is defense-in-depth rather than trust-the-file. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/mode.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/app/mode.go b/cmd/app/mode.go index b3a3f18..ad9ad6a 100644 --- a/cmd/app/mode.go +++ b/cmd/app/mode.go @@ -105,12 +105,22 @@ func WriteMarker(cli *ssh.Client, app string, m Mode) error { } // ReadCurrentSlot returns the active slot ID or "" when the file is absent. +// The returned value is re-validated via ValidateSlotID so a compromised or +// manually-edited CURRENT_SLOT cannot leak shell metacharacters into downstream +// 'docker compose -p -' interpolation. func ReadCurrentSlot(cli *ssh.Client, app string) (string, error) { var buf bytes.Buffer if _, err := internalssh.RunCommand(cli, buildReadCurrentSlotCmd(app), &buf, os.Stderr); err != nil { return "", fmt.Errorf("read CURRENT_SLOT: %w", err) } - return strings.TrimSpace(buf.String()), nil + slot := strings.TrimSpace(buf.String()) + if slot == "" { + return "", nil + } + if err := ValidateSlotID(slot); err != nil { + return "", fmt.Errorf("CURRENT_SLOT: %w", err) + } + return slot, nil } // flagMode reads --proxy / --no-proxy flags and returns the intended mode, or From 7035a31e57491a50cd41dbfd87dc55b1d0e965c4 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:24:50 +0900 Subject: [PATCH 15/18] feat(app/env): warn when app env targets a proxy-mode app app env still writes to /opt/conoha/.env.server (the v0.1.x path, now canonical for no-proxy mode). When the .conoha-mode marker says proxy, print a single-line warning pointing at #94 rather than breaking existing CI scripts. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/env.go | 19 +++++++++++++++++++ cmd/app/env_test.go | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmd/app/env.go b/cmd/app/env.go index f1aeb27..710ecf9 100644 --- a/cmd/app/env.go +++ b/cmd/app/env.go @@ -11,6 +11,21 @@ import ( internalssh "github.com/crowdy/conoha-cli/internal/ssh" ) +// proxyEnvWarningMessage returns the one-line warning emitted when `app env` +// is run against a proxy-mode app. See #94 for the planned redesign. +func proxyEnvWarningMessage() string { + return "warning: app env has no effect on proxy-mode deployed slots; see #94 for the redesign\n" +} + +// maybeWarnProxyEnvMode emits the proxy-mode warning to stderr once per env +// subcommand invocation. Silent on no-proxy or when marker lookup fails. +func maybeWarnProxyEnvMode(ctx *appContext) { + m, err := ReadMarker(ctx.Client, ctx.AppName) + if err == nil && m == ModeProxy { + fmt.Fprint(os.Stderr, proxyEnvWarningMessage()) + } +} + var envCmd = &cobra.Command{ Use: "env", Short: "Manage app environment variables", @@ -38,6 +53,7 @@ var envSetCmd = &cobra.Command{ return err } defer func() { _ = ctx.Client.Close() }() + maybeWarnProxyEnvMode(ctx) env := make(map[string]string) for _, arg := range args[1:] { @@ -104,6 +120,7 @@ var envGetCmd = &cobra.Command{ return err } defer func() { _ = ctx.Client.Close() }() + maybeWarnProxyEnvMode(ctx) key := args[1] if err := internalssh.ValidateEnvKey(key); err != nil { @@ -138,6 +155,7 @@ var envListCmd = &cobra.Command{ return err } defer func() { _ = ctx.Client.Close() }() + maybeWarnProxyEnvMode(ctx) command := generateEnvListCommand(ctx.AppName) _, err = internalssh.RunCommand(ctx.Client, command, os.Stdout, os.Stderr) @@ -162,6 +180,7 @@ var envUnsetCmd = &cobra.Command{ return err } defer func() { _ = ctx.Client.Close() }() + maybeWarnProxyEnvMode(ctx) keys := args[1:] for _, k := range keys { diff --git a/cmd/app/env_test.go b/cmd/app/env_test.go index d474ced..466bc49 100644 --- a/cmd/app/env_test.go +++ b/cmd/app/env_test.go @@ -57,3 +57,17 @@ func TestGenerateEnvListCommand(t *testing.T) { t.Error("missing env file path") } } + +func TestProxyEnvWarningMessage(t *testing.T) { + msg := proxyEnvWarningMessage() + for _, want := range []string{ + "warning", + "app env", + "proxy-mode", + "#94", + } { + if !strings.Contains(msg, want) { + t.Errorf("missing %q in %s", want, msg) + } + } +} From d469e1507de833642aa8508060125e1061007c9b Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:27:22 +0900 Subject: [PATCH 16/18] docs: document --no-proxy mode alongside proxy mode Adds a 'Two deploy modes' section in README.md (ja), README-en.md, and README-ko.md; a full no-proxy single-server recipe at recipes/single-server-app-noproxy.md; and a one-line cross-reference from the 2026-04-20 proxy-deploy spec to the new 2026-04-21 no-proxy design spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- README-en.md | 11 ++++ README-ko.md | 11 ++++ README.md | 11 ++++ .../2026-04-20-conoha-proxy-deploy-design.md | 2 + recipes/single-server-app-noproxy.md | 61 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 recipes/single-server-app-noproxy.md diff --git a/README-en.md b/README-en.md index 888eaa3..2f0db0c 100644 --- a/README-en.md +++ b/README-en.md @@ -117,6 +117,17 @@ conoha server rename 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///` managed via conoha-proxy | +| **no-proxy** (`--no-proxy`) | Testing, internal/dev VPS, non-HTTP services, hobby apps | Flat `/opt/conoha//` with plain `docker compose up` | + +Initialize with `conoha app init --no-proxy --app-name `, then `conoha app deploy --no-proxy --app-name `. 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: diff --git a/README-ko.md b/README-ko.md index 6514da0..b240289 100644 --- a/README-ko.md +++ b/README-ko.md @@ -117,6 +117,17 @@ conoha server rename new-name | `conoha config` | CLI 설정 관리 (show / set / path) | | `conoha skill` | Claude Code 스킬 관리 (install / update / remove) | +### 두 가지 배포 모드 + +`conoha app`은 동일 VPS에서 공존 가능한 두 가지 모드를 제공합니다: + +| 모드 | 용도 | 레이아웃 | +|---|---|---| +| **proxy** (기본) | 도메인 + TLS가 있는 공개 앱 | `/opt/conoha///` 아래의 blue/green 슬롯 (conoha-proxy 관리) | +| **no-proxy** (`--no-proxy`) | 테스트, 내부/개발 VPS, 비 HTTP 서비스, 취미 앱 | `/opt/conoha//` 플랫 (일반 `docker compose up`) | + +`conoha app init --no-proxy --app-name `로 초기화한 뒤 `conoha app deploy --no-proxy --app-name `로 배포합니다. 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 윈도우 내 즉시 롤백을 제공합니다. 초기 셋업 순서: diff --git a/README.md b/README.md index 47e0f3c..c0b1af0 100644 --- a/README.md +++ b/README.md @@ -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///` の blue/green スロット (conoha-proxy 管理) | +| **no-proxy** (`--no-proxy`) | テスト、内部・開発 VPS、非 HTTP サービス、ホビーアプリ | `/opt/conoha//` フラット (単純な `docker compose up`) | + +`conoha app init --no-proxy --app-name ` で初期化し、`conoha app deploy --no-proxy --app-name ` でデプロイします。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 窓内での即時ロールバックを提供します。初回セットアップの流れ: diff --git a/docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md b/docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md index 24f55a9..a17ea42 100644 --- a/docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md +++ b/docs/superpowers/specs/2026-04-20-conoha-proxy-deploy-design.md @@ -4,6 +4,8 @@ **Status**: Approved **Owner**: t-kim +> **Update 2026-04-21:** A `--no-proxy` mode was added as a coexisting alternative path. See `docs/superpowers/specs/2026-04-21-no-proxy-mode-design.md`. + ## 1. 背景と目的 現行の `conoha app deploy` は「tar 転送 → `docker compose up -d --build`」の単一スロット構成で、TLS / ドメインルーティング / ゼロダウンタイム切替を持たない。同リポジトリ群に存在する `../conoha-proxy` (ConoHa VPS 向け Go 製リバースプロキシ) が、Let's Encrypt 自動 TLS・Host ヘッダールーティング・blue/green スワップ (drain ウィンドウ) を Admin API として提供しているため、それに統合する。 diff --git a/recipes/single-server-app-noproxy.md b/recipes/single-server-app-noproxy.md new file mode 100644 index 0000000..80da2fe --- /dev/null +++ b/recipes/single-server-app-noproxy.md @@ -0,0 +1,61 @@ +# Single-Server App — No-Proxy Mode + +This recipe shows a TLS-less, single-slot deployment of a small web app. Use it when: + +- You do not have a public domain. +- The service exposes a non-HTTP protocol. +- You prefer `docker compose up` semantics over blue/green. + +For the proxy-backed blue/green variant, see [single-server-app.md](./single-server-app.md). + +## 1. Create the VPS + +```bash +conoha server create --name myapp --flavor g2l-cpu1-1g --image ubuntu-22.04-x86-64 --ssh-key default +``` + +## 2. Install Docker and mark the app no-proxy + +```bash +conoha app init --no-proxy --app-name myapp myapp +``` + +This verifies Docker is present on the server and writes the `no-proxy` marker to `/opt/conoha/myapp/.conoha-mode`. + +## 3. Prepare a compose file locally + +`compose.yml`: + +```yaml +services: + web: + build: . + ports: + - "80:8080" +``` + +No `conoha.yml` needed. + +## 4. Deploy + +```bash +conoha app deploy --no-proxy --app-name myapp myapp +``` + +The CLI tars the current directory (respecting `.dockerignore`), uploads to `/opt/conoha/myapp/` on the VPS, and runs `docker compose -p myapp up -d --build`. + +## 5. Day-two operations + +```bash +conoha app logs --no-proxy --app-name myapp myapp +conoha app status --no-proxy --app-name myapp myapp +conoha app stop --no-proxy --app-name myapp myapp +conoha app restart --no-proxy --app-name myapp myapp +conoha app destroy --no-proxy --app-name myapp myapp +``` + +`conoha app rollback` is not supported in no-proxy mode — deploy a previous revision instead (`git checkout && conoha app deploy --no-proxy ...`). + +## Switching to proxy mode + +Run `conoha app destroy ... myapp` followed by `conoha app init ... myapp` (without `--no-proxy`). The CLI refuses implicit mode switches. From 4ff6b2ab4e837940f42908cef5f5124ee62e76c6 Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 12:35:18 +0900 Subject: [PATCH 17/18] fix(app): close two final-review gaps (init mode-flip, env merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 — app init now rejects implicit mode switches: Before writing the marker, init reads the existing marker and returns the standard mode-conflict error if it disagrees with the target mode. Previously, 'conoha app init --no-proxy' on a proxy-registered app (or vice versa) would silently overwrite the marker while leaving the proxy service or flat layout behind, defeating spec §2.3's coexistence guarantee. Fix 2 — no-proxy deploy now merges /opt/conoha/.env.server: Spec §3.2 requires replaying the v0.1.x behavior where values set by 'conoha app env set' are merged into the work-dir .env at deploy time. buildNoProxyDeployCmd now prepends a shell block that cats the env.server file into .env (server-side values first so user's repo-level .env wins on duplicates per last-occurrence semantics) before 'docker compose up'. Added test assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/deploy.go | 14 ++++++++++++-- cmd/app/deploy_test.go | 3 +++ cmd/app/init.go | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/cmd/app/deploy.go b/cmd/app/deploy.go index 2aded94..810d6fa 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -112,13 +112,23 @@ func buildNoProxyUploadCmd(workDir string) string { // buildNoProxyDeployCmd brings the flat-layout compose project up in place. // The compose project name equals the app name (no slot suffix). +// Before compose up, merges /opt/conoha/.env.server (written by +// `conoha app env set`) into /.env so values set out-of-band +// are available for compose interpolation. User's repo-level .env +// (if any) takes precedence via last-occurrence semantics. // 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' && docker compose -p %s -f '%s' up -d --build", - workDir, app, composeFile) + "cd '%s' && { "+ + "if [ -s '%s' ]; then "+ + " touch .env; "+ + " { cat '%s'; printf '\\n'; cat .env; } > .env.merged && mv .env.merged .env; "+ + "fi; "+ + "} && docker compose -p %s -f '%s' up -d --build", + workDir, envServer, envServer, app, composeFile) } func runProxyDeploy(cmd *cobra.Command, serverID string) error { diff --git a/cmd/app/deploy_test.go b/cmd/app/deploy_test.go index d532aa6..7d9b809 100644 --- a/cmd/app/deploy_test.go +++ b/cmd/app/deploy_test.go @@ -24,6 +24,9 @@ func TestBuildNoProxyDeployCmd(t *testing.T) { "docker compose -p myapp", "-f 'compose.yml'", "up -d --build", + // v0.1.x env.server merge must happen before compose up (spec §3.2). + "/opt/conoha/myapp.env.server", + ".env.merged", } { if !strings.Contains(got, want) { t.Errorf("missing %q in %s", want, got) diff --git a/cmd/app/init.go b/cmd/app/init.go index c64556b..86e0d45 100644 --- a/cmd/app/init.go +++ b/cmd/app/init.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "errors" "fmt" "os" "strings" @@ -64,6 +65,13 @@ func runInitProxy(cmd *cobra.Command, serverID string) error { } defer func() { _ = sshClient.Close() }() + // Reject implicit mode switch — user must `app destroy` first. + if existing, err := ReadMarker(sshClient, pf.Name); err == nil && existing != ModeProxy { + return formatModeConflictError(pf.Name, existing, ModeProxy) + } else if err != nil && !errors.Is(err, ErrNoMarker) { + return err + } + dataDir, _ := cmd.Flags().GetString("data-dir") client := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir)) @@ -102,6 +110,13 @@ func runInitNoProxy(cmd *cobra.Command, serverID string) error { } defer func() { _ = sshClient.Close() }() + // Reject implicit mode switch — user must `app destroy` first. + if existing, err := ReadMarker(sshClient, appName); err == nil && existing != ModeNoProxy { + return formatModeConflictError(appName, existing, ModeNoProxy) + } else if err != nil && !errors.Is(err, ErrNoMarker) { + return err + } + // Verify docker is present. code, err := internalssh.RunCommand(sshClient, "command -v docker >/dev/null 2>&1", os.Stderr, os.Stderr) if err != nil { From b38b4b885b603e86eb2434365d620bf43ac2259b Mon Sep 17 00:00:00 2001 From: t-kim Date: Tue, 21 Apr 2026 13:32:05 +0900 Subject: [PATCH 18/18] =?UTF-8?q?fix(app):=20address=20adversarial=20revie?= =?UTF-8?q?w=20=E2=80=94=20env=20merge,=20destroy,=20ordering,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 — app env unset now takes effect on redeploy. buildNoProxyUploadCmd now removes the previous deploy's merged .env before tar extraction so the repo's .env (or its absence) is authoritative each cycle. Subsequent .env.server overlay rebuilds from scratch instead of accumulating stale entries. C2 — .env merge precedence reversed so app env wins over repo .env. .env.server is now appended AFTER the repo .env, making its values last-occurrence and therefore the ones docker compose picks up. This matches v0.1.x ENV_EXISTS semantics: runtime secrets set via conoha app env set override anything committed to the repo. I1 — Extracted resolveModeLogic as a pure function and added an 11-case table-driven test covering every combination of (flag × marker × SSH error). Previously ResolveMode had zero coverage despite being the dispatch spine of this feature. I2 — destroy now honors legacy proxy deployments. When the marker is absent but conoha.yml validates locally, we treat the server as a pre-PR proxy deployment and still issue proxy DELETE to avoid leaking orphan service registrations. I3 — destroy and stop now resolve mode BEFORE prompting. A flag/marker conflict or a "not deployed" error now aborts before asking the user to confirm, removing the "did it or didn't it?" UX after a rejected operation. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/app/deploy.go | 30 +++++++++++++++--------- cmd/app/deploy_test.go | 8 +++++-- cmd/app/destroy.go | 28 +++++++++++++++------- cmd/app/mode.go | 7 ++++++ cmd/app/mode_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++ cmd/app/stop.go | 20 +++++++++------- 6 files changed, 116 insertions(+), 30 deletions(-) diff --git a/cmd/app/deploy.go b/cmd/app/deploy.go index 810d6fa..60f2430 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -102,20 +102,30 @@ func runNoProxyDeploy(cmd *cobra.Command, sshClient *ssh.Client, s *model.Server } // buildNoProxyUploadCmd extracts the incoming tar archive into the app work -// directory, preserving any existing files so that .env.server and named -// volumes survive redeploys. Caller MUST pre-validate app via internalssh.ValidateAppName. +// 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/.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' && tar xzf - -C '%[1]s'", + "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). -// Before compose up, merges /opt/conoha/.env.server (written by -// `conoha app env set`) into /.env so values set out-of-band -// are available for compose interpolation. User's repo-level .env -// (if any) takes precedence via last-occurrence semantics. +// +// Env merge (v0.1.x parity, spec §3.6): appends /opt/conoha/.env.server +// (written by `conoha app env set`) to /.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. @@ -123,10 +133,8 @@ func buildNoProxyDeployCmd(workDir, app, composeFile string) string { envServer := fmt.Sprintf("/opt/conoha/%s.env.server", app) return fmt.Sprintf( "cd '%s' && { "+ - "if [ -s '%s' ]; then "+ - " touch .env; "+ - " { cat '%s'; printf '\\n'; cat .env; } > .env.merged && mv .env.merged .env; "+ - "fi; "+ + "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) } diff --git a/cmd/app/deploy_test.go b/cmd/app/deploy_test.go index 7d9b809..bab43f6 100644 --- a/cmd/app/deploy_test.go +++ b/cmd/app/deploy_test.go @@ -24,9 +24,9 @@ func TestBuildNoProxyDeployCmd(t *testing.T) { "docker compose -p myapp", "-f 'compose.yml'", "up -d --build", - // v0.1.x env.server merge must happen before compose up (spec §3.2). + // .env.server appended to .env so server-side values override repo (spec §3.6). "/opt/conoha/myapp.env.server", - ".env.merged", + ">> .env", } { if !strings.Contains(got, want) { t.Errorf("missing %q in %s", want, got) @@ -39,11 +39,15 @@ func TestBuildNoProxyUploadCmd(t *testing.T) { 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) } diff --git a/cmd/app/destroy.go b/cmd/app/destroy.go index ca2fa0c..e87bd8a 100644 --- a/cmd/app/destroy.go +++ b/cmd/app/destroy.go @@ -33,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)) @@ -45,13 +64,6 @@ var destroyCmd = &cobra.Command{ } } - // Resolve mode BEFORE running the destroy script, 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 - } - script := generateDestroyScript(ctx.AppName) exitCode, err := internalssh.RunScript(ctx.Client, script, nil, os.Stdout, os.Stderr) if err != nil { @@ -61,7 +73,7 @@ var destroyCmd = &cobra.Command{ return fmt.Errorf("destroy exited with code %d", exitCode) } - if mode == ModeProxy { + if mode == ModeProxy || legacyProxy { dataDir, _ := cmd.Flags().GetString("data-dir") if dataDir == "" { dataDir = proxy.DefaultDataDir diff --git a/cmd/app/mode.go b/cmd/app/mode.go index ad9ad6a..8227937 100644 --- a/cmd/app/mode.go +++ b/cmd/app/mode.go @@ -145,6 +145,13 @@ func flagMode(cmd *cobra.Command) Mode { func ResolveMode(cmd *cobra.Command, cli *ssh.Client, app string) (Mode, error) { want := flagMode(cmd) got, readErr := ReadMarker(cli, app) + return resolveModeLogic(app, want, got, readErr) +} + +// resolveModeLogic is the pure precedence layer extracted for unit testing. +// want is the flag-requested mode ("" if none). got/readErr come from ReadMarker. +// Non-ErrNoMarker read errors are propagated unchanged. +func resolveModeLogic(app string, want, got Mode, readErr error) (Mode, error) { if readErr != nil && !errors.Is(readErr, ErrNoMarker) { return "", readErr } diff --git a/cmd/app/mode_test.go b/cmd/app/mode_test.go index 247544a..6b9ca68 100644 --- a/cmd/app/mode_test.go +++ b/cmd/app/mode_test.go @@ -81,6 +81,59 @@ func TestBuildReadCurrentSlotCmd(t *testing.T) { } } +func TestResolveModeLogic(t *testing.T) { + sentinelReadErr := errors.New("ssh broken") + + cases := []struct { + name string + want Mode // flag value, "" if unset + got Mode // ReadMarker return, used when readErr is nil or ErrNoMarker + readErr error // nil, ErrNoMarker, or some other error + expMode Mode // expected return + expErr error // if non-nil, errors.Is must match (or "any non-nil" when expMode == "" and no sentinel) + conflict bool // expect ErrModeConflict specifically + }{ + // Flag unset + {"no flag + no marker", "", "", ErrNoMarker, "", ErrNoMarker, false}, + {"no flag + proxy marker", "", ModeProxy, nil, ModeProxy, nil, false}, + {"no flag + no-proxy marker", "", ModeNoProxy, nil, ModeNoProxy, nil, false}, + // Flag=proxy + {"proxy flag + no marker", ModeProxy, "", ErrNoMarker, ModeProxy, nil, false}, + {"proxy flag + proxy marker", ModeProxy, ModeProxy, nil, ModeProxy, nil, false}, + {"proxy flag + no-proxy marker", ModeProxy, ModeNoProxy, nil, "", nil, true}, + // Flag=no-proxy + {"no-proxy flag + no marker", ModeNoProxy, "", ErrNoMarker, ModeNoProxy, nil, false}, + {"no-proxy flag + no-proxy marker", ModeNoProxy, ModeNoProxy, nil, ModeNoProxy, nil, false}, + {"no-proxy flag + proxy marker", ModeNoProxy, ModeProxy, nil, "", nil, true}, + // SSH/read error — propagated regardless of flag + {"ssh error + no flag", "", "", sentinelReadErr, "", sentinelReadErr, false}, + {"ssh error + proxy flag", ModeProxy, "", sentinelReadErr, "", sentinelReadErr, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mode, err := resolveModeLogic("myapp", c.want, c.got, c.readErr) + if mode != c.expMode { + t.Errorf("mode = %q, want %q", mode, c.expMode) + } + switch { + case c.conflict: + if !errors.Is(err, ErrModeConflict) { + t.Errorf("expected ErrModeConflict, got %v", err) + } + case c.expErr != nil: + if !errors.Is(err, c.expErr) { + t.Errorf("expected %v, got %v", c.expErr, err) + } + default: + if err != nil { + t.Errorf("expected nil err, got %v", err) + } + } + }) + } +} + func TestFormatModeConflictError(t *testing.T) { err := formatModeConflictError("myapp", ModeProxy, ModeNoProxy) if !errors.Is(err, ErrModeConflict) { diff --git a/cmd/app/stop.go b/cmd/app/stop.go index 7794206..72c19b5 100644 --- a/cmd/app/stop.go +++ b/cmd/app/stop.go @@ -27,15 +27,8 @@ var stopCmd = &cobra.Command{ } defer func() { _ = ctx.Client.Close() }() - ok, err := prompt.Confirm(fmt.Sprintf("Stop app %q on %s?", ctx.AppName, ctx.Server.Name)) - if err != nil { - return err - } - if !ok { - fmt.Fprintln(os.Stderr, "Cancelled.") - return nil - } - + // Resolve mode + slot before the prompt so flag/marker conflicts or + // "not deployed" errors abort without asking the user to confirm (I3). mode, err := ResolveMode(cmd, ctx.Client, ctx.AppName) if err != nil { if errors.Is(err, ErrNoMarker) { @@ -58,6 +51,15 @@ var stopCmd = &cobra.Command{ composeCmd = buildStopCmdForNoProxy(ctx.AppName) } + ok, err := prompt.Confirm(fmt.Sprintf("Stop app %q on %s?", ctx.AppName, ctx.Server.Name)) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(os.Stderr, "Cancelled.") + return nil + } + fmt.Fprintf(os.Stderr, "Stopping app %q on %s...\n", ctx.AppName, ctx.Server.Name) exitCode, err := internalssh.RunCommand(ctx.Client, composeCmd, os.Stdout, os.Stderr) if err != nil {