From 62d308d61239801c16e755b92f51a079fd650435 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 22:58:05 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix(snapshot):=20=E6=97=A2=E3=81=AB?= =?UTF-8?q?=E3=81=82=E3=82=8B=E4=B8=96=E4=BB=A3=E3=81=B8=20full=20?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E3=82=8B=E3=81=A8=E3=81=8D=E3=81=AF=E4=BB=AE?= =?UTF-8?q?=E3=81=AE=E5=90=8D=E5=89=8D=E3=81=A7=E6=8E=A7=E3=81=88=E3=81=A6?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=85=A5=E3=82=8C=E6=9B=BF=E3=81=88=E3=82=8B?= =?UTF-8?q?=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前の snapshot.snar を引き継がずに full.tar.zst.new・snapshot.snar.new へ控え、 成功したら状態ファイル → 差分(番号の大きい順)→ 印付きの meta.yml → full → 印無しの meta.yml → 状態ファイルの順に入れ替える。印のある世代は restore が消去の 前に止める。テストの docker の差し替えは、書くファイルの名前をコマンドから拾う。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 116 ++++++++++++++++++-- tests/snapshot/fake_tar.py | 20 ++++ tests/snapshot/test_archive_layout.py | 6 +- tests/snapshot/test_auto_snapshot_series.py | 6 +- tests/snapshot/test_exclude_generations.py | 6 +- tests/snapshot/test_manager_series.py | 10 +- tests/snapshot/test_manager_volumes.py | 5 +- tests/snapshot/test_pre_restore_backup.py | 5 +- 8 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 tests/snapshot/fake_tar.py diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index b293bc32..bec925d9 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1,5 +1,6 @@ """スナップショット管理のコアロジック""" +import os import re import shlex import shutil @@ -47,6 +48,17 @@ INCR_ARCHIVE_GLOB = 'incr-*.tar.zst' _INCR_ARCHIVE_RE = re.compile(r'^incr-(\d+)\.tar\.zst$') +# 世代の作り直し (既にある世代へ full を作る) で使う仮の名前 (#458)。新しい控えをここへ +# 書いてから改名で入れ替え、控えが失敗しても前のファイルに触らない。``is_archive_file`` と +# ``INCR_ARCHIVE_GLOB`` に当たらない名前にする。 +FULL_ARCHIVE_TMP = f'{FULL_ARCHIVE}.new' +SNAR_FILE_TMP = f'{SNAR_FILE}.new' +SNAP_META_FILE = 'meta.yml' +META_FILE_TMP = f'{SNAP_META_FILE}.new' +# 作り直しの途中で full と meta.yml の組が合わない間だけ meta.yml に立てる印。 +# 印のある世代は復元しない。 +REBUILDING_KEY = 'rebuilding' + # コンテナ内のマウント先。作成時は対象ボリュームを /source の下へ読み取り専用で、 # 復元時は /target の下へ書き込み可で並べる。世代ディレクトリは /backup。 BACKUP_ROOT = '/source' @@ -493,6 +505,12 @@ def restore(self, name: str, point: int | None = None) -> None: Noneなら全差分を適用。 """ snap_dir = self._restore_source(name, point) + if self._load_snap_meta(snap_dir).get(REBUILDING_KEY): + # full と meta.yml の組が合わない。復元前バックアップも消去も行わない (#458) + raise SnapshotError( + f"世代 '{name}' は作り直しの途中で止まっているため復元できません。" + f"devbase snapshot create --name {name} --full を打って作り直してください" + ) # 控える組を決めるため、組は控えより先に読む。検証を通らない世代では # 控えも復元も行わない。 @@ -980,11 +998,12 @@ def _ensure_snapshot_image(self) -> str: return ensure_snapshot_image(self.devbase_root) @staticmethod - def backup_command(archive: str, volumes: dict, excludes=()) -> str: + def backup_command(archive: str, volumes: dict, excludes=(), snar: str = SNAR_FILE) -> str: """対象を 1 本のアーカイブへ書き出すコマンドを組み立てる。 - full も差分も同じ形で、``snapshot.snar`` に前回の状態を積み上げる。 - 差分は ``archive`` に ``incr-NNN.tar.zst`` を渡す。 + full も差分も同じ形で、``snar`` (既定 ``snapshot.snar``) に前回の状態を積み上げる。 + 差分は ``archive`` に ``incr-NNN.tar.zst`` を渡す。世代の作り直しは、空から始まる + 仮の名前の ``snar`` を渡して全体 (レベル 0) を控える (#458)。 起点は各ボリュームのマウント先 (``ai`` / ``group``) で、``/source`` そのものではない。 ``/source`` はコンテナを起動するたびに作られ inode が変わるため、GNU tar が毎回 @@ -998,7 +1017,7 @@ def backup_command(archive: str, volumes: dict, excludes=()) -> str: exclude_args = tar_exclude_args(excludes, member_list) exclude_part = f"{exclude_args} " if exclude_args else '' return ( - f"tar --listed-incremental={ARCHIVE_MOUNT}/{SNAR_FILE} {exclude_part}" + f"tar --listed-incremental={ARCHIVE_MOUNT}/{snar} {exclude_part}" f"-cf - -C {BACKUP_ROOT} {members} | zstd -1 -T0 -o {ARCHIVE_MOUNT}/{archive}" ) @@ -1096,6 +1115,9 @@ def _create_full(self, name: str, snap_dir: Path, volumes: Optional[dict] = None) -> None: """フルバックアップを作成する。 + 世代に前のファイル (full・snar・差分のどれか) があれば、世代の作り直しとして + 仮の名前へ控えてから入れ替える (#458)。無ければ本来の名前へ直接控える。 + Args: volumes: 控える組。省けば作成時の対象 (``self.volumes``) """ @@ -1103,11 +1125,41 @@ def _create_full(self, name: str, snap_dir: Path, volumes = self.volumes excludes = self._excludes_for(snap_dir, volumes) logger.info("フルバックアップを作成中: %s", name) - self._run_docker_tar( - snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes, excludes), volumes, - ) + if not self._has_previous_files(snap_dir): + self._run_docker_tar( + snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes, excludes), volumes, + ) + self._save_snap_meta(snap_dir, self._full_meta(name, volumes, excludes)) + return + + # 前の中断の残りを消してから、前の snar を引き継がずに仮の名前で控える。 + # 控えが失敗しても前のファイルは 1 バイトも変わらない (決定 1) + self._discard_rebuild_temps(snap_dir) + try: + self._run_docker_tar( + snap_dir, 'backup', + self.backup_command(FULL_ARCHIVE_TMP, volumes, excludes, snar=SNAR_FILE_TMP), + volumes, + ) + except SnapshotError: + self._discard_rebuild_temps(snap_dir) + raise - # meta.yml を作成 + try: + removed = self._install_rebuilt_full( + snap_dir, self._full_meta(name, volumes, excludes)) + except (OSError, SnapshotError) as e: + self._discard_rebuild_temps(snap_dir) + raise SnapshotError( + f"世代 '{name}' の作り直しを終えられませんでした: {e}。" + f"もう一度 devbase snapshot create --name {name} --full を打ってください" + ) from e + logger.info("世代 %s を今のボリュームの全体で作り直しました(前の差分 %d 本を除きました)", + name, removed) + + @staticmethod + def _full_meta(name: str, volumes: dict, excludes) -> dict: + """full を作った直後の ``meta.yml`` の内容を組む。""" meta = { 'name': name, 'created_at': datetime.now().isoformat(), @@ -1121,7 +1173,53 @@ def _create_full(self, name: str, snap_dir: Path, if '' not in volumes: # 旧レイアウト (ルートへ直接マウント) の起点は ``.`` のままなので書かない meta['archive_root'] = ARCHIVE_ROOT_MEMBERS - self._save_snap_meta(snap_dir, meta) + return meta + + @staticmethod + def _has_previous_files(snap_dir: Path) -> bool: + """世代が前の控え (full・snar・差分のどれか) を持つか。持てば作り直しになる。""" + return ((snap_dir / FULL_ARCHIVE).exists() or (snap_dir / SNAR_FILE).exists() + or any(snap_dir.glob(INCR_ARCHIVE_GLOB))) + + def _install_rebuilt_full(self, snap_dir: Path, meta: dict) -> int: + """仮の名前で控えた full を世代へ入れ替え、除いた差分の本数を返す (#458・決定 2)。 + + 状態ファイル → 差分 (番号の大きい順) → 印付きの meta.yml → full → 印無しの + meta.yml → 状態ファイルの順に 1 つずつ行う。どこで止まっても世代は snar を持たず、 + 「印が無く full と meta.yml が同じ控えの組」か「印があり復元しない組」のどちらかになる。 + 途中の失敗は ``OSError`` のまま外へ出す。 + """ + for stale in (f'{SNAR_FILE}.bak', SNAR_FILE): + (snap_dir / stale).unlink(missing_ok=True) + + incrs = sorted(snap_dir.glob(INCR_ARCHIVE_GLOB), + key=lambda p: (incr_archive_number(p.name) or 0, p.name), + reverse=True) + for incr in incrs: + incr.unlink() + + self._replace_snap_meta(snap_dir, {**meta, REBUILDING_KEY: True}) + os.replace(snap_dir / FULL_ARCHIVE_TMP, snap_dir / FULL_ARCHIVE) + self._replace_snap_meta(snap_dir, meta) + os.replace(snap_dir / SNAR_FILE_TMP, snap_dir / SNAR_FILE) + return len(incrs) + + def _replace_snap_meta(self, snap_dir: Path, meta: dict) -> None: + """``meta.yml`` を仮の名前へ書いてから改名で置き換える (途中まで書いた形を残さない)。""" + tmp = snap_dir / META_FILE_TMP + with open(tmp, 'w') as f: + yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + os.replace(tmp, snap_dir / SNAP_META_FILE) + + @staticmethod + def _discard_rebuild_temps(snap_dir: Path) -> None: + """作り直しの仮の名前のファイルを消す。消せなければ警告して続ける。""" + for tmp in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP): + try: + (snap_dir / tmp).unlink(missing_ok=True) + except OSError as e: + logger.warning("作り直しの仮のファイル %s を消せませんでした: %s", + snap_dir / tmp, e) def _create_incremental(self, name: str, snap_dir: Path) -> None: """差分バックアップを作成""" diff --git a/tests/snapshot/fake_tar.py b/tests/snapshot/fake_tar.py new file mode 100644 index 00000000..cfe4f052 --- /dev/null +++ b/tests/snapshot/fake_tar.py @@ -0,0 +1,20 @@ +"""Docker を差し替えたテストで、控えのコマンドが書くはずのファイルを置く。 + +控えのコマンドが書くのは ``-o /backup/<アーカイブ>`` と ``--listed-incremental=/backup/<状態>`` の +2 本である。世代の作り直しは仮の名前 (``full.tar.zst.new``・``snapshot.snar.new``) へ書く (#458) +ため、名前はコマンドから拾う。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_ARCHIVE_RE = re.compile(r"-o /backup/(\S+)") +_SNAR_RE = re.compile(r"--listed-incremental=/backup/(\S+)") + + +def write_backup_outputs(snap_dir: Path, command: str) -> None: + """``command`` が書くアーカイブと状態ファイルを ``snap_dir`` へ置く。""" + (snap_dir / _ARCHIVE_RE.search(command).group(1)).write_text("archive") + (snap_dir / _SNAR_RE.search(command).group(1)).write_text("snar") diff --git a/tests/snapshot/test_archive_layout.py b/tests/snapshot/test_archive_layout.py index 69e9287d..064f9197 100644 --- a/tests/snapshot/test_archive_layout.py +++ b/tests/snapshot/test_archive_layout.py @@ -26,6 +26,8 @@ is_archive_file, ) +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _group_env(monkeypatch): @@ -42,9 +44,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"-o /backup/(\S+)", command).group(1) - (snap_dir / archive).write_text("archive") - (snap_dir / SNAR_FILE).write_text("snar") + write_backup_outputs(snap_dir, command) # --------------------------------------------------------------------------- diff --git a/tests/snapshot/test_auto_snapshot_series.py b/tests/snapshot/test_auto_snapshot_series.py index effa9024..c1e7d47f 100644 --- a/tests/snapshot/test_auto_snapshot_series.py +++ b/tests/snapshot/test_auto_snapshot_series.py @@ -8,7 +8,6 @@ import logging import os -import re import time import pytest @@ -17,6 +16,7 @@ from devbase.commands import container from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs from .test_manager_series import names, write_state @@ -27,9 +27,7 @@ def root(tmp_path, monkeypatch): monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") # up が宣言から置いた後 (#315) def fake(self, snap_dir, mode, command, volumes=None): if mode == "backup": - archive = re.search(r"/backup/(full\.tar\.zst|incr-\d+\.tar\.zst)", command) - (snap_dir / archive.group(1)).write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) monkeypatch.setattr(SnapshotManager, "_run_docker_tar", fake) return tmp_path diff --git a/tests/snapshot/test_exclude_generations.py b/tests/snapshot/test_exclude_generations.py index 515ad44d..7c9fb673 100644 --- a/tests/snapshot/test_exclude_generations.py +++ b/tests/snapshot/test_exclude_generations.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import re import shlex import types @@ -20,6 +19,7 @@ from devbase.snapshot.exclude import DEFAULT_EXCLUDES, USER_CONFIG from devbase.snapshot.manager import SNAR_FILE, SnapshotManager +from .fake_tar import write_backup_outputs from .test_manager_series import vols, write_state DEFAULTS = sorted(DEFAULT_EXCLUDES) @@ -41,9 +41,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"-o /backup/(\S+)", command).group(1) - (snap_dir / archive).write_text("archive") - (snap_dir / SNAR_FILE).write_text("snar") + write_backup_outputs(snap_dir, command) def backup_excludes(self) -> list: """記録した控えのコマンドごとに、``--exclude=`` の値の並びを返す。""" diff --git a/tests/snapshot/test_manager_series.py b/tests/snapshot/test_manager_series.py index 6fda54b3..7dc83345 100644 --- a/tests/snapshot/test_manager_series.py +++ b/tests/snapshot/test_manager_series.py @@ -9,7 +9,6 @@ import logging import os -import re import time import types from datetime import datetime @@ -23,6 +22,8 @@ from devbase.snapshot.exclude import DEFAULT_EXCLUDES from devbase.snapshot.manager import RotateResult, SnapshotManager +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): @@ -39,8 +40,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) def vols(group: str) -> dict: @@ -217,9 +217,7 @@ class ArchiveRecordingManager(RecordingManager): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"/backup/(full\.tar\.zst|incr-\d+\.tar\.zst)", command) - (snap_dir / archive.group(1)).write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) def _entry(root: Path, name: str) -> dict: diff --git a/tests/snapshot/test_manager_volumes.py b/tests/snapshot/test_manager_volumes.py index 2da2cf62..a3ab2a3e 100644 --- a/tests/snapshot/test_manager_volumes.py +++ b/tests/snapshot/test_manager_volumes.py @@ -15,6 +15,8 @@ from devbase.errors import SnapshotError from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): @@ -42,8 +44,7 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): }) # フルバックアップの実体が無いと restore が止まるので、印だけ作る if mode == "backup": - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) # --------------------------------------------------------------------------- diff --git a/tests/snapshot/test_pre_restore_backup.py b/tests/snapshot/test_pre_restore_backup.py index 64358931..b64aa26a 100644 --- a/tests/snapshot/test_pre_restore_backup.py +++ b/tests/snapshot/test_pre_restore_backup.py @@ -15,6 +15,8 @@ from devbase.errors import SnapshotCommandError, SnapshotError from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs + INITECH_VOLUMES = {"ai": "devbase_home_ubuntu", "group": "devbase_home_initech"} REAL_ERROR_STDERR = "tar: ./ai/x: Cannot write: No space left on device\n" @@ -46,8 +48,7 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): raise SnapshotCommandError( f"Dockerでのtar操作に失敗しました: {REAL_ERROR_STDERR}", stderr=REAL_ERROR_STDERR) - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) return None if self._fail_restore: raise SnapshotCommandError( From a182ec3734a79e2838baa7dc324a5b70ee9bc981 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:04:12 +0900 Subject: [PATCH 02/20] =?UTF-8?q?test(snapshot):=20=E4=B8=96=E4=BB=A3?= =?UTF-8?q?=E3=81=AE=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=97=E3=81=AE=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E3=83=BB=E5=A4=B1=E6=95=97=E3=83=BB=E5=8D=B0=E3=83=BB?= =?UTF-8?q?=E5=AE=9F=E3=81=AE=20tar=20=E3=81=AE=E4=B8=AD=E8=BA=AB=E3=82=92?= =?UTF-8?q?=E7=B8=9B=E3=82=8B=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/snapshot/test_backup_real_tar.py | 73 +++++ tests/snapshot/test_rebuild_generation.py | 359 ++++++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 tests/snapshot/test_rebuild_generation.py diff --git a/tests/snapshot/test_backup_real_tar.py b/tests/snapshot/test_backup_real_tar.py index 047f93d4..e0befea0 100644 --- a/tests/snapshot/test_backup_real_tar.py +++ b/tests/snapshot/test_backup_real_tar.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re import shutil import subprocess from pathlib import Path @@ -199,3 +200,75 @@ def test_missing_excluded_paths_do_not_fail_or_warn(tmp_path): result = subprocess.run(["bash", "-c", command], capture_output=True, text=True) assert result.returncode == 0 assert "tar:" not in result.stderr + + +# --------------------------------------------------------------------------- +# 世代の作り直し (#458) +# --------------------------------------------------------------------------- + +_MOUNT_RE = re.compile(r"/(backup|source|target)(?=[/ ;]|$)") + + +class LocalTarManager(SnapshotManager): + """控え・復元のコマンドを Docker でなく手元の bash で走らせる。 + + マウント先 (``/backup``・``/source``・``/target``) は 1 回の置き換えで手元のディレクトリへ写す。 + """ + + def __init__(self, root: Path, source: Path, target: Path): + super().__init__(root, group="acme") + self.source = source + self.target = target + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + paths = {"backup": str(snap_dir), "source": str(self.source), + "target": str(self.target)} + command = _MOUNT_RE.sub(lambda m: paths[m.group(1)], command) + return subprocess.run(["bash", "-c", command], check=True, + capture_output=True, text=True) + + +def _rebuild_setup(tmp_path: Path) -> tuple: + source, target = tmp_path / "source", tmp_path / "target" + for sub in VOLUMES: + (source / sub).mkdir(parents=True) + (target / sub).mkdir(parents=True) + ai = source / "ai" + (ai / "a").write_text("a1") + (ai / "b").write_text("b1") + mgr = LocalTarManager(tmp_path, source, target) + mgr.create(name="g") + (ai / "a").write_text("a2 changed") + mgr.create(name="g") + assert _members(tmp_path / "backups" / "g" / incr_archive_name(1)) == ["ai/a"] + (ai / "c").write_text("c1") + (ai / "b").write_text("b3 rebuilt") + mgr.create(name="g", full=True) + return mgr, ai + + +def test_rebuilt_full_holds_the_whole_volume(tmp_path): + """受け入れ条件 1""" + _rebuild_setup(tmp_path) + snap_dir = tmp_path / "backups" / "g" + assert {"ai/a", "ai/b", "ai/c"} <= set(_members(snap_dir / FULL_ARCHIVE)) + assert not list(snap_dir.glob("incr-*")) + + +def test_restore_after_rebuild_brings_back_every_file(tmp_path): + """受け入れ条件 2""" + mgr, _ = _rebuild_setup(tmp_path) + mgr.restore("g") + restored = tmp_path / "target" / "ai" + assert (restored / "a").read_text() == "a2 changed" + assert (restored / "b").read_text() == "b3 rebuilt" + assert (restored / "c").read_text() == "c1" + + +def test_incremental_after_rebuild_holds_only_later_changes(tmp_path): + """受け入れ条件 7""" + mgr, ai = _rebuild_setup(tmp_path) + (ai / "d").write_text("d1") + mgr.create(name="g") + snap_dir = tmp_path / "backups" / "g" + assert _members(snap_dir / incr_archive_name(1)) == ["ai/d"] diff --git a/tests/snapshot/test_rebuild_generation.py b/tests/snapshot/test_rebuild_generation.py new file mode 100644 index 00000000..59aef9a3 --- /dev/null +++ b/tests/snapshot/test_rebuild_generation.py @@ -0,0 +1,359 @@ +"""世代の作り直し (#458) + +既にある世代へ full を作るとき (``create --name <既存> --full``・snar の無い世代への差分の指定・ +同じ秒の復元前バックアップ)、前の snar を引き継がずに仮の名前で控えてから入れ替える。 +``DEVBASE_ROOT`` を使わず ``tmp_path`` に閉じ、``_run_docker_tar`` を差し替えて Docker を起動しない。 +""" + +from __future__ import annotations + +import logging +import os +import types +from datetime import datetime +from pathlib import Path + +import pytest +import yaml + +from devbase.commands.snapshot import cmd_snapshot +from devbase.errors import SnapshotCommandError, SnapshotError +from devbase.snapshot import manager as manager_mod +from devbase.snapshot.exclude import DEFAULT_EXCLUDES +from devbase.snapshot.manager import ( + FULL_ARCHIVE, + FULL_ARCHIVE_TMP, + META_FILE_TMP, + REBUILDING_KEY, + SNAR_FILE, + SNAR_FILE_TMP, + SnapshotManager, +) + +from .fake_tar import write_backup_outputs + +DEFAULTS = sorted(DEFAULT_EXCLUDES) +REBUILT = "rebuilt" + + +@pytest.fixture(autouse=True) +def _group_env(monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") + + +class FakeManager(SnapshotManager): + """``docker run`` を実行せず、コマンドを記録して書くはずのファイルを置く。 + + ``fail_backup`` が真なら控えのコマンドを失敗させる (仮の名前のファイルを書いた後に)。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list = [] + self.fail_backup = False + self.content = "archive" + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + self.calls.append({"mode": mode, "command": command, "snap_dir": snap_dir}) + if mode != "backup": + return None + write_backup_outputs(snap_dir, command) + if self.fail_backup: + raise SnapshotCommandError("Dockerでのtar操作に失敗しました: boom", stderr="boom") + if FULL_ARCHIVE_TMP in command: + (snap_dir / FULL_ARCHIVE_TMP).write_text(REBUILT) + return None + + +def _backups(root: Path) -> Path: + return root / "backups" + + +def _listing(snap_dir: Path) -> set: + return {p.name for p in snap_dir.iterdir()} + + +def _snapshot(snap_dir: Path) -> dict: + return {p.name: p.read_bytes() for p in snap_dir.iterdir()} + + +def _meta(snap_dir: Path) -> dict: + return yaml.safe_load((snap_dir / "meta.yml").read_text()) + + +def _entries(root: Path) -> list: + return yaml.safe_load((_backups(root) / "snapshot.yml").read_text())["snapshots"] + + +def _with_incrementals(root: Path, count: int = 1) -> FakeManager: + """世代 ``g`` を full と ``count`` 本の差分で作る (``snapshot.snar.bak`` も持つ)。""" + mgr = FakeManager(root) + mgr.create(name="g") + for _ in range(count): + snap_dir = _backups(root) / "g" + (snap_dir / f"{SNAR_FILE}.bak").write_text("bak") + mgr.create(name="g") + mgr.calls.clear() + return mgr + + +# --------------------------------------------------------------------------- +# 作り直しの成功 (受け入れ条件 3・4・I1・INFO) +# --------------------------------------------------------------------------- + +def test_rebuild_leaves_only_the_new_full(tmp_path, caplog): + mgr = _with_incrementals(tmp_path, 2) + snap_dir = _backups(tmp_path) / "g" + created_at = _entries(tmp_path)[0]["created_at"] + + with caplog.at_level(logging.INFO, logger="devbase"): + mgr.create(name="g", full=True) + + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert (snap_dir / FULL_ARCHIVE).read_text() == REBUILT + meta = _meta(snap_dir) + assert meta["type"] == "full" + assert meta["files"] == [FULL_ARCHIVE] + assert meta["incremental_count"] == 0 + assert meta["exclude"] == DEFAULTS + assert REBUILDING_KEY not in meta + + entries = _entries(tmp_path) + assert [e["name"] for e in entries] == ["g"] + assert entries[0]["incremental_count"] == 0 + assert entries[0]["created_at"] == created_at + + # 前の snar を tar に渡さず、仮の名前へ控える + [call] = mgr.calls + assert f"--listed-incremental=/backup/{SNAR_FILE_TMP} " in call["command"] + assert call["command"].endswith(f"-o /backup/{FULL_ARCHIVE_TMP}") + + rebuilt = [r.getMessage() for r in caplog.records + if r.levelno == logging.INFO and "作り直しました" in r.getMessage()] + assert len(rebuilt) == 1 + assert "g" in rebuilt[0] and "2 本" in rebuilt[0] + + +def test_rebuild_uses_the_current_excludes(tmp_path): + mgr = _with_incrementals(tmp_path) + (_backups(tmp_path) / "exclude.yml").write_text("exclude:\n - .cache/pip\n") + mgr = FakeManager(tmp_path) + mgr.create(name="g", full=True) + assert _meta(_backups(tmp_path) / "g")["exclude"] == sorted(set(DEFAULTS) | {".cache/pip"}) + + +def test_incremental_after_rebuild_starts_from_one(tmp_path): + """受け入れ条件 7 (ファイルの側。中身は実の tar のテスト)""" + mgr = _with_incrementals(tmp_path) + mgr.create(name="g", full=True) + mgr.create(name="g") + snap_dir = _backups(tmp_path) / "g" + assert _meta(snap_dir)["files"] == [FULL_ARCHIVE, "incr-001.tar.zst"] + assert f"--listed-incremental=/backup/{SNAR_FILE} " in mgr.calls[-1]["command"] + + +# --------------------------------------------------------------------------- +# 新しい世代は今と同じ (受け入れ条件 9・I5) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("full", [False, True]) +def test_new_generation_is_written_directly(tmp_path, caplog, full): + mgr = FakeManager(tmp_path) + with caplog.at_level(logging.INFO, logger="devbase"): + mgr.create(name="g", full=full) + [call] = mgr.calls + assert call["command"] == SnapshotManager.backup_command( + FULL_ARCHIVE, mgr.volumes, mgr.excludes) + assert _listing(_backups(tmp_path) / "g") == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert "作り直しました" not in caplog.text + + +# --------------------------------------------------------------------------- +# snar の無い世代への差分の指定 (受け入れ条件 8) +# --------------------------------------------------------------------------- + +def test_incremental_without_snar_rebuilds_and_drops_incrementals(tmp_path): + mgr = _with_incrementals(tmp_path, 2) + snap_dir = _backups(tmp_path) / "g" + (snap_dir / SNAR_FILE).unlink() + mgr.create(name="g") + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert _meta(snap_dir)["files"] == [FULL_ARCHIVE] + + +# --------------------------------------------------------------------------- +# 控えの失敗 (受け入れ条件 5・I2) +# --------------------------------------------------------------------------- + +def test_failed_backup_keeps_the_generation_byte_for_byte(tmp_path): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + (snap_dir / f"{SNAR_FILE}.bak").unlink() + before = _snapshot(snap_dir) + assert set(before) == {FULL_ARCHIVE, "incr-001.tar.zst", SNAR_FILE, "meta.yml"} + state_before = (_backups(tmp_path) / "snapshot.yml").read_bytes() + + mgr.fail_backup = True + with pytest.raises(SnapshotError): + mgr.create(name="g", full=True) + + assert _snapshot(snap_dir) == before + assert (_backups(tmp_path) / "snapshot.yml").read_bytes() == state_before + + +def test_leftover_temps_are_discarded_before_the_backup(tmp_path): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + for tmp in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP): + (snap_dir / tmp).write_text("stale") + seen = {} + + original = FakeManager._run_docker_tar + + def spy(self, snap_dir_, mode, command, volumes=None): + seen["temps"] = {t for t in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP) + if (snap_dir_ / t).exists()} + return original(self, snap_dir_, mode, command, volumes) + + mgr._run_docker_tar = spy.__get__(mgr) + mgr.create(name="g", full=True) + assert seen["temps"] == set() + + +def test_cli_exits_1_when_the_backup_fails(tmp_path, monkeypatch): + mgr = _with_incrementals(tmp_path) + mgr.fail_backup = True + monkeypatch.setattr("devbase.commands.snapshot.SnapshotManager", lambda *a, **k: mgr) + args = types.SimpleNamespace(subcommand="create", name="g", full=True, group="acme") + assert cmd_snapshot(tmp_path, args) == 1 + + +# --------------------------------------------------------------------------- +# 入れ替えの途中の失敗 (受け入れ条件 6・I3) +# --------------------------------------------------------------------------- + +def _fail_replace_at(monkeypatch, nth: int) -> None: + """``os.replace`` の ``nth`` 回目 (1 始まり) を失敗させる。 + + 入れ替えの順は 1 印付きの meta.yml・2 full・3 印無しの meta.yml・4 状態ファイル。 + """ + real = os.replace + count = {"n": 0} + + def fake(src, dst): + count["n"] += 1 + if count["n"] == nth: + raise OSError("injected") + return real(src, dst) + + monkeypatch.setattr(manager_mod.os, "replace", fake) + + +def _fail_incr_unlink(monkeypatch) -> None: + real = Path.unlink + + def fake(self, missing_ok=False): + if self.name.startswith("incr-"): + raise OSError("injected") + return real(self, missing_ok=missing_ok) + + monkeypatch.setattr(Path, "unlink", fake) + + +def test_incrementals_are_dropped_from_the_highest_number(tmp_path, monkeypatch): + """止まっても、残る差分は前の full から番号の小さい順に続く。""" + mgr = _with_incrementals(tmp_path, 3) + snap_dir = _backups(tmp_path) / "g" + real = Path.unlink + count = {"n": 0} + + def fake(self, missing_ok=False): + if self.name.startswith("incr-"): + count["n"] += 1 + if count["n"] == 2: + raise OSError("injected") + return real(self, missing_ok=missing_ok) + + with monkeypatch.context() as m: + m.setattr(Path, "unlink", fake) + with pytest.raises(SnapshotError): + mgr.create(name="g", full=True) + assert sorted(p.name for p in snap_dir.glob("incr-*")) == [ + "incr-001.tar.zst", "incr-002.tar.zst"] + + +@pytest.mark.parametrize("stop", ["incr", 1, 2, 3, 4]) +def test_failed_install_reports_and_next_create_rebuilds(tmp_path, monkeypatch, stop): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + with monkeypatch.context() as m: + if stop == "incr": + _fail_incr_unlink(m) + else: + _fail_replace_at(m, stop) + with pytest.raises(SnapshotError, match="作り直しを終えられませんでした"): + mgr.create(name="g", full=True) + + names = _listing(snap_dir) + assert SNAR_FILE not in names + assert f"{SNAR_FILE}.bak" not in names + assert not names & {FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP} + + # 次の差分の指定は snar 無しの経路で作り直し、印を外す + mgr.create(name="g") + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert REBUILDING_KEY not in _meta(snap_dir) + assert _meta(snap_dir)["files"] == [FULL_ARCHIVE] + + +@pytest.mark.parametrize("stop,marked", [("incr", False), (1, False), (2, True), + (3, True), (4, False)]) +def test_marked_generation_is_not_restored(tmp_path, monkeypatch, stop, marked): + """I3: 別のボリュームの組で作り直して止めると、印のある世代は消去の前に止まる。""" + _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + other = FakeManager(tmp_path, group="other") + with monkeypatch.context() as m: + if stop == "incr": + _fail_incr_unlink(m) + else: + _fail_replace_at(m, stop) + with pytest.raises(SnapshotError): + other.create(name="g", full=True) + + meta = _meta(snap_dir) + assert bool(meta.get(REBUILDING_KEY)) is marked + rebuilt = (snap_dir / FULL_ARCHIVE).read_text() == REBUILT + # 印が無ければ full と meta.yml の組が合う + if not marked: + assert rebuilt is (meta["volumes"]["group"] == "devbase_home_other") + + other.calls.clear() + if marked: + with pytest.raises(SnapshotError, match="作り直しの途中で止まっている"): + other.restore("g") + assert other.calls == [] + else: + other.restore("g") + restores = [c for c in other.calls if c["mode"] == "restore"] + assert restores + + +# --------------------------------------------------------------------------- +# 同じ秒の復元前バックアップ (F3) +# --------------------------------------------------------------------------- + +def test_pre_restore_backup_rebuilds_an_existing_generation(tmp_path, monkeypatch): + class FixedNow(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 10, 12, 12, 0, 0) + + monkeypatch.setattr(manager_mod, "datetime", FixedNow) + mgr = FakeManager(tmp_path) + name = "pre-restore-20261012-120000" + mgr.create(name=name) + (_backups(tmp_path) / name / "incr-001.tar.zst").write_text("old") + + assert mgr._backup_before_restore(mgr.volumes) == name + assert _listing(_backups(tmp_path) / name) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} From bd6b0580a24887aa30fa5f9127b2a9d019620a3d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:05:18 +0900 Subject: [PATCH 03/20] =?UTF-8?q?docs(snapshot):=20=E6=97=A2=E5=AD=98?= =?UTF-8?q?=E3=81=AE=E4=B8=96=E4=BB=A3=E3=81=B8=E3=81=AE=20--full=20?= =?UTF-8?q?=E3=81=AE=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=97=E3=82=92=E6=96=87?= =?UTF-8?q?=E6=9B=B8=E3=81=A8=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=A8?= =?UTF-8?q?=20CHANGELOG=20=E3=81=AB=E6=9B=B8=E3=81=8F=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++ docs/specifications/snapshot-exclude.md | 5 ++- docs/specifications/snapshot-series.md | 60 +++++++++++++++++++++++++ docs/user/cli-reference/05-snapshot.md | 6 +++ docs/user/snapshot-guide.md | 10 +++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d540df..c4fc58db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ 既存の世代はそのまま一覧・復元できます。 ### Fixed +- **既にある世代の名前で `devbase snapshot create --name <名前> --full` を打つと、その世代を今のボリュームの全体で + 作り直すようにしました(#458)。** これまでは `full.tar.zst` を作り直せずに終了コード 1 で止まり、そのとき + `snapshot.snar` だけが進んでいました。作り直した世代は前のフルと差分を持たず、その世代から前の時点へは戻せません。 + 前の時点を残したいときは別の名前で作るか、先に `devbase snapshot copy` で写してください。控えに失敗したときは + 世代が打つ前のまま残ります。入れ替えの途中で止まった世代は `restore` がボリュームを消す前に止まり、同じ名前で + もう一度 `--full` を打つと作り直せます。 + **これまでに既存の世代の名前へ `--full` を打って失敗したことがあり、その後に同じ世代へ差分が積まれていれば、 + その世代の差分は失敗の前の変更を欠いていることがあります。** 世代のファイルからは見分けられないため、 + 心当たりがあれば新しい世代を作ってください(`devbase snapshot create`)。 - **プロジェクトの `env` に `DEV_SERVICE_NAME=` と空で書いたとき、開発サービス名を `dev` として扱うようにしました(#425)。** これまで `devbase build` は `dev` を建てる一方、`up`・`login`・`open`・`post-start`・`scale`・`--expires` の判定・ token の配布は空の名前のサービスを探して開発サービスを見失っていました。未設定や空でない値のプロジェクトは変わりません。 diff --git a/docs/specifications/snapshot-exclude.md b/docs/specifications/snapshot-exclude.md index d7f60b6a..c8d1d063 100644 --- a/docs/specifications/snapshot-exclude.md +++ b/docs/specifications/snapshot-exclude.md @@ -261,8 +261,9 @@ exclude: Claude Code は最初のセッションが始まるまで `claude plugin list` がプラグインを `cache-miss` と出し、最初の セッションでマーケットプレイスとプラグインを取り直して `installed_plugins.json` の一覧のまま読み込む。 Codex は消した `logs_2.sqlite` と `cache` を作り直す -- 既存の世代の名前へ `--full` を打つと full が変更分だけになる不具合は #458 にある。除外の一覧が違う世代への - 案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない +- 既存の世代の名前へ `--full` を打つと、その世代を作り直した時点の除外の一覧で作り直す(世代の作り直し。 + [スナップショットの系列](snapshot-series.md#世代の作り直し))。前の差分は消え、前の時点へは戻せない。除外の一覧が + 違う世代への案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない ## テスト観点 diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 0b9eb5fa..6f97ff1e 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -56,6 +56,7 @@ | 系列の最新の世代 | 系列の中で `created_at` が最も新しい世代。同じ値なら `snapshot.yml` で後ろのもの | | 全体の上限 | 系列をまたいで数えた世代の数の上限(`max_total`) | | 復元前バックアップ | `restore` が書き戻す前に自動で作るフルの世代 `pre-restore-<時刻>`。復元する世代の対象ボリュームの組を控える。実行時の `DEVBASE_ACCOUNT_GROUP` には依らない。控えに失敗したら警告を出してそのディレクトリを消し、控え無しで復元を続ける | +| 世代の作り直し | 既にある世代のディレクトリへ、前の `snapshot.snar` を引き継がずに新しい full を作り、前の差分と状態のファイルを除くこと。`--full` を既存の名前へ打ったとき・snar の無い世代への差分の指定・同じ秒に重なった復元前バックアップで起きる | ## 構成要素 @@ -249,6 +250,45 @@ classDiagram (`name` が無い・上の 3 つ・場所にディレクトリ以外がある)で不正なエントリを判定し、中を読まずに警告して 一覧から外す。`snapshot.yml` は書き換えない +### 世代の作り直し + +`_create_full` は、世代に前のファイル(`full.tar.zst`・`snapshot.snar`・`incr-*.tar.zst` のどれか)があれば +世代の作り直しとして控える。無ければ本来の名前(`full.tar.zst`・`snapshot.snar`)へ直接控え、最後に +`meta.yml` を書く(新しい世代の控えのコマンドと `meta.yml` はこの規則の前と同じ)。 + +作り直しの手順: + +1. 前の中断の残りの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を消す +2. 前の `snapshot.snar` を tar に渡さず、空から始まる `snapshot.snar.new` と `full.tar.zst.new` へ控える + (tar はレベル 0 でボリュームの全体を控える)。除外の一覧は新しい世代と同じ規則(`_excludes_for`)で決める +3. 控えが成功したら `_install_rebuilt_full` が次の順に 1 つずつ入れ替える。改名は同じディレクトリの中の + `os.replace` で、ファイルを写さない + 1. `snapshot.snar.bak` と `snapshot.snar` を消す + 2. `incr-*.tar.zst` を番号の大きい順に消す + 3. `meta.yml` を新しい控えの内容に `rebuilding: true` を足した形へ置き換える + 4. `full.tar.zst.new` を `full.tar.zst` へ改名する + 5. `meta.yml` を新しい控えの内容(印無し)へ置き換える + 6. `snapshot.snar.new` を `snapshot.snar` へ改名する +4. INFO を 1 行出す。`snapshot.yml` のエントリは `create` / `_backup_before_restore` が今の規則で更新する + (`incremental_count` と `updated_at`。`created_at` は変えない) + +`meta.yml` の置き換えは `meta.yml.new` へ書いてから改名し、途中まで書いた `meta.yml` を残さない。 +作り直した `meta.yml` は `type: full`・`files: [full.tar.zst]`・`incremental_count: 0`・作り直した時点の +`volumes`・`exclude`・`created_at` を持つ。 + +常に成り立つ条件: + +- 控えのコマンドが失敗したら、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは + 1 バイトも変わらず、ファイルは増えない +- 入れ替え(手順 3)の途中で止まった世代は `snapshot.snar` を持たない。次の `create`(名前の指定・自動 + スナップショット)は snar の無い世代の経路で作り直す +- 入れ替えの途中で止まった世代は、次のどちらかである + - `meta.yml` に印が無く、`full.tar.zst` と `meta.yml` が同じ 1 回の控えのもので、前の full から番号の + 小さい順に続く差分とともにある時点へ戻せる + - `meta.yml` が `rebuilding: true` を持つ。`restore` はこの世代を、復元前バックアップとボリュームの消去の前に + `SnapshotError` で止める +- 作り直した後は、その世代から前の時点へ戻す手段は無い + ## データ・設定 **形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。各世代の `meta.yml` に @@ -261,6 +301,7 @@ classDiagram | 同 `snapshots[].incremental_count` | 差分の上限の判定 | | 同 `max_generations` | `rotate` が消したときに `keep` を書く。読む側は無い | | 各世代の `meta.yml` の `volumes` | 系列の判定には使わない。積む直前の検証(`auto_snapshot_target` の順 3 と `_create_incremental`)でだけ読む | +| 各世代の `meta.yml` の `rebuilding` | 世代の作り直しの入れ替えの途中だけ `true`。持つ世代は `restore` が止める。作り直しを終えた世代と新しい世代は持たない | | 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む | | アーカイブの mtime | 最小間隔の判定 | @@ -282,6 +323,7 @@ classDiagram | 全体の上限で消した | INFO | `ローテーション: 全体の上限 {max_total} 世代を超えたため、{系列} の {名前} を削除しました` | | 全体の上限を満たせない | WARNING | `全体の上限 {max_total} 世代を超えていますが、各グループの最新の世代は消さないため {残り} 世代を残します` | | 場所が不正なエントリ | WARNING | `snapshot.yml の世代 '{名前}' は場所が不正なため、ディレクトリを消さずに一覧からだけ外します: {理由}` | +| 世代を作り直した | INFO | `世代 {名前} を今のボリュームの全体で作り直しました(前の差分 {本数} 本を除きました)` | | `snapshot rotate` で消す候補が無い | INFO | `ローテーション不要です`(`_snapshot_rotate`。消した数も外した数も 0 のときだけ) | | `snapshot rotate` で一覧から外しただけ | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました(削除した世代はありません)`(`_snapshot_rotate`) | | `snapshot rotate` で世代を消し、一覧からも外した | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました`(`_snapshot_rotate`。`rotate` の削除の INFO の後) | @@ -299,6 +341,10 @@ classDiagram | `rotate` の `keep` / `max_total` が 1 未満 | `SnapshotError`。何も消さず `snapshot.yml` も書かない | | `rotate` の候補が `_safe_snap_dir` を通らない | ディレクトリを消さず、一覧から外して警告。他の候補の削除は続ける | | `create` / `restore` / `copy` / `delete` の世代が `_safe_snap_dir` を通らない | `SnapshotError` → CLI は終了コード 1 | +| 世代の作り直しの控えのコマンドが失敗 | 仮の名前のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ CLI は終了コード 1。世代は打つ前のまま | +| 世代の作り直しの入れ替えが失敗 | 仮の名前のファイルを消して `SnapshotError`(`世代 '{名前}' の作り直しを終えられませんでした: {理由}。もう一度 devbase snapshot create --name {名前} --full を打ってください`)→ CLI は終了コード 1 | +| `meta.yml` に `rebuilding: true` を持つ世代の `restore` | 復元前バックアップを作らず、ボリュームを消さずに `SnapshotError`(`世代 '{名前}' は作り直しの途中で止まっているため復元できません。devbase snapshot create --name {名前} --full を打って作り直してください`)→ CLI は終了コード 1 | +| 仮の名前のファイルを消せない | 警告 1 行で続ける。次の作り直しが控えの前に消す | ## 運用 @@ -365,6 +411,20 @@ CLI と TUI: - TUI の問いの文言と、`max_total` を渡さないこと(`tests/cli/tui/test_actions_snapshot.py`) - `cmd_down` の `rotate()` の失敗で終了コード 0(`tests/commands/test_container_down_snapshot.py`) +世代の作り直し(`tests/snapshot/test_rebuild_generation.py`、`tests/snapshot/test_backup_real_tar.py`): + +- 差分を持つ世代へ `--full` を打つと、`full.tar.zst`・`snapshot.snar`・`meta.yml` だけが残り、`meta.yml` が + full の形で今の除外の一覧を持ち、`snapshot.yml` のエントリが 1 件のまま `created_at` を変えないこと。 + 控えのコマンドが前の snar を受け取らないこと +- 控えのコマンドの失敗で世代のファイルのバイト列と `snapshot.yml` が変わらないこと。入れ替えの各段で + 止めると `SnapshotError` になり、snar と仮の名前のファイルが残らず、次の差分の指定が作り直すこと +- 別のボリュームの組で作り直して止めた世代は、印があれば `restore` が docker を 1 回も呼ばずに止まり、 + 印が無ければ `full.tar.zst` と `meta.yml` の組が合うこと。差分が番号の大きい順に消えること +- snar の無い世代への差分の指定と、同じ秒の復元前バックアップが作り直しになること。新しい世代の控えの + コマンドがこの規則の前と同じであること +- 実の GNU tar と zstd で、作り直した full がボリュームの全体を持ち、復元で全ファイルが作り直した時点の + 中身で戻り、その後の差分が後の変更だけを持つこと(GNU tar と zstd が無ければ飛ぶ) + 復元のテスト(`tests/snapshot/test_restore_incremental.py`、`test_manager_volumes.py` の復元)は 変えずに通ること。ただし `test_restore_incremental.py` の実の Docker で入れ替える場所は、既定の除外 (#368)に入らない `data/cache` に置く。 diff --git a/docs/user/cli-reference/05-snapshot.md b/docs/user/cli-reference/05-snapshot.md index 47c125e4..ead4a1cf 100644 --- a/docs/user/cli-reference/05-snapshot.md +++ b/docs/user/cli-reference/05-snapshot.md @@ -25,6 +25,12 @@ devbase snapshot create [--name NAME] [--full] [--group NAME] 既にある世代の名前を `--full` なしで指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で 作られている(除外の一覧を記録していない世代を含む)ときは、差分を作らずに終了コード 1 で止まります。 +既にある世代の名前を `--full` 付きで指定すると、その世代を今のボリュームの全体で作り直します。前の +フルと差分は消え、その世代から前の時点へは戻せません。前の時点を残したいときは、別の名前で作るか、 +先に `devbase snapshot copy` で写してください。作り直しに失敗したときは終了コード 1 で止まり、控えの +コマンドが失敗した場合は世代が打つ前のまま残ります。入れ替えの途中で止まった世代は、もう一度同じ名前で +`--full` を打つと作り直せます(それまで復元は止まることがあります)。 + 控えには、既定の除外(作り直せる一時ファイル・ログ・取り直せる取得物)と `backups/exclude.yml` に 書いた除外が入りません。一覧と書き方は [スナップショットガイドの「控えから外すもの」](../snapshot-guide.md#控えから外すもの) にあります。 diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index 7c195c7e..0089d74b 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -279,6 +279,16 @@ devbase snapshot create --full devbase snapshot create --name before-migration --full ``` +既にある世代の名前を `--full` 付きで指定すると、その世代を今のボリュームの全体で作り直します。 +前のフルと差分は消え、その世代から前の時点へは戻せません。前の時点を残したいときは、別の名前で作るか、 +先に `devbase snapshot copy <名前> <新しい名前>` で写してください。 + +- 控えのコマンドが失敗したときは終了コード 1 で止まり、世代は打つ前のまま残ります +- 控えの後の入れ替えで止まったときも終了コード 1 で止まり、同じ名前でもう一度 `--full` を打つよう案内します。 + 入れ替えの途中で止まった世代を `restore` すると、ボリュームを消す前に「作り直しの途中で止まっているため + 復元できません」と出て止まります +- 作り直したときは「世代 <名前> を今のボリュームの全体で作り直しました(前の差分 N 本を除きました)」と出ます + ### スナップショットの一覧 ```bash From d154325ae5fcfb54ce8463a461cf8fa8da4549a4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:09:54 +0900 Subject: [PATCH 04/20] =?UTF-8?q?docs(snapshot):=20=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E4=BB=95=E6=A7=98=E3=81=AE=20backup=5Fcommand=20=E3=81=AE?= =?UTF-8?q?=E5=BC=95=E6=95=B0=E3=81=A8=E6=A7=8B=E6=88=90=E8=A6=81=E7=B4=A0?= =?UTF-8?q?=E3=81=AB=E4=B8=96=E4=BB=A3=E3=81=AE=E4=BD=9C=E3=82=8A=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=82=92=E8=B6=B3=E3=81=99=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/snapshot-exclude.md | 4 ++-- docs/specifications/snapshot-series.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specifications/snapshot-exclude.md b/docs/specifications/snapshot-exclude.md index c8d1d063..283eb9b2 100644 --- a/docs/specifications/snapshot-exclude.md +++ b/docs/specifications/snapshot-exclude.md @@ -47,7 +47,7 @@ | 世代の記録 | 同 `_recorded_excludes` | 世代の `meta.yml` の `exclude` を読む。無い・リストでない・文字列でない要素を含むなら `None` | | 比較 | 同 `_excludes_differ`・`_incremental_block_reason` | 世代の記録が今の一覧と違うかを決める。差分を積めない理由(対象ボリューム → 控えの起点 → 除外の一覧の順)の最後に当てる | | 控えに使う一覧 | 同 `_excludes_for`・`_latest_recorded_excludes` | 設定を読めたら今の一覧、読めなければ記録の一覧を返す(下の「読めない設定の間」) | -| tar のコマンド | 同 `backup_command(archive, volumes, excludes)` | 除外の引数を `-C` とメンバーより前に置く | +| tar のコマンド | 同 `backup_command(archive, volumes, excludes, snar)` | 除外の引数を `-C` とメンバーより前に置く。`snar` は既定で `snapshot.snar`、世代の作り直しは `snapshot.snar.new` | | 作成 | 同 `_create_full`・`_create_incremental` | `_excludes_for` の一覧で控える。full は `meta.yml` に `exclude` を書き、差分は書き換えない | 世代に関わる判断(記録・比較・積み先)は `manager.py` に置き、`exclude.py` は世代の状態に依らない処理だけを @@ -58,7 +58,7 @@ classDiagram class SnapshotManager { +excludes: tuple~str~ +exclude_config_readable: bool - +backup_command(archive, volumes, excludes)$ str + +backup_command(archive, volumes, excludes, snar)$ str +auto_snapshot_target(max_incrementals) str|None -_exclude_resolution: tuple -_create_full(name, snap_dir, volumes) diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 6f97ff1e..6e8b5810 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -66,6 +66,7 @@ | 積み先の判定 | 同 `auto_snapshot_target` | 積み先の世代の名前か、新しい世代を作るべきこと(`None`)を返し、理由をログへ出す | | 最小間隔の元 | 同 `last_snapshot_time(volumes=None)` | 渡された組の系列の世代のアーカイブの mtime の最大を返す | | 保持 | 同 `rotate(keep, max_total)` と、副作用の無い `_rotation_plan` | 削除候補を決め、場所を検証してから消す | +| 世代の作り直し | 同 `_create_full` / `_install_rebuilt_full` / `_discard_rebuild_temps` | 前のファイルを持つ世代へ full を作るとき、仮の名前へ控えてから決まった順で入れ替える(下の「世代の作り直し」) | | 世代の場所の検証 | 同 `_safe_snap_dir` | 名前・シンボリックリンク・`backups/` の外を `SnapshotError` で止める | | 自動スナップショット | `lib/devbase/commands/container.py` の `_auto_snapshot` / `_should_skip_by_interval` | 最小間隔を系列で判定し、判定の結果に従って `create` と `rotate()` を呼ぶ | | 自動ローテーション | 同 `cmd_down` | `mgr.rotate()` を既定の引数で呼ぶ | From 7fe53d954e62dcc7214487e6979145df651586dc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:19:01 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat(openbao):=20=E6=89=8B=E5=85=83?= =?UTF-8?q?=E3=81=AE=E3=82=B5=E3=83=BC=E3=83=90=E3=81=A7=E3=82=82=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=83=86=E3=83=8A=E3=81=AE=E4=B8=AD=E3=81=AE=20bao=20?= =?UTF-8?q?=E3=81=8C=E5=B1=8A=E3=81=8F=E3=82=88=E3=81=86=E3=80=81loopback?= =?UTF-8?q?=20=E3=81=AE=E6=8E=A5=E7=B6=9A=E5=85=88=E3=82=92=20host.docker.?= =?UTF-8?q?internal=20=E3=81=AB=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88?= =?UTF-8?q?=E3=82=8B=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openbao.url が http の loopback で docker の接続先がローカル扱いのときだけ、dev の インスタンスの BAO_ADDR のホストを host.docker.internal に置き換え、 host.docker.internal:host-gateway の行を足す。https・リモート扱い・network_mode: host の dev サービスは今のまま。ホストの devbase が使う URL と検査は変えない。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 60 +++++++++++- lib/devbase/volume/compose.py | 64 +++++++++++++ tests/commands/test_container_bao.py | 67 +++++++++++++ tests/volume/test_compose_host_gateway.py | 110 ++++++++++++++++++++++ 4 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 tests/volume/test_compose_host_gateway.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 0f263767..dca42be6 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -188,7 +188,8 @@ def _remote_generate_kwargs(target: docker_context.DockerTarget) -> dict: def _generate_compose_for(scale: int, secrets, dev_environment=None, docker_home: Optional[str] = None, - remote: bool = False) -> Path: + remote: bool = False, + host_gateway_environment=None) -> Path: """機密の内訳と devbase 由来の環境変数を渡してスケール構成を生成する。 ``dev_environment`` は ``project.yml`` から作った clone プラン等 @@ -197,6 +198,9 @@ def _generate_compose_for(scale: int, secrets, dev_environment=None, ``docker_home`` / ``remote`` はリモート扱いのときの bind mount の書き換えと警告 (PLAN52)。ローカル扱いでは両方とも既定値のまま渡す。 + + ``host_gateway_environment`` はコンテナからホストの loopback へ届かせるときに + 上書きする環境変数 (#295。:func:`_host_gateway_kwargs`)。 """ return generate_scaled_compose( scale, @@ -206,6 +210,7 @@ def _generate_compose_for(scale: int, secrets, dev_environment=None, dev_environment=dev_environment, docker_home=docker_home, remote=remote, + host_gateway_environment=host_gateway_environment, ) @@ -219,11 +224,13 @@ def _build_scaled_override(scale: int, config, project_name: str, ため、1 箇所へ寄せる。 """ secrets = _inject_secrets(required=True) + bao_environment = _bao_environment() dev_environment = {**project_runtime.container_env(config, project_name), - **_bao_environment()} + **bao_environment} return _generate_compose_for( scale, secrets, dev_environment=dev_environment, - **_remote_generate_kwargs(target)) + **_remote_generate_kwargs(target), + **_host_gateway_kwargs(bao_environment, target)) @contextmanager @@ -931,6 +938,53 @@ def _bao_environment() -> dict: return {'BAO_ADDR': store.config.openbao.url} +#: コンテナからホストを指す名前 (#295)。Linux の Docker では ``host-gateway`` の行で引ける +CONTAINER_HOST_NAME = 'host.docker.internal' + + +def container_bao_address(url: Optional[str], *, remote: bool) -> Optional[str]: + """コンテナから見た接続先 (#295)。置き換えないときは ``None``。 + + ``openbao.url`` が ``http`` でホストが loopback、かつ docker の接続先がローカル扱いの + ときだけ、ホストを :data:`CONTAINER_HOST_NAME` に置き換える。コンテナの中の + ``127.0.0.1`` はコンテナ自身を指し、ホストのサーバへ届かないため。スキームと + ポートは保つ。 + + 置き換えないもの: + + - リモート扱い: loopback はホストの端末を指し、コンテナのある端末からは届かない + - ``https``: 証明書の名前が置き換えた名前と合わず、TLS の検証で落ちる + """ + from urllib.parse import urlsplit, urlunsplit + + from devbase.env.backend_config import _LOOPBACK_HOSTS + + if not url or remote: + return None + parts = urlsplit(url) + if parts.scheme != 'http' or parts.hostname not in _LOOPBACK_HOSTS: + return None + netloc = CONTAINER_HOST_NAME + if parts.port is not None: + netloc = f'{netloc}:{parts.port}' + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def _host_gateway_kwargs(bao_environment: dict, + target: docker_context.DockerTarget) -> dict: + """コンテナから見た接続先を置き換えるときだけ構成生成へ渡す引数 (#295)。 + + 置き換えないときは空 (従来の呼び出しの形)。``BAO_ADDR`` を ``dev_environment`` + で直に書き換えず別に渡すのは、dev サービスが ``network_mode: host`` のとき + (loopback がそのまま届き、``extra_hosts`` も使えない) に生成の側が元の値を残せる + ようにするため。 + """ + address = container_bao_address(bao_environment.get('BAO_ADDR'), remote=target.remote) + if address is None: + return {} + return {'host_gateway_environment': {'BAO_ADDR': address}} + + def _target_indices(indices, scale: int, start: int = 1) -> list[int]: """個々の処理が対象にする番号。``indices`` が無ければ ``start`` から ``scale`` まで。""" return list(indices) if indices is not None else list(range(start, scale + 1)) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 9f3660de..bae0dfd4 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -2,6 +2,7 @@ import copy import os +import re import yaml from pathlib import Path from typing import ( @@ -451,12 +452,65 @@ def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: "追記できませんでした", type(existing).__name__) +#: コンテナからホストを指す名前と、Linux の Docker でそれを引けるようにする行 (#295) +HOST_GATEWAY_NAME = 'host.docker.internal' +HOST_GATEWAY_ENTRY = f'{HOST_GATEWAY_NAME}:host-gateway' + + +def _extra_host_names(existing: Any) -> Set[str]: + """``extra_hosts`` (リスト形 ``name:ip`` / ``name=ip`` と辞書形) に書かれた名前。""" + if isinstance(existing, Mapping): + return {str(name) for name in existing} + names: Set[str] = set() + if isinstance(existing, list): + for item in existing: + if isinstance(item, str): + names.add(re.split(r'[:=]', item, maxsplit=1)[0].strip()) + return names + + +def _add_host_gateway(service: dict) -> None: + """dev のインスタンスへ ``host.docker.internal:host-gateway`` を、元の形を保って足す。 + + 元の ``extra_hosts`` が既に ``host.docker.internal`` を持てば足さない (利用者の指定を + 尊重する)。解釈できない形なら触らない。 + """ + existing = service.get('extra_hosts') + if HOST_GATEWAY_NAME in _extra_host_names(existing): + return + if existing is None: + service['extra_hosts'] = [HOST_GATEWAY_ENTRY] + elif isinstance(existing, list): + existing.append(HOST_GATEWAY_ENTRY) + elif isinstance(existing, dict): + existing[HOST_GATEWAY_NAME] = 'host-gateway' + else: + logger.warning( + "extra_hosts の形式 (%s) を解釈できないため、%s を足せませんでした", + type(existing).__name__, HOST_GATEWAY_ENTRY) + + +def _apply_host_gateway(service: dict, environment: Mapping[str, str]) -> None: + """コンテナからホストの loopback へ届かせる設定を dev のインスタンスへ当てる (#295)。 + + ``network_mode: host`` の dev サービスは loopback がそのまま届き、``extra_hosts`` も + 使えないため、何もしない (``dev_environment`` の元の値が残る)。 + """ + if not environment: + return + if service.get('network_mode') == 'host': + return + _apply_dev_environment(service, environment) + _add_host_gateway(service) + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, group_volume: str, secret_env_names: Sequence[str] = (), dev_environment: Optional[Mapping[str, str]] = None, vscode_volume: Optional[str] = None, + host_gateway_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -470,6 +524,7 @@ def _build_dev_instance( # 機密の伏せ字化のあとに載せる。devbase 由来の値 (clone プラン等) は機密では # なく、そのままコンテナへ渡す必要があるため。 _apply_dev_environment(service, dev_environment or {}) + _apply_host_gateway(service, host_gateway_environment or {}) # Update volume mounts for /persistent/ai and /work ai_volume = get_ai_volume_for_index(index) @@ -489,6 +544,7 @@ def _build_scaled_services( secret_services: Optional[Mapping[str, Set[str]]] = None, dev_environment: Optional[Mapping[str, str]] = None, vscode_volumes: Sequence[str] = (), + host_gateway_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances. @@ -536,6 +592,7 @@ def _build_scaled_services( secret_names.for_dev, dev_environment=dev_environment, vscode_volume=vscode_by_index.get(i), + host_gateway_environment=host_gateway_environment, ) return scaled_services @@ -735,6 +792,7 @@ def generate_scaled_compose( dev_environment: Optional[Mapping[str, str]] = None, docker_home: Optional[str] = None, remote: bool = False, + host_gateway_environment: Optional[Mapping[str, str]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -753,6 +811,11 @@ def generate_scaled_compose( 展開に委ねる) remote: リモート扱いか。``docker_home`` が無いときに、リモートに存在しない パスを指す mount を警告する + host_gateway_environment: コンテナからホストの loopback へ届かせるときに dev の + インスタンスで上書きする環境変数 (#295。``BAO_ADDR`` のホストを + ``host.docker.internal`` にした値)。与えると dev のインスタンスへ + ``host.docker.internal:host-gateway`` の行も足す。``network_mode: host`` の + dev サービスには当てない 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを @@ -793,6 +856,7 @@ def generate_scaled_compose( secret_services=secret_services, dev_environment=dev_environment, vscode_volumes=vscode_volumes, + host_gateway_environment=host_gateway_environment, ) # 列挙を絞るだけでは、元の compose.yml が environment に**直書き**している diff --git a/tests/commands/test_container_bao.py b/tests/commands/test_container_bao.py index e82cafa1..c41a4517 100644 --- a/tests/commands/test_container_bao.py +++ b/tests/commands/test_container_bao.py @@ -4,6 +4,8 @@ [5/6] の後に各 dev コンテナの ``~/.vault-token`` へ token を書く - それ以外の backend では何も足さず、``docker exec`` を呼ばない (生成する構成は変わらない) - token を書けなくても ``up`` は倒さない +- ``openbao.url`` が ``http`` の loopback でローカル扱いなら、コンテナから見た接続先 + (ホストを ``host.docker.internal`` にした値) を構成生成へ別に渡す (#295) """ from __future__ import annotations @@ -51,6 +53,7 @@ def _harness(monkeypatch, root: Path, seen: dict): def fake_generate(scale, secrets, dev_environment=None, **kw): seen['dev_environment'] = dict(dev_environment or {}) + seen['generate_kwargs'] = dict(kw) compose = Path.cwd() / '.docker-compose.scale.yml' compose.write_text("services:\n dev-1: {}\n") return compose @@ -141,3 +144,67 @@ def test_push_bao_token_starts_from_the_added_instance(openbao_root, openbao, mo (names, _token), = seen['pushes'] assert names == ['web-dev-3'] + + +REMOTE = docker_context.DockerTarget(context='far', source='cli', remote=True, + home='/home/far', gid=999) + + +@pytest.mark.parametrize('url, expected', [ + ('http://127.0.0.1:8200', 'http://host.docker.internal:8200'), + ('http://localhost:8200', 'http://host.docker.internal:8200'), + ('http://[::1]:8200', 'http://host.docker.internal:8200'), + ('http://localhost', 'http://host.docker.internal'), + ('https://localhost:8200', None), + ('https://127.0.0.1:8200', None), + ('https://openbao.example.com', None), + (None, None), +]) +def test_container_address_rewrites_only_http_loopback(url, expected): + assert container.container_bao_address(url, remote=False) == expected + + +@pytest.mark.parametrize('url', ['http://127.0.0.1:8200', 'http://localhost:8200', + 'https://openbao.example.com']) +def test_container_address_is_kept_for_remote_targets(url): + """リモート扱いでは loopback はホストの端末を指すため置き換えない""" + assert container.container_bao_address(url, remote=True) is None + + +def test_host_gateway_kwargs_are_empty_unless_rewritten(): + assert container._host_gateway_kwargs({}, LOCAL) == {} + assert container._host_gateway_kwargs( + {'BAO_ADDR': 'https://openbao.example.com'}, LOCAL) == {} + assert container._host_gateway_kwargs( + {'BAO_ADDR': 'http://127.0.0.1:8200'}, REMOTE) == {} + assert container._host_gateway_kwargs({'BAO_ADDR': 'http://127.0.0.1:8200'}, LOCAL) == { + 'host_gateway_environment': {'BAO_ADDR': 'http://host.docker.internal:8200'}} + + +def test_loopback_openbao_up_passes_the_container_address(openbao_root, openbao, monkeypatch): + """手元のサーバ (loopback) では、dev_environment は openbao.url のまま、 + コンテナから見た接続先を別に渡す (ホストの devbase の URL は変えない)""" + seen: dict = {} + web = _project(openbao_root) + _harness(monkeypatch, openbao_root, seen) + + assert _up(web, monkeypatch) == 0 + + assert seen['dev_environment']['BAO_ADDR'] == openbao.url + assert seen['generate_kwargs']['host_gateway_environment'] == { + 'BAO_ADDR': f'http://host.docker.internal:{openbao.port}'} + + +def test_age_up_passes_no_container_address(tmp_path, monkeypatch): + from devbase.env import agekeys + + seen: dict = {} + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + agekeys.generate_key_file() + web = _project(tmp_path) + _harness(monkeypatch, tmp_path, seen) + + assert _up(web, monkeypatch) == 0 + + assert 'host_gateway_environment' not in seen['generate_kwargs'] diff --git a/tests/volume/test_compose_host_gateway.py b/tests/volume/test_compose_host_gateway.py new file mode 100644 index 00000000..5037b594 --- /dev/null +++ b/tests/volume/test_compose_host_gateway.py @@ -0,0 +1,110 @@ +"""コンテナからホストの loopback へ届かせる設定 (#295) + +``host_gateway_environment`` を渡すと、dev のインスタンスだけが + +- その環境変数で ``dev_environment`` の値を上書きし +- ``extra_hosts`` に ``host.docker.internal:host-gateway`` を (元の形を保って) 足す + +``host.docker.internal`` の行が元からあれば足さず、``network_mode: host`` の dev サービスには +何も当てない。渡さなければ生成物は今と同じ。 +""" + +from __future__ import annotations + +import pytest + +from devbase.volume.compose import generate_scaled_compose +from tests.volume.conftest import load_scaled, write_compose + +RAW = {'BAO_ADDR': 'http://127.0.0.1:8200'} +GATEWAY = {'BAO_ADDR': 'http://host.docker.internal:8200'} + + +def _env(service: dict) -> dict: + environment = service.get('environment') or {} + if isinstance(environment, dict): + return environment + return dict(entry.split('=', 1) for entry in environment) + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _generate(project, dev: dict, scale: int = 2, **kwargs) -> dict: + write_compose(project, {'dev': dev, 'db': {'image': 'mysql'}}) + generate_scaled_compose(scale, dev_environment=RAW, **kwargs) + return load_scaled(project)['services'] + + +def test_dev_instances_get_the_gateway_address_and_the_host_line(project): + services = _generate(project, {'image': 'alpine'}, host_gateway_environment=GATEWAY) + + for name in ('dev-1', 'dev-2'): + assert _env(services[name])['BAO_ADDR'] == 'http://host.docker.internal:8200' + assert services[name]['extra_hosts'] == ['host.docker.internal:host-gateway'] + # dev でないサービスへは足さない + assert 'extra_hosts' not in services['db'] + assert 'BAO_ADDR' not in _env(services['db']) + + +def test_without_gateway_the_output_is_unchanged(project): + services = _generate(project, {'image': 'alpine'}) + + assert _env(services['dev-1'])['BAO_ADDR'] == 'http://127.0.0.1:8200' + assert 'extra_hosts' not in services['dev-1'] + + +def test_list_extra_hosts_keeps_its_entries_and_shape(project): + dev = {'image': 'alpine', 'extra_hosts': ['db.local:10.0.0.5']} + + services = _generate(project, dev, host_gateway_environment=GATEWAY) + + assert services['dev-1']['extra_hosts'] == [ + 'db.local:10.0.0.5', 'host.docker.internal:host-gateway'] + + +def test_dict_extra_hosts_keeps_its_entries_and_shape(project): + dev = {'image': 'alpine', 'extra_hosts': {'db.local': '10.0.0.5'}} + + services = _generate(project, dev, host_gateway_environment=GATEWAY) + + assert services['dev-1']['extra_hosts'] == { + 'db.local': '10.0.0.5', 'host.docker.internal': 'host-gateway'} + + +@pytest.mark.parametrize('extra_hosts', [ + ['host.docker.internal:192.168.65.254'], + ['host.docker.internal=192.168.65.254'], + {'host.docker.internal': '192.168.65.254'}, +]) +def test_an_existing_host_line_is_not_duplicated(project, extra_hosts): + dev = {'image': 'alpine', 'extra_hosts': extra_hosts} + + services = _generate(project, dev, scale=1, host_gateway_environment=GATEWAY) + + assert services['dev-1']['extra_hosts'] == extra_hosts + # 接続先の置き換えは行う (利用者の行で host.docker.internal が引ける) + assert _env(services['dev-1'])['BAO_ADDR'] == 'http://host.docker.internal:8200' + + +def test_host_network_dev_service_keeps_the_loopback_address(project): + dev = {'image': 'alpine', 'network_mode': 'host'} + + services = _generate(project, dev, scale=1, host_gateway_environment=GATEWAY) + + assert _env(services['dev-1'])['BAO_ADDR'] == 'http://127.0.0.1:8200' + assert 'extra_hosts' not in services['dev-1'] + + +def test_list_environment_is_overwritten_in_place(project): + dev = {'image': 'alpine', 'environment': ['FEATURE=on']} + + services = _generate(project, dev, scale=1, host_gateway_environment=GATEWAY) + + entries = services['dev-1']['environment'] + assert 'FEATURE=on' in entries + assert [e for e in entries if e.startswith('BAO_ADDR=')] == [ + 'BAO_ADDR=http://host.docker.internal:8200'] From 67d6d69e243872e7be5d1ff532784917a9ea5371 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:25:25 +0900 Subject: [PATCH 06/20] =?UTF-8?q?docs(openbao):=20=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=81=AE=E7=94=A8=E6=84=8F=E3=81=AE=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=81=A8=E3=83=9D=E3=83=AA=E3=82=B7=E3=83=BC=E3=81=AE=E9=9B=9B?= =?UTF-8?q?=E5=BD=A2=E3=83=BB=E8=A9=A6=E7=94=A8=E3=81=AE=E6=A7=8B=E6=88=90?= =?UTF-8?q?=E3=82=92=E7=BD=AE=E3=81=8D=E3=80=81=E6=8E=A5=E7=B6=9A=E3=81=AE?= =?UTF-8?q?=E6=96=87=E6=9B=B8=E3=82=92=E5=88=A9=E7=94=A8=E3=81=AE=E5=BD=A2?= =?UTF-8?q?=E3=81=AB=E6=8F=83=E3=81=88=E3=82=8B=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/user/openbao-server.md に個人・チーム・既存のサーバの 3 つの形と、マウント・AppRole・ ポリシー・entity と alias・ロール・端末の secret_id の発行と失効の手順を書く。 docs/user/openbao/ に version 1 / 2 のポリシーの雛形と、開発モードの試用の構成 (compose.yml) を置く。env-backend.md は管理者から受け取る前提を既存のサーバの形だけにし、 利用者の識別子をサーバの entity 名と同じ値と説明する。 Co-Authored-By: Claude Opus 5.5 --- docs/user/env-backend.md | 37 +- docs/user/openbao-server.md | 436 +++++++++++++++++++ docs/user/openbao/compose.yml | 21 + docs/user/openbao/policies/devbase-user.hcl | 18 + docs/user/openbao/policies/v1/team-read.hcl | 12 + docs/user/openbao/policies/v1/team-write.hcl | 12 + docs/user/openbao/policies/v2/team-read.hcl | 13 + docs/user/openbao/policies/v2/team-write.hcl | 14 + 8 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 docs/user/openbao-server.md create mode 100644 docs/user/openbao/compose.yml create mode 100644 docs/user/openbao/policies/devbase-user.hcl create mode 100644 docs/user/openbao/policies/v1/team-read.hcl create mode 100644 docs/user/openbao/policies/v1/team-write.hcl create mode 100644 docs/user/openbao/policies/v2/team-read.hcl create mode 100644 docs/user/openbao/policies/v2/team-write.hcl diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index d14f52bf..24a29187 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -33,18 +33,27 @@ age の鍵は必要です。** 先に `devbase env keygen` を済ませてくだ ### 1. OpenBao 側で用意するもの -サーバの構築と運用は devbase の範囲外です。管理者から次の値を受け取ってください。 +サーバの持ち方(利用の形)は 3 つあります。サーバの用意の手順とポリシーの雛形は +[OpenBao サーバを用意する](openbao-server.md)にあります。 + +| 利用の形 | 次の値をどう得るか | +|---|---| +| 個人の形(自分で 1 台立てて 1 人で使う) | 自分でサーバを立てて作る([個人の形](openbao-server.md#個人の形)。手元で試すなら[試用の構成](openbao-server.md#試用の構成で試す)) | +| チームの形(チームで 1 台立てる) | チームでサーバを管理する人から受け取る([チームの形](openbao-server.md#チームの形)) | +| 既存のサーバの形(組織が運用しているサーバ) | 管理者から受け取る([既存のサーバの形](openbao-server.md#既存のサーバの形)) | + +接続に使う値は次のとおりです。 | 値 | 例 | |---|---| -| 接続先 URL(`https`。ホストとポートまで) | `https://openbao.example.com` | +| 接続先 URL(`https`。ホストとポートまで。手元のサーバなら `http` の loopback も可) | `https://openbao.example.com` | | KV v2 のマウント名(既定 `devbase`) | `devbase` | -| あなたの識別子(個人単位の機密の置き場に使う。社内メールアドレスの `@` より前) | `member01` | +| あなたの識別子(個人単位の機密の置き場に使う。サーバの entity 名と同じ値) | `member01` | | あなたの AppRole の `role_id` | — | | **この端末用**の `secret_id` | — | `secret_id` は端末ごとに発行されます。端末を増やすときは、その端末用の `secret_id` を -新しく受け取ってください(1 台の失効が他の端末に及ばないようにするためです)。 +新しく発行してください(1 台の失効が他の端末に及ばないようにするためです)。 ### 2. backend を切り替える @@ -131,7 +140,7 @@ devbase env backend migrate --to openbao --group acme --exclude-project myapp-co 移すのはチーム単位の機密(共通とプロジェクト)だけです。個人単位の機密は `devbase env set --user` で入れ直してください。チーム単位のパスへ書けるのは、サーバ側で 書き込みの権限を付けられた人だけです。権限が無いと「書き込み権限が無い」と表示されて -止まるので、管理者に移行を依頼してください。 +止まるので、書き込みの権限を持つ人に移行を依頼してください。 ## 日々の操作 @@ -440,9 +449,16 @@ base イメージには OpenBao の CLI `bao`(サーバと同じ 2.6 系)が | 渡るもの | 形 | |---|---| -| 接続先 | 環境変数 `BAO_ADDR`(`backend.yml` の `openbao.url`) | +| 接続先 | 環境変数 `BAO_ADDR`(`backend.yml` の `openbao.url`。`http` の loopback でローカル扱いのときはホストを `host.docker.internal` に置き換えた値。下の注を参照) | | token | ファイル `~/.vault-token`(`0600`)。`bao` が既定で読む | +`openbao.url` が `http://127.0.0.1:8200` のような手元のサーバのとき、コンテナの中の `127.0.0.1` は +コンテナ自身を指すため、そのままでは届きません。Docker が手元の端末のもの(ローカル扱い)なら、 +devbase はコンテナへ渡す `BAO_ADDR` を `http://host.docker.internal:8200` にし、dev コンテナへ +`host.docker.internal:host-gateway` の行を足します。`https` の URL と、`docker.context` で別の端末の +Docker を使うとき(リモート扱い)は `openbao.url` のままです。確かめた環境と Linux の Docker での +扱いは [コンテナの中の `bao`](openbao-server.md#コンテナの中の-bao) を参照してください。 + コンテナに置くのは 1 時間で切れる token だけで、`secret_id` はホストから出ません。token を 環境変数にしないのは、`docker inspect` や子プロセスの環境に残るためです。 @@ -575,7 +591,7 @@ devbase env backend use auto # 設定なしと同じ (ファイルの存 version: 1 backend: openbao # auto / plaintext / age / openbao openbao: - url: https://openbao.example.com # https のみ (http は localhost 宛てだけ可) + url: https://openbao.example.com # https のみ (http は localhost / 127.0.0.1 / ::1 宛てだけ可) mount: devbase # KV v2 のマウント名 user: member01 # 個人単位の置き場に使う識別子 (必須) path_team_global: team/global # 先頭に / を付けない @@ -604,8 +620,9 @@ cache: `bootstrap.env.age` / `cache/` の権限、Git の除外設定を点検します。`version: 2` では、 対象のグループの同期済みハッシュの控え(`.env.sources..yml`)とキャッシュが Git から 除外されているかも確かめます -- 端末を手放すときは、管理者にその端末の `secret_id` の失効を依頼してください。他の端末の - `secret_id` はそのまま使えます +- 端末を手放すときは、その端末の `secret_id` を失効させてください(サーバを管理する人の作業です。 + 手順は [端末の `secret_id` を失効させる](openbao-server.md#端末の-secret_id-を失効させる))。 + 他の端末の `secret_id` はそのまま使えます ## コマンド一覧 @@ -616,4 +633,4 @@ cache: | `devbase env backend test` | サーバへ接続し、参照ごとに読めるかを確かめる(`version: 2` では対象のグループの置き場だけ) | | `devbase env backend migrate --to age\|openbao [--exclude-project NAME]... [--dry-run] [--yes]` | チーム単位の機密を別の backend へ写す | -関連: [環境変数の暗号化](env-encryption.md) / [環境変数ガイド](environment-variables.md) +関連: [OpenBao サーバを用意する](openbao-server.md) / [環境変数の暗号化](env-encryption.md) / [環境変数ガイド](environment-variables.md) diff --git a/docs/user/openbao-server.md b/docs/user/openbao-server.md new file mode 100644 index 00000000..ea0a5944 --- /dev/null +++ b/docs/user/openbao-server.md @@ -0,0 +1,436 @@ +# OpenBao サーバを用意する + +devbase の機密の保存先に [OpenBao](https://openbao.org/) を使うとき、サーバをどう持つか +(利用の形)と、サーバの側で用意するもの(マウント・AppRole・ポリシー・利用者・端末)を +説明するガイドです。用意したサーバへ devbase をつなぐ手順と日々の操作は +[機密の保存先を選ぶ](env-backend.md)にあります。 + +devbase は共通の OpenBao サーバを運用していません。サーバは利用者が個人またはチームで立てます。 +この文書の手順と、リポジトリに置いた雛形だけで同じ構成を作れます。 + +## 利用の形を選ぶ + +| 利用の形 | サーバ | 管理する人 | 利用者が用意するもの | +|---|---|---|---| +| [個人の形](#個人の形) | 自分で 1 台立て、1 人で使う | 自分 | サーバ・[管理の手順](#管理の手順個人とチームで共通)の全部 | +| [チームの形](#チームの形) | チームで 1 台立て、チーム共通と個人の置き場を分ける | チームの誰か | 管理する人: サーバ・管理の手順の全部と、利用者と端末の追加・失効。利用者: 受け取った値で接続する | +| [既存のサーバの形](#既存のサーバの形) | 組織が運用しているサーバへ接続する | 組織の管理者 | 管理者から受け取った値で接続する | + +この文書の「管理する人」は、サーバでマウント・AppRole・ポリシー・entity を作る人です。 +個人の形では利用者本人です。 + +まず試すだけなら、[試用の構成](#試用の構成で試す)で手元に 1 台立て、数分で接続から +`devbase up` まで通せます。 + +## リポジトリに置いた雛形 + +`$DEVBASE_ROOT/docs/user/openbao/` に、サーバの側で使うファイルがあります。devbase は実行時に +これらを読みません。 + +| ファイル | 内容 | +|---|---| +| [`compose.yml`](openbao/compose.yml) | 試用の構成。開発モードの OpenBao を手元に立てる。OpenBao の版はこのファイルの `image` の 1 行にある | +| [`policies/devbase-user.hcl`](openbao/policies/devbase-user.hcl) | 個人の雛形。本人の `users/<本人>/` の下だけを読み書きさせる(`version: 1` / `2` 共通) | +| [`policies/v2/team-read.hcl`](openbao/policies/v2/team-read.hcl) | チームの読み取りの雛形(`version: 2`)。`__GROUP__` を置き換えたグループの `team//` を読ませる | +| [`policies/v2/team-write.hcl`](openbao/policies/v2/team-write.hcl) | チームの書き込みの雛形(`version: 2`)。同じグループの `team//` へ書かせる | +| [`policies/v1/team-read.hcl`](openbao/policies/v1/team-read.hcl) | チームの読み取りの雛形(`version: 1`)。`team/global` と `team/projects/` を読ませる | +| [`policies/v1/team-write.hcl`](openbao/policies/v1/team-write.hcl) | チームの書き込みの雛形(`version: 1`) | + +`version: 2` は置き場をアカウントグループごとに分ける設定で、新しく `devbase env backend use openbao` +した端末の既定です([アカウントグループごとの置き場](env-backend.md#アカウントグループごとの置き場version-2))。 +個人の形も `version: 2` を使い、グループは `personal` にします。 + +雛形は KV v2 のマウント名 `devbase` を書いています。ほかの名前でマウントするときは、書き込む前に +置き換えます(例: `sed 's#^path "devbase/#path "secrets/#'`)。 + +## 個人の形 + +### 試用の構成で試す + +手元の 1 台に開発モードの OpenBao を立て、devbase をつないで `devbase up` まで通します。 + +**開発モードのサーバは機密を記憶にだけ置き、止めると全部消えます。本物の機密は置かないでください。** +続けて使うなら[自分のサーバで使う](#自分のサーバで使う)へ進みます。 + +使うのは Docker と Docker Compose だけで、ホストに `bao` は要りません。管理の操作は、サーバの +コンテナの中の `bao` で打ちます(開発モードのサーバが root token をコンテナの中に置くため、 +root token を画面に出さずに済みます)。 + +#### 1. 今の接続設定を退避する + +`devbase env backend use openbao` は `secrets/backend.yml` と `secrets/bootstrap.env.age` を +上書きします。すでに使っている接続設定があれば、先に退避します。 + +```bash +cd "$DEVBASE_ROOT" +mkdir -p secrets/trial-backup +for f in backend.yml bootstrap.env.age; do + [ -f "secrets/$f" ] && cp -p "secrets/$f" secrets/trial-backup/ +done +``` + +age の鍵がまだ無ければ作ります(OpenBao を使うときも要ります)。 + +```bash +devbase env keygen +``` + +#### 2. サーバを起動する + +```bash +docker compose -f "$DEVBASE_ROOT/docs/user/openbao/compose.yml" up -d + +# 管理の操作を打つための関数 (この後の手順で使う) +bao_admin() { + docker compose -f "$DEVBASE_ROOT/docs/user/openbao/compose.yml" exec -T openbao bao "$@" +} + +bao_admin status # Initialized true / Sealed false と Version が出れば起動している +``` + +サーバはホストの `127.0.0.1:8200` にだけ公開されます(同じ LAN の他の端末からは届きません)。 +ポート 8200 がほかで使われていて起動しないときは、`compose.yml` の `ports` の左側のポートを変え、 +以後の `http://127.0.0.1:8200` も同じポートにします。 + +#### 3. サーバを用意する + +[管理の手順](#管理の手順個人とチームで共通)の 1〜4 を上から打ちます。利用者は自分 1 人 +(例: `member01`)で、グループは `personal`、チーム共通への書き込みも自分に許します。 + +#### 4. 接続する + +[管理の手順](#管理の手順個人とチームで共通)の 5 で、この端末の `secret_id` を発行して +そのまま `devbase env backend use openbao` へ渡します。`--url` は `http://127.0.0.1:8200` です。 + +```bash +devbase env backend test --group personal +``` + +#### 5. プロジェクトで使う + +プロジェクトの `projects//env` でアカウントグループ `personal` を宣言します(宣言の無い +プロジェクトは機密のコマンドも `up` も止まります)。 + +```bash +DEVBASE_ACCOUNT_GROUP=personal +``` + +プロジェクトのディレクトリで読み書きし、起動します。 + +```bash +devbase env set API_KEY=example-value # チーム共通 (team/personal/global) +devbase env set --user MY_TOKEN=example-value # 個人共通 (users/<本人>/personal/global) +devbase env get API_KEY +devbase up +``` + +起動したコンテナの環境変数に、書いた値が入ります。コンテナの中の `bao` も手元のサーバへ +届きます([コンテナの中の `bao`](#コンテナの中の-bao))。 + +```bash +bao kv get -mount=devbase users/<本人>/personal/global # dev コンテナの中で +``` + +#### 6. 片付ける + +サーバを止めると、サーバの機密は全部消えます。 + +```bash +docker compose -f "$DEVBASE_ROOT/docs/user/openbao/compose.yml" down +``` + +devbase の接続設定を元へ戻します。退避した設定があれば戻します(手元のキャッシュ +`secrets/cache/` は元の接続の控えを含むため消しません。試用の控えは接続先が違うため使われません)。 + +```bash +cd "$DEVBASE_ROOT" +cp -p secrets/trial-backup/* secrets/ && rm -r secrets/trial-backup +``` + +退避した設定が無ければ(試用の前は OpenBao を使っていなければ)、設定なしへ戻し、試用の +資格情報と控えを消します。 + +```bash +cd "$DEVBASE_ROOT" +devbase env backend use auto +rm -f secrets/bootstrap.env.age +rm -rf secrets/cache +rmdir secrets/trial-backup 2>/dev/null +``` + +どちらのときも、最後に OpenBao の試用のサーバを指していないことを確かめます。 + +```bash +devbase env backend status +``` + +`up` したプロジェクトのコンテナは `devbase down` で止めます。 + +### 自分のサーバで使う + +本物の機密を置くサーバは、開発モードではなく、ストレージを持つ構成で立てます。サーバの構築・ +初期化(`bao operator init`)・封印の解除・TLS の証明書・バックアップ・監査ログは OpenBao の +文書に従ってください。 + +- [Install OpenBao](https://openbao.org/docs/install/) +- [Configuration](https://openbao.org/docs/configuration/) + +ここから先は、初期化と封印の解除が済み、管理の権限を持つ token で `bao` を打てるサーバを前提にします。 + +1. `bao_admin` を、管理する人の端末の `bao` の呼び出しにします。`BAO_ADDR` はサーバの URL です + + ```bash + export BAO_ADDR=https://openbao.example.com + bao login # 管理の権限を持つ token (入力は伏せ字) + bao_admin() { bao "$@"; } + ``` + +2. [管理の手順](#管理の手順個人とチームで共通)を上から打ちます。手順は試用の構成と同じです +3. 接続の `--url` は、devbase がサーバへ届く URL です。`https`、または手元のサーバの `http` の + loopback(`http://127.0.0.1:<ポート>` / `http://localhost:<ポート>`)だけを受け付けます + +devbase の接続に root token は使いません。devbase は AppRole の `role_id` と端末ごとの +`secret_id` だけで接続します。 + +## 管理の手順(個人とチームで共通) + +例の利用者は `member01`、グループは `personal`、端末の名前は `laptop01` です。コマンドの +`bao_admin` は、試用の構成ではサーバのコンテナの中の `bao`、自分のサーバでは管理する人の端末の +`bao` です([試用の構成](#2-サーバを起動する) / [自分のサーバ](#自分のサーバで使う))。 +雛形の置き場は `T="$DEVBASE_ROOT/docs/user/openbao/policies"` とします。 + +### 1. KV v2 をマウントする + +```bash +bao_admin secrets list # devbase/ がまだ無いことを確かめる +bao_admin secrets enable -path=devbase -version=2 kv +``` + +`devbase/` が既にあれば作り直さず、`bao_admin secrets list -detailed` で種類が `kv`・ +`version` が `2` であることを確かめます。別の名前のマウントを使うときは、雛形のマウント名を +置き換え([雛形](#リポジトリに置いた雛形))、接続で `--mount <名前>` を付けます。 + +### 2. AppRole を有効にする + +```bash +bao_admin auth enable approle +``` + +### 3. ポリシーを書き込む + +個人の雛形は `version: 1` と `2` で共通です。 + +```bash +T="$DEVBASE_ROOT/docs/user/openbao/policies" +bao_admin policy write devbase-user - < "$T/devbase-user.hcl" +``` + +`version: 2` は、使うグループごとに読み取りと書き込みの 2 つを書きます。 + +```bash +g=personal +sed "s/__GROUP__/$g/g" "$T/v2/team-read.hcl" | bao_admin policy write "devbase-team-read-$g" - +sed "s/__GROUP__/$g/g" "$T/v2/team-write.hcl" | bao_admin policy write "devbase-team-write-$g" - +``` + +`version: 1`(`devbase env backend use openbao --layout flat`)では次の 2 つです。 + +```bash +bao_admin policy write devbase-team-read - < "$T/v1/team-read.hcl" +bao_admin policy write devbase-team-write - < "$T/v1/team-write.hcl" +``` + +### 4. 利用者を足す + +利用者 1 人につき、AppRole のロール・entity・entity alias を 1 つずつ作ります。**entity の名前は、 +その利用者が devbase の接続で `--user` に渡す識別子と同じ値にします。** 個人の雛形は entity の +名前で本人の置き場を絞るため、違えると本人の置き場が 403 になります。 + +```bash +u=member01 + +# ロール: token は 1 時間 (devbase が更新する)。権限は entity の側に付ける +bao_admin write "auth/approle/role/$u" token_policies=default token_ttl=1h token_max_ttl=1h + +# entity: 名前は利用者の識別子。付けるポリシーで読み書きの範囲が決まる +bao_admin write identity/entity name="$u" \ + policies=devbase-user,devbase-team-read-personal,devbase-team-write-personal + +# entity alias: AppRole の alias の名前は role_id +role_id=$(bao_admin read -field=role_id "auth/approle/role/$u/role-id") +entity_id=$(bao_admin read -field=id "identity/entity/name/$u") +accessor=$(bao_admin read -field=accessor sys/auth/approle) +bao_admin write identity/entity-alias name="$role_id" canonical_id="$entity_id" mount_accessor="$accessor" +``` + +| 付けるポリシー | 許すこと | +|---|---| +| `devbase-user` | 本人の `users/<本人>/` の下の読み書き | +| `devbase-team-read-`(`version: 1` は `devbase-team-read`) | そのグループのチーム共通とチームのプロジェクトの読み取り | +| `devbase-team-write-`(`version: 1` は `devbase-team-write`) | そのグループのチーム共通とチームのプロジェクトへの書き込み。書いてよい人にだけ付ける | + +個人の形では、自分に 3 つとも付けます。 + +### 5. 端末の `secret_id` を発行して接続する + +`secret_id` は端末ごとに発行します。端末の名前を metadata に入れておくと、失効させるときに +`secret_id` の値を控えずに端末を選べます([端末を失効させる](#端末の-secret_id-を失効させる))。 + +接続する端末で管理の操作も打てるとき(個人の形)は、発行した `secret_id` を画面に出さず、 +そのまま `--secret-id-stdin` へ渡します。 + +```bash +u=member01 +role_id=$(bao_admin read -field=role_id "auth/approle/role/$u/role-id") +bao_admin write -f -field=secret_id "auth/approle/role/$u/secret-id" \ + metadata='{"terminal":"laptop01"}' \ + | devbase env backend use openbao \ + --url http://127.0.0.1:8200 \ + --user "$u" \ + --role-id "$role_id" \ + --secret-id-stdin +``` + +`--layout` を付けなければ `version: 2` になります。`version: 1` の雛形を書いたサーバでは +`--layout flat` を付けます。 + +## チームの形 + +管理する人がサーバを 1 台立て、[管理の手順](#管理の手順個人とチームで共通)の 1〜3 を一度だけ +打ちます。グループごとに 3 の `version: 2` の 2 つを書きます。以後、利用者と端末が増えるたびに +次を打ちます。 + +### 利用者を足す + +[管理の手順の 4](#4-利用者を足す)を、その利用者の識別子で打ちます。チーム共通へ書かせない +利用者には、書き込みのポリシーを付けません。 + +```bash +u=member02 +bao_admin write "auth/approle/role/$u" token_policies=default token_ttl=1h token_max_ttl=1h +bao_admin write identity/entity name="$u" policies=devbase-user,devbase-team-read-personal +role_id=$(bao_admin read -field=role_id "auth/approle/role/$u/role-id") +entity_id=$(bao_admin read -field=id "identity/entity/name/$u") +accessor=$(bao_admin read -field=accessor sys/auth/approle) +bao_admin write identity/entity-alias name="$role_id" canonical_id="$entity_id" mount_accessor="$accessor" +``` + +利用者には、接続先の URL・マウント名・識別子(`member02`)・`role_id` を渡します。 + +### チーム共通への書き込みを許す・外す + +書き込みは entity のポリシーで決まります。`policies` は書いた一覧で丸ごと置き換わるため、 +今の一覧を確かめてから、残すものを全部並べて書きます。 + +```bash +bao_admin read -field=policies identity/entity/name/member02 +bao_admin write identity/entity/name/member02 \ + policies=devbase-user,devbase-team-read-personal,devbase-team-write-personal # 許す +bao_admin write identity/entity/name/member02 \ + policies=devbase-user,devbase-team-read-personal # 外す +``` + +ほかのグループの置き場も読ませるときは、そのグループの `devbase-team-read-` を足します。 + +### 端末を足す + +端末ごとに `secret_id` を発行します。管理する人と利用者が別の人なら、発行した `secret_id` は +安全な経路で利用者へ渡します。 + +```bash +bao_admin write -f -field=secret_id auth/approle/role/member02/secret-id \ + metadata='{"terminal":"desktop02"}' +``` + +受け取った利用者は、自分の端末で接続します。`--secret-id-stdin` を省くと伏せ字で入力を +求められます(`secret_id` をコマンド引数では渡しません)。 + +```bash +devbase env backend use openbao \ + --url https://openbao.example.com \ + --user member02 \ + --role-id +``` + +### 端末の `secret_id` を失効させる + +端末を手放したときは、その端末の `secret_id` だけを失効させます。同じ利用者の別の端末は +そのまま使えます。 + +```bash +u=member02 +for a in $(bao_admin list -format=json "auth/approle/role/$u/secret-id" | python3 -c 'import json,sys; print(" ".join(json.load(sys.stdin)))'); do + echo "$a $(bao_admin write -field=metadata "auth/approle/role/$u/secret-id-accessor/lookup" secret_id_accessor="$a")" +done +# 端末の名前で accessor を選んで消す +bao_admin write "auth/approle/role/$u/secret-id-accessor/destroy" secret_id_accessor= +``` + +失効させた端末では `devbase env backend test` が「認証が拒まれました」で止まります。 + +利用者ごと外すときは、ロールと entity を消します(`bao_admin delete auth/approle/role/` と +`bao_admin delete identity/entity/name/`)。その利用者の全端末が止まります。 + +### 雛形で決まる権限 + +`version: 2` の雛形で、`member01` にだけ `personal` の書き込みのポリシーを付けたときの結果です。 + +| 操作 | `member01` | `member02` | +|---|---|---| +| 本人の `users/<本人>/personal/global` の読み書き | 通る | 通る | +| 他人の `users/<他人>/…` の読み取り | 403 | 403 | +| `users/` 直下の一覧 | 403 | 403 | +| `team/personal/global` の読み取り | 通る | 通る | +| `team/personal/global` への書き込み | 通る | 403 | +| ポリシーを付けていないグループの `team//…` | 403 | 403 | +| `sys/` の管理の経路 | 403 | 403 | + +`version: 1` の雛形でも、他人の個人の置き場の 403 と、`team/global` への書き込みの制限は +同じく効きます。 + +## 既存のサーバの形 + +組織が運用しているサーバへ接続するときは、サーバの用意は要りません。管理者から次の値を +受け取り、[機密の保存先を選ぶ](env-backend.md#2-backend-を切り替える)の手順で接続します。 + +| 値 | 例 | +|---|---| +| 接続先 URL(`https`。ホストとポートまで) | `https://openbao.example.com` | +| KV v2 のマウント名(既定 `devbase`) | `devbase` | +| あなたの識別子(サーバの entity 名と同じ値) | `member01` | +| あなたの AppRole の `role_id` | — | +| **この端末用**の `secret_id` | — | +| 置き場の版(`version: 1` か `2`)と、使うアカウントグループ | `version: 2`、`acme` | + +## コンテナの中の `bao` + +`devbase up` は dev コンテナへ接続先 `BAO_ADDR` と 1 時間の token を渡し、コンテナの中の `bao` で +本人の置き場を読み書きできます([コンテナの中から `bao` を使う](env-backend.md#コンテナの中から-bao-を使う))。 + +コンテナの中の `127.0.0.1` はコンテナ自身を指します。そのため、接続先 URL が `http` の loopback +(`http://127.0.0.1:8200` など)で、Docker が手元の端末のものなら、devbase はコンテナへ渡す +`BAO_ADDR` のホストを `host.docker.internal` に置き換え(`http://host.docker.internal:8200`)、 +dev コンテナに `host.docker.internal:host-gateway` の行を足します。手元の devbase が使う URL は +変わりません。 + +| 接続先 URL | Docker | コンテナの `BAO_ADDR` | +|---|---|---| +| `http://127.0.0.1:8200` / `http://localhost:8200` / `http://[::1]:8200` | 手元の端末 | `http://host.docker.internal:8200` | +| 同上 | 別の端末(`project.local.yml` の `docker.context`) | 置き換えない(コンテナのある端末からは手元のサーバへ届きません) | +| `https://…` | どちらでも | 置き換えない | + +dev サービスが `network_mode: host` のときは置き換えません(loopback がそのまま届きます)。 +dev サービスの `extra_hosts` に `host.docker.internal` の行が既にあれば、その行を使います。 + +確かめた環境は macOS の Docker Desktop です。Linux の Docker では確かめていません。Linux の +Docker では、試用の構成のようにサーバのポートをホストの `127.0.0.1` にだけ公開すると、 +`host-gateway` 宛ての要求が届かない見込みです。コンテナの中の `bao` を使うには、サーバを +Docker のブリッジから届くアドレスでも待たせてください。 + +## 関連 + +- [機密の保存先を選ぶ(OpenBao との連携)](env-backend.md) +- 確定仕様: [機密の保存先(secret backend)](../specifications/secret-backend.md#openbao-との契約) +- [OpenBao: AppRole auth](https://openbao.org/docs/auth/approle/) / + [Identity](https://openbao.org/docs/concepts/identity/) / + [Policies](https://openbao.org/docs/concepts/policies/) diff --git a/docs/user/openbao/compose.yml b/docs/user/openbao/compose.yml new file mode 100644 index 00000000..3dc68ab5 --- /dev/null +++ b/docs/user/openbao/compose.yml @@ -0,0 +1,21 @@ +# devbase の試用の構成: 開発モードの OpenBao を手元に 1 台立てる +# +# docker compose -f "$DEVBASE_ROOT/docs/user/openbao/compose.yml" up -d +# +# 開発モードのサーバは機密を記憶にだけ置き、止めると消える。本物の機密は置かない。 +# root token はサーバのコンテナの中(~/.vault-token)にだけあり、管理の操作は +# docker compose exec で同じコンテナの中の bao から打つ。 +# 手順は docs/user/openbao-server.md の「試用の構成」を見る。 +name: devbase-openbao-trial + +services: + openbao: + # OpenBao の版はこの 1 行だけに書く(base イメージの BAO_VERSION と同じ 2.6 系) + image: openbao/openbao:2.6.2 + command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200"] + environment: + # コンテナの中の bao(管理の操作)が自分のサーバを指すように + BAO_ADDR: http://127.0.0.1:8200 + ports: + # ホストの loopback にだけ公開する(同じ LAN の他の端末からは届かない) + - "127.0.0.1:8200:8200" diff --git a/docs/user/openbao/policies/devbase-user.hcl b/docs/user/openbao/policies/devbase-user.hcl new file mode 100644 index 00000000..5cabd183 --- /dev/null +++ b/docs/user/openbao/policies/devbase-user.hcl @@ -0,0 +1,18 @@ +# devbase の個人の雛形(version: 1 / version: 2 共通) +# +# 本人(entity の名前)の users/<本人>/ の下だけを読み書きさせる。 +# 他人の users/<名前>/ と、users/ 直下の一覧は許さない。 +# entity の名前は、devbase の secrets/backend.yml の openbao.user と同じ値にする。 +# +# マウント名が devbase でなければ、devbase/ を書き換えてから書き込む: +# sed 's#^path "devbase/#path "<マウント名>/#' devbase-user.hcl + +# 値の読み書き(env get / env set / env edit) +path "devbase/data/users/{{identity.entity.name}}/*" { + capabilities = ["create", "read", "update"] +} + +# 参照ごとの削除と、本人の置き場の中の一覧 +path "devbase/metadata/users/{{identity.entity.name}}/*" { + capabilities = ["read", "list", "delete"] +} diff --git a/docs/user/openbao/policies/v1/team-read.hcl b/docs/user/openbao/policies/v1/team-read.hcl new file mode 100644 index 00000000..3244440c --- /dev/null +++ b/docs/user/openbao/policies/v1/team-read.hcl @@ -0,0 +1,12 @@ +# devbase のチームの読み取りの雛形(version: 1) +# +# チーム共通(team/global)とチームのプロジェクト(team/projects/<名前>)を読ませる。 +# 書き込みは許さない。書かせる人には team-write.hcl も付ける。 + +path "devbase/data/team/global" { + capabilities = ["read"] +} + +path "devbase/data/team/projects/*" { + capabilities = ["read"] +} diff --git a/docs/user/openbao/policies/v1/team-write.hcl b/docs/user/openbao/policies/v1/team-write.hcl new file mode 100644 index 00000000..953adae4 --- /dev/null +++ b/docs/user/openbao/policies/v1/team-write.hcl @@ -0,0 +1,12 @@ +# devbase のチームの書き込みの雛形(version: 1) +# +# チーム共通(team/global)とチームのプロジェクト(team/projects/<名前>)へ書かせる。 +# チーム共通へ書いてよい人の entity にだけ付ける。読み取りは team-read.hcl が持つ。 + +path "devbase/data/team/global" { + capabilities = ["create", "read", "update"] +} + +path "devbase/data/team/projects/*" { + capabilities = ["create", "read", "update"] +} diff --git a/docs/user/openbao/policies/v2/team-read.hcl b/docs/user/openbao/policies/v2/team-read.hcl new file mode 100644 index 00000000..b9e25513 --- /dev/null +++ b/docs/user/openbao/policies/v2/team-read.hcl @@ -0,0 +1,13 @@ +# devbase のチームの読み取りの雛形(version: 2。グループ単位) +# +# __GROUP__ をグループの名前に置き換えて使う(例: personal)。 +# sed 's/__GROUP__/personal/g' team-read.hcl +# 置き換えたグループの team/<グループ>/ の下だけを読ませる。書き込みは許さない。 + +path "devbase/data/team/__GROUP__/global" { + capabilities = ["read"] +} + +path "devbase/data/team/__GROUP__/projects/*" { + capabilities = ["read"] +} diff --git a/docs/user/openbao/policies/v2/team-write.hcl b/docs/user/openbao/policies/v2/team-write.hcl new file mode 100644 index 00000000..dce5bb66 --- /dev/null +++ b/docs/user/openbao/policies/v2/team-write.hcl @@ -0,0 +1,14 @@ +# devbase のチームの書き込みの雛形(version: 2。グループ単位) +# +# __GROUP__ をグループの名前に置き換えて使う(例: personal)。 +# sed 's/__GROUP__/personal/g' team-write.hcl +# 置き換えたグループの team/<グループ>/ の下へ書かせる。 +# そのグループのチーム共通へ書いてよい人の entity にだけ付ける。 + +path "devbase/data/team/__GROUP__/global" { + capabilities = ["create", "read", "update"] +} + +path "devbase/data/team/__GROUP__/projects/*" { + capabilities = ["create", "read", "update"] +} From 5110b03d4f694320524a54702d49284c6469c3bf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:26:22 +0900 Subject: [PATCH 07/20] =?UTF-8?q?docs(spec):=20=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=8B=E3=82=89=E3=82=B5=E3=83=BC=E3=83=90=E3=81=AE?= =?UTF-8?q?=E5=89=8D=E6=8F=90=E3=82=92=E5=A4=96=E3=81=97=E3=80=81=E3=82=B5?= =?UTF-8?q?=E3=83=BC=E3=83=90=E3=81=AB=E6=B1=82=E3=82=81=E3=82=8B=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=A8=E3=82=B3=E3=83=B3=E3=83=86=E3=83=8A=E3=81=8B?= =?UTF-8?q?=E3=82=89=E8=A6=8B=E3=81=9F=E6=8E=A5=E7=B6=9A=E5=85=88=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8F=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非公開の運用のリポジトリへの参照と、利用者の識別子を組織の決まりで説明した記述を外す。 devbase がサーバに求める条件(マウント・パス・AppRole・本人とチームの権限・拒否・ version 2 のグループ単位)と雛形の置き場、loopback の接続先の置き換えを書く。目次に サーバの用意の文書を足す。 Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 4 ++ docs/specifications/secret-backend.md | 91 ++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/docs/README.md b/docs/README.md index 71c157f1..e84be050 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ graph TD | [環境変数ガイド](user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | | [環境変数の暗号化](user/env-encryption.md) | 認証情報を暗号化して保存する / 鍵とチーム共有 / 平文へ戻す | | [機密の保存先を選ぶ](user/env-backend.md) | OpenBao サーバへ載せ替える / 個人単位の機密 / 不達時のキャッシュ | +| [OpenBao サーバを用意する](user/openbao-server.md) | 個人・チーム・既存のサーバの 3 つの形 / ポリシーの雛形 / 試用の構成 | | [コンテナ操作ガイド](user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](user/troubleshooting.md) | カテゴリ別の問題と解決策 | @@ -108,6 +109,8 @@ docs/ │ ├── environment-variables.md ← 環境変数ガイド │ ├── env-encryption.md ← 環境変数の暗号化 │ ├── env-backend.md ← 機密の保存先を選ぶ (OpenBao) +│ ├── openbao-server.md ← OpenBao サーバを用意する +│ ├── openbao/ ← ポリシーの雛形と試用の構成 (compose.yml) │ ├── container-operations.md ← コンテナ操作ガイド │ ├── snapshot-guide.md ← スナップショットガイド │ └── troubleshooting.md ← トラブルシューティング @@ -134,6 +137,7 @@ docs/ | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | | 認証情報を暗号化して保存する | [環境変数の暗号化](user/env-encryption.md) | | 機密を OpenBao サーバで管理する | [機密の保存先を選ぶ](user/env-backend.md) | +| 自分の OpenBao サーバを立てる | [OpenBao サーバを用意する](user/openbao-server.md) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | | 1 つのコンテナで複数リポジトリを扱う | [project.yml リファレンス](user/project-yml.md#複数リポジトリ) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index 271ba33b..22e18019 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -390,7 +390,8 @@ DEVBASE_ACCOUNT_GROUP{付ける引数}{実行場所} 付け、無ければ空で作る(`VolumeManager.ensure_volumes` → `create_volume`)。規則を満たす名前はどれも 同じ経路で動き、devbase はグループ名ごとの分岐を持たない(`personal` などの新しいグループは、宣言と サーバのポリシーだけで使える。新しいグループのボリュームは空で作られ、ログインを取り直す)。 -グループの機密の置き場のポリシーは運用側のリポジトリが持ち、無ければ取得が 403 の今の形で止まる。 +グループの機密の置き場のポリシーは利用者のサーバが持ち(雛形は「サーバに求める条件」)、無ければ取得が +403 の今の形で止まる。 base イメージの entrypoint は、`DEVBASE_ACCOUNT_GROUP` が空なら設定のリンクを張る前に `ERROR: DEVBASE_ACCOUNT_GROUP が渡っていません。…` を標準エラーへ出して 1 で終わり、コンテナは @@ -919,16 +920,38 @@ token をホストのディスクへ置かない決まりを守るためであ `` と `` はパス区切りと `..` を含まない検査を通っており、`` は `DEVBASE_ACCOUNT_GROUP` と同じ名前の検査を通っているため、組み立てたパスが設定した親の外へ 出ることはない。URL へ埋め込む前に各要素を符号化する(`/` は区切りとして -残す)。`` は社内メールアドレスの `@` より前の部分で、サーバ側の entity 名と同じ値に -する。サーバのポリシーは entity 名で個人単位のパスを絞るため、設定の `user` が本人と違えば +残す)。`` は利用者の識別子(`openbao.user`)で、サーバの entity 名と同じ値にする。 +サーバのポリシーは entity 名で個人単位のパスを絞るため、設定の `user` が本人と違えば `users//...` の取得が 403 になる。 -サーバ側の構成(KV v2 のマウント、ポリシー、AppRole、token の期限)は devbase の範囲外で、 -運用側のリポジトリが持つ。devbase が前提にするのは、上の 4 経路と -「本人のパスは読み書きでき、チームのパスは読め、他人のパスは拒まれる」ことだけである。 -ポリシーを `version: 2` のグループ単位に絞る変更も運用側のリポジトリの課題が -扱う。devbase は 1 回の操作で対象のグループのパスだけを要求するため、グループ単位に絞った -サーバでも対象のグループの操作は通る。 +#### サーバに求める条件 + +サーバは利用者が個人またはチームで立てる(利用の形は個人・チーム・既存のサーバの 3 つ。手順は +[OpenBao サーバを用意する](../user/openbao-server.md))。devbase はサーバの構成を書き換えず、次を前提にする。 + +| 項目 | 条件 | +| --- | --- | +| マウント | KV v2 を `openbao.mount`(既定 `devbase`)にマウントしてある | +| パス | 上の 4 経路(チーム共通・チームのプロジェクト・個人共通・個人のプロジェクト)を、上の対応表のパスで読み書きする | +| 認証 | AppRole(既定のマウント `approle`)の `role_id` と端末ごとの `secret_id` でログインできる。token の認証は持たない | +| 利用者の識別子 | AppRole のロールの entity alias(名前は `role_id`)が、名前を利用者の識別子にした entity に付いている | +| 本人のパス | `users/<本人>/` の下を読み書き(作成・読み取り・更新と、参照ごとの削除)できる | +| チームのパス | 対象のグループのチーム共通とチームのプロジェクトを読める | +| チーム共通への書き込み | 書き込みのポリシーを付けた利用者だけが書ける。ほかの利用者は 403 | +| 拒否 | 他人の `users/<名前>/` と、`users/` 直下の一覧は 403 | +| `version: 2` | 権限はグループ単位で、ポリシーを付けていないグループの `team//` は 403 | +| token の期限 | ログインで得る token は 1 時間で切れる(ロールの `token_ttl`)。devbase は期限の前に取り直す | + +ポリシーの雛形は `docs/user/openbao/policies/` にある(個人 `devbase-user.hcl` は `version: 1` / `2` 共通、 +チームは `v1/` と `v2/` の `team-read.hcl` / `team-write.hcl`)。雛形は entity に付け、ロールの +`token_policies` は `default` だけにする。`version: 2` の雛形はグループの置き換え語 `__GROUP__` を +グループ名に置き換えて使う。雛形はマウント `devbase` の `data/` と `metadata/` の下だけを書き、 +`sys/`・`auth/`・`identity/` の経路を含まない。devbase は 1 回の操作で対象のグループのパスだけを +要求するため、グループ単位に絞ったサーバでも対象のグループの操作は通る。 + +試用の構成は `docs/user/openbao/compose.yml` で、開発モードの OpenBao をホストの `127.0.0.1:8200` に +だけ公開して立てる。OpenBao の版はこのファイルの `image` の 1 行だけに書き、base イメージの +`BAO_VERSION` と同じ 2.6 系に揃える。開発モードのサーバは機密を記憶にだけ置き、止めると消える。 ### `SecretStore` の持ち回り @@ -971,16 +994,33 @@ token をホストのディスクへ置かない決まりを守るためであ base イメージは OpenBao CLI `bao`(`ARG BAO_VERSION`、サーバと同じ 2.6 系)を含む。amd64 / arm64 の tar.gz を同じリリースの `checksums.txt` と突き合わせ、対象の行が無い・値が合わない ときはビルドを止める。`.deb` は systemd ユニットやシステムユーザーを伴うため使わない。 -backend によらず全端末のイメージに入れる(管理スクリプトが `bao` に依存し、管理者の端末の -backend 設定とは関係しない)。 +backend によらず全端末のイメージに入れる(サーバを管理する人の操作も `bao` で打ち、管理する人の +端末の backend 設定とは関係しない)。 backend が `openbao` のとき、`up` と `scale` は dev コンテナへ次を渡す。 | 名前 | 形 | いつ | | --- | --- | --- | -| `BAO_ADDR` | dev サービスの `environment` にリテラル(`openbao.url`)。機密ではない | 構成の生成時 | +| `BAO_ADDR` | dev サービスの `environment` にリテラル(コンテナから見た接続先。下の表)。機密ではない | 構成の生成時 | | `~/.vault-token` | ファイル `0600`、token 1 行(改行なし) | `up` / `scale` の起動の待ちの後(後処理の対象だけ)、`project post-start`、`env token` | +コンテナから見た接続先は `openbao.url` と docker の接続先で決まる(`commands/container.py` の +`container_bao_address`)。コンテナの中の `127.0.0.1` はコンテナ自身を指すためである。 + +| `openbao.url` | docker の接続先 | `BAO_ADDR` | dev のインスタンスへ足す `extra_hosts` | +| --- | --- | --- | --- | +| `http` で、ホストが `localhost` / `127.0.0.1` / `::1` | ローカル扱い | ホストを `host.docker.internal` に置き換えた値(スキームとポートは保つ) | `host.docker.internal:host-gateway` | +| 同上 | リモート扱い | `openbao.url` のまま | 足さない | +| `https`(loopback を含む) | どちらでも | `openbao.url` のまま(証明書の名前が合わなくなるため置き換えない) | 足さない | + +- 置き換えた値は `generate_scaled_compose` の `host_gateway_environment` で渡し、dev のインスタンスにだけ + 当てる。元の dev サービスの `extra_hosts` はリストの形と辞書の形の両方を受けて元の形で足し、 + `host.docker.internal` の行が既にあれば足さない。`network_mode: host` の dev サービスには当てず、 + `openbao.url` のままにする(loopback がそのまま届き、`extra_hosts` も使えない) +- ホストの devbase が使う URL(`openbao.url`)・URL の検査・`backend.yml` の形は変わらない。 + コンテナから見た接続先は `up` / `scale` のたびに組み直す +- 確かめた環境は macOS の Docker Desktop である。Linux の Docker で、ホストの `127.0.0.1` にだけ + 公開したポートへ `host-gateway` から届くかは確かめていない - `up` / `scale` が token を書くのは後処理の対象だけである。`up` は起動できたインスタンス、`scale` は 増やしたインスタンスのうち起動できたものに書き、起動できなかったインスタンスには書かない。 `devbase project post-start` は、動いていて entrypoint の完了(`/tmp/entrypoint-ready`)を確かめられた @@ -1385,6 +1425,11 @@ groups: TTY の伏せ字入力だけを経路にする - `http` はループバック宛てだけ受け付ける。環境変数やテスト専用のオプションで例外を開ける 手段は置かない(実サーバを使う端末でも開けられる抜け道になる) +- devbase は root token を受け取らず、AppRole の `role_id` と端末ごとの `secret_id` だけで接続する。 + 試用の構成も root token を決めず、管理の操作はサーバのコンテナの中の `bao` で打つ。ポリシーの雛形は + 本人以外の個人の置き場と `sys/` の管理の経路を許さない +- コンテナから見た接続先の置き換えは `http` の loopback に限る。`https` の URL は置き換えず、TLS の + 検証の相手を変えない - `scope` を SHA-256 にするのは、`role_id` とパスを平文で残さないためである - 個人単位の機密はパスで分け、他人のパスを読む権限が無ければサーバが 403 を返す。宛先を 指すのは設定、渡してよいかを決めるのはサーバ(entity 名で絞るポリシー)という分担である @@ -1415,9 +1460,12 @@ groups: - 設定が無ければ挙動は変わらない。`backend.yml` を消せば `auto` に戻る - OpenBao を使う場合も age の鍵が要る(ブートストラップとキャッシュの暗号化に使う)。 受信者の入れ替えは `rekey` が担い、外した受信者は資格情報も控えも復号できなくなる -- サーバの構築・運用は devbase の範囲外で、devbase は既存のサーバへ接続するだけである。 - 利用者は管理者から接続先 URL・`mount`・自分の識別子・`role_id`・端末ごとの `secret_id` を - 受け取る +- サーバは利用者が個人またはチームで立てる。devbasex は共通のサーバを運用しない。サーバの用意 + (マウント・AppRole・ポリシー・entity と alias・ロール・端末ごとの `secret_id`)の手順と雛形は + [OpenBao サーバを用意する](../user/openbao-server.md) にあり、devbase のコードはサーバの構成を + 書き換えない。サーバの冗長化・バックアップ・監査ログ・TLS の証明書は OpenBao の文書に従う +- 利用者は接続先 URL・`mount`・自分の識別子・`role_id`・端末ごとの `secret_id` で接続する。 + 個人の形では自分で作り、チームの形と既存のサーバの形ではサーバを管理する人から受け取る - 1 台の端末が同時に使う backend は 1 つ、扱う個人単位の機密は 1 人分である - 複数人の同時編集は版の不一致として後から書いた側で止まる。黙って上書きせず、読み直して やり直す。不達のときに書き込みを控えへ溜める経路は無い @@ -1635,7 +1683,15 @@ groups: パスの読み書きと、プロジェクトのコンテナに別グループの機密が無いことも同じ。コンテナの中の `bao kv get` / `patch`、チーム共通への書き込みの 403、`env token` での取り直しも同じ。ポリシーによる 他人のパスと `users/` 直下の一覧の拒否、端末 1 台の `secret_id` の失効も同じ(開発モードの - サーバでの確認結果は #166。本番のサーバでは配布後に確かめる) + サーバでの確認結果は #166 と #295。本番のサーバでは配布後に確かめる) +- ポリシーの雛形(`version: 1` / `2`)を OpenBao 2.6 系の `bao policy write` で書き込めること、2 人の + 利用者で他人の個人の置き場の 403・権限の無い者のチーム共通への書き込みの 403 になること、試用の構成で + 接続から `devbase up` とコンテナの中の `bao kv get` まで通ることは手動確認(#295) +- コンテナから見た接続先の表の各行、`extra_hosts` のリストと辞書の形、既にある + `host.docker.internal` の行、`network_mode: host` は pytest(`tests/commands/test_container_bao.py`・ + `tests/volume/test_compose_host_gateway.py`) +- 雛形と試用の構成の形(パスの接頭辞・管理の経路を含まない・版が `BAO_VERSION` と同じ 2.6 系・ポートが + loopback だけ)は pytest(`tests/docs/test_openbao_templates.py`) ## 関連リンク @@ -1649,9 +1705,10 @@ groups: #317 / #318(アカウントグループの宣言の必須化と `default` の廃止、#315。設計は #316)、 #391(TUI の接続設定の確認でグループを選ばせる、#386) - Infisical から OpenBao への切り替えの経緯: devbasex/devbase#166 -- サーバのポリシーをグループ単位に絞る課題: 運用側のリポジトリ +- [OpenBao サーバを用意する](../user/openbao-server.md)(利用の形・管理の手順・ポリシーの雛形・試用の構成。#295) - [環境変数ガイド: アカウントグループ](../user/environment-variables.md#アカウントグループ-devbase_account_group) - [OpenBao: KV v2 API](https://openbao.org/api-docs/secret/kv/kv-v2/) - [OpenBao: AppRole auth](https://openbao.org/docs/auth/approle/) - [OpenBao: Policies](https://openbao.org/docs/concepts/policies/) +- [OpenBao: Identity](https://openbao.org/docs/concepts/identity/) - [OpenBao: releases](https://github.com/openbao/openbao/releases) From 12c7342539e42458ad70555e184263327f2f6769 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:27:04 +0900 Subject: [PATCH 08/20] =?UTF-8?q?test(openbao):=20=E3=83=9D=E3=83=AA?= =?UTF-8?q?=E3=82=B7=E3=83=BC=E3=81=AE=E9=9B=9B=E5=BD=A2=E3=81=A8=E8=A9=A6?= =?UTF-8?q?=E7=94=A8=E3=81=AE=E6=A7=8B=E6=88=90=E3=81=AE=E5=BD=A2=E3=82=92?= =?UTF-8?q?=E7=B8=9B=E3=82=8B=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 雛形がマウント devbase の data/ と metadata/ の下だけを書き、管理の経路を含まないこと、 個人の雛形が本人の entity に限ること、読み取りの雛形が読むだけであること、version 2 の 雛形が __GROUP__ の置き換えで 1 つのグループを指すこと、試用の構成の版が base の BAO_VERSION と同じ 2.6 系でポートが loopback だけであることを確かめる。 Co-Authored-By: Claude Opus 5.5 --- tests/docs/__init__.py | 0 tests/docs/test_openbao_templates.py | 140 +++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/docs/__init__.py create mode 100644 tests/docs/test_openbao_templates.py diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/docs/test_openbao_templates.py b/tests/docs/test_openbao_templates.py new file mode 100644 index 00000000..78226df9 --- /dev/null +++ b/tests/docs/test_openbao_templates.py @@ -0,0 +1,140 @@ +"""OpenBao のポリシーの雛形と試用の構成の形 (#295) + +雛形 (``docs/user/openbao/policies/``) は利用者が自分のサーバへ書き込むもので、devbase は +実行時に読まない。ここでは、雛形が devbase の前提にした権限のほかを許さないこと +(マウント ``devbase`` の ``data/`` と ``metadata/`` の下だけ、管理の経路を含まない、読み取りの +雛形は読むだけ) と、試用の構成の版とポートを縛る。サーバが雛形を受け付けることと、2 人の +利用者での 403 / 通るの組は実機で確かめる (手動確認)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +OPENBAO = ROOT / 'docs' / 'user' / 'openbao' +POLICIES = OPENBAO / 'policies' + +PERSONAL = 'devbase-user.hcl' +V1 = ('v1/team-read.hcl', 'v1/team-write.hcl') +V2 = ('v2/team-read.hcl', 'v2/team-write.hcl') +ALL = (PERSONAL, *V1, *V2) + +_PATH_BLOCK = re.compile(r'^path\s+"([^"]+)"\s*\{(.*?)^\}', re.MULTILINE | re.DOTALL) +_CAPABILITIES = re.compile(r'capabilities\s*=\s*\[([^\]]*)\]') + + +def _blocks(text: str) -> dict: + """``path "<パス>" { capabilities = [...] }`` を パス → 許す操作の集合 にする""" + blocks = {} + for path, body in _PATH_BLOCK.findall(text): + caps, = _CAPABILITIES.findall(body) + blocks[path] = {c.strip().strip('"') for c in caps.split(',') if c.strip()} + return blocks + + +def _policy(name: str, group: str | None = None) -> dict: + text = (POLICIES / name).read_text(encoding='utf-8') + if group is not None: + text = text.replace('__GROUP__', group) + return _blocks(text) + + +def _code(text: str) -> str: + """コメント行を除いた HCL""" + return '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + + +@pytest.mark.parametrize('name', ALL) +def test_every_template_exists_and_has_paths(name): + assert _policy(name), name + + +@pytest.mark.parametrize('name', ALL) +def test_templates_only_touch_the_devbase_kv_mount(name): + for path in _policy(name, group='personal'): + assert path.startswith(('devbase/data/', 'devbase/metadata/')), path + assert not re.match(r'(sys|auth|identity)/', path), path + + +@pytest.mark.parametrize('name', ALL) +def test_templates_grant_no_admin_capabilities(name): + for path, caps in _policy(name, group='personal').items(): + assert caps <= {'create', 'read', 'update', 'delete', 'list'}, (path, caps) + assert 'sudo' not in caps and 'deny' not in caps + + +def test_personal_template_is_limited_to_the_own_entity(): + paths = _policy(PERSONAL) + assert set(paths) == { + 'devbase/data/users/{{identity.entity.name}}/*', + 'devbase/metadata/users/{{identity.entity.name}}/*', + } + # 個人の雛形は version 1 / 2 共通。グループの置き換え語を持たない + assert '__GROUP__' not in _code((POLICIES / PERSONAL).read_text()) + + +@pytest.mark.parametrize('name', ('v1/team-read.hcl', 'v2/team-read.hcl')) +def test_read_templates_only_read(name): + for path, caps in _policy(name, group='personal').items(): + assert caps == {'read'}, (path, caps) + + +@pytest.mark.parametrize('name', ('v1/team-write.hcl', 'v2/team-write.hcl')) +def test_write_templates_write_only_team_paths(name): + for path, caps in _policy(name, group='personal').items(): + assert {'create', 'update'} <= caps, (path, caps) + assert '/team/' in path and '/users/' not in path, path + + +def test_v1_templates_cover_the_flat_team_paths(): + for name in V1: + assert set(_policy(name)) == {'devbase/data/team/global', 'devbase/data/team/projects/*'} + assert '__GROUP__' not in (POLICIES / name).read_text() + + +@pytest.mark.parametrize('name', V2) +def test_v2_templates_become_one_group_by_replacing_the_placeholder(name): + code = _code((POLICIES / name).read_text()) + # 置き換え語は全てのパスにあり、置き換えれば team// の下だけを指す + assert all('__GROUP__' in path for path in _blocks(code)) + assert set(_policy(name, group='personal')) == { + 'devbase/data/team/personal/global', 'devbase/data/team/personal/projects/*'} + + +def _compose() -> dict: + return yaml.safe_load((OPENBAO / 'compose.yml').read_text(encoding='utf-8')) + + +def _major_minor(version: str) -> tuple: + return tuple(version.split('.')[:2]) + + +def test_trial_server_uses_the_same_minor_as_the_base_image(): + dockerfile = (ROOT / 'containers' / 'base' / 'Dockerfile').read_text() + base, = re.findall(r'^ARG BAO_VERSION=(\S+)$', dockerfile, re.MULTILINE) + services = _compose()['services'] + images = [s['image'] for s in services.values()] + assert len(images) == 1 + repo, tag = images[0].split(':') + assert repo == 'openbao/openbao' + assert _major_minor(tag) == _major_minor(base) + + +def test_trial_server_publishes_only_on_loopback(): + for service in _compose()['services'].values(): + ports = service.get('ports', []) + assert ports + for port in ports: + assert str(port).startswith('127.0.0.1:'), port + + +def test_trial_server_does_not_fix_a_root_token(): + """root token は決めない (開発モードのサーバがコンテナの中にだけ置く)""" + text = _code((OPENBAO / 'compose.yml').read_text()) + assert 'DEV_ROOT_TOKEN' not in text + assert '-dev-root-token-id' not in text From 46426ddcc92a992f5a858dd4fd92709935aa8b0f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:27:44 +0900 Subject: [PATCH 09/20] =?UTF-8?q?docs:=20=E7=94=A8=E8=AA=9E=E9=9B=86?= =?UTF-8?q?=E3=81=AE=206=20=E8=AA=9E=E3=81=AE=E5=87=BA=E6=89=80=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=97=E3=80=81?= =?UTF-8?q?CHANGELOG=20=E3=81=AB=20#295=20=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++++++ docs/glossary.md | 12 ++++++------ docs/glossary/glossary.json | 18 ++++++------------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4fc58db..df027965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### Added +- **自分で OpenBao サーバを立てて使うための文書とポリシーの雛形・試用の構成を足しました(#295)。** + [OpenBao サーバを用意する](docs/user/openbao-server.md)が、個人の形(自分で 1 台)・チームの形(チームで 1 台)・ + 既存のサーバの形(組織のサーバへ接続)の 3 つを分け、KV v2 のマウント・AppRole・ポリシー・entity と alias・ + ロール・端末ごとの `secret_id` の発行と失効を `bao` のコマンドで示します。ポリシーの雛形(`version: 1` / `2`)と、 + 開発モードの OpenBao を手元に立てる試用の構成(`compose.yml`)は `docs/user/openbao/` にあります。 + 個人の形もアカウントグループごとの置き場(`version: 2`)を使い、プロジェクトで `DEVBASE_ACCOUNT_GROUP=personal` を + 宣言します。接続のコマンドと引数、既存のサーバへの接続の手順は変わりません。 + ### Changed - **シェル補完(bash・zsh)が、`--context NAME` を受け付けるすべてのサブコマンドで `--context` を出すようにしました(#435)。** これまでは `open` と `project post-start` だけでした。`up`・`down`・`ps`・`login`・`scale`・`build`・`rebuild`、 @@ -22,6 +31,12 @@ 既存の世代はそのまま一覧・復元できます。 ### Fixed +- **接続先 URL が手元のサーバ(`http://127.0.0.1:8200` など `http` の loopback)のとき、`devbase up` したコンテナの中の + `bao` がサーバへ届くようにしました(#295)。** これまではコンテナへ渡す `BAO_ADDR` がそのまま `127.0.0.1` を指し、 + コンテナ自身へつながっていました。Docker が手元の端末のものなら、コンテナの `BAO_ADDR` のホストを + `host.docker.internal` にし、dev コンテナへ `host.docker.internal:host-gateway` の行を足します。`https` の URL・ + 別の端末の Docker(`docker.context`)・`network_mode: host` の dev サービスでは今のままです。手元の devbase が使う + URL は変わりません。次の `devbase up` から効きます。確かめた環境は macOS の Docker Desktop です。 - **既にある世代の名前で `devbase snapshot create --name <名前> --full` を打つと、その世代を今のボリュームの全体で 作り直すようにしました(#458)。** これまでは `full.tar.zst` を作り直せずに終了コード 1 で止まり、そのとき `snapshot.snar` だけが進んでいました。作り直した世代は前のフルと差分を持たず、その世代から前の時点へは戻せません。 diff --git a/docs/glossary.md b/docs/glossary.md index e4129f85..8928c6bc 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -94,12 +94,12 @@ up の後に VS Code で dev コンテナを開く(docs/specifications/editor- | region の出所 | — | 参照の AWS_DEFAULT_REGION へ書く値をどこから得たか。参照に既にある・ホストの region・利用者の入力・既定の値(ap-northeast-1)の 4 つ | — | — | `docs/specifications/secret-backend.md` | | 鍵の確認の結果 | — | AWS の方法 3 で、ホストの [default] の鍵を取り込むかを決めた結果。受け入れた・断った・確認しなかった(ホストに鍵が無い)の 3 つ。方針が import なら受け入れた。skip ならホストに鍵があれば断った、無ければ確認しなかったになる | — | — | `docs/specifications/secret-backend.md` | | 鍵ファイルの発見 | — | ~/gcp-credentials/ の *.json を読み、正規化したプロファイル名から鍵ファイルへの対応を作ること。出力を持たない | — | — | `docs/specifications/secret-backend.md` | -| 利用の形 | — | OpenBao サーバの持ち方の 3 つの区分。個人の形(1 人で 1 台)・チームの形(チームで 1 台、チーム共通と個人の置き場を分ける)・既存のサーバの形(組織が運用するサーバへ接続する) | — | — | — | -| ポリシーの雛形 | — | devbase が前提にする権限を OpenBao のポリシー(HCL)として書いた、docs/user/openbao/policies/ のファイル。管理する人が自分のサーバへ書き込んで使う | — | — | — | -| 試用の構成 | — | docs/user/openbao/compose.yml で開発モードの OpenBao を手元に立て、devbase の接続を試して片付けるまでの最小の構成 | — | — | — | -| 利用者の識別子 | — | secrets/backend.yml の openbao.user。サーバの entity 名と同じ値にし、個人単位の置き場のパスに使う | — | — | — | -| コンテナから見た接続先 | — | dev コンテナへ BAO_ADDR として渡す OpenBao の接続先。openbao.url が http の loopback でローカル扱いのときはホストを host.docker.internal に置き換えた値、それ以外(https を含む)は openbao.url のまま | — | — | — | -| 管理する人 | — | 利用者の OpenBao サーバで、マウント・AppRole・ポリシー・entity を作る人。個人の形では利用者本人 | — | — | — | +| 利用の形 | — | OpenBao サーバの持ち方の 3 つの区分。個人の形(1 人で 1 台)・チームの形(チームで 1 台、チーム共通と個人の置き場を分ける)・既存のサーバの形(組織が運用するサーバへ接続する) | — | — | `docs/specifications/secret-backend.md` | +| ポリシーの雛形 | — | devbase が前提にする権限を OpenBao のポリシー(HCL)として書いた、docs/user/openbao/policies/ のファイル。管理する人が自分のサーバへ書き込んで使う | — | — | `docs/specifications/secret-backend.md` | +| 試用の構成 | — | docs/user/openbao/compose.yml で開発モードの OpenBao を手元に立て、devbase の接続を試して片付けるまでの最小の構成 | — | — | `docs/specifications/secret-backend.md` | +| 利用者の識別子 | — | secrets/backend.yml の openbao.user。サーバの entity 名と同じ値にし、個人単位の置き場のパスに使う | — | — | `docs/specifications/secret-backend.md` | +| コンテナから見た接続先 | — | dev コンテナへ BAO_ADDR として渡す OpenBao の接続先。openbao.url が http の loopback でローカル扱いのときはホストを host.docker.internal に置き換えた値、それ以外(https を含む)は openbao.url のまま | — | — | `docs/specifications/secret-backend.md` | +| 管理する人 | — | 利用者の OpenBao サーバで、マウント・AppRole・ポリシー・entity を作る人。個人の形では利用者本人 | — | — | `docs/specifications/secret-backend.md` | ## スナップショット(`snapshot`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 73a5a5f5..eda54320 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -959,43 +959,37 @@ "term": "利用の形", "context": "secret", "meaning": "OpenBao サーバの持ち方の 3 つの区分。個人の形(1 人で 1 台)・チームの形(チームで 1 台、チーム共通と個人の置き場を分ける)・既存のサーバの形(組織が運用するサーバへ接続する)", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" }, { "term": "ポリシーの雛形", "context": "secret", "meaning": "devbase が前提にする権限を OpenBao のポリシー(HCL)として書いた、docs/user/openbao/policies/ のファイル。管理する人が自分のサーバへ書き込んで使う", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" }, { "term": "試用の構成", "context": "secret", "meaning": "docs/user/openbao/compose.yml で開発モードの OpenBao を手元に立て、devbase の接続を試して片付けるまでの最小の構成", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" }, { "term": "利用者の識別子", "context": "secret", "meaning": "secrets/backend.yml の openbao.user。サーバの entity 名と同じ値にし、個人単位の置き場のパスに使う", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" }, { "term": "コンテナから見た接続先", "context": "secret", "meaning": "dev コンテナへ BAO_ADDR として渡す OpenBao の接続先。openbao.url が http の loopback でローカル扱いのときはホストを host.docker.internal に置き換えた値、それ以外(https を含む)は openbao.url のまま", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" }, { "term": "管理する人", "context": "secret", "meaning": "利用者の OpenBao サーバで、マウント・AppRole・ポリシー・entity を作る人。個人の形では利用者本人", - "source": "", - "pending_source": "issues/issue-295-design.md" + "source": "docs/specifications/secret-backend.md" } ] } From 7fa881d82018047d2ddcda85a3675dbcb00df299 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:35:36 +0900 Subject: [PATCH 10/20] =?UTF-8?q?docs(env-backend):=20=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=83=86=E3=83=8A=E3=81=AE=20BAO=5FADDR=20=E3=82=92=E7=BD=AE?= =?UTF-8?q?=E3=81=8D=E6=8F=9B=E3=81=88=E3=81=AA=E3=81=84=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=81=AB=20network=5Fmode:=20host=20=E3=81=AE=20dev=20?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/user/env-backend.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index 24a29187..99737f4d 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -455,8 +455,8 @@ base イメージには OpenBao の CLI `bao`(サーバと同じ 2.6 系)が `openbao.url` が `http://127.0.0.1:8200` のような手元のサーバのとき、コンテナの中の `127.0.0.1` は コンテナ自身を指すため、そのままでは届きません。Docker が手元の端末のもの(ローカル扱い)なら、 devbase はコンテナへ渡す `BAO_ADDR` を `http://host.docker.internal:8200` にし、dev コンテナへ -`host.docker.internal:host-gateway` の行を足します。`https` の URL と、`docker.context` で別の端末の -Docker を使うとき(リモート扱い)は `openbao.url` のままです。確かめた環境と Linux の Docker での +`host.docker.internal:host-gateway` の行を足します。`https` の URL・`docker.context` で別の端末の +Docker を使うとき(リモート扱い)・`network_mode: host` の dev サービスは `openbao.url` のままです。確かめた環境と Linux の Docker での 扱いは [コンテナの中の `bao`](openbao-server.md#コンテナの中の-bao) を参照してください。 コンテナに置くのは 1 時間で切れる token だけで、`secret_id` はホストから出ません。token を From 3398c888043260ffce66ade1847d735c6ee77d02 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:42:34 +0900 Subject: [PATCH 11/20] =?UTF-8?q?test(cli):=20=E8=A3=9C=E5=AE=8C=E3=81=8C?= =?UTF-8?q?=20--context=20=E3=82=92=E5=8F=96=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=E6=9C=AB=E7=AB=AF=E3=81=AE=E9=81=93=E3=81=A7=20--context=20?= =?UTF-8?q?=E3=82=92=E5=8B=A7=E3=82=81=E3=81=9F=E3=82=89=E8=90=BD=E3=81=A8?= =?UTF-8?q?=E3=81=99=20(#453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一致テストに、子の parser を持たない末端の道から --context の集合を引いた NO_CONTEXT_LEAF_PATHS を足し、bash の補完の余分を比べる。zsh は補完の実行テスト に X1 の行を足し、実際の候補で余分を比べる。親の道は子の分岐を含むため比べない。 Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_name_context_consistency.py | 26 +++++++++++++++++++- tests/cli/test_zsh_completion_run.py | 28 ++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tests/cli/test_name_context_consistency.py b/tests/cli/test_name_context_consistency.py index 1da729c5..8f56c7ec 100644 --- a/tests/cli/test_name_context_consistency.py +++ b/tests/cli/test_name_context_consistency.py @@ -6,7 +6,9 @@ - `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` / `_NAME_RESOLVABLE_SHORTCUTS` - 補完 2 ファイル(`etc/devbase-completion.bash` は実行して、`etc/_devbase` は静的に読む)。 - `[name]` の位置のプロジェクト名と、`--context` の旗(値は補完しない)を比べる + `[name]` の位置のプロジェクト名と、`--context` の旗(値は補完しない)を比べる。`--context` の + 余分(取らない道で勧める)は、子の parser を持たない末端の道に限って比べる。zsh の余分は + 実際の候補で比べる(test_zsh_completion_run.py) - 確定仕様 `docs/specifications/cli-argument-resolution.md` の 2 つの表 集合の要素は「サブコマンドへの道筋」のタプルで表す(例: `("project", "profile", "up")`、 @@ -95,6 +97,17 @@ def _scan(): NAME_PATHS, CONTEXT_PATHS = _scan() +def _scan_leaves(): + """子の parser を持たない末端の道(`ct` は畳む)。""" + return {_canonical(path) for path, parser in _walk(cli._create_parser()) + if path and not any(isinstance(a, argparse._SubParsersAction) for a in parser._actions)} + + +# 補完が `--context` を出してはならない道。余分を比べるのは末端の道に限る。親の道(`project +# profile` など)は、補完の本体が子の分岐を含み、子の `--context` を拾って誤って落ちるため外す。 +NO_CONTEXT_LEAF_PATHS = _scan_leaves() - CONTEXT_PATHS + + def _diff_message(where, expected, actual): missing = sorted(expected - actual) extra = sorted(actual - expected) @@ -111,6 +124,9 @@ def test_scan_finds_both_sets(): assert any(len(p) == 3 for p in CONTEXT_PATHS) # プロジェクト名でない `name`(plugin / snapshot / env backend use)を含めないこと assert not any(p[0] in ("plugin", "snapshot", "env") for p in NAME_PATHS) + # 余分を比べる末端の道を拾えていること(`env set` は `--context` を取らない末端の道) + assert ("env", "set") in NO_CONTEXT_LEAF_PATHS + assert not NO_CONTEXT_LEAF_PATHS & CONTEXT_PATHS # --------------------------------------------------------------------------- @@ -252,6 +268,14 @@ def test_bash_completion_offers_context_after_name(fake_root): f"{missing}") +def test_bash_completion_has_no_extra_context(fake_root): + # `--context` を取らない末端の道で補完が `--context` を勧めると、argparse が退ける + extra = [path for path in sorted(NO_CONTEXT_LEAF_PATHS) + if "--context" in _bash_complete(path, fake_root, "-")] + assert not extra, (f"etc/devbase-completion.bash が `--context` を取らない道の `devbase <道> -` で " + f"--context を補完する(余分): {extra}") + + # 値の補完を持たない `--context` の指定(`:context:` の後ろに補完の関数を置かない) _ZSH_CONTEXT_SPEC = re.compile(r"'--context\[[^\]']*\]:context:'") diff --git a/tests/cli/test_zsh_completion_run.py b/tests/cli/test_zsh_completion_run.py index b61299ad..5bda401a 100644 --- a/tests/cli/test_zsh_completion_run.py +++ b/tests/cli/test_zsh_completion_run.py @@ -29,6 +29,7 @@ COMPLETION_NAME_EXCEPTIONS, CONTEXT_PATHS, NAME_PATHS, + NO_CONTEXT_LEAF_PATHS, ) pytestmark = pytest.mark.xdist_group("zsh_completion_run") @@ -43,16 +44,26 @@ @dataclass(frozen=True) class Request: - invariant: str # どの不変条件の行か(I1〜I5) + invariant: str # どの不変条件の行か(I1〜I5、余分は X1) line: str # 打つ行(この後で Tab を押す) include: frozenset[str] # 候補に含まれるべき語。empty が真なら使わない empty: bool = False # 候補が 0 件であるべき + exclude: frozenset[str] = frozenset() # 候補に含まれてはならない語 def expected(self) -> str: - return "0 件" if self.empty else f"{', '.join(sorted(self.include))} を含む" + if self.empty: + return "0 件" + parts = [] + if self.include: + parts.append(f"{', '.join(sorted(self.include))} を含む") + if self.exclude: + parts.append(f"{', '.join(sorted(self.exclude))} を含まない") + return "・".join(parts) def met_by(self, candidates: frozenset[str]) -> bool: - return not candidates if self.empty else self.include <= candidates + if self.empty: + return not candidates + return self.include <= candidates and not self.exclude & candidates def _with_alias(paths): @@ -74,6 +85,9 @@ def _requests() -> list[Request]: for p in sorted(named & CONTEXT_PATHS)] reqs += [Request("I5", _line(p, "--context", ""), frozenset(), empty=True) for p in _with_alias(CONTEXT_PATHS)] + # 余分: `--context` を取らない末端の道の後の `-` で `--context` を勧めない(#453) + reqs += [Request("X1", _line(p, "-"), frozenset(), exclude=frozenset({"--context"})) + for p in sorted(NO_CONTEXT_LEAF_PATHS)] return reqs @@ -165,13 +179,19 @@ def test_context_value_has_no_candidates(completion_run): _check(completion_run, "I5") +def test_no_extra_context_on_leaf_without_it(completion_run): + """X1: `--context` を取らない末端の道の後の `-` で `--context` を出さない。""" + _check(completion_run, "X1") + + def test_requests_cover_acceptance_lines(): # 要求の受け入れ条件 2〜5 の代表の行が一覧に入っていること(走査の変化で黙って抜けない) lines = {(r.invariant, r.line) for r in REQUESTS} for item in [("I1", "devbase up "), ("I1", "devbase project up "), ("I2", "devbase login "), ("I3", "devbase up -"), ("I3", "devbase env exec -"), ("I3", "devbase ct profile up -"), ("I3", "devbase project post-start -"), ("I4", "devbase up proj-a -"), - ("I5", "devbase up --context "), ("I5", "devbase env exec --context ")]: + ("I5", "devbase up --context "), ("I5", "devbase env exec --context "), + ("X1", "devbase env set -")]: assert item in lines, f"打つ行の一覧に {item} が無い" From 7621abd3f785ab85cbaff05171ed7d45e39efaca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 12 Oct 2026 23:42:34 +0900 Subject: [PATCH 12/20] =?UTF-8?q?docs(spec):=20=E4=B8=80=E8=87=B4=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=A8=E5=AE=9F=E8=A1=8C=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=8C=20--context=20=E3=81=AE=E4=BD=99=E5=88=86?= =?UTF-8?q?=E3=82=92=E6=9C=AB=E7=AB=AF=E3=81=AE=E9=81=93=E3=81=A7=E6=AF=94?= =?UTF-8?q?=E3=81=B9=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92=E6=9B=B8=E3=81=8F?= =?UTF-8?q?=20(#453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/cli-argument-resolution.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index bbfaf86a..9e02138e 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -121,13 +121,14 @@ graph TD | 走査 | `_create_parser()` の木を入れ子の parser まで辿る。別名のグループ(`ct`)は `cli.GROUP_ALIASES` で正式の名前に直して数える | | `[name]` の集合 | 位置引数 `name` を持つ parser のうち、トップレベルと `project` グループ(入れ子の `profile` を含む)の道。`plugin` / `snapshot` / `env backend use` の `name` は含めない | | `--context` の集合 | `--context` を持つすべての parser の道に、トップレベルの `build`(ラッパーだけが受け付ける)を名指しで足したもの | +| `--context` を取らない末端の道 | 子の parser を持たない parser の道から `--context` の集合を引いたもの。補完の `--context` の余分を比べる道で、親の道(`project profile` など)は含めない。親の道の zsh の分岐は子の分岐を含み、子の `--context` を拾うため | | 走査の健全性 | どちらの集合も空でなく、入れ子の道(3 語)を含み、`[name]` の集合が `plugin` / `snapshot` / `env` の道を含まない | | 写し | 比べ方 | | --- | --- | | `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` | `project` の直下で `[name]` を取る parser の名前の集合に等しい | | `bin/devbase` の `_NAME_RESOLVABLE_SHORTCUTS` | `cli.SHORTCUTS` のキーと `build` の和に等しい。あわせて、トップレベルで `[name]` を取る parser がすべて `cli.SHORTCUTS` にあること | -| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる | +| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`--context` を取らない末端の道では、`devbase <道> -` の候補に `--context` が出ない。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる | | `etc/_devbase` | zsh を起動せず内容を読む。`case "$words[2]"` から字下げの深さで道の分岐(`up\|down)` のような `\|` 区切りの見出し)を辿り、`[name]` の集合から補完の例外を引いた道の本体に `_devbase_project_names` がある。`--context` の集合のすべての道の本体に、値の補完を持たない `'--context[...]:context:'`(`:context:` の後ろに補完の関数を置かない形)がある。`project_subcommands` / `container_subcommands` の一覧に bash と同じサブコマンドが含まれる | | この節の 2 つの表 | 見出し `` #### `[name]` を受け付けるサブコマンド `` と `` #### `--context` を受け付けるサブコマンド `` の直後の表を読む。1 列目は `devbase <グループ...> ` の形で、間の語が道の頭になる。2 列目にバッククォートで並べた語を `` に入れる。表は `[name]` の集合・`--context` の集合にそれぞれ等しい | @@ -139,13 +140,14 @@ graph TD `tests/cli/test_completion.py` が `--context` の直後の候補が 0 件であることを代表の道で確かめる。 一致テストは zsh の補完を内容で読む。zsh が実際に出す候補そのものは、補完の実行テストが見る -([zsh の補完の実行テスト](#zsh-の補完の実行テスト))。 +([zsh の補完の実行テスト](#zsh-の補完の実行テスト))。zsh の `--context` の余分(取らない道で出す)は、 +分岐の文字列でなく実行テストの候補で比べる(X1)。 #### 補完が `--context` を出す位置 | 位置 | 例 | bash | zsh | | --- | --- | --- | --- | -| (a) 道の直後 | `devbase up -`・`devbase env exec -`・`devbase ct profile up -` | `--context` の集合のすべての道で出す | `--context` の集合のすべての道の分岐で出す | +| (a) 道の直後 | `devbase up -`・`devbase env exec -`・`devbase ct profile up -` | `--context` の集合のすべての道で出し、`--context` を取らない末端の道では出さない | 同じ(`--context` の集合のすべての道の分岐で出し、取らない末端の道では出さない) | | (b) プロジェクト名の直後 | `devbase up web -`・`devbase project open web -` | `[name]` の集合から補完の例外を引いた道で出す | `_arguments` が位置を問わず出す | | (c) `--context` の直後(値) | `devbase up --context ` | 候補は 0 件 | 説明だけを出し、候補は 0 件 | | (a)・(b) より後ろ | `devbase scale web 3 -`・`devbase env exec -- ls -` | 出すことを求めない | 出し得る(argparse では、`env exec` のコマンドの後の `--context` はそのコマンドへ渡る) | @@ -179,6 +181,7 @@ zsh(`etc/_devbase`)は、サブコマンドの分岐から `_arguments` を | I3 | `--context` の集合のすべての道(`container` の道は `ct` で始めても)の `devbase <道> -` | 候補に `--context` が入る | | I4 | `[name]` の集合から補完の例外を引いた道のうち `--context` の集合にもある道の `devbase <道> proj-a -` | 候補に `--context` が入る | | I5 | `--context` の集合のすべての道(`ct` で始めても)の `devbase <道> --context ` | 候補が 0 件 | +| X1 | `--context` を取らない末端の道の `devbase <道> -` | 候補に `--context` が入らない | | 項目 | 規則 | | --- | --- | From 9a655094577cf09169323df8e4f3f12968a28677 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:00:29 +0900 Subject: [PATCH 13/20] =?UTF-8?q?Refactor:=20introduce=5Fnamed=5Fconstant?= =?UTF-8?q?=20=E2=80=94=20lib/devbase/snapshot/manager.py#SNAP=5FMETA=5FFI?= =?UTF-8?q?LE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_snap_meta・_save_snap_meta・snapshot_volumes に残っていた 'meta.yml' の直書きを SNAP_META_FILE に置き換える。META_FILE_TMP と同じ定数から名前を引くようにする。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index bec925d9..39633dfa 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1380,7 +1380,7 @@ def _validate_volumes(volumes: dict, snap_dir: Path) -> dict: - グループ側 (``group``) は ``devbase_home_`` の形で、```` がアカウントグループ名として妥当なものに限る """ - meta_path = snap_dir / 'meta.yml' + meta_path = snap_dir / SNAP_META_FILE def reject(reason: str) -> None: raise SnapshotError( @@ -1433,7 +1433,7 @@ def _reject_invalid_group(name: str, reject) -> None: def _load_snap_meta(self, snap_dir: Path) -> dict: """個別スナップショットのmeta.ymlを読み込む""" - meta_path = snap_dir / 'meta.yml' + meta_path = snap_dir / SNAP_META_FILE if meta_path.exists(): with open(meta_path) as f: return yaml.safe_load(f) or {} @@ -1441,6 +1441,6 @@ def _load_snap_meta(self, snap_dir: Path) -> dict: def _save_snap_meta(self, snap_dir: Path, meta: dict) -> None: """個別スナップショットのmeta.ymlを保存する""" - meta_path = snap_dir / 'meta.yml' + meta_path = snap_dir / SNAP_META_FILE with open(meta_path, 'w') as f: yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) From 1bd5318a3918ed6e7bec91a4289d551ada1aa5a3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:00:58 +0900 Subject: [PATCH 14/20] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#CONTAINER=5FHOST?= =?UTF-8?q?=5FNAME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コンテナからホストを指す名前を volume/compose.py の HOST_GATEWAY_NAME に寄せる。 BAO_ADDR の書き換え先と extra_hosts に足す名前を同じ定数から取る。 container.py は既に devbase.volume.compose に依存しており、依存の向きは変わらない。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index dca42be6..58b36414 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -17,6 +17,7 @@ from devbase.log import get_logger from devbase.volume.manager import ensure_volumes from devbase.volume.compose import ( + HOST_GATEWAY_NAME, generate_scaled_compose, get_dev_service_name, ) @@ -938,15 +939,11 @@ def _bao_environment() -> dict: return {'BAO_ADDR': store.config.openbao.url} -#: コンテナからホストを指す名前 (#295)。Linux の Docker では ``host-gateway`` の行で引ける -CONTAINER_HOST_NAME = 'host.docker.internal' - - def container_bao_address(url: Optional[str], *, remote: bool) -> Optional[str]: """コンテナから見た接続先 (#295)。置き換えないときは ``None``。 ``openbao.url`` が ``http`` でホストが loopback、かつ docker の接続先がローカル扱いの - ときだけ、ホストを :data:`CONTAINER_HOST_NAME` に置き換える。コンテナの中の + ときだけ、ホストを :data:`~devbase.volume.compose.HOST_GATEWAY_NAME` に置き換える。コンテナの中の ``127.0.0.1`` はコンテナ自身を指し、ホストのサーバへ届かないため。スキームと ポートは保つ。 @@ -964,7 +961,7 @@ def container_bao_address(url: Optional[str], *, remote: bool) -> Optional[str]: parts = urlsplit(url) if parts.scheme != 'http' or parts.hostname not in _LOOPBACK_HOSTS: return None - netloc = CONTAINER_HOST_NAME + netloc = HOST_GATEWAY_NAME if parts.port is not None: netloc = f'{netloc}:{parts.port}' return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) From 1899b3719c7ef89de8a654f2da2e4cbfe5c38bd3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:01:31 +0900 Subject: [PATCH 15/20] =?UTF-8?q?Refactor:=20extract=5Ftest=5Fhelper=20?= =?UTF-8?q?=E2=80=94=20tests/snapshot/test=5Frestore=5Fincremental.py#=5Fr?= =?UTF-8?q?un=5Fdocker=5Ftar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StubManager._run_docker_tar の backup の分岐を fake_tar.write_backup_outputs に寄せ、 コマンドが書く名前のファイルを置く。前準備(backup のコマンドを通らない)の直書きは残す。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/snapshot/test_restore_incremental.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py index 6ade88e9..4753aeee 100644 --- a/tests/snapshot/test_restore_incremental.py +++ b/tests/snapshot/test_restore_incremental.py @@ -29,6 +29,8 @@ ) from tests.conftest import HOST_DOCKER_ENV +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): """復元前の自動バックアップがグループを解決するので、環境で揺らさない (宣言済みの acme)。""" @@ -132,8 +134,7 @@ def __init__(self, root: Path, failures: dict[str, str]): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): if mode == 'backup': - (snap_dir / 'full.tar.zst').write_text('archive') - (snap_dir / 'snapshot.snar').write_text('snar') + write_backup_outputs(snap_dir, command) return archive = self._archive_in(command) if archive is None: From 9e226fbbf2c4e3baac4265d700495fea21e03e4d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:02:29 +0900 Subject: [PATCH 16/20] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/volume/compose.py#=5Fadd=5Fhost=5Fgatew?= =?UTF-8?q?ay=EF=BC=88=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extra_hosts が解釈できない形(文字列)のときに触らないことを現状固定テストで押さえる。 list / dict / None の場合は既存のテストが押さえている。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/volume/test_compose_host_gateway.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/volume/test_compose_host_gateway.py b/tests/volume/test_compose_host_gateway.py index 5037b594..848a7a54 100644 --- a/tests/volume/test_compose_host_gateway.py +++ b/tests/volume/test_compose_host_gateway.py @@ -90,6 +90,14 @@ def test_an_existing_host_line_is_not_duplicated(project, extra_hosts): assert _env(services['dev-1'])['BAO_ADDR'] == 'http://host.docker.internal:8200' +def test_unknown_extra_hosts_shape_is_left_untouched(project): + dev = {'image': 'alpine', 'extra_hosts': 'db.local:10.0.0.5'} + + services = _generate(project, dev, scale=1, host_gateway_environment=GATEWAY) + + assert services['dev-1']['extra_hosts'] == 'db.local:10.0.0.5' + + def test_host_network_dev_service_keeps_the_loopback_address(project): dev = {'image': 'alpine', 'network_mode': 'host'} From de1e623e6a608e94c8b653e10d81a1e2f455222c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:02:39 +0900 Subject: [PATCH 17/20] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/volume/compose.py#=5Fadd=5Fhost=5Fgatew?= =?UTF-8?q?ay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _extra_host_names の辞書形の判定を _add_host_gateway と同じ isinstance(existing, dict) に揃え、 名前の読み取りと追記で形の分類が食い違わないようにする。extra_hosts は yaml.safe_load の 出力なので、辞書形は常に dict であり振る舞いは変わらない。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/volume/compose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index bae0dfd4..106e71e4 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -459,7 +459,7 @@ def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: def _extra_host_names(existing: Any) -> Set[str]: """``extra_hosts`` (リスト形 ``name:ip`` / ``name=ip`` と辞書形) に書かれた名前。""" - if isinstance(existing, Mapping): + if isinstance(existing, dict): return {str(name) for name in existing} names: Set[str] = set() if isinstance(existing, list): From 72e0217ee6cd3de1d9ed352f1f775bb5f7a179e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:03:19 +0900 Subject: [PATCH 18/20] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/snapshot/manager.py#SnapshotManager.=5F?= =?UTF-8?q?replace=5Fsnap=5Fmeta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit メタデータと meta.yml を YAML で書く open と yaml.dump の組をモジュールの _dump_yaml に寄せ、 _save_snap_meta・_replace_snap_meta・_save_metadata から呼ぶ。書式の知識を 1 か所にする。 _replace_snap_meta は仮の名前へ _dump_yaml したあと os.replace する形のまま。 Item-Id: I-007 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 39633dfa..52c80b74 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -92,6 +92,13 @@ def is_archive_file(name: str) -> bool: """アーカイブ実体 (full / incr-NNN) の名前か。meta.yml・snar・.bak は含めない。""" return name == FULL_ARCHIVE or incr_archive_number(name) is not None + +def _dump_yaml(path: Path, data: dict) -> None: + """``data`` を YAML で ``path`` へ書く (メタデータ・``meta.yml`` の共通の書式)。""" + with open(path, 'w') as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + + # GNU tar の incremental はディレクトリを (dev, ino) で追跡して rename を検出する。 # ディレクトリが削除され作り直されると **inode 番号が再利用される**ため、tar は無関係な # ディレクトリを rename されたものと誤判定し、dumpdir に偽の R/T レコードを書く。 @@ -1207,8 +1214,7 @@ def _install_rebuilt_full(self, snap_dir: Path, meta: dict) -> int: def _replace_snap_meta(self, snap_dir: Path, meta: dict) -> None: """``meta.yml`` を仮の名前へ書いてから改名で置き換える (途中まで書いた形を残さない)。""" tmp = snap_dir / META_FILE_TMP - with open(tmp, 'w') as f: - yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + _dump_yaml(tmp, meta) os.replace(tmp, snap_dir / SNAP_META_FILE) @staticmethod @@ -1340,8 +1346,7 @@ def _normalize_created_at(entry: dict) -> None: def _save_metadata(self, meta: dict) -> None: """グローバルメタデータを保存する""" - with open(self._metadata_path, 'w') as f: - yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + _dump_yaml(self._metadata_path, meta) def snapshot_volumes(self, snap_dir: Path) -> dict: """スナップショットの対象ボリュームを、そのメタデータから解決する。 @@ -1441,6 +1446,4 @@ def _load_snap_meta(self, snap_dir: Path) -> dict: def _save_snap_meta(self, snap_dir: Path, meta: dict) -> None: """個別スナップショットのmeta.ymlを保存する""" - meta_path = snap_dir / SNAP_META_FILE - with open(meta_path, 'w') as f: - yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + _dump_yaml(snap_dir / SNAP_META_FILE, meta) From cac6b7514ffc50e330b3aefaa40133673c01f5b2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:22:24 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20=E4=B8=96=E4=BB=A3=E3=81=AE?= =?UTF-8?q?=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=97=E3=81=A7=20tar=20=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E6=8B=BE=E3=81=84=E3=80=81=E7=AB=AF?= =?UTF-8?q?=E6=9C=AB=E3=81=AE=E5=A4=B1=E5=8A=B9=E3=81=A7=E7=99=BA=E8=A1=8C?= =?UTF-8?q?=E6=B8=88=E3=81=BF=20token=20=E3=82=82=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 作り直しの控えのコマンドに PIPESTATUS の検査を足す。tar が読み取りで 止まっても zstd が成功すると入れ替えへ進み、前の full と差分を消していた - 端末を手放す手順に、その端末へ発行済みの token を accessor で失効させる 操作を足す。secret_id の破棄だけでは ~/.vault-token が最大 1 時間使える Co-Authored-By: Claude Opus 5.5 --- docs/user/openbao-server.md | 21 ++++++++++++++++++- lib/devbase/snapshot/manager.py | 17 ++++++++++++++- tests/snapshot/fake_tar.py | 4 ++-- tests/snapshot/test_backup_real_tar.py | 25 +++++++++++++++++++++-- tests/snapshot/test_rebuild_generation.py | 4 +++- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/docs/user/openbao-server.md b/docs/user/openbao-server.md index ea0a5944..cfbf3761 100644 --- a/docs/user/openbao-server.md +++ b/docs/user/openbao-server.md @@ -366,7 +366,26 @@ done bao_admin write "auth/approle/role/$u/secret-id-accessor/destroy" secret_id_accessor= ``` -失効させた端末では `devbase env backend test` が「認証が拒まれました」で止まります。 +`secret_id` を消しても止まるのは新しいログインだけで、その端末へ発行済みの token(コンテナの +`~/.vault-token`)は期限(最大 1 時間)まで読み書きできます。続けて、その端末の token も失効させます。 +token には発行に使った `secret_id` の metadata(`terminal`)とロールの名前(`role_name`)が載るため、 +その 2 つで選びます。 + +```bash +t=laptop01 # 失効させる端末の名前 (secret_id の metadata の terminal) +for a in $(bao_admin list -format=json auth/token/accessors | python3 -c 'import json,sys; print(" ".join(json.load(sys.stdin)))'); do + m=$(bao_admin write -format=json auth/token/lookup-accessor accessor="$a" \ + | python3 -c 'import json,sys; m=json.load(sys.stdin)["data"].get("meta") or {}; print(m.get("role_name", ""), m.get("terminal", ""))') + if [ "$m" = "$u $t" ]; then + bao_admin write auth/token/revoke-accessor accessor="$a" + fi +done +``` + +`secret_id` を失効させた端末では、`devbase env backend test` が「認証が拒まれました」で止まります。 +このコマンドは毎回 `secret_id` で新しくログインするため、確かめられるのは新しいログインが止まったこと +だけです。発行済みの token が残っていないことは、上の手順をもう一度打って `revoke-accessor` が 1 度も +走らないことで確かめます。 利用者ごと外すときは、ロールと entity を消します(`bao_admin delete auth/approle/role/` と `bao_admin delete identity/entity/name/`)。その利用者の全端末が止まります。 diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 52c80b74..805a0d1a 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1028,6 +1028,20 @@ def backup_command(archive: str, volumes: dict, excludes=(), snar: str = SNAR_FI f"-cf - -C {BACKUP_ROOT} {members} | zstd -1 -T0 -o {ARCHIVE_MOUNT}/{archive}" ) + @staticmethod + def checked_backup_command(command: str) -> str: + """``backup_command`` の終了コードに tar の失敗も載せる (世代の作り直し用)。 + + パイプの終了コードは zstd のものなので、tar が読み取りで止まっても成功に見える。 + 作り直しはこの後で前の full と差分を消すため、tar の致命的な失敗 (2 以上) と + zstd の失敗のどちらでも止める。tar の 1 (読む間にファイルが変わった) は、 + 新しい世代へ直接控えるときと同じく通す。 + """ + return ( + f"{command}; s=(\"${{PIPESTATUS[@]}}\"); " + f"[ \"${{s[0]}}\" -le 1 ] && [ \"${{s[1]}}\" -eq 0 ]" + ) + @staticmethod def restore_command(archive: str) -> str: """アーカイブ 1 本を復元先へ展開するコマンドを組み立てる。 @@ -1145,7 +1159,8 @@ def _create_full(self, name: str, snap_dir: Path, try: self._run_docker_tar( snap_dir, 'backup', - self.backup_command(FULL_ARCHIVE_TMP, volumes, excludes, snar=SNAR_FILE_TMP), + self.checked_backup_command(self.backup_command( + FULL_ARCHIVE_TMP, volumes, excludes, snar=SNAR_FILE_TMP)), volumes, ) except SnapshotError: diff --git a/tests/snapshot/fake_tar.py b/tests/snapshot/fake_tar.py index cfe4f052..10295760 100644 --- a/tests/snapshot/fake_tar.py +++ b/tests/snapshot/fake_tar.py @@ -10,8 +10,8 @@ import re from pathlib import Path -_ARCHIVE_RE = re.compile(r"-o /backup/(\S+)") -_SNAR_RE = re.compile(r"--listed-incremental=/backup/(\S+)") +_ARCHIVE_RE = re.compile(r"-o /backup/([^\s;]+)") +_SNAR_RE = re.compile(r"--listed-incremental=/backup/([^\s;]+)") def write_backup_outputs(snap_dir: Path, command: str) -> None: diff --git a/tests/snapshot/test_backup_real_tar.py b/tests/snapshot/test_backup_real_tar.py index e0befea0..fdee9c98 100644 --- a/tests/snapshot/test_backup_real_tar.py +++ b/tests/snapshot/test_backup_real_tar.py @@ -14,6 +14,7 @@ import pytest +from devbase.errors import SnapshotCommandError, SnapshotError from devbase.snapshot.exclude import DEFAULT_EXCLUDES from devbase.snapshot.manager import ( ARCHIVE_MOUNT, @@ -224,8 +225,12 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): paths = {"backup": str(snap_dir), "source": str(self.source), "target": str(self.target)} command = _MOUNT_RE.sub(lambda m: paths[m.group(1)], command) - return subprocess.run(["bash", "-c", command], check=True, - capture_output=True, text=True) + try: + return subprocess.run(["bash", "-c", command], check=True, + capture_output=True, text=True) + except subprocess.CalledProcessError as e: + raise SnapshotCommandError(f"tar の操作に失敗しました: {e.stderr}", + stderr=e.stderr or "") from e def _rebuild_setup(tmp_path: Path) -> tuple: @@ -272,3 +277,19 @@ def test_incremental_after_rebuild_holds_only_later_changes(tmp_path): mgr.create(name="g") snap_dir = tmp_path / "backups" / "g" assert _members(snap_dir / incr_archive_name(1)) == ["ai/d"] + + +def test_rebuild_stops_when_tar_fails_even_if_zstd_succeeds(tmp_path): + """tar が読み取りで止まっても zstd は成功する。前の full と差分を消さずに止める。""" + mgr, ai = _rebuild_setup(tmp_path) + (ai / "e").write_text("e1") + mgr.create(name="g") + snap_dir = tmp_path / "backups" / "g" + before = {p.name: p.read_bytes() for p in snap_dir.iterdir()} + assert incr_archive_name(1) in before + shutil.rmtree(tmp_path / "source" / "group") # tar は Cannot stat で 2 を返す + + with pytest.raises(SnapshotError): + mgr.create(name="g", full=True) + + assert {p.name: p.read_bytes() for p in snap_dir.iterdir()} == before diff --git a/tests/snapshot/test_rebuild_generation.py b/tests/snapshot/test_rebuild_generation.py index 59aef9a3..63f87e12 100644 --- a/tests/snapshot/test_rebuild_generation.py +++ b/tests/snapshot/test_rebuild_generation.py @@ -126,7 +126,9 @@ def test_rebuild_leaves_only_the_new_full(tmp_path, caplog): # 前の snar を tar に渡さず、仮の名前へ控える [call] = mgr.calls assert f"--listed-incremental=/backup/{SNAR_FILE_TMP} " in call["command"] - assert call["command"].endswith(f"-o /backup/{FULL_ARCHIVE_TMP}") + assert f"-o /backup/{FULL_ARCHIVE_TMP};" in call["command"] + # 入れ替えの前に tar の失敗も拾う (パイプの終了コードは zstd のもの) + assert "PIPESTATUS" in call["command"] rebuilt = [r.getMessage() for r in caplog.records if r.levelno == logging.INFO and "作り直しました" in r.getMessage()] From d313de5c46cca214809d8b6d0fbe2db008ef21de Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 00:29:18 +0900 Subject: [PATCH 20/20] =?UTF-8?q?docs:=20=E7=AB=AF=E6=9C=AB=E3=81=AE=20tok?= =?UTF-8?q?en=20=E5=A4=B1=E5=8A=B9=E3=81=AE=E4=BE=8B=E3=81=A8=E4=BD=9C?= =?UTF-8?q?=E3=82=8A=E7=9B=B4=E3=81=97=E3=81=AE=E6=8E=A7=E3=81=88=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=81=AE=E5=AE=9A=E7=BE=A9=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=99=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openbao-server: 失効の例を member02 の端末 desktop02 に揃え、失効した accessor を出して 1 回目に 1 件も当たらない取り違えを見分けられるようにする - snapshot-series: 作り直しの控えの失敗を tar の 2 以上または zstd の 0 以外で判定する (checked_backup_command) と明記し、テストの節に tar の失敗で世代が変わらないことを足す Co-Authored-By: Claude Opus 5.5 --- docs/specifications/snapshot-series.md | 14 ++++++++++---- docs/user/openbao-server.md | 11 ++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 6e8b5810..90fe72e3 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -261,7 +261,11 @@ classDiagram 1. 前の中断の残りの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を消す 2. 前の `snapshot.snar` を tar に渡さず、空から始まる `snapshot.snar.new` と `full.tar.zst.new` へ控える - (tar はレベル 0 でボリュームの全体を控える)。除外の一覧は新しい世代と同じ規則(`_excludes_for`)で決める + (tar はレベル 0 でボリュームの全体を控える)。除外の一覧は新しい世代と同じ規則(`_excludes_for`)で決める。 + 控えのコマンドは `checked_backup_command` で包み、tar の終了コードが 2 以上、または zstd の終了コードが + 0 以外のときに失敗とする。パイプ全体の終了コード(zstd のもの)だけでは判定しない。tar が読み取りで + 止まっても zstd は成功するため、パイプの終了コードだけで判定すると、壊れた控えで前の full と差分を消してしまう。 + tar の 1(読む間にファイルが変わった)は、新しい世代へ直接控えるときと同じく通す 3. 控えが成功したら `_install_rebuilt_full` が次の順に 1 つずつ入れ替える。改名は同じディレクトリの中の `os.replace` で、ファイルを写さない 1. `snapshot.snar.bak` と `snapshot.snar` を消す @@ -279,7 +283,7 @@ classDiagram 常に成り立つ条件: -- 控えのコマンドが失敗したら、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは +- 控えのコマンドが失敗したら(手順 2 の定義: tar が 2 以上、または zstd が 0 以外)、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは 1 バイトも変わらず、ファイルは増えない - 入れ替え(手順 3)の途中で止まった世代は `snapshot.snar` を持たない。次の `create`(名前の指定・自動 スナップショット)は snar の無い世代の経路で作り直す @@ -342,7 +346,7 @@ classDiagram | `rotate` の `keep` / `max_total` が 1 未満 | `SnapshotError`。何も消さず `snapshot.yml` も書かない | | `rotate` の候補が `_safe_snap_dir` を通らない | ディレクトリを消さず、一覧から外して警告。他の候補の削除は続ける | | `create` / `restore` / `copy` / `delete` の世代が `_safe_snap_dir` を通らない | `SnapshotError` → CLI は終了コード 1 | -| 世代の作り直しの控えのコマンドが失敗 | 仮の名前のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ CLI は終了コード 1。世代は打つ前のまま | +| 世代の作り直しの控えのコマンドが失敗(tar の終了コードが 2 以上、または zstd が 0 以外。`checked_backup_command`) | 仮の名前のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ CLI は終了コード 1。世代は打つ前のまま | | 世代の作り直しの入れ替えが失敗 | 仮の名前のファイルを消して `SnapshotError`(`世代 '{名前}' の作り直しを終えられませんでした: {理由}。もう一度 devbase snapshot create --name {名前} --full を打ってください`)→ CLI は終了コード 1 | | `meta.yml` に `rebuilding: true` を持つ世代の `restore` | 復元前バックアップを作らず、ボリュームを消さずに `SnapshotError`(`世代 '{名前}' は作り直しの途中で止まっているため復元できません。devbase snapshot create --name {名前} --full を打って作り直してください`)→ CLI は終了コード 1 | | 仮の名前のファイルを消せない | 警告 1 行で続ける。次の作り直しが控えの前に消す | @@ -416,7 +420,9 @@ CLI と TUI: - 差分を持つ世代へ `--full` を打つと、`full.tar.zst`・`snapshot.snar`・`meta.yml` だけが残り、`meta.yml` が full の形で今の除外の一覧を持ち、`snapshot.yml` のエントリが 1 件のまま `created_at` を変えないこと。 - 控えのコマンドが前の snar を受け取らないこと + 控えのコマンドが前の snar を受け取らないこと。控えのコマンドが tar の終了コード(`PIPESTATUS`)も見ること +- 実の GNU tar と zstd で、tar が読み取りで止まり(終了コード 2)zstd が成功しても、作り直しが `SnapshotError` で + 止まり、世代のファイル(前の full と差分)のバイト列が変わらないこと - 控えのコマンドの失敗で世代のファイルのバイト列と `snapshot.yml` が変わらないこと。入れ替えの各段で 止めると `SnapshotError` になり、snar と仮の名前のファイルが残らず、次の差分の指定が作り直すこと - 別のボリュームの組で作り直して止めた世代は、印があれば `restore` が docker を 1 回も呼ばずに止まり、 diff --git a/docs/user/openbao-server.md b/docs/user/openbao-server.md index cfbf3761..afd70568 100644 --- a/docs/user/openbao-server.md +++ b/docs/user/openbao-server.md @@ -372,11 +372,12 @@ token には発行に使った `secret_id` の metadata(`terminal`)とロー その 2 つで選びます。 ```bash -t=laptop01 # 失効させる端末の名前 (secret_id の metadata の terminal) +t=desktop02 # 失効させる端末の名前 (secret_id の metadata の terminal。上の u の端末) for a in $(bao_admin list -format=json auth/token/accessors | python3 -c 'import json,sys; print(" ".join(json.load(sys.stdin)))'); do m=$(bao_admin write -format=json auth/token/lookup-accessor accessor="$a" \ | python3 -c 'import json,sys; m=json.load(sys.stdin)["data"].get("meta") or {}; print(m.get("role_name", ""), m.get("terminal", ""))') if [ "$m" = "$u $t" ]; then + echo "revoke $a" bao_admin write auth/token/revoke-accessor accessor="$a" fi done @@ -384,8 +385,12 @@ done `secret_id` を失効させた端末では、`devbase env backend test` が「認証が拒まれました」で止まります。 このコマンドは毎回 `secret_id` で新しくログインするため、確かめられるのは新しいログインが止まったこと -だけです。発行済みの token が残っていないことは、上の手順をもう一度打って `revoke-accessor` が 1 度も -走らないことで確かめます。 +だけです。発行済みの token は次の 2 つで確かめます。 + +1. 1 回目に打ったとき、失効させた token の accessor が `revoke ` の行で出ること。 + 1 行も出なければ、`u` と `t` が端末の `secret_id` の metadata と合っていません(その端末へ + 発行済みの token が無いときも 1 行も出ません)。上の `secret-id` の一覧で端末の名前を確かめ直します +2. もう一度打って、`revoke` の行が 1 行も出ないこと(残っている token が無いこと) 利用者ごと外すときは、ロールと entity を消します(`bao_admin delete auth/approle/role/` と `bao_admin delete identity/entity/name/`)。その利用者の全端末が止まります。