diff --git a/PROJECT.md b/PROJECT.md index 39093f9..6d50506 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,7 +3,7 @@ title: UnifyDrive2NAS の現状 tags: - homelab - UnifyDrive2NAS -status: コピーコア実装済み +status: DAG 定義・配置手順作成済み --- # UnifyDrive2NAS の現状 @@ -15,7 +15,7 @@ status: コピーコア実装済み - Python 3.8 の標準ライブラリと同梱した `exifread` 3.5.1 だけで動くコピーコアを実装済み - コピー済み一覧、初期化、ドライラン、二重起動防止、マウント確認を含む -- NAS への配置と Dagu の DAG 定義は未実施 +- Dagu の DAG 定義と配置手順を作成済み。NAS と Dagu VM への実配置、初期化、定期実行の開始は未実施 ## 目的 @@ -44,6 +44,11 @@ status: コピーコア実装済み | 隠しファイル | **先頭が `.` のファイル・フォルダはコピーしない**(`.<元の名前>.z<16 進 32 桁>` の書きかけと推定されるファイルを含む)(ユーザー判断、2026-10-12) | | カメラの管理フォルダ内の動画 | **コピー対象とする**。Sony の動画は `PlugBackup/<回>/.../PRIVATE/M4ROOT/CLIP/*.MP4` にあり(2026-10-12 時点で 9 本・約 5.4 GB)、`mvhd` を JST に変換した時刻が同じフォルダの `*M01.XML` の `CreationDate` と一致することを確認済み(ユーザー判断、2026-10-12) | | コピー済み DB の既定値 | `~/.local/state/unifydrive2nas/copied.sqlite3`。`--database` で変更できる | +| 実行時刻 | **毎日 04:00(Asia/Tokyo)**。Dagu の `DAGU_TZ=Asia/Tokyo` を前提に `schedule: "0 4 * * *"` とする(ユーザー判断、2026-10-12) | +| NAS 上の配置先 | `/volume1/homes/kazu634/bin/unifydrive2nas/`。`unifydrive2nas.py` と `vendor/` を置き、`tests/` は配置しない。DB は既定の `~/.local/state/unifydrive2nas/copied.sqlite3` を使う(ユーザー判断、2026-10-12) | +| DAG の正本 | このリポジトリの `deploy/dagu/unifydrive2nas.yaml`。Dagu VM(`192.168.10.156`)の `/var/lib/dagu/dags/` へ手で配置し、Dagu 用リポジトリができたら移す(ユーザー判断、2026-10-12) | +| リポジトリ | Gitea の非公開リポジトリ `kazu634/UnifyDrive2NAS`。リポジトリの作成はユーザーが行う(ユーザー判断、2026-10-12) | +| 本番開始 | 配置した日に `--init` を 1 回だけ実行し、翌朝 04:00 から定期実行を始める(ユーザー判断、2026-10-12) | ## 要件 @@ -67,6 +72,8 @@ status: コピーコア実装済み | `unifydrive2nas.py` | ファイル列挙、日時判定、コピー、SQLite 記録、安全装置を持つ CLI | | `vendor/exifread/` | `exifread` 3.5.1 と BSD-2-Clause ライセンス | | `tests/test_unifydrive2nas.py` | 最小の画像・動画バイト列を生成して実行する標準ライブラリ `unittest` | +| `tests/test_deployment.py` | DAG と PROJECT.md の配置契約を確認する静的テスト | +| `deploy/dagu/unifydrive2nas.yaml` | Dagu VM へ手動配置する DAG の正本 | SQLite の `copied_files` は、コピー元からの相対パス・サイズ・更新日時(nanosecond)を複合主キーとする。コピー先、日付の出どころ、処理種別、処理日時も記録する。 @@ -81,7 +88,7 @@ SQLite の `copied_files` は、コピー元からの相対パス・サイズ・ | `--database` | `~/.local/state/unifydrive2nas/copied.sqlite3` | > [!warning] 初回運用 -> 本番開始の前日に `python3 unifydrive2nas.py --init` を 1 回実行する。`--init` は現在の対象を DB に登録するだけで、コピーしない。事前確認には `--dry-run` を付ける。 +> 配置した日に `python3 unifydrive2nas.py --init` を 1 回実行する。`--init` は現在の対象を DB に登録するだけで、コピーしない。事前確認には `--dry-run` を付け、初期化が成功する前に DAG を手動実行しない。 通常実行とドライランの例: @@ -90,6 +97,67 @@ python3 unifydrive2nas.py python3 unifydrive2nas.py --dry-run ``` +### 配置手順 + +ローカルから NAS へ直接接続する鍵は無いため、踏み台、Dagu VM、NAS の順に SSH を中継する。以下はリポジトリのルートで実行する。 + +1. `unifydrive2nas.py` と `vendor/` だけを NAS へ配置する。`tests/` は転送対象に含めない。 + + ```bash + COPYFILE_DISABLE=1 tar --no-xattrs -cf - --exclude='__pycache__' unifydrive2nas.py vendor \ + | ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"mkdir -p /volume1/homes/kazu634/bin/unifydrive2nas && tar -C /volume1/homes/kazu634/bin/unifydrive2nas -xf -\""' + ``` + +2. NAS 上のスクリプトをドライランし、対象と失敗件数を確認する。 + + ```bash + ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"python3 /volume1/homes/kazu634/bin/unifydrive2nas/unifydrive2nas.py --dry-run\""' + ``` + +3. ドライランに問題が無ければ、配置した日に NAS 上で初期化を 1 回だけ実行する。 + + ```bash + ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"python3 /volume1/homes/kazu634/bin/unifydrive2nas/unifydrive2nas.py --init\""' + ``` + +4. DAG の正本を、スケジュール対象外の Dagu VM `/tmp` へ一時配置する。 + + ```bash + COPYFILE_DISABLE=1 tar --no-xattrs -cf - -C deploy/dagu unifydrive2nas.yaml \ + | ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "tar -C /tmp -xf -"' + ``` + +5. `/tmp` の YAML を検証する。 + + ```bash + ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu validate /tmp/unifydrive2nas.yaml"' + ``` + +6. `dagu dry` で変数展開後のコマンドを確認する。この操作は実際の SSH コマンドを実行しない。 + + ```bash + ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu dry /tmp/unifydrive2nas.yaml"' + ``` + +7. 初期化成功後に `/tmp` の YAML を手動実行し、接続と終了コードを確認する。初期化直後なのでコピーは 0 件となる想定。終了コード 1 または 2 は `ssh.run` の失敗となり、Dagu の失敗通知に委ねる。 + + ```bash + ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu start /tmp/unifydrive2nas.yaml"' + ``` + +8. 手動実行まで成功してから、DAG を `/var/lib/dagu/dags/` へ配置してスケジュールを有効にする。一時ファイルも削除する。 + + ```bash + COPYFILE_DISABLE=1 tar --no-xattrs -cf - -C deploy/dagu unifydrive2nas.yaml \ + | ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "tar -C /var/lib/dagu/dags -xf - && rm -f /tmp/unifydrive2nas.yaml"' + ``` + +> [!danger] 対話シェルでは `DAGU_HOME` を必ず付ける +> Dagu サービスは root だけが読める `/etc/dagu/dagu.env` から `DAGU_HOME=/var/lib/dagu` を受け取るが、`kazu634` の対話シェルには継承されない。付けずに `dagu validate` / `dagu dry` / `dagu start` を実行すると `~/.config/dagu` が作られ、サービスとは別の設定・実行履歴を使ってしまう。 + +> [!note] スケジュールと二重起動 +> Dagu VM は OS と Dagu の両方が `Asia/Tokyo` に設定済みで、`0 4 * * *` は JST 04:00 として解釈される。DAG 全体の上限は 7,200 秒。`overlap_policy: skip` は catchup の重複を抑え、通常の二重起動はスクリプト側の `fcntl.flock` が最終的に防ぐ。 + ## 外部とのつながり | 相手 | アドレス | 用途 | 根拠 | @@ -269,7 +337,8 @@ python3 unifydrive2nas.py --dry-run ## 課題 -- NAS への配置と Dagu の DAG 定義を行う +- NAS と Dagu VM へ配置し、`--init` と DAG の検証・手動実行を行う +- Gitea に非公開リポジトリ `kazu634/UnifyDrive2NAS` を作成する - マウントが NAS 再起動後に自動復旧するか確認する([[#未決事項]]) ## 経緯 @@ -280,6 +349,7 @@ python3 unifydrive2nas.py --dry-run - 2026-10-12: Dagu → NAS の SSH 鍵を登録。NAS 上の純 Python で撮影日時が取れることを確認 - 2026-10-12: Python のコピーコア、SQLite のコピー済み一覧、安全装置、単体テストを実装 - 2026-10-12: Apple 作成日時の読み取りを、version/flags の無い QuickTime 形式と FullBox 形式の両方の `meta` に対応 +- 2026-10-12: 毎日 04:00 に NAS 上のコピー処理を呼ぶ Dagu DAG と手動配置手順を作成 ## 確かめ方 @@ -289,15 +359,31 @@ python3 unifydrive2nas.py --dry-run python3 -m unittest discover -s tests -v ``` +DAG と配置手順の静的契約テスト: + +```bash +python3 -m unittest tests.test_deployment -v +``` + CLI と引数の確認: ```bash python3 unifydrive2nas.py --help ``` -NAS では配置先で同じ単体テストを実行した後、実マウントを使うドライランで対象と日付の出どころを確認する。 +NAS の本番配置には `tests/` を置かない。NAS で単体テストを再実行するときは、`unifydrive2nas.py`・`vendor/`・`tests/` を `/tmp/unifydrive2nas-test` へ一時展開して実行し、終了後に削除する。その後、本番配置したスクリプトのドライランで対象と日付の出どころを確認する。 ```bash -python3 -m unittest discover -s tests -v -python3 unifydrive2nas.py --dry-run +COPYFILE_DISABLE=1 tar --no-xattrs -cf - --exclude='__pycache__' unifydrive2nas.py vendor tests \ + | ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"rm -rf /tmp/unifydrive2nas-test && mkdir -p /tmp/unifydrive2nas-test && tar -C /tmp/unifydrive2nas-test -xf -\""' +ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"cd /tmp/unifydrive2nas-test && python3 -W error::ResourceWarning -m unittest discover -s tests -v\""' +ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"rm -rf /tmp/unifydrive2nas-test\""' +ssh bastion 'ssh -p 10022 kazu634@192.168.10.156 "ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201 \"python3 /volume1/homes/kazu634/bin/unifydrive2nas/unifydrive2nas.py --dry-run\""' +``` + +Dagu VM では、スケジュールを有効にする前に `/tmp` の YAML を Dagu 2.13.0 の validator と dry-run で確認する。 + +```bash +DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu validate /tmp/unifydrive2nas.yaml +DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu dry /tmp/unifydrive2nas.yaml ``` diff --git a/deploy/dagu/unifydrive2nas.yaml b/deploy/dagu/unifydrive2nas.yaml new file mode 100644 index 0000000..7a00d8e --- /dev/null +++ b/deploy/dagu/unifydrive2nas.yaml @@ -0,0 +1,16 @@ +description: Unify Drive の新しい画像・動画を Synology NAS の PhotoLibrary へコピーする +schedule: "0 4 * * *" +overlap_policy: skip +timeout_sec: 7200 + +steps: + - id: copy_unifydrive_to_nas + action: ssh.run + with: + host: 192.168.10.201 + port: 22 + user: kazu634 + key: /home/kazu634/.ssh/id_ed25519_nas + strict_host_key: true + known_host_file: /home/kazu634/.ssh/known_hosts + command: python3 /volume1/homes/kazu634/bin/unifydrive2nas/unifydrive2nas.py diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..19fabc0 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,162 @@ +from pathlib import Path +import re +import unittest + +import unifydrive2nas as app + + +ROOT = Path(__file__).resolve().parents[1] +DAG_PATH = ROOT / "deploy" / "dagu" / "unifydrive2nas.yaml" +PROJECT_PATH = ROOT / "PROJECT.md" +INSTALL_DIRECTORY = "/volume1/homes/kazu634/bin/unifydrive2nas" +SCRIPT_PATH = INSTALL_DIRECTORY + "/unifydrive2nas.py" +DAG_STAGING_PATH = "/tmp/unifydrive2nas.yaml" +DAGU_COMMAND = "DAGU_HOME=/var/lib/dagu /usr/local/bin/dagu" + + +class DaguDeploymentContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dag = DAG_PATH.read_text(encoding="utf-8") + cls.project = PROJECT_PATH.read_text(encoding="utf-8") + + def dag_value(self, key): + pattern = r"^\s*" + re.escape(key) + r":\s*(.*?)\s*$" + values = re.findall(pattern, self.dag, flags=re.MULTILINE) + self.assertEqual(1, len(values), "DAG の {0} は一意であること".format(key)) + return values[0].strip('"\'') + + def test_schedule_and_execution_guards(self): + self.assertEqual("0 4 * * *", self.dag_value("schedule")) + self.assertEqual("skip", self.dag_value("overlap_policy")) + self.assertEqual("7200", self.dag_value("timeout_sec")) + self.assertIn("毎日 04:00(Asia/Tokyo)", self.project) + self.assertIn('schedule: "0 4 * * *"', self.project) + + def test_ssh_target_and_single_command(self): + expected = { + "action": "ssh.run", + "host": "192.168.10.201", + "port": "22", + "user": "kazu634", + "key": "/home/kazu634/.ssh/id_ed25519_nas", + "strict_host_key": "true", + "known_host_file": "/home/kazu634/.ssh/known_hosts", + "command": "python3 " + SCRIPT_PATH, + } + for key, value in expected.items(): + with self.subTest(key=key): + self.assertEqual(value, self.dag_value(key)) + self.assertIn("| Synology `ds923p` | `192.168.10.201`", self.project) + self.assertIn("ssh -i ~/.ssh/id_ed25519_nas kazu634@192.168.10.201", self.project) + self.assertIn("`" + INSTALL_DIRECTORY + "/`", self.project) + + def test_ssh_settings_are_nested_in_one_step_with_block(self): + match = re.search( + r"^steps:\n" + r" - id: copy_unifydrive_to_nas\n" + r" action: ssh\.run\n" + r" with:\n" + r"((?: [^\n]+\n?)+)$", + self.dag, + flags=re.MULTILINE, + ) + self.assertIsNotNone(match) + with_block = match.group(1) + for key in ( + "host", + "port", + "user", + "key", + "strict_host_key", + "known_host_file", + "command", + ): + with self.subTest(key=key): + self.assertRegex(with_block, r"(?m)^ " + re.escape(key) + r":") + self.assertEqual(1, self.dag.count("command:")) + + def test_project_documents_deployment_contract(self): + required_values = ( + "毎日 04:00", + "/volume1/homes/kazu634/bin/unifydrive2nas/", + "deploy/dagu/unifydrive2nas.yaml", + "/var/lib/dagu/dags/", + "kazu634/UnifyDrive2NAS", + "--init", + ) + for value in required_values: + with self.subTest(value=value): + self.assertIn(value, self.project) + + def test_default_database_is_shared_by_implementation_project_and_dag(self): + expected = Path.home() / ".local" / "state" / "unifydrive2nas" / "copied.sqlite3" + self.assertEqual(expected, app.DEFAULT_DATABASE) + self.assertIn("`~/.local/state/unifydrive2nas/copied.sqlite3`", self.project) + self.assertNotIn("--database", self.dag_value("command")) + + def test_deployment_commands_use_consistent_paths_and_exclude_tests(self): + section = self.project.split("### 配置手順", 1)[1].split("## 外部とのつながり", 1)[0] + blocks = re.findall(r"```bash\n(.*?)\n[ \t]*```", section, flags=re.DOTALL) + self.assertEqual(8, len(blocks)) + ( + runtime_copy, + runtime_dry_run, + initialize, + staging_copy, + validate, + dag_dry_run, + manual_start, + schedule_enable, + ) = blocks + + self.assertIn("--exclude='__pycache__' unifydrive2nas.py vendor", runtime_copy) + self.assertNotIn("tests/", runtime_copy) + self.assertIn("tar -C " + INSTALL_DIRECTORY + " -xf -", runtime_copy) + + self.assertIn("python3 " + SCRIPT_PATH + " --dry-run", runtime_dry_run) + self.assertIn("python3 " + SCRIPT_PATH + " --init", initialize) + self.assertNotIn("--database", initialize) + + self.assertIn("-C deploy/dagu unifydrive2nas.yaml", staging_copy) + self.assertIn("tar -C /tmp -xf -", staging_copy) + self.assertNotIn("tests/", staging_copy) + + self.assertIn(DAGU_COMMAND + " validate " + DAG_STAGING_PATH, validate) + self.assertIn(DAGU_COMMAND + " dry " + DAG_STAGING_PATH, dag_dry_run) + self.assertIn(DAGU_COMMAND + " start " + DAG_STAGING_PATH, manual_start) + + self.assertIn("-C deploy/dagu unifydrive2nas.yaml", schedule_enable) + self.assertIn("tar -C /var/lib/dagu/dags -xf -", schedule_enable) + self.assertIn("rm -f " + DAG_STAGING_PATH, schedule_enable) + + def test_verification_uses_temporary_tests_on_nas(self): + section = self.project.split("## 確かめ方", 1)[1] + blocks = re.findall(r"```bash\n(.*?)\n[ \t]*```", section, flags=re.DOTALL) + verification = next(block for block in blocks if "unifydrive2nas.py vendor tests" in block) + unpack = "tar -C /tmp/unifydrive2nas-test -xf -" + run_tests = ( + "cd /tmp/unifydrive2nas-test && " + "python3 -W error::ResourceWarning -m unittest discover -s tests -v" + ) + cleanup = "rm -rf /tmp/unifydrive2nas-test" + production_dry_run = "python3 " + SCRIPT_PATH + " --dry-run" + + self.assertIn("--exclude='__pycache__' unifydrive2nas.py vendor tests", verification) + self.assertIn(unpack, verification) + self.assertIn(run_tests, verification) + self.assertEqual(2, verification.count(cleanup)) + self.assertIn(production_dry_run, verification) + self.assertLess(verification.index(unpack), verification.index(run_tests)) + self.assertLess(verification.index(run_tests), verification.rindex(cleanup)) + self.assertLess(verification.rindex(cleanup), verification.index(production_dry_run)) + + def test_all_documented_dagu_commands_use_service_home(self): + commands = re.findall(r"/usr/local/bin/dagu (?:validate|dry|start) [^\s\"']+", self.project) + self.assertEqual(5, len(commands)) + self.assertEqual(5, self.project.count(DAGU_COMMAND)) + self.assertNotIn("/usr/local/bin/dagu", self.project.replace(DAGU_COMMAND, "")) + + +if __name__ == "__main__": + unittest.main()