diff --git a/.github/workflows/run_cypress.yml b/.github/workflows/run_cypress.yml index bcbb15a30..fc8e8b158 100644 --- a/.github/workflows/run_cypress.yml +++ b/.github/workflows/run_cypress.yml @@ -42,7 +42,7 @@ jobs: docker run -d --name wheel --network wheel-e2e-net \ -p 8089:8089 \ -e WHEEL_USE_HTTP=1 \ - -e WHEEL_LOGLEVEL=ERROR \ + -e WHEEL_LOG_LEVEL=ERROR \ wheel_e2e_test docker cp test/wheel_config/. wheel:/root/.wheel/ docker run -d --name wheel_auth --network wheel-e2e-net \ @@ -50,7 +50,7 @@ jobs: -e WHEEL_USE_HTTP=1 \ -e WHEEL_ANONYMOUS_LOGIN=YES \ -e WHEEL_ANONYMOUS_PASSWORD=WheelTest123! \ - -e WHEEL_LOGLEVEL=ERROR \ + -e WHEEL_LOG_LEVEL=ERROR \ wheel_e2e_test docker cp test/wheel_config_auth/. wheel_auth:/root/.wheel/ docker run -d --name mock --network wheel-e2e-net \ diff --git a/TEST_GUIDE.md b/TEST_GUIDE.md index d39e47087..7bf32301a 100644 --- a/TEST_GUIDE.md +++ b/TEST_GUIDE.md @@ -375,7 +375,7 @@ Chromeクラッシュ対策: /dev/shm を 512MB に拡張 | `WHEEL_TEST_REMOTE_PASSWORD` | リモートホストパスワード | `passw0rd` | | `WHEEL_CONFIG_DIR` | WHEEL設定ディレクトリパス | (setup.shが自動生成) | | `NODE_ENV` | Node環境 | `test` | -| `WHEEL_LOGLEVEL` | ログレベル | `OFF` | +| `WHEEL_LOG_LEVEL` | ログレベル | `OFF` | ### E2Eテスト diff --git a/client/src/components/Workflow.vue b/client/src/components/Workflow.vue index a807f1da5..47b5bc7c9 100644 --- a/client/src/components/Workflow.vue +++ b/client/src/components/Workflow.vue @@ -129,6 +129,7 @@ variant="outlined" icon="mdi-restore" :disabled="! cleanProjectAllowed" + data-cy="workflow-cleanup_project-btn" v-bind="props" @click="openProjectOperationComfirmationDialog('cleanProject')" /> @@ -916,6 +917,7 @@ export default { ...mapActions({ showSnackbar: "showSnackbar", closeSnackbar: "closeSnackbar", + clearSnackbarQueue: "clearSnackbarQueue", commitSelectedComponent: "selectedComponent" }), ...mapMutations({ @@ -941,6 +943,7 @@ export default { } if (operation === "cleanProject") { this.firstViewDataAlived = false; + this.clearSnackbarQueue(); } if (operation === "stopProject" || operation === "cleanProject") { this.commitWaitingWorkflow(true); @@ -953,6 +956,12 @@ export default { if (operation === "cleanProject") { this.viewerDataDir = null; } + const label = operation.replace("Project", " project"); + if (rt) { + this.showSnackbar({ message: `${label} accepted`, timeout: 3000 }); + } else { + this.showSnackbar({ message: `${label} failed`, timeout: -1 }); + } }); }, openProjectOperationComfirmationDialog(operation) { diff --git a/client/src/components/common/NavigationDrawer.vue b/client/src/components/common/NavigationDrawer.vue index adfdca986..e84162abc 100644 --- a/client/src/components/common/NavigationDrawer.vue +++ b/client/src/components/common/NavigationDrawer.vue @@ -17,7 +17,7 @@ class="text-capitalize" text="Remotehost editor" data-cy="navigation-manage_remote_host-btn" - @click="$emit('open-remotehost-manager')" + @click="openRemotehostManager" /> @@ -64,6 +65,16 @@ export default { this.$emit("update:modelValue", value); } } + }, + methods: { + + /** + * Close the drawer and emit the open-remotehost-manager event. + */ + openRemotehostManager() { + this.drawer = false; + this.$emit("open-remotehost-manager"); + } } }; diff --git a/client/src/components/componentProperty.vue b/client/src/components/componentProperty.vue index 4cc2f1418..d9534f223 100644 --- a/client/src/components/componentProperty.vue +++ b/client/src/components/componentProperty.vue @@ -14,50 +14,25 @@ - - - - - - - - - - +
+ enable + + disable +
+ + + + + - Discard following changes and exit text editor? + Do you want to keep or discard the changes you made? diff --git a/client/src/lib/constants.json b/client/src/lib/constants.json index a282b9d24..4c618cbb1 100644 --- a/client/src/lib/constants.json +++ b/client/src/lib/constants.json @@ -6,9 +6,9 @@ "plugColor": "#338033", "elsePlugColor": "#9944bb", "filePlugColor": "#5673BF", - "fileLinkCopyColor": "#E07B39", - "fileLinkRemoteSymlinkColor": "#CC5500", - "fileLinkCrossBoundaryColor": "#2AABBB", + "fileLinkCopyColor": "#FFB300", + "fileLinkRemoteSymlinkColor": "#55DDFF", + "fileLinkCrossBoundaryColor": "#FF5555", "textHeight": 32, "borderWidth": 1.5, "textLengthLimit": 100, diff --git a/client/src/store/index.js b/client/src/store/index.js index cbe33a869..ef04d9d76 100644 --- a/client/src/store/index.js +++ b/client/src/store/index.js @@ -204,6 +204,16 @@ export default new Vuex.Store({ context.dispatch("showSnackbar"); } }, + + /** + * Clear all pending snackbar messages from the queue and dismiss any currently visible toast. + * @param {object} context - Vuex action context + */ + clearSnackbarQueue: (context)=>{ + context.state.snackbarQueue.splice(0); + context.commit("snackbarMessage", ""); + context.commit("openSnackbar", false); + }, showDialog: (context, payload)=>{ //ignore if dialog is already opend //we have to use dialog queue for this case diff --git a/documentMD/AdminGuide.md b/documentMD/AdminGuide.md deleted file mode 100644 index 6b580864b..000000000 --- a/documentMD/AdminGuide.md +++ /dev/null @@ -1,251 +0,0 @@ -# WHEEL administrator's guide -## prerequisites -WHEELの実行にあたって必須のソフトウェアは以下のとおりです。 -- docker - -## 動作確認環境 -以下の2種類のホスト環境で動作確認を行なっています。 -1. ubuntu -- Ubuntu 18.04.2 LTS (Bionic Beaver) -- Docker version 18.09.7, build 2d0083d - -2. macOS -- macOS Catalina (v10.15.5) -- docker desktop 2.3.0.3(45519) - - -## 初期設定 -WHEELを起動する前に、設定ファイルを格納するためのディレクトリ(以下、`CONFIG_DIR`)と、 -プロジェクトファイルを格納するためのディレクトリ(以下、`PROJECT_ROOT`)を用意します。 -`PROJECT_ROOT`以下にはその後生成されるプロジェクトに関する全データが(実行結果ファイルなども含めて)保存されるため -十分な容量のストレージデバイス上に作成してください。 - -`CONFIG_DIR`にgithubから次の2つのファイルをダウンロードして保存してください。 -- [jobScheduler.json](https://raw.githubusercontent.com/RIKEN-RCCS/OPEN-WHEEL/master/server/app/config/jobScheduler.json) -- [server.json](https://raw.githubusercontent.com/RIKEN-RCCS/OPEN-WHEEL/master/server/app/config/server.json) - -jobScheduler.jsonファイルには、WHEELがバッチサーバに対してジョブを投入する際に用いる設定が記載されています。 -詳細は[jobScheduler setting](#jobScheduler setting)を参照してください。 - -server.jsonファイルにはWHEELの動作をカスタマイズする変数が記載されています。詳細は[server setting](#server setting)を参照してください。 - -WHEELはhttpsおよびwssにて通信を行なうため、起動する前にサーバ証明書等を用意する必要があります。 -秘密鍵ファイルを `server.key`、証明書ファイルを `server.crt`という名前で`CONFIG_DIR`内に配置してください。 -テスト目的やlocalhostでの運用など、自己証明書を用いた運用を行なう場合は、[自己証明書作成ガイド](./self-signed_certification.md)を参照してください。 - - -## 起動方法 -``` -> docker run -d -v PROJECT_ROOT:/root -v CONFIG_DIR:/usr/src/app/config -p 8089:8089 tmkawanabe/wheel:latest -``` -この時、`PROJECT_ROOT`と`CONFIG_DIR`は絶対パスで指定する必要があります。 - -8089ポートへの転送を指定していますが、このポート番号は後述のserver.json内で変更することもできますので、 -変更した場合は起動時のオプショも合わせって変更してください。 -また、server.jsonの設定は変更せずに、ホスト側のポート番号のみを変えることで待受ポートを変更することもできます。 -例えば、server.jsonはデフォルトのままでwheelの待ち受けポートを3000とする場合、-pオプションを次のように変更してください。 - -``` --p 3000:8089 -``` - -また、上記の実行例では設定していませんがWHEELが外部アクセスに用いる秘密鍵を -ホストOSと共有する場合は、鍵を保存しているディレクトリをコンテナにマウントする必要があります。 -その際、マウントするパスは前述の`PROJECT_ROOT`の外のディレクトリでも問題ありません。 - -## server setting -WHEELの動作に影響するパラメータは、`server.json`ファイルにjson形式で記述します。 -設定可能なプロパティは以下のとおりです。 - -server.jsonの既定値 - -``` - "port": 8089, - "remotehostJsonFile": "remotehost.json", - "projectListJsonFile": "projectList.json", - "jobScriptJsonFile": "jobScript.json", - "interval": 1000, - "defaultCleanupRemoteRoot": true, - "logFilename": "wheel.log", - "numLogFiles": 5, - "maxLogSize": 8388608, - "compressLogFile": true, - "numJobOnLocal": 2, - "defaultTaskRetryCount": 1, - "shutdownDelay": 0, - "rootDir": undefined, - "gitLFSSize": 200 -``` - -#### port (整数) -WHEELが待ち受けるポート番号を設定します。 -整数値かつ、利用可能なポート番号を指定してください。 - -#### remotehostJsonFile (文字列) -リモートホスト設定を記載するJsonファイルのファイル名を指定します。 -app/configディレクトリからの相対パスで指定します。 - -#### projectListJsonFile(文字列) -WHEELが管理するプロジェクトの一覧を記載するJsonファイルのファイル名を指定します。 -app/configディレクトリからの相対パスで指定します。 - -#### jobScriptJsonFile(文字列) - -#### interval (整数) -一部のイベントループを実行するインターバル(ミリ秒単位) - -#### defaultCleanupRemoteRoot (真偽値) - -プロジェクトのルートコンポーネントでリモート環境に作成した一時ファイルを削除するかどうかの設定が -"上位コンポーネントを参照"となっていた時に、削除する(True)として扱うか、削除しない(False)として扱うかの設定です。 - -#### logFilename (文字列) -ログファイルのファイル名を指定します。 - -#### numLogFiles (整数) -ログファイルのローテーションを行なった時に保持するファイル数を指定します。 - -#### maxLogSize (整数) -ログファイルのローテーションを行うファイルサイズのしきい値を指定します。 - -#### compressLogFile (真偽値) -ログファイルのローテーションを行なった時に古いファイルを圧縮するかどうかを指定します。 - -#### numJobOnLocal (整数) -localhostで実行するtaskの同時実行本数を指定します。 - -#### defaultTaskRetryCount (整数) -taskのリトライ機能を有効にした時にリトライする回数のデフォルト値 -本設定の値にかかわらず、taskコンポーネント側でretryを指定しなければretryは行なわれない - -#### shutdownDelay (整数) -workflow画面に接続するクライアントが0になってからWHEEL自身のプロセスをkillするまでの待ち時間(ミリ秒単位) - -### rootDir (文字列) -プロジェクトファイル等のユーザが作成/使用するファイルを格納するディレクトリのパスを指定します。 -デフォルト値は未指定ですが、指定が無い場合は環境変数HOMEが使われ、さらにHOMEが未設定の場合は/が使われます。 - -### gitLFSSize(整数) -グラフビューでアップロードされたファイルをgit LFSによる管理対象とするかどうかを判断するしきい値をMB単位で指定します。 -この値より大きいサイズのファイルはアップロード完了時に`git lfs track`コマンドによってgit LFSの対象として指定されます。 - -## jobScheduler setting -ワークフローをリモートホスト上で処理する場合、Taskコンポーネントにリモートホストの設定を行います。(Taskコンポーネントに関する詳細は後述) -Taskコンポーネントは、child_process又はsshを用いて指定されたスクリプトを直接実行する以外に、ジョブスケジューラにジョブとして投入することが可能です。 -本機能に関する設定は次の5つがあります。 -1. Taskコンポーネントの[ useJobScheduler ]プロパティを有効にしている場合、Taskはジョブスケジューラ経由で実行されます。 -1. Taskコンポーネントの[ queue ]プロパティには、投入先のキュー名を指定することができます。 -null(デフォルト値)が指定されていた場合は、ジョブスケジューラ側で指定されているデフォルトキューに対してジョブが投入されます。 -1. ホスト登録画面[ JobScheduler ]には、当該ホストから投入可能なジョブスケジューラの名称を設定します。 -1. ホスト登録画面[ Max Job ]には、本プロパティに設定された値以下の投入本数を上限として、WHEELからのジョブ投入を抑制します。 -1. ホスト登録画面[ Queue ]で登録したQueue情報は、Taskコンポーネントの[ queue ]プロパティでセレクトボックスとして表示されます。 - -ジョブスケジューラの定義は"app/config/jobSceduler.json"にて行います。 スケジューラの名称をkeyとし、以下の各keyを持つテーブルを値として各ジョブスケジューラを設定します。 - -| key | value | -|----|----| -| submit | ジョブ投入に用いるコマンド名 | -| queueOpt | 投入先キューを指定するためのsubmitコマンドのオプション | -| stat | ジョブの状態表示に用いるコマンド名 | -| del | ジョブの削除に用いるコマンド名 | -| reJobID | submitコマンドの出力からジョブIDを抽出するための正規表現 | -| reFinishdState | statコマンドの出力を正常終了と判定するための正規表現 | -| reFailedState | statコマンドの出力を異常終了と判定するための正規表現 | - -reJobIDは1つ以上のキャプチャを含む正規表現でなければなりません。また、1つ目のキャプチャ文字列がjobIDを示す文字列として扱われます。 -reFinishedStateとreFailedStateは、前者が先に評価され前者がマッチした場合は後者の判定は行なわずに正常終了と判定します。また、両者にマッチしない場合はジョブは実行待ちもしくは実行中と判定します。 -※いずれの正規表現もプログラム内でコンパイルして利用するため、正規表現リテラル(//)は使うことができません。 - -> 富士通 ParallelNaviでの設定は次のようになります。 -``` -{ - "ParallelNavi": { - "submit": "pjsub -X", - "queueOpt": "-L rscgrp=", - "stat": "pjstat -v -H day=3 --choose st,ec", - "del": "pjdel", - "reJobID": "pjsub Job (\\d+) submitted.", - "reFinishedState": "^ST *EC *\\ nEXT *0", - "reReturnCode": "^ST *EC *\\nEXT *(\\d+)", - "reFailedState": "^ST *EC *\\n(CCL|ERR|EXT|RJT)", - "reJobStatus": "^ST *EC *\\n(\\S+)" - } -} -``` - - - -## WHEELが動作中に作成するファイル -WHEELが動作中に参照するユーザデータの保存先として、以下の2ファイルが使われます。 -(ファイル名は、前述のserver.jsonで変更することもできます。) - -- remotehost.json -- projectList.json - -remotehost.jsonには、ユーザが接続する外部サーバのホスト名、IDなどの情報が平文で保存されています。 -定期的にバックアップを取るなど障害への準備を行なうとともに、取り扱いに注意してください。 -なお、パスワードおよび秘密鍵のパスフレーズは本ファイル以外にも一切保存しておらず、必要になる都度ユーザから入力を受け取っています。 - -projectList.jsonには、ユーザが使用するワークフロープロジェクトディレクトリのパスとそれを識別するためのID文字列が保存されています。 -本ファイルも定期的にバックアップをとるなどして障害発生時に備えてください。 - -## 設定ファイル探索ポリシー -WHEELは起動時に、これまでに説明したserver.json, remotehost.json, projectList.jsonファイルの他に -ジョブスケジューラ設定ファイル(jobScheduler.json)、SSL鍵ファイル(server.key)、SSL証明書ファイル(server.crt) -を読み込みます。 -これらのファイルは、 - -1. 起動したユーザの`${HOME}/.wheel` 以下 -2. 環境変数 `WHEEL_CONFIG_DIR` で指定されたディレクトリ -3. WHEELのインストール先以下のapp/configディレクトリ - -の順に探索され、最初に見つかったファイルのみが読み込まれます。 - -また、ログファイルの出力も同様のポリシーでディレクトリを探索し、最初に見つかったディレクトリ以下に -wheel.log(server.jsonの設定で変更可能)という名前のファイルに出力します。 - -## Docker imageのビルド方法 -dockerhubで配布されているイメージよりも新しいバージョンを使う場合など、ソースからdocker イメージをビルドする時には -以下の手順でビルドしてください。 - -``` -> cd WHEEL -> docker build -t wheel . -``` - -この方法で作成したイメージは、"wheel"というイメージ名でアクセスできるので、起動コマンドの末尾にあ -`tmkawanabe/wheel:latest`を`wheel`と変更することでdockerhubにて配布されているイメージと同様に使用できます。 - - -## dockerを使わない起動方法 (非推奨) -### prerequisites -開発用途などでdockerを使わずに起動する場合、以下のソフトウェアがインストールされている必要があります。 - -- node.js (12以降) -- git -- git lfs -- bash -- python3(option) - - -### インストール方法 -dockerを使わずにホストOSでWHEELを起動する場合は以下の手順でインストールを行ないます。 -``` -> git clone https://github.com/RIKEN-RCCS/OPEN-WHEEL.git -> cd WHEEL -> npm install -``` - -`npm install` を実行すると依存するパッケージがnode\_modules以下にインストールされますが、一部のOS(RHELおよびCentOS 7)ではnodegitのインストールに失敗する現象が確認されています。 -この場合、`npm install`終了後に次の手順で、nodegitのみを再ビルドしてください。 - -``` -> cd WHEEL/node_modules/nodegit -> BUILD_ONLY=yes npm install -``` - -### 起動方法 -WHEELのインストールディレクトリまたは、そのサブディレクトリ内で以下のコマンドを実行してください。 -``` -> npm run start -``` diff --git a/documentMD/auth.md b/documentMD/auth.md deleted file mode 100644 index 9af15b71f..000000000 --- a/documentMD/auth.md +++ /dev/null @@ -1,88 +0,0 @@ -本ドキュメントは、WHEELに2024年度から導入された認証機構に関して記述したものです。 - -WHEELの認証機構は、passport-localモジュールを用いてDBに登録済のユーザが正しいパスワードを入力した時のみ -WHEELの各画面(home, remotehost, workflow, viewer)へのアクセスを許すという単純なものです。 - - -## 認証機構の利用方法 -認証機構はデフォルトでは無効になっており、起動時に環境変数 `WHEEL_ENABLE_AUTH` に何かの値が設定されている時のみ -有効になります。 - -## 認証機構の詳細 -認証機構が有効な場合、WHEELサーバのhttp(s)でのアクセスは全て login ページへリダイレクトされます。 - -loginページで入力したユーザ/パスワードがユーザDB内に存在するユーザと一致した場合、そのsessionはログイン済とみなし -それ以降のアクセスではログインを要求せず各ページにアクセスすることができます。 - -なお、ブラウザ画面から新規ユーザを登録するいわゆるサインアップ機構や、一旦認証したブラウザを未認証状態に戻すログアウト機能は -提供されません。また、パスワードに使用可能な文字数、文字種等の制限もなくログイン機能に対するbrute force attackの対策も無いなど -本認証機構は非常に簡易的なものですので、WANでの運用にあたっては別途リバースプロキシ等を用意してそちらで認証を行なうことを強く推奨します。 - - -## ユーザDB -DBはWHEEL serverのインストールディレクトリ配下の次のパスに置かれたsqliteのファイルを用います。 - -``` -app/db/user.db -``` - -DBのメンテナンス用のツールとして、bin/passwordDBTool.js が用意されています。 -本ツールはnode.js用のjavascriptファイルなので、次のコマンドで実行してください。 - -``` -node bin/passwordDBTool.js -``` - -オプション無しで起動した場合は、DB内に登録されているユーザ名の一覧を表示します。 -それ以外の利用方法は次のとおりです。 - -### ユーザの追加 -次のコマンドで、ユーザ (foo)、 パスワード (bar) をDBに登録します - -``` -node bin/passwordDBTool.js -u foo -p bar -``` - -### ユーザの削除 -次のコマンドで既存のユーザ(foo)をDBから削除します - -``` -node bin/passwordDBTool.js -d -u foo -``` - - -### アノニマスユーザの作成 -次のコマンドでアノニマスユーザを作成します。 -既にDB内に存在する場合は、新規パスワードを発行します。 - -``` -node bin/passwordDBTool.js -A -``` - -正常にアノニマスユーザが作成されると、画面に次のようにパスワードが出力されます。 - -``` -Anonymous user created with password = XXXXXXXXXXXX -``` - -ログイン時には、ユーザ名として `anonymous` パスワードとして上記出力中の "XXXXXXXXXXXX" を入力してください。 - - -### DBの全削除 -次のコマンドを実行するとDBのファイルが削除されます。 - -``` -node bin/passwordDBTool.js -c -``` - -本処理は、ユーザの追加やアノニマスユーザの作成と同時に行なうことができます。 -例えば `-A -c` オプションを指定すると、DBを一旦削除して空のユーザDBに新規にアノニマスユーザのみを追加します。 - - - -## docker版起動時のアノニマスユーザ作成方法 -docker版wheelの起動時に環境変数 `WHEEL_ANONYMOUS_LOGIN="YES"` が指定されていると -認証機構が有効になり、起動中に新規にアノニマスユーザが作成されます。(パスワードは起動ログ中に出力されます) - -この方法で起動した場合、パスワードは平文でログファイル内に保存される可能性があるので -パスワード出力部のみ通常のログ出力とは分けるなど、取扱には十分に留意してください。 diff --git a/documentMD/APIGuide.md b/documentMD/design/APIGuide.md similarity index 100% rename from documentMD/APIGuide.md rename to documentMD/design/APIGuide.md diff --git a/documentMD/Cloud.md b/documentMD/design/Cloud.md similarity index 100% rename from documentMD/Cloud.md rename to documentMD/design/Cloud.md diff --git a/documentMD/JS.md b/documentMD/design/JS.md similarity index 100% rename from documentMD/JS.md rename to documentMD/design/JS.md diff --git a/documentMD/JobScriptEditor.md b/documentMD/design/JobScriptEditor.md similarity index 100% rename from documentMD/JobScriptEditor.md rename to documentMD/design/JobScriptEditor.md diff --git a/documentMD/PS.md b/documentMD/design/PS.md similarity index 100% rename from documentMD/PS.md rename to documentMD/design/PS.md diff --git a/documentMD/design/architecture.md b/documentMD/design/architecture.md new file mode 100644 index 000000000..7cc08e609 --- /dev/null +++ b/documentMD/design/architecture.md @@ -0,0 +1,165 @@ +# システムアーキテクチャ概要 + +## 1. 全体構成 + +WHEELは、科学技術計算ワークフローを管理・実行するWebアプリケーションである。 +モノレポ構成で3つのパッケージから成り、それぞれ以下の役割を担う。 + +``` +OpenWHEEL/ +├── client/ クライアントサイド(Vue.js SPA) +├── server/ サーバーサイド(Node.js + Express + Socket.IO) +└── common/ クライアント・サーバー間で共有するコード +``` + +## 2. ディレクトリ構成 + +### server/ + +``` +server/ +└── app/ + ├── index.js サーバーエントリーポイント + ├── logSettings.js ログ設定(log4js) + ├── core/ コアロジック(ワークフロー実行エンジン等) + ├── db/ 設定・データ管理 + │ ├── db.js 設定ロード・エクスポート + │ ├── server.json デフォルト設定値 + │ ├── jobScheduler.json ジョブスケジューラ定義 + │ └── jsonSchemas.js JSONスキーマ検証定義 + ├── routes/ Expressルーティング + └── handlers/ Socket.IOイベントハンドラ +``` + +### client/ + +クライアントはVue.js(Vuetify UI)を使用したSPAである。 +ビルド成果物は `server/app/public/` に出力され、Expressが静的ファイルとして配信する。 + +### common/ + +クライアントとサーバーで共有するユーティリティ(定数定義、バリデーションヘルパー等)が含まれる。 + +## 3. サーバー起動シーケンス + +`server/app/index.js` を起点として、以下の順序で初期化が行われる。 + +``` +1. db.js ロード(設定ファイル読み込み) + └── runMigrations() マイグレーション実行(旧設定ファイルの自動変換) + └── loadWheelConfig() c12 による設定マージ + └── JsonArrayManager 初期化(remotehost.json, projectList.json 等) + +2. logSettings.js ロード + └── log4js の設定(コンソール・ファイル・Socket.IO アペンダー設定) + +3. index.js 初期化 + ├── 必須コマンド存在確認(checkAllCommands) + ├── セッションDB クリア(WHEEL_CLEAR_SESSION_DB 指定時) + ├── Express アプリ生成 + ├── HTTP / HTTPS サーバー生成(useHttp フラグで切替) + ├── Socket.IO サーバー生成 + ├── バージョン情報・環境変数ログ出力(aboutWheel) + ├── ミドルウェア設定(CORS, JSON, Cookie, セッション, 認証) + ├── Socket.IO 接続ハンドラ登録(registerHandlers) + ├── Expressルート登録(ログイン, ファイルアップロード, 静的ファイル等) + └── サーバー起動(listen) +``` + +## 4. Express + Socket.IO 構成 + +### HTTP/HTTPS サーバー + +| 設定項目 | デフォルト | 説明 | +|----------|-----------|------| +| `useHttp` | `false` | `true` の場合 HTTP(ポート80相当)、`false` の場合 HTTPS | +| `port` | `8089` | リスンポート番号 | +| `acceptAddress` | `null` | バインドするIPアドレス(null の場合は全インターフェース) | + +HTTPS の場合、`server.key` と `server.crt` を設定ファイル検索パスから読み込む。 + +### Express ミドルウェアスタック(順序) + +1. `IpFilter`(`acceptAddress` 設定時のみ)— IPアドレスによるアクセス制限 +2. `cors()` — クロスオリジン設定 +3. `express.json()` / `express.urlencoded()` — リクエストボディパース +4. `cookieParser()` — クッキーパース +5. `Siofu.router` — Socket.IO ファイルアップロード(socketio-file-upload) +6. `session()` — セッション管理(connect-sqlite3 バックエンド) +7. `passport.*`(`enableAuth` 有効時)— 認証ミドルウェア + +### Socket.IO 接続ハンドラ + +クライアントが Socket.IO で接続すると、`socket.handshake.auth.projectRootDir` に基づいてルームに参加する。 +プロジェクトルートディレクトリが指定されている場合はそのディレクトリ名のルームへ、 +未指定の場合は `"default"` ルームへ参加する。 + +全イベントに対して `prependAny` ハンドラが登録されており、 +`siofu_*` プレフィックス以外のイベントはサーバーサイドでログに記録される。 + +## 5. クライアント・サーバー間通信 + +WHEELはほぼ全ての機能を **Socket.IO** 経由で実装する。REST APIはログインとファイルアップロードのみ。 + +### Socket.IO イベント方向 + +| 方向 | 説明 | +|------|------| +| クライアント → サーバー | 操作要求(プロジェクト操作、ワークフロー編集、実行制御等) | +| サーバー → クライアント | 状態更新(ログ、タスク状態、ファイルリスト等) | + +### 主なサーバー → クライアント通知イベント + +| イベント名 | 内容 | +|-----------|------| +| `WHEEL_LOG` | ワークフロー実行ログ(INFO/ERROR/stdout/stderr/SSH出力) | +| `projectState` | プロジェクト実行状態の変化通知 | +| `taskStateList` | タスク状態一覧の更新 | +| `fileList` | ファイルリストの更新 | +| `projectList` | プロジェクト一覧の更新 | + +詳細なAPIリファレンスは [APIGuide.md](./APIGuide.md) を参照。 + +## 6. モジュール依存関係(概略) + +``` +index.js + ├── db/db.js ← 設定値・定数のハブ(全モジュールが参照) + ├── logSettings.js ← ログAPI(db.jsの設定を参照) + ├── core/global.js ← グローバル状態(baseURL, Socket.IOインスタンス) + ├── handlers/ ← Socket.IOイベントハンドラ群 + │ └── core/dispatcher.js ← ワークフロー実行エンジン(最も複雑なモジュール) + └── routes/ ← Expressルート(ログイン、ファイルアップロード等) +``` + +`db/db.js` は設定値の唯一のソースとして機能し、ほぼ全てのモジュールがここから +設定をインポートする。直接 `process.env` を読んではならない(インフラ系変数を除く)。 + +## 7. データフロー概略 + +``` +ユーザー操作(ブラウザ) + ↓ Socket.IO イベント +handlers/ (イベントハンドラ) + ↓ 実行指示 +core/dispatcher.js (ワークフロー実行エンジン) + ├── ローカルタスク実行 + │ └── executerManager.js → child_process + └── リモートタスク実行 + └── executerManager.js → sshManager → SSH接続 + └── transferManager → transferrer → rsync/SCP +``` + +## 8. 主要な設定ファイル + +| ファイル | 場所 | 説明 | +|---------|------|------| +| `server.json` | 設定検索パス | サーバー動作設定 | +| `remotehost.json` | 設定検索パス | リモートホスト定義 | +| `jobScheduler.json` | 設定検索パス | ジョブスケジューラコマンド定義 | +| `jobScriptTemplate.json` | 設定検索パス | ジョブスクリプトテンプレート | +| `projectList.json` | 設定検索パス | 管理対象プロジェクト一覧 | +| `user.db` | `WHEEL_USER_DB_DIR` or `server/app/db/` | ユーザー認証DB(SQLite) | +| `session.db` | `WHEEL_SESSION_DB_DIR` or `server/app/db/` | セッションDB(SQLite) | + +設定ファイルの検索順序については [configuration.md](./configuration.md) を参照。 diff --git a/documentMD/design/authentication.md b/documentMD/design/authentication.md new file mode 100644 index 000000000..0b95e68fa --- /dev/null +++ b/documentMD/design/authentication.md @@ -0,0 +1,132 @@ +# 認証・認可 + +## 1. 概要 + +WHEELの認証機能は **オプション** であり、`enableAuth` フラグで有効/無効を切り替える。 +デフォルトは無効(匿名アクセス可)。 + +| 設定 | 説明 | +|------|------| +| `enableAuth: false`(デフォルト) | 認証なし。誰でもアクセス可能。 | +| `enableAuth: true` | ユーザー名・パスワードによるログイン必須。 | + +## 2. 認証スタック + +認証には以下のパッケージを使用する。 + +| パッケージ | 役割 | +|-----------|------| +| `passport` | 認証フレームワーク | +| `passport-local` | ユーザー名/パスワード認証ストラテジー | +| `connect-ensure-login` | 未ログイン時のリダイレクト | +| `connect-sqlite3` | セッションストア(SQLite) | +| `express-session` | セッション管理 | + +## 3. ユーザーデータベース + +### スキーマ(SQLite) + +```sql +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY, -- UUID (crypto.randomUUID) + username TEXT UNIQUE, -- ユーザー名 + hashed_password BLOB, -- PBKDF2ハッシュ済みパスワード + salt BLOB -- ランダムソルト(16バイト) +); +``` + +### ファイルパス + +``` +${WHEEL_USER_DB_DIR}/user.db +``` + +`WHEEL_USER_DB_DIR` 未設定の場合は `server/app/db/user.db` に作成される。 + +### パスワードハッシュ化 + +PBKDF2(SHA-512)を使用する。 + +```javascript +crypto.pbkdf2(password, salt, 210000, 32, "sha512") +``` + +- 反復回数: **210,000回** +- 出力長: 32バイト +- アルゴリズム: SHA-512 +- ソルト: `crypto.randomBytes(16)` で生成(ユーザーごとに一意) + +パスワード検証には `crypto.timingSafeEqual` を使用し、タイミング攻撃を防ぐ。 + +### ユーザー管理API(`server/app/core/auth.js`) + +| 関数 | 説明 | +|------|------| +| `initialize()` | DBを開き、テーブルを作成する(初回のみ) | +| `addUser(username, password)` | ユーザーを追加する(重複時はエラー) | +| `isValidUser(username, password)` | 認証を検証し、成功時はユーザーデータを返す | +| `listUser()` | 全ユーザー名の配列を返す | +| `delUser(username)` | ユーザーを削除する | + +ユーザー管理は `passwordDBTool.js`(CLIツール)から呼び出す。 + +## 4. ログインフロー + +``` +クライアント → POST /login (username, password) + ↓ +passport-local ストラテジー + ↓ +auth.isValidUser(username, password) + ↓ 成功 +セッションにユーザー情報を保存(passport.session) + ↓ +リダイレクト → アプリケーション画面 + ↓ 失敗 +リダイレクト → ログイン画面(エラーメッセージ表示) +``` + +### セッション管理 + +セッションは SQLite に永続化される。 + +``` +${WHEEL_SESSION_DB_DIR}/session.db +``` + +`WHEEL_SESSION_DB_DIR` 未設定の場合は `server/app/db/session.db`。 +`WHEEL_CLEAR_SESSION_DB` 環境変数が設定されている場合、起動時にセッションDBを削除してクリアする。 + +## 5. Socket.IO の認証保護 + +`enableAuth` 有効時は、Socket.IO の全イベントハンドラに `ensureLoggedIn` チェックが適用され、 +未認証の場合はエラーレスポンスを返す。 + +## 6. IPアドレスフィルタリング + +`acceptAddress` を設定すると、指定したIPアドレスのみアクセスを許可する。 +`express-ipfilter` ミドルウェアが Express の最初のスタックとして適用される。 + +```json +{ + "acceptAddress": "192.168.1.100" +} +``` + +`null`(デフォルト)の場合は全IPからのアクセスを許可する。 + +## 7. Web API 認証(OAuth2) + +`enableWebApi: true` のとき、OAuth2ベースのWeb API認証が有効になる。 +詳細は `server/app/core/webAPI.js` および `server/app/core/jwtServerPassphraseManager.js` を参照。 + +リモートホストとの認証には JWT(JSON Web Token)を使用しており、 +`jwtServerPassphraseManager.js` がパスフレーズを管理する。 + +## 8. セキュリティ上の注意事項 + +- 本番環境では必ず `enableAuth: true` を設定し、HTTPS を使用すること +- セッションシークレットは `"wheel"` にハードコードされているため、 + 機密性の高い環境では変更を検討すること +- `acceptAddress` によるIPフィルタリングと組み合わせることで、 + アクセスを特定のホストに限定できる diff --git a/documentMD/design/configuration.md b/documentMD/design/configuration.md new file mode 100644 index 000000000..b5ea0b109 --- /dev/null +++ b/documentMD/design/configuration.md @@ -0,0 +1,166 @@ +# 設定システムリファレンス + +## 1. 概要 + +WHEELの設定は **c12** ライブラリを使用してロードされ、複数のソースをマージする。 +優先順位は高い順に以下のとおり。 + +``` +優先度(高) + 1. WHEEL_* 環境変数(WHEEL_CONFIG_DIR, WHEEL_TEMPD 等のインフラ変数を除く) + 2. WHEEL_CONFIG_DIR/server.json + 3. ~/.wheel/server.json + 4. server/app/db/server.json(パッケージデフォルト) +優先度(低) +``` + +## 2. 設定ファイル検索パス + +設定ファイルは `getConfigFile()` 関数が以下の順序で検索する。 + +| 順序 | パス | 説明 | +|------|------|------| +| 1 | `$WHEEL_CONFIG_DIR/{filename}` | 環境変数で指定したディレクトリ | +| 2 | `~/.wheel/{filename}` | ユーザーホームディレクトリ | +| 3 | `server/app/db/{filename}` | パッケージ同梱のデフォルト | + +`server.json` の場合: 存在しなければ検索順位1または2のディレクトリに新規作成される。 +`server.key`, `server.crt` の場合: 見つからなければ起動エラー(HTTPS使用時)。 + +## 3. server.json プロパティ一覧 + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|----|-----------|----| +| `port` | number | `8089` | リスンポート番号 | +| `useHttp` | boolean | `false` | `true` にすると HTTPS の代わりに HTTP を使用 | +| `acceptAddress` | string \| null | `null` | バインドするIPアドレス(null で全インターフェース) | +| `numLocalJob` | number | `1` | ローカルタスクの最大同時実行数 | +| `numLogFiles` | number | `5` | ログファイルのバックアップ数 | +| `maxLogSize` | number | `8388608` | ログファイルの最大サイズ(バイト、デフォルト8MB) | +| `compressLogFile` | boolean | `true` | ログファイルを圧縮するか | +| `logLevel` | string | `"debug"` | ログレベル(下記参照) | +| `verboseSsh` | boolean | `false` | SSH通信の詳細ログを出力するか | +| `enableAuth` | boolean | `false` | ユーザー認証を有効にするか | +| `enableWebApi` | boolean | `false` | Web API(OAuth2)を有効にするか | +| `baseURL` | string | `""` | サーバーのベースURL(リバースプロキシ配下の場合に設定) | +| `defaultTaskRetryCount` | number | `1` | タスクのデフォルト再試行回数 | +| `defaultCleanupRemoteRoot` | boolean | `true` | リモート実行後のクリーンアップデフォルト値 | +| `gitLFSSize` | number | `200` | Git LFS管理の閾値(MB) | +| `rootDir` | string | `~` | プロジェクトルートのデフォルトディレクトリ | +| `logFilename` | string | `"wheel.log"` | ログファイル名 | +| `remotehostJsonFile` | string | `"remotehost.json"` | リモートホスト設定ファイル名 | +| `jobScriptTemplateJsonFile` | string | `"jobScriptTemplate.json"` | ジョブスクリプトテンプレートファイル名 | +| `projectListJsonFile` | string | `"projectList.json"` | プロジェクト一覧ファイル名 | +| `credentialFilename` | string | `"credentials.json"` | 認証情報ファイル名 | + +### ログレベルの有効値 + +`ALL`, `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`, `MARK`, `OFF` +(大文字・小文字は問わない) + +## 4. WHEEL_* 環境変数一覧 + +### 自動マッピング変数(`WHEEL_` プレフィックス → camelCase) + +`WHEEL_` プレフィックスを持つ環境変数は、自動的に `server.json` プロパティへマッピングされる。 +変換ルール: `WHEEL_XXX_YYY` → `xxxYyy`(UPPER_SNAKE_CASE → camelCase) + +| 環境変数 | マッピング先 | 説明 | +|---------|------------|------| +| `WHEEL_PORT` | `port` | リスンポート番号 | +| `WHEEL_USE_HTTP` | `useHttp` | HTTP使用フラグ | +| `WHEEL_ACCEPT_ADDRESS` | `acceptAddress` | バインドIPアドレス | +| `WHEEL_NUM_LOCAL_JOB` | `numLocalJob` | ローカル同時実行数 | +| `WHEEL_LOG_LEVEL` | `logLevel` | ログレベル | +| `WHEEL_VERBOSE_SSH` | `verboseSsh` | SSH詳細ログ | +| `WHEEL_ENABLE_AUTH` | `enableAuth` | 認証有効化 | +| `WHEEL_ENABLE_WEB_API` | `enableWebApi` | Web API有効化 | +| `WHEEL_BASE_URL` | `baseURL` | ベースURL | + +### インフラ変数(自動マッピング対象外) + +以下の変数は起動処理で特別に扱われるため、`server.json` にはマッピングされない。 + +| 環境変数 | 説明 | +|---------|------| +| `WHEEL_CONFIG_DIR` | 設定ファイルの検索・保存先ディレクトリ | +| `WHEEL_TEMPD` | 一時ディレクトリのルート | +| `WHEEL_USER_DB_DIR` | ユーザーDB(`user.db`)の保存先ディレクトリ | +| `WHEEL_SESSION_DB_DIR` | セッションDB(`session.db`)の保存先ディレクトリ | +| `WHEEL_CLEAR_SESSION_DB` | 起動時にセッションDBを削除する(値は問わない) | +| `WHEEL_CERT_FILENAME` | SSL証明書ファイルパス(個別指定) | +| `WHEEL_CERT_PASSPHRASE` | SSL証明書パスフレーズ(個別指定) | + +### Docker/entrypoint 専用変数 + +| 環境変数 | 説明 | +|---------|------| +| `WHEEL_GENERATE_KEYPAIR` | `YES` に設定するとSSH鍵ペアを自動生成 | +| `WHEEL_ANONYMOUS_LOGIN` | `YES` に設定すると匿名ログインユーザーを作成し認証を有効化 | +| `WHEEL_ANONYMOUS_PASSWORD` | 匿名ユーザーのパスワード(省略時はランダム生成) | + +## 5. 型の自動変換(coerce) + +環境変数はすべて文字列として渡されるため、以下のルールで自動変換される。 + +| 値 | 変換後 | +|----|--------| +| `"true"` | `true`(boolean) | +| `"false"` | `false`(boolean) | +| `""` / `" "` | `undefined`(デフォルト値にフォールバック) | +| 数値文字列(`"8089"` 等) | `number` | +| その他の文字列 | `string`(そのまま) | + +空文字列を設定した場合はデフォルト値が使用される点に注意。 + +## 6. 設定ファイルのエクスポートされる定数 + +`db.js` からエクスポートされる主な定数(コード内での参照用)。 + +| 定数名 | 説明 | +|--------|------| +| `suffix` | `.wheel`(WHEELファイルの拡張子) | +| `projectJsonFilename` | `prj.wheel.json` | +| `componentJsonFilename` | `cmp.wheel.json` | +| `statusFilename` | `status.wheel.txt` | +| `jobManagerJsonFilename` | `jm.wheel.json` | +| `filesJsonFilename` | `files.wheel.json` | +| `defaultPSconfigFilename` | `parameterSetting.json` | +| `rsyncExcludeOptionOfWheelSystemFiles` | WHEELシステムファイルのrsync除外オプション配列 | + +## 7. マイグレーションヘルパー + +サーバー起動時(`db.js` のロード時)に `migrationHelper.js` の `runMigrations()` が自動実行される。 + +### 機能 + +1. **server.json プロパティ名の自動リネーム** + - `~/.wheel/server.json` と `$WHEEL_CONFIG_DIR/server.json` を対象に実行 + - 旧プロパティ名を新プロパティ名に書き換え、ファイルを上書き保存 + - `console.warn` で変更内容を通知 + +2. **非推奨環境変数の警告** + - 廃止された環境変数が設定されている場合に `console.warn` で警告 + +### 現在の移行ルール + +| 種別 | 旧 | 新 | +|------|----|----| +| server.json プロパティ | `numJobOnLocal` | `numLocalJob` | +| 環境変数 | `WHEEL_LOGLEVEL` | `WHEEL_LOG_LEVEL` | + +> **注意**: `console.warn` を使用するのは、マイグレーションが `log4js` 初期化より前に実行されるため(循環依存を避けるため)。 + +## 8. その他の設定ファイル + +### remotehost.json + +リモートホストの定義を配列形式で記述する。スキーマは `server/app/db/remotehostJsonSchema.js` を参照。 + +### jobScheduler.json + +ジョブスケジューラのコマンド定義。詳細は [JS.md](./JS.md) を参照。 + +### jobScriptTemplate.json + +ジョブスクリプトテンプレートの定義。詳細は [JobScriptEditor.md](./JobScriptEditor.md) を参照。 diff --git a/documentMD/design/deployment.md b/documentMD/design/deployment.md new file mode 100644 index 000000000..6bca49be1 --- /dev/null +++ b/documentMD/design/deployment.md @@ -0,0 +1,275 @@ +# デプロイ・運用手順 + +## 1. デプロイ方式 + +WHEELは以下の2つの方式でデプロイできる。 + +| 方式 | 説明 | 推奨用途 | +|------|------|---------| +| **Dockerコンテナ** | 公式イメージまたはDockerfileからビルド | 本番環境・CI/CD | +| **直接インストール** | Node.js環境に直接インストール | 開発・デバッグ | + +## 2. Dockerコンテナによるデプロイ + +### Dockerfile の構成 + +マルチステージビルドで複数のターゲットが定義されている。 + +| ターゲット | 用途 | +|-----------|------| +| `base` | 共通ベースイメージ(Node.js + システムパッケージ) | +| `run_base` | npm install済みのベースイメージ | +| `builder` | クライアントビルド専用 | +| `ut` | ユニットテスト実行用 | +| `exec` | 本番実行用(デフォルト) | + +### 本番用イメージのビルド + +```bash +docker build --target exec -t wheel:latest . +``` + +### コンテナの起動 + +```bash +docker run -d \ + -p 8089:8089 \ + -v ~/.wheel:/root/.wheel \ + -v /path/to/projects:/root \ + -e WHEEL_USE_HTTP=true \ + wheel:latest +``` + +### 主要なマウントポイント + +| ホストパス | コンテナパス | 用途 | +|-----------|------------|------| +| `~/.wheel/` | `/root/.wheel/` | 設定ファイル(server.json, remotehost.json等) | +| プロジェクトディレクトリ | `/root/` | WORKFLOWプロジェクトの格納先 | +| SSL証明書 | `/root/.wheel/server.key`, `/root/.wheel/server.crt` | HTTPS使用時 | + +### entrypoint.sh の処理 + +コンテナ起動時に以下の処理が実行される。 + +``` +1. WHEEL_GENERATE_KEYPAIR=YES の場合: + - ed25519鍵ペアを生成(/tmp_identify, ~/.wheel/wheel_tmp_pubkey) + +2. WHEEL_ANONYMOUS_LOGIN=YES の場合: + - anonymousユーザーを作成(WHEEL_ANONYMOUS_PASSWORD が設定されていればそのパスワードで) + - WHEEL_ENABLE_AUTH=YES を設定して認証を有効化 + +3. SSH エージェントの起動 + +4. npm start でサーバー起動 +``` + +### Docker Compose の例 + +```yaml +version: "3.8" +services: + wheel: + build: + context: . + target: exec + ports: + - "8089:8089" + volumes: + - ./wheel_config:/root/.wheel + - ./projects:/root/projects + environment: + - WHEEL_USE_HTTP=true + - WHEEL_LOG_LEVEL=info + - WHEEL_NUM_LOCAL_JOB=4 + restart: unless-stopped +``` + +## 3. 設定ファイルの配置 + +### ディレクトリ構成例 + +``` +~/.wheel/ +├── server.json # サーバー設定 +├── remotehost.json # リモートホスト定義 +├── jobScheduler.json # ジョブスケジューラ設定 +├── server.key # SSL秘密鍵(HTTPS使用時) +├── server.crt # SSL証明書(HTTPS使用時) +└── wheel.log # ログファイル +``` + +### server.json の初期設定例(本番環境) + +```json +{ + "port": 443, + "useHttp": false, + "enableAuth": true, + "logLevel": "info", + "numLocalJob": 4, + "numLogFiles": 10, + "maxLogSize": 10485760, + "acceptAddress": null +} +``` + +## 4. ユーザー管理 + +### ユーザーの追加 + +```bash +# コンテナ内で実行 +node bin/passwordDBTool.js -u -p -c + +# コンテナ外から実行 +docker exec wheel node bin/passwordDBTool.js -u -p -c +``` + +### 主要オプション + +| オプション | 説明 | +|-----------|------| +| `-u ` | ユーザー名 | +| `-p ` | パスワード | +| `-c` | ユーザーを作成 | +| `-d` | ユーザーを削除 | +| `-l` | ユーザー一覧を表示 | +| `-A` | 匿名ユーザーを作成(ランダムパスワード) | + +## 5. バージョンアップ手順 + +### 一般的な手順 + +```bash +# 1. 新バージョンのイメージをビルド +docker build --target exec -t wheel:new . + +# 2. 設定ファイルのバックアップ +cp -r ~/.wheel ~/.wheel.backup + +# 3. 旧コンテナの停止 +docker stop wheel_container + +# 4. 新コンテナの起動 +docker run -d ... wheel:new + +# 5. 動作確認後、旧バックアップを削除 +``` + +### server.json のマイグレーション + +サーバー起動時に `migrationHelper.js` が自動的に旧プロパティ名を新名称に変換する。 +手動での変更は不要。ただし起動ログの `console.warn` メッセージを確認すること。 + +### 廃止環境変数の確認 + +起動時に廃止された環境変数が設定されている場合、`console.warn` で警告が表示される。 +警告メッセージに従って環境変数を新しい名称に変更する。 + +現在の廃止変数: +| 旧変数名 | 新変数名 | +|---------|---------| +| `WHEEL_LOGLEVEL` | `WHEEL_LOG_LEVEL` | + +## 6. SSL証明書の管理 + +### 自己署名証明書の作成 + +```bash +openssl req -x509 -newkey rsa:4096 \ + -keyout ~/.wheel/server.key \ + -out ~/.wheel/server.crt \ + -days 365 -nodes +``` + +詳細は [self-signed_certification.md](./self-signed_certification.md) を参照。 + +### Let's Encrypt 証明書の使用 + +Let's Encrypt で取得した証明書を `server.key` / `server.crt` として配置する。 +証明書の更新時はコンテナを再起動する必要がある。 + +## 7. ログ管理 + +### ログファイルの場所 + +- デフォルト: `{projectRootDir}/wheel.log` +- ログファイルは `numLogFiles` 世代分保存される +- `compressLogFile: true` で古いファイルをgzip圧縮 + +### ログレベルの変更(再起動不要・環境変数経由) + +```bash +# コンテナの再起動なしには変更できない(起動時に設定が読み込まれるため) +docker restart wheel_container +``` + +### ログの確認 + +```bash +# コンテナのコンソールログ +docker logs wheel_container + +# プロジェクトのログファイル +tail -f /path/to/project/wheel.log +``` + +## 8. バックアップ + +### バックアップすべき対象 + +| 対象 | 説明 | +|------|------| +| `~/.wheel/*.json` | 全設定ファイル | +| `~/.wheel/user.db` | ユーザー認証DB | +| プロジェクトディレクトリ | 全プロジェクトファイル | + +### セッションDBについて + +`session.db` はセッション情報のみであり、バックアップ不要。 +`WHEEL_CLEAR_SESSION_DB=1` 環境変数でクリアできる(ユーザーの強制ログアウト)。 + +## 9. リバースプロキシ設定 + +nginxやApacheの後ろにWHEELを配置する場合、`baseURL` を設定する。 + +```json +{ + "baseURL": "/wheel" +} +``` + +#### nginx の設定例 + +```nginx +location /wheel { + proxy_pass http://localhost:8089; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} +``` + +Socket.IO の WebSocket 通信のために `Upgrade` ヘッダーの転送が必須。 + +## 10. トラブルシューティング + +### サーバーが起動しない + +1. SSL証明書のパスを確認(HTTPS使用時) +2. ポートが使用中でないか確認: `lsof -i :8089` +3. 必須コマンドの存在を確認: `which rsync ssh git` +4. `WHEEL_USE_HTTP=true` で HTTP モードを試す + +### ログが出力されない + +- `logLevel` が `OFF` になっていないか確認 +- ログファイルへの書き込み権限を確認 + +### 認証でログインできない + +- `user.db` のパスを確認(`WHEEL_USER_DB_DIR`) +- ユーザーが作成されているか確認: `node bin/passwordDBTool.js -l` diff --git a/documentMD/design/developer_guide.md b/documentMD/design/developer_guide.md new file mode 100644 index 000000000..951e2d272 --- /dev/null +++ b/documentMD/design/developer_guide.md @@ -0,0 +1,227 @@ +# 開発者向けオンボーディングガイド + +## 1. 開発環境セットアップ + +### 前提条件 + +| ツール | バージョン | 用途 | +|--------|-----------|------| +| Node.js | 20.x 以上 | サーバー・クライアント実行 | +| npm | 9.x 以上 | パッケージ管理(ワークスペース) | +| Docker | 最新安定版 | テスト用コンテナ | +| rsync | - | ファイル転送(必須コマンド) | +| git | - | バージョン管理 | + +### 初期セットアップ + +```bash +# リポジトリのクローン +git clone https://github.com/RIKEN-RCCS/OPEN-WHEEL.git +cd OPEN-WHEEL + +# 依存パッケージのインストール(全ワークスペース) +npm install + +# SSL証明書の作成(HTTPS使用時) +# 詳細は documentMD/design/self-signed_certification.md を参照 +``` + +### クライアントのビルド + +クライアントコードを変更した場合、または初回セットアップ時にビルドが必要。 + +```bash +npm run build -w client +``` + +ビルド成果物は `server/app/public/` に出力される。 + +### サーバーの起動(開発時) + +```bash +# HTTPS用の自己署名証明書が必要 +# ~/.wheel/server.key と ~/.wheel/server.crt を配置してから実行 +npm start -w server + +# または HTTP モードで起動(証明書不要) +WHEEL_USE_HTTP=true npm start -w server +``` + +## 2. プロジェクト構造 + +### モノレポ構成 + +``` +OpenWHEEL/ +├── package.json ルートワークスペース定義 +├── client/ クライアントサイド(Vue.js + Vuetify) +│ ├── src/ +│ │ ├── components/ Vueコンポーネント +│ │ └── views/ ページビュー +│ └── package.json +├── common/ 共有コード +│ └── (定数、ユーティリティ等) +├── server/ サーバーサイド(Node.js) +│ ├── app/ +│ │ ├── index.js エントリーポイント +│ │ ├── logSettings.js ログ設定 +│ │ ├── core/ コアロジック(60+ファイル) +│ │ ├── db/ 設定・データ管理 +│ │ ├── handlers/ Socket.IOイベントハンドラ +│ │ └── routes/ Expressルート +│ ├── bin/ CLIツール(passwordDBTool等) +│ └── test/ サーバーサイドユニットテスト +├── test/ E2Eテスト(Cypress) +│ └── cypress/ +│ ├── e2e/ E2Eテストスペック +│ └── component/ Cypressコンポーネントテスト +└── documentMD/ ドキュメント + ├── design/ 設計ドキュメント(本ファイルのディレクトリ) + └── user_guide/ ユーザーガイド +``` + +### server/app/core/ のモジュール分類 + +| カテゴリ | ファイル | 説明 | +|---------|---------|------| +| **実行エンジン** | `dispatcher.js` | ワークフロー実行の中核 | +| | `executerManager.js` | ジョブ実行管理 | +| | `executer.js` | ステージイン/アウト実行 | +| | `jobManager.js` | ジョブ投入・状態管理 | +| **ファイル転送** | `sshManager.js` | SSH接続プール | +| | `transferManager.js` | 転送キュー管理 | +| | `transferrer.js` | 転送実行 | +| | `deliverFile.js` | ファイル配信ロジック | +| | `rsync.js` | rsyncラッパー | +| **コンポーネント** | `componentOperations.js` | CRUD操作 | +| | `componentState.js` | 状態管理 | +| | `componentFiles.js` | ファイル処理 | +| | `componentLinks.js` | リンク管理 | +| | `workflowComponent.js` | コンポーネントモデル定義 | +| **ループ・PS** | `loopUtils.js` | ループ制御ユーティリティ | +| | `parameterParser.js` | パラメータ空間解析 | +| | `psUtils.js` | PS補助関数 | +| **プロジェクト** | `projectOperations.js` | プロジェクト管理 | +| | `projectController.js` | 実行制御 | +| | `gitOperator2.js` | Git操作 | +| **認証・設定** | `auth.js` | ユーザー認証DB | +| | `webAPI.js` | OAuth2 | +| | `jwtServerPassphraseManager.js` | JWT管理 | +| **外部連携** | `gfarmOperator.js` | GFarm操作 | +| | `webhook.js` | Webhook設定 | +| **ユーティリティ** | `fileUtils.js`, `pathUtils.js`, `taskUtil.js` 等 | 共通ユーティリティ | +| **バリデーション** | `validateComponents.js`, `taskValidator.js` 等 | 入力検証 | + +## 3. コーディング規約 + +### 必須ルール +1. コード変更後は必ずlintを実行する +2. 関数には JSDoc コメントを付ける +3. `server/app/db/version.json` は変更しない(GitHub Workflowが自動更新) + +### 実装スタイル + +- **async/await** スタイルで記述する(`Promise.then()` を使わない) +- **try/catch** で全ての非同期関数のエラーを処理する +- ログには **debugモジュール** ではなく `logSettings.js` の関数を使用する +- 一時デバッグ用の `console.log` はコミット前に必ず削除する +- 環境変数は `process.env` から直接読まず、`db.js` からインポートする + +### ログの書き方 + +```javascript +import { logInfo, logWarn, logError, logDebug, logStdout, logStderr } from "../logSettings.js"; + +// 第1引数: projectRootDir(プロジェクトルートパス) +// 第2引数: componentDir(コンポーネントディレクトリパス) +// 第3引数以降: メッセージ +logInfo(projectRootDir, componentDir, "処理開始:", target.name); +logError(projectRootDir, componentDir, "エラー発生:", err); +``` + +### JSDoc コメントの書き方 + +```javascript +/** + * 関数の説明を日本語または英語で記述する。 + * @param {string} projectRootDir - プロジェクトルートディレクトリ + * @param {object} options - オプション設定 + * @returns {Promise} - 成功時 true + */ +async function myFunction(projectRootDir, options) { + try { + // 実装 + } catch (e) { + logError(projectRootDir, null, "エラー:", e); + throw e; + } +} +``` + +## 4. 新しいコンポーネントタイプの追加手順 + +新しいコンポーネントタイプを追加する場合、以下のファイルを変更する。 + +### 1. コンポーネントモデルの定義(workflowComponent.js) + +新しいコンポーネントクラスを定義し、プロパティのデフォルト値を設定する。 + +### 2. ディスパッチハンドラの追加(dispatcher.js) + +`_cmdFactory()` メソッドに新しいタイプのケースを追加し、 +対応するハンドラメソッド(`_myNewHandler`)を実装する。 + +### 3. コンポーネントタイプバリデーター(componentTypeValidator.js) + +有効なコンポーネントタイプのリストを更新する。 + +### 4. JSONスキーマ(db/jsonSchemas.js) + +新しいプロパティのスキーマを定義する。 + +### 5. Socket.IOハンドラ(handlers/) + +クライアントからの操作を受け付けるハンドラを追加する。 + +### 6. クライアントUI(client/src/components/) + +コンポーネントのプロパティパネルを実装する。 + +### 7. テストの追加 + +- `server/test/app/core/dispatcher.js` にユニットテストを追加 +- `test/cypress/component/` にコンポーネントテストを追加 +- `test/cypress/e2e/` にE2Eテストを追加 + +### 8. ドキュメントの更新 + +- `documentMD/design/design.md` にコンポーネントの設計を追記 +- `documentMD/user_guide/_reference/4_component/` にユーザーガイドを追加(JP/EN両方) +- `documentMD/user_guide/_reference/4_component/index.md` と `index.en.md` にリンクを追加 + +## 5. テストの実行 + +### リント + +```bash +npm run lint -w server +npm run lint -w client +``` + +### サーバーサイドユニットテスト + +```bash +# 全テスト(Dockerが必要) +npm run test -w server + +# 特定ファイルのみ(デバッグ時) +# .only を使用して対象テストを絞る(コミット前に必ず .only を削除) +``` + +### E2Eテスト + +```bash +npm run test:e2e:mock -w test +``` + +詳細は [testing.md](./testing.md) および `TEST_GUIDE.md` を参照。 diff --git a/documentMD/design/dispatching.md b/documentMD/design/dispatching.md new file mode 100644 index 000000000..0cdeda428 --- /dev/null +++ b/documentMD/design/dispatching.md @@ -0,0 +1,182 @@ +# コンポーネント実行・ディスパッチアルゴリズム + +## 1. 概要 + +ワークフローの実行エンジンは `server/app/core/dispatcher.js` に実装されている。 +`Dispatcher` クラスが中心となり、コンポーネントの依存関係を解析しながら +実行可能なコンポーネントを順次ディスパッチする。 + +## 2. Dispatcher クラス + +### 責務 + +- ワークフロー内コンポーネントの実行順序制御(依存グラフ走査) +- 各コンポーネントタイプへの処理振り分け +- inputFiles / outputFiles の転送(ステージイン/アウト) +- ループ・分岐・パラメータスタディの展開制御 +- 子ワークフロー(サブフロー)への委譲 + +### 主なフィールド + +| フィールド | 説明 | +|-----------|------| +| `projectRootDir` | プロジェクトルートディレクトリ | +| `cwfDir` | 現在処理中のワークフローディレクトリ | +| `currentSearchList` | 次回 `_dispatch()` で処理するコンポーネントリスト | +| `pendingComponents` | 依存関係未解決で待機中のコンポーネントリスト | +| `runningTasks` | 現在実行中のタスク一覧 | +| `children` | 子 Dispatcher インスタンスの Set | +| `hasFailedComponent` | いずれかのコンポーネントが失敗したか | + +## 3. ディスパッチサイクル + +`_dispatch()` メソッドが実行の中核となる。イベント駆動で繰り返し呼ばれる。 + +``` +[start()] → dispatch イベント発行 + ↓ +[_dispatch()] + 1. 初回のみ: 子コンポーネント一覧を取得し「初期コンポーネント」を特定 + - 前依存(previous)が0件 かつ inputFiles の src が 0件のコンポーネントが初期コンポーネント + 2. currentSearchList を走査: + a. disabled コンポーネントをスキップ + b. _isReady() で実行可否を確認 → 未準備なら pendingComponents へ + c. _getInputFiles() でステージイン実行 + d. _warnMissingInputFiles() で非mandatory ファイル欠落を警告 + e. _checkMandatoryInputFilesExist() で mandatory ファイル存在確認 + f. _dispatchOneComponent() で各コンポーネントの処理を呼び出す + 3. pendingComponents を次の currentSearchList に設定 + 4. 全タスクが完了しリストが空になれば "done" イベントを発行 + 5. 未完了なら次の "dispatch" イベントを待機 +``` + +### イベント + +| イベント | タイミング | +|---------|-----------| +| `dispatch` | ディスパッチサイクル開始の合図 | +| `taskCompleted` | タスクの実行が完了したとき | +| `done` | ワークフロー全体が完了したとき | +| `error` | 例外発生時 | +| `stop` | 実行停止要求時 | + +## 4. 実行準備チェック(_isReady) + +コンポーネントが実行可能かどうかは以下の条件で判定する。 + +- `previous` リスト内の全コンポーネントが「完了状態」であること +- `inputFiles` の全 `src` コンポーネントが「完了状態」であること +- `disable` フラグが `false` であること + +無効化されたコンポーネント(`disable: true`)の上流を再帰的に辿り、 +全ての依存がdisabledである場合はスキップする(`_removeComponentsWhichHasDisabledDependency`)。 + +## 5. コンポーネントタイプ別ハンドラ + +`_cmdFactory(type)` がコンポーネントタイプに応じてハンドラメソッドを返す。 + +| コンポーネントタイプ | ハンドラメソッド | 説明 | +|-------------------|----------------|------| +| `task` | `_dispatchTask` | タスクの実行(ローカル/リモート) | +| `stepjobTask` | `_dispatchTask` | ステップジョブタスク | +| `bulkjobTask` | `_dispatchTask` | バルクジョブタスク | +| `if` | `_checkIf` | 条件分岐 | +| `for` | `_loopHandler`(For設定) | Forループ | +| `while` | `_loopHandler`(While設定) | Whileループ | +| `foreach` | `_loopHandler`(Foreach設定) | Foreachループ | +| `workflow` | `_delegate` | サブワークフロー委譲 | +| `stepjob` | `_delegate` | ステップジョブ委譲 | +| `parameterstudy` | `_PSHandler` | パラメータスタディ展開 | +| `viewer` | `_viewerHandler` | ビューアファイル収集 | +| `storage` | `_storageHandler` | ストレージ操作 | +| `source` | `_sourceHandler` | ソースファイル提供 | +| `break` | `_jumpHandler("break")` | ループ中断 | +| `continue` | `_jumpHandler("continue")` | 次ループへ進む | +| `hpciss` | `_hpcissHandler(false)` | HPCI共有ストレージ | +| `hpcisstar` | `_hpcissHandler(true)` | HPCI共有ストレージ(tar) | + +## 6. タスク実行(_dispatchTask) + +ローカルタスクとリモートタスクで実行パスが異なる。 + +``` +_dispatchTask(component) + ↓ +isLocal(component) ? + Yes → executerManager.exec(task) (ローカル実行) + No → executerManager.exec(task) + SSH経由ジョブ投入 +``` + +`executerManager` は `numLocalJob`(ローカル同時実行数)または +`hostinfo.maxNumJobs`(リモートジョブ最大数)を超えないようにキューイングする。 + +## 7. ループ展開(_loopHandler) + +For / While / Foreach コンポーネントを処理する汎用ループハンドラ。 +引数として渡される関数群(`getNextIndex`, `isFinished` 等)でループ条件を抽象化している。 + +``` +_loopHandler(getNextIndex, getPrevIndex, isFinished, getTripCount, keepLoopInstance, component) + +1. テンプレートディレクトリを最初のインスタンスディレクトリへコピー + - skipCopy パターンに一致するファイルは除外 +2. 子Dispatcherを生成して _delegate() でサブワークフローとして実行 +3. サブワークフロー完了後、isFinished() でループ終了判定 + - 終了の場合: クリーンアップしてループ完了 + - 継続の場合: 前インスタンスを次インスタンスディレクトリへコピー(skipCopy除外) +4. keepLoopInstance 設定に基づき古いインスタンスディレクトリを削除 +``` + +## 8. パラメータスタディ展開(_PSHandler) + +``` +_PSHandler(component) + +1. parameterSetting.json を読み込みパラメータ空間を解析 +2. 全パラメータ組み合わせ(ベクター)を生成 +3. 各パラメータベクターに対してインスタンスディレクトリを作成 +4. targetFiles 内のプレースホルダーを Nunjucks でレンダリング +5. 各インスタンスに対して子Dispatcherを生成し並列実行 +6. gather 設定に基づきリモートタスクのファイル収集先を更新 +``` + +バルクジョブ(`bulkjobTask`)の場合は +`replaceByNunjucksForBulkjob` でテンプレートを展開し、 +`writeParameterSetFile` でパラメータを環境変数形式に書き出す。 + +## 9. 条件分岐(_checkIf) + +`if` コンポーネントは条件式(JavaScriptまたはシェルスクリプト)を評価し、 +結果が `true` / `false` のどちらに対応するコンポーネントに制御を渡す。 + +## 10. サブワークフロー委譲(_delegate) + +`workflow` / `stepjob` コンポーネントを子Dispatcherとして独立に実行する。 +子Dispatcherは `this.children` に登録され、親Dispatcherと並行して実行される。 + +## 11. Break / Continue(_jumpHandler) + +Break / Continue はループ制御のシグナルを親DispatcherにEmitする。 + +- `continue`: `forceFinishedLoops` に現在のループを追加し、次のイテレーションへ進む +- `break`: ループを強制終了し、`_loopHandler` の後続処理をスキップする + +## 12. コンポーネント状態 + +| 状態 | 説明 | +|------|------| +| `notset` | 未設定(初期状態) | +| `waiting` | 依存待ち | +| `ready` | 実行準備完了 | +| `running` | 実行中 | +| `finished` | 正常完了 | +| `failed` | 失敗 | +| `unknown` | 状態不明(スクリプト戻り値が不明等) | + +## 13. 並行実行制御 + +同一ワークフロー内で依存関係のない複数のコンポーネントは `Promise.all` で並行実行される。 +子ワークフロー(`_delegate`)も非同期に実行されるため、 +異なるサブフロー内のタスクは同時に実行されうる。 + +ローカルタスクの最大同時実行数は `numLocalJob` で制限される(デフォルト: 1)。 diff --git a/documentMD/design/error_handling.md b/documentMD/design/error_handling.md new file mode 100644 index 000000000..2b017e5c2 --- /dev/null +++ b/documentMD/design/error_handling.md @@ -0,0 +1,167 @@ +# エラーハンドリングと回復 + +## 1. 概要 + +WHEELのエラーハンドリングは複数の層で構成される。 + +``` +コンポーネント実行エラー + ↓ throw +Dispatcher._dispatchOneComponent() の try/catch + ↓ コンポーネント状態を "failed" に設定 +Dispatcher.emit("error", e) + ↓ +projectController.js の エラーハンドラ + ↓ プロジェクト状態を "failed" に設定 +Socket.IO イベントでクライアントへ通知 +``` + +## 2. コンポーネントレベルのエラー処理 + +### _dispatchOneComponent() の例外処理 + +```javascript +async _dispatchOneComponent(target) { + try { + await this._cmdFactory(target.type).call(this, target); + } catch (err) { + await this._setComponentState(target, "failed"); + this.hasFailedComponent = true; + throw err; // 上位の _dispatch() へ再スロー + } finally { + this.setStateFlag(target.state); + if (isFinishedState(target.state)) { + logInfo(...); + } + this._reserveDispatch(); // 次のディスパッチサイクルを予約 + } +} +``` + +コンポーネントの実行で例外が発生した場合: +1. コンポーネントの状態を `"failed"` に設定する +2. `hasFailedComponent` フラグを `true` にする +3. 例外を再スローして `_dispatch()` の catch ブロックで捉える + +### _dispatch() の例外処理 + +`_dispatch()` の catch ブロックは `this.emit("error", e)` を呼び出すだけであり、 +プロジェクト全体の状態変更は上位のコントローラに委ねられる。 + +## 3. タスクレベルのエラー処理 + +### ローカルタスク + +- プロセスの終了コードが非0の場合は失敗として扱う +- `defaultTaskRetryCount` 回まで自動リトライする +- `try/catch` で例外を捕捉し、タスク状態を `"failed"` に設定する + +### リモートタスク(ジョブスケジューラ) + +- ジョブスケジューラのリターンコードを `jobScheduler.json` の `acceptableReturnCode` と照合する +- 状態パターン(正規表現)でジョブの終了状態を判定する +- 意図しない終了状態は `"unknown"` として扱う + +### ファイル転送エラー(inputFiles) + +inputFiles の転送失敗は `mandatory` フラグで挙動が異なる。 + +| mandatory | 挙動 | +|-----------|------| +| `true` | コンポーネントを失敗させる(全mandatory転送完了後に判定) | +| `false` | 警告ログのみ出力し、処理を継続する | + +実装: `Promise.allSettled` を使用し、全転送の完了を待ってから失敗を判定する。 + +## 4. ログシステム(logSettings.js) + +### ログレベルと Socket.IO イベントの対応 + +| ログレベル | Socket.IO イベント | 説明 | +|-----------|------------------|------| +| `TRACE` | (なし) | トレース詳細(ファイルのみ) | +| `DEBUG` | (なし) | デバッグ情報(ファイルのみ) | +| `INFO` | `WHEEL_LOG` | 一般的な情報 | +| `WARN` | (なし) | 警告(ファイルのみ) | +| `ERROR` | `WHEEL_LOG` | エラー情報 | +| `FATAL` | `WHEEL_LOG` | 致命的エラー | +| `STDOUT` | `WHEEL_LOG` | タスクの標準出力 | +| `STDERR` | `WHEEL_LOG` | タスクの標準エラー出力 | +| `SSHOUT` | `WHEEL_LOG` | SSH標準出力 | +| `SSHERR` | `WHEEL_LOG` | SSH標準エラー出力 | + +`WHEEL_LOG` イベント内の `level` フィールドでレベルを区別する。 +クライアントはこのイベントを受信してログ画面に表示する。 + +### ログ関数の使い方 + +全てのログ関数は `projectRootDir` と `componentDir` を第1・第2引数に取る。 +これにより、プロジェクト別のログファイルに自動的に振り分けられる。 + +```javascript +import { logInfo, logWarn, logError, logDebug, logStdout, logStderr } from "../logSettings.js"; + +logInfo(projectRootDir, componentDir, "メッセージ"); +logWarn(projectRootDir, componentDir, "警告メッセージ"); +logError(projectRootDir, componentDir, "エラー:", error); +logStdout(projectRootDir, componentDir, "タスク出力:", line); +``` + +### ログファイルの保存先 + +``` +{projectRootDir}/wheel.log プロジェクトのログファイル +``` + +ログファイル名は `logFilename` 設定で変更可能。 +`numLogFiles` 世代分のバックアップが保持され、`compressLogFile` で圧縮可能。 + +### ログアペンダー構成 + +| アペンダー | 出力先 | フィルター | +|-----------|--------|-----------| +| `console` | コンソール | なし | +| `socketIO` | Socket.IO クライアント | logLevel以上 | +| `multi` | プロジェクト別ファイル | logLevel以上 | + +## 5. 未処理例外のハンドリング + +```javascript +process.on("unhandledRejection", logger.debug.bind(logger)); +process.on("uncaughtException", logger.debug.bind(logger)); +``` + +予期しない例外はデバッグレベルでログに記録されるが、サーバープロセスは停止しない。 +本番環境では `logLevel` を適切に設定して重要なエラーを見逃さないようにすること。 + +## 6. SSH接続エラー + +SSH接続が見つからない場合(`sshManager.getSsh()` でエラー)は +例外が即座にスローされ、タスクの実行が中断される。 +エラーには `projectRootDir` と `id`(ホストID)が含まれる。 + +## 7. コマンド存在確認 + +起動時に `commandCheck.js` が必須コマンド(`rsync`, `ssh`, `git` 等)の存在を確認する。 +いずれかが見つからない場合は `process.exit(1)` でサーバーが停止する。 + +## 8. エラー回復・再実行 + +### プロジェクトの再実行 + +失敗したプロジェクトは状態をリセットして再実行できる。 +WHEEL はコンポーネントの状態ファイル(`status.wheel.txt`)を参照し、 +既に `"finished"` 状態のコンポーネントはスキップして続きから実行する。 + +### リワインド + +`askRewindState.js` を使用してプロジェクトの状態を以前のチェックポイントに巻き戻すことができる。 +クライアントからのリワインド要求時にユーザーへ対話的に確認を求める。 + +## 9. 注意事項(既知の制限) + +- `_dispatch()` で例外が発生した場合、`component.files` が未設定のままになる可能性がある +- ファイル転送の部分的な失敗後にリモート宛先に不完全なファイルが残る場合があるが、 + ロールバック機構は現時点では未実装 +- `Promise.all` で並行実行中のコンポーネントのうち1つが失敗しても、 + 他のコンポーネントは実行を継続する diff --git a/documentMD/design/file_transfer.md b/documentMD/design/file_transfer.md new file mode 100644 index 000000000..10ba82a35 --- /dev/null +++ b/documentMD/design/file_transfer.md @@ -0,0 +1,169 @@ +# ファイル転送・リモートホスト管理 + +## 1. 概要 + +WHEELは、ローカルマシンとリモートホスト間でファイルを転送するための +複数のモジュールを階層的に組み合わせて使用する。 + +``` +Dispatcher(実行エンジン) + ↓ +transferrer.js(ステージイン/アウト制御) + ↓ +transferManager.js(転送キュー・並列制御) + ↓ +sshManager.js(SSH接続プール) + ↓ +ssh-client-wrapper(SSH/rsync実行) +``` + +## 2. SSH接続プール(sshManager.js) + +### 概要 + +プロジェクトごと・ホストごとにSSH接続インスタンスをキャッシュし、 +接続の再利用と管理を行う。 + +### データ構造 + +``` +Map> +``` + +### 主要関数 + +| 関数 | 説明 | +|------|------| +| `addSsh(projectRootDir, hostinfo, ssh, pw, ph, isStorage)` | SSH接続をプールに登録する | +| `getSsh(projectRootDir, id)` | 接続インスタンスを取得する(未登録時は例外) | +| `getSshHostinfo(projectRootDir, id)` | ホスト設定情報を取得する | +| `hasEntry(projectRootDir, id)` | 接続が登録済みか確認する | + +### 接続確立タイミング + +SSH接続はプロジェクト実行開始時に確立され、実行終了まで維持される。 +パスワードやパスフレーズが必要な場合は、Socket.IO 経由でクライアントに入力を要求する。 + +## 3. ファイル転送管理(transferManager.js) + +### 概要 + +プロジェクト・ホストの組み合わせごとに転送キューを管理し、 +並列転送数を制御する。SBS(Simple Batch System)パターンを採用。 + +### データ構造 + +``` +Map<"projectRootDir-remotehostID", SBS転送インスタンス> +``` + +### 主要関数 + +| 関数 | 説明 | +|------|------| +| `register(hostinfo, task, direction, src, dst, opt)` | 転送を登録・実行する | +| `removeTransferrers(projectRootDir)` | プロジェクトの転送インスタンスをクリアする | + +### 並列転送数 + +`hostinfo.maxNumParallelTransfer`(デフォルト: 1)で制御する。 +同一ホストへの同時転送数がこの値を超えないようにキューイングされる。 + +### 転送方向 + +| 値 | 説明 | +|----|------| +| `"send"` | ローカル → リモート(ステージイン) | +| `"recv"` | リモート → ローカル(ステージアウト) | + +## 4. 転送実行(transferrer.js / deliverFile.js) + +### ステージイン/アウト + +- **ステージイン**: タスク実行前に `inputFiles` で指定されたファイルを転送先に送る +- **ステージアウト**: タスク実行後に `outputFiles` で指定されたファイルを回収する + +### ファイル配信パターン(deliverFile.js) + +| パターン | 説明 | +|---------|------| +| ローカル→ローカル | `fs.copy` によるローカルコピー | +| ローカル→リモート | SSH経由のファイル送信(rsync) | +| リモート→ローカル | SSH経由のファイル受信(rsync) | +| リモート→リモート | SSH経由のリモート間コピー(rsync over SSH) | +| ローカル共有→* | 共有ファイルシステム経由のコピー | +| リモート共有→* | 共有ファイルシステム経由のコピー | + +### rsync オプション + +WHEELのシステムファイル(`*.wheel.json`, `jm.wheel.json`, `status.wheel.txt` 等)は +rsync の `--exclude` オプションで自動的に転送対象から除外される。 + +```javascript +export const rsyncExcludeOptionOfWheelSystemFiles = [ + `--exclude=**/${projectJsonFilename}`, + `--exclude=**/${componentJsonFilename}`, + `--exclude=**/${statusFilename}`, + `--exclude=**/${jobManagerJsonFilename}`, + `--exclude=**/${filesJsonFilename}`, + ... +]; +``` + +## 5. リモートホスト定義(remotehost.json) + +リモートホストは `remotehost.json` の配列として定義する。 +各エントリの主なプロパティは以下のとおり。 + +| プロパティ | 型 | 説明 | +|-----------|----|----| +| `id` | string | ホストの一意識別子(UUID) | +| `name` | string | 表示名 | +| `host` | string | ホスト名またはIPアドレス | +| `port` | number | SSHポート番号(デフォルト: 22) | +| `username` | string | SSHユーザー名 | +| `privateKeyPath` | string | 秘密鍵ファイルパス | +| `jobScheduler` | string | 使用するジョブスケジューラ名 | +| `queue` | string[] | 使用するキュー名のリスト | +| `maxNumParallelTransfer` | number | 最大並列転送数 | +| `maxNumJobs` | number | 最大同時投入ジョブ数 | +| `useGfarm` | boolean | GFarmを使用するか | +| `sharedPath` | string | 共有ファイルシステムのパス | + +### queue フィールドの移行 + +`remotehost.json` の `queue` フィールドは旧バージョンでは文字列(カンマ区切り)だったが、 +現在は文字列配列に変更されている。 +サーバー起動時に `db.js` で自動的に配列形式へ変換される。 + +## 6. HPCI共有ストレージ(GFarm)連携 + +GFarmを使用するホストでは、通常のrsyncの代わりにGFarmコマンド +(`gfcp`, `gfpcopy`, `gfptar`等)を使用してファイルを転送する。 + +詳細は [gfarm.md](./gfarm.md) を参照。 + +## 7. エラーハンドリング + +### 転送失敗時の挙動 + +- `mandatory: true` のinputFileの転送失敗はコンポーネントの失敗を引き起こす +- `mandatory: false` のinputFileの転送失敗は警告ログのみで無視される +- 複数のmandatoryファイルのうち一部が失敗した場合、全ての転送完了を待ってからコンポーネントを失敗させる + +### SSH接続エラー + +SSH接続が登録されていない場合(`hasEntry` が `false`)は +即座に例外がスローされ、上位の実行エンジンに伝播する。 + +## 8. VerboseSSH ログ + +`verboseSsh: true`(または環境変数 `WHEEL_VERBOSE_SSH=true`)を設定すると、 +SSH通信の詳細ログが `logSSHout` / `logSSHerr` レベルで記録される。 +これらのログはSocket.IO経由でクライアントのログ画面にも表示される。 diff --git a/documentMD/design/gfarm.md b/documentMD/design/gfarm.md new file mode 100644 index 000000000..bd0e220a0 --- /dev/null +++ b/documentMD/design/gfarm.md @@ -0,0 +1,133 @@ +# GFarm連携 + +## 1. 概要 + +GFarm(Grid Datafarm)は分散ファイルシステムであり、 +HPCIの共有ストレージ(HPCI-SS)として使用される。 +WHEELは `gfarmOperator.js` を通じてGFarmへのファイル操作を行う。 + +GFarmを使用するコンポーネントは以下の2種類。 + +| コンポーネント | 説明 | +|--------------|------| +| `hpciss` | GFarmにファイルをコピー・管理する(gfcp/gfpcopy使用) | +| `hpcisstar` | GFarmにtar形式でファイルを保存・展開する(gfptar使用) | + +## 2. アーキテクチャ + +GFarm操作は **CSGW(Compute Space GateWay)** ホスト経由で実行される。 +WHEEL自体がGFarmコマンドを直接実行するのではなく、SSHで接続したCSGWホスト上でコマンドを実行する。 + +``` +WHEEL(サーバー) + ↓ SSH接続(sshManager経由) +CSGWホスト(GFarmが利用可能なホスト) + ↓ GFarmコマンド実行(gfcp, gfpcopy, gfptar等) +HPCI共有ストレージ(GFarm) +``` + +## 3. ホスト設定要件 + +GFarmを使用するリモートホストは `remotehost.json` で `useGfarm: true` を設定する必要がある。 + +```json +{ + "id": "hpciss-host-uuid", + "name": "HPCI-SS ホスト", + "host": "csgw.example.ac.jp", + "username": "user", + "useGfarm": true, + "JWTServerURL": "https://jwt-server.hpci.example.ac.jp", + "JWTServerUser": "hpciuser" +} +``` + +## 4. JWT認証 + +GFarmへのアクセスにはHPCI JWT(JSON Web Token)認証が必要。 +WHEELは `jwt-agent` コマンドを使用してJWT認証エージェントを管理する。 + +### 認証フロー + +``` +1. GFarmコマンド実行前に checkJWTAgent() でエージェントが起動中か確認 +2. 未起動の場合、startJWTAgent() でエージェントを起動 + - JWTサーバーURL(JWTServerURL)とユーザー(JWTServerUser)を使用 + - パスフレーズは jwtServerPassphraseManager.js で管理 +3. GFarmコマンドを実行 +``` + +### パスフレーズ管理 + +`jwtServerPassphraseManager.js` がJWTサーバーへのパスフレーズを管理する。 +パスフレーズが必要な場合はSocket.IO経由でユーザーに入力を要求する。 + +## 5. GFarm操作関数(gfarmOperator.js) + +### コアユーティリティ + +| 関数 | 説明 | +|------|------| +| `execOnCSGW(projectRootDir, hostID, timeout, cmd, ...args)` | CSGWホスト上でコマンドを実行する | +| `formatGfarmURL(target)` | パスを `gfarm:///path` 形式に変換する | +| `checkJWTAgent(projectRootDir, hostID)` | JWTエージェントが起動中か確認する | +| `startJWTAgent(projectRootDir, hostID, passphrase)` | JWTエージェントを起動する | +| `stopJWTAgent(projectRootDir, hostID, timeout)` | JWTエージェントを停止する | + +### ファイルコピー操作 + +| 関数 | 使用コマンド | 説明 | +|------|------------|------| +| `gfcp(projectRootDir, hostID, src, dst, toGfarm, timeout)` | `gfcp -p -f` | 単一ファイルをGFarmとの間でコピーする | +| `gfpcopy(projectRootDir, hostID, src, dst, toGfarm, timeout)` | `gfpcopy -p -v -f` | ディレクトリをGFarmとの間でコピーする | + +`toGfarm: true` の場合はGFarm方向へ、`false` の場合はGFarmから取り出す方向へコピーする。 + +### gfptar操作(HPCI-SS-tar用) + +| 関数 | 使用コマンド | 説明 | +|------|------------|------| +| `gfptarCreate(projectRootDir, hostID, src, target, timeout)` | `gfptar -v -c` | ディレクトリをtar形式でGFarmに保存する | +| `gfptarExtract(projectRootDir, hostID, target, dst, timeout)` | `gfptar -v -x` | GFarmのtarアーカイブを展開する | +| `gfptarList(projectRootDir, hostID, target, timeout)` | `gfptar -t` | tarアーカイブ内のファイル一覧を取得する | + +### ファイルシステム操作 + +| 関数 | 使用コマンド | 説明 | +|------|------------|------| +| `gfls(projectRootDir, hostID, target, opt, timeout)` | `gfls -l` | GFarm上のファイル一覧を取得する | +| `gfrm(projectRootDir, hostID, target, timeout)` | `gfrm` | GFarm上のファイル・ディレクトリを削除する | + +## 6. GFarmパス形式 + +GFarmのパスは `gfarm:///` URI形式で表現される。 + +``` +gfarm:///home/user/data # 絶対パス +gfarm:///path/to/project/output # プロジェクト配下 +``` + +相対パス(`./` や `../` から始まるパス)はエラーになる。 +`formatGfarmURL()` が自動的に絶対パスに変換する。 + +## 7. gfptarの制約 + +- `gfptar -c` は対象アーカイブパスが存在しない場合のみ実行可能 +- アーカイブパスが既存の場合はエラーになる +- `gfptar` で作成したアーカイブはGFarm上でファイルの削除・リネームができない +- プロジェクトを複数回実行する場合は、事前にアーカイブパスを削除するか、 + 別のパスを設定する必要がある(コンポーネントの "remove storage directory" ボタンで削除可能) + +## 8. タイムアウト設定 + +各GFarm操作関数はデフォルトのタイムアウト(秒)が設定されている。 +大容量ファイルを扱う場合は適切なタイムアウト値を設定すること。 + +| 操作 | デフォルトタイムアウト | +|------|-----------------| +| `gfcp`(単一ファイル) | 600秒 | +| `gfpcopy`(ディレクトリ) | 60秒 | +| `gfptarCreate` | 60秒 | +| `gfptarExtract` | 60秒 | +| `gfls`, `gfrm` | 60秒 | +| JWT操作 | 60秒 | diff --git a/documentMD/mock_guide.md b/documentMD/design/mock_guide.md similarity index 100% rename from documentMD/mock_guide.md rename to documentMD/design/mock_guide.md diff --git a/documentMD/design/performance.md b/documentMD/design/performance.md new file mode 100644 index 000000000..608cfd17f --- /dev/null +++ b/documentMD/design/performance.md @@ -0,0 +1,180 @@ +# パフォーマンスチューニング + +## 1. 概要 + +WHEELのパフォーマンスに影響する主な設定と、その最適化方法をまとめる。 +設定は `~/.wheel/server.json` または対応する環境変数で行う。 + +--- + +## 2. ローカルジョブの並列数(numLocalJob) + +### 設定 + +| 設定方法 | キー/変数名 | +|---------|-----------| +| server.json | `numLocalJob` | +| 環境変数 | `WHEEL_NUM_LOCAL_JOB` | +| デフォルト値 | `1` | + +### 説明 + +ローカルマシン上で同時実行できるタスクの最大数。 +タスクとは「Task」コンポーネントを実行する処理を指す。 + +```json +{ + "numLocalJob": 4 +} +``` + +**推奨値:** ローカルマシンのCPUコア数 - 1 程度。 +ただし、タスクがI/Oバウンドの場合はコア数以上に増やしても効果がある場合がある。 + +--- + +## 3. リモートジョブの並列数(numJob) + +### 設定 + +`remotehost.json` の各ホスト定義内で設定する。 + +```json +{ + "id": "...", + "name": "HPCクラスタ", + "host": "cluster.example.ac.jp", + "numJob": 10 +} +``` + +| フィールド | 説明 | デフォルト | +|-----------|------|-----------| +| `numJob` | リモートホストへの同時ジョブ投入数の上限 | `1` | + +### 動作 + +`getMaxNumJob()` 関数が上限を決定する: +- ホストが `null`(ローカル実行)の場合 → `numLocalJob` を使用 +- `hostinfo.numJob` が数値の場合 → `max(numJob, 1)` を使用 +- それ以外 → `1` + +### 動的調整(フィードバック制御) + +ジョブ投入時にキューが満杯のエラーが返ってきた場合、 +`maxConcurrent` を自動的に1つ減らす(下限1まで)。 +逆に正常投入が続いた場合、徐々に元の値に戻す。 + +--- + +## 4. ファイル転送の並列数(maxNumParallelTransfer) + +### 設定 + +`remotehost.json` の各ホスト定義内で設定する。 + +```json +{ + "id": "...", + "name": "リモートホスト", + "host": "remote.example.ac.jp", + "maxNumParallelTransfer": 4 +} +``` + +| フィールド | 説明 | デフォルト | +|-----------|------|-----------| +| `maxNumParallelTransfer` | 同一ホストへの同時ファイル転送数の上限 | `1` | + +### 動作 + +`transferManager.js` の `SBS`(Simple Batch System)がこの値を使用して +並列転送数を制御する。キーは `"projectRootDir-remotehostID"` で管理され、 +プロジェクトとリモートホストの組み合わせごとに独立したキューを持つ。 + +--- + +## 5. ログ設定 + +### server.json でのログ設定 + +```json +{ + "logLevel": "info", + "numLogFiles": 5, + "maxLogSize": 8388608, + "compressLogFile": true +} +``` + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|-----|-----------|------| +| `logLevel` | `string` | `"debug"` | ログレベル(`trace`/`debug`/`info`/`warn`/`error`/`fatal`/`off`) | +| `numLogFiles` | `number` | `5` | ログファイルのローテーション世代数 | +| `maxLogSize` | `number` | `8388608`(8MB) | 1ファイルの最大サイズ(バイト) | +| `compressLogFile` | `boolean` | `true` | 古いログファイルをgzip圧縮するか | + +### 環境変数 + +| 変数 | 対応プロパティ | +|------|-------------| +| `WHEEL_LOG_LEVEL` | `logLevel` | +| `WHEEL_NUM_LOG_FILES` | `numLogFiles` | +| `WHEEL_MAX_LOG_SIZE` | `maxLogSize` | +| `WHEEL_COMPRESS_LOG_FILE` | `compressLogFile` | + +### ログレベルの選択指針 + +| ログレベル | 用途 | +|-----------|------| +| `debug` | 開発中・問題調査 | +| `info` | 本番環境の標準 | +| `warn` | 最小限のログ出力(警告以上のみ) | +| `off` | ログを完全に無効化(テスト時) | + +`verboseSsh: true` を設定すると、SSH接続の詳細ログが追加される。 + +--- + +## 6. タスクリトライ設定 + +### server.json + +```json +{ + "defaultTaskRetryCount": 1 +} +``` + +| プロパティ | デフォルト | 説明 | +|-----------|-----------|------| +| `defaultTaskRetryCount` | `1` | タスク失敗時のデフォルトリトライ回数 | + +個別のタスクコンポーネントでも上書き設定が可能。 + +--- + +## 7. チューニングの実践ガイド + +### 大量ファイル転送が遅い場合 + +1. `maxNumParallelTransfer` を増やす(例: 4〜8) +2. ネットワーク帯域が十分あるか確認 +3. `rsync` の圧縮オプション(`-z`)は帯域幅が制限されている場合に有効 + +### ローカルCPU使用率が低い場合 + +1. `numLocalJob` を現在の値より増やす +2. タスクがCPUバウンドか確認(CPUバウンドなら超過設定は逆効果) + +### ログが大量に出力される場合 + +1. `logLevel` を `info` または `warn` に変更 +2. `maxLogSize` を増やして頻繁なローテーションを抑制 +3. `compressLogFile: true` でディスク使用量を削減 + +### リモートジョブの投入が制限される場合 + +1. `numJob` の設定がジョブスケジューラの制限以下になっているか確認 +2. ジョブスケジューラ(PBSなど)の最大投入数ポリシーを確認 +3. 自動フィードバック制御に任せる(WHEELが自動調整する) diff --git a/documentMD/self-signed_certification.md b/documentMD/design/self-signed_certification.md similarity index 100% rename from documentMD/self-signed_certification.md rename to documentMD/design/self-signed_certification.md diff --git a/documentMD/design/testing.md b/documentMD/design/testing.md new file mode 100644 index 000000000..a1aef4d6e --- /dev/null +++ b/documentMD/design/testing.md @@ -0,0 +1,218 @@ +# テスト戦略 + +## 1. テスト構成の概要 + +OPEN-WHEELのテストは2つのカテゴリに分かれる。 + +| 種別 | 対象 | フレームワーク | ディレクトリ | +|------|------|--------------|------------| +| サーバーサイド単体テスト (UT) | サーバーJSモジュール | Mocha + Chai + Sinon | `server/test/` | +| E2Eテスト | UI全体 | Cypress | `test/cypress/e2e/` | + +--- + +## 2. サーバーサイド単体テスト + +### 概要 + +- テストフレームワーク: Mocha + Chai + Sinon +- テストファイル: `server/test/app/` 以下(70ファイル超) +- SSH接続テストには `naoso5/openpbs` Dockerイメージをリモートホストとして使用 + +### 実行コマンド + +```bash +npm run test -w server +``` + +これは以下を順番に実行する: +1. `setup.sh` — SSHテストサーバー(Docker)を起動、テスト用設定ファイルを生成 +2. Mocha でテストを実行 +3. `teardown.sh` — Dockerコンテナを停止・クリーンアップ + +> **注意**: Dockerが起動している必要がある。数十分かかることがある。 + +### 特定ファイルのみを実行(デバッグ時) + +```bash +cd server +npm run UT:setup +npx mocha test/app/core/specificModule.js +npm run UT:teardown +``` + +`only` モディファイアを使用して特定テストのみ実行することも可能だが、 +最終チェック前に `only` を必ず削除すること。 + +### テストのディレクトリ構成 + +``` +server/test/ +├── app/ +│ ├── core/ # コアロジックのUT(dispatcher, db, migrationHelper等) +│ ├── handlers/ # ハンドラのUT +│ └── utils/ # ユーティリティのUT +└── test_setting*.txt # テスト用設定ファイル(gitignore) +``` + +### 新しい単体テストの書き方 + +```js +import { strict as assert } from "node:assert"; +import { stub, restore } from "sinon"; + +describe("myModule", () => { + afterEach(() => { + restore(); + }); + + it("should do something", async () => { + // Sinonでモジュールをスタブ + const stubFn = stub(dependency, "method").resolves("expected"); + + const result = await myFunction("input"); + assert.equal(result, "expected"); + assert(stubFn.calledOnce); + }); +}); +``` + +**注意点:** +- `assert.strict` または `assert` (strict mode)を使用する +- 非同期関数は `async/await` で書く +- `sinon.stub` → `sinon.restore` を `afterEach` で必ず呼ぶ +- `only` を本番テストに残さない(CI失敗の原因になる) + +### テスト環境変数 + +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `WHEEL_TEST_REMOTEHOST` | テスト用リモートホスト名 | `testServer` | +| `WHEEL_TEST_REMOTE_PASSWORD` | リモートホストパスワード | `passw0rd` | +| `WHEEL_CONFIG_DIR` | WHEEL設定ディレクトリパス | setup.shが自動生成 | +| `NODE_ENV` | Node環境 | `test` | +| `WHEEL_LOG_LEVEL` | ログレベル | `OFF` | + +--- + +## 3. E2Eテスト + +### 概要 + +- テストフレームワーク: Cypress +- テストファイル: `test/cypress/e2e/` 以下 +- WHEELサーバーに対してブラウザ操作を自動化してテスト + +### アーキテクチャ(モックサーバーモード) + +``` +Cypress(テスト) + ↓ HTTP / WebSocket +Gateway (port 3001) ← ws-gateway.cjs + ├─ Socket.IO → Mock SIO Server (port 3101) ← mock_server/server.js + ├─ HTTP API → Mock HTTP Server (port 3102) ← @mocks-server/main + └─ その他 → WHEEL App (port 8089) +``` + +Gateway が Socket.IO と HTTP を振り分けることで、WHEELの実際の動作とモックを組み合わせたテストが可能。 + +### 実行コマンド + +| モード | コマンド | 説明 | +|--------|---------|------| +| モックサーバー(標準) | `npm run test:e2e:mock` | Docker Compose で起動、モックあり | +| 非モック | `npm run test:e2e` | Docker Compose で起動、モックなし | +| リモート | `npm run test:e2e:remote` | 既存サーバーに対してテスト | +| インタラクティブ | `npm run test` | 開発中のデバッグ用 | + +### 実行前の準備 + +```bash +cd test +npm install +npx cypress install +``` + +### 新しいE2Eテストの書き方 + +```js +describe("My Feature", () => { + beforeEach(() => { + cy.visit("/"); + }); + + it("should show the home page", () => { + cy.contains("WHEEL").should("be.visible"); + }); +}); +``` + +### テスト出力 + +| ディレクトリ | 内容 | +|------------|------| +| `test/cypress/screenshots/` | 失敗時のスクリーンショット | +| `test/cypress/videos/` | テスト実行動画 | + +--- + +## 4. CI/CD(GitHub Actions) + +### サーバーサイド単体テスト(`run_test.yml`) + +``` +トリガー: master以外の全ブランチへのpush +SSHテストサーバー: naoso5/openpbs(port 4000:22) +実行コマンド: npm run test(server/) +成功後: server/app/db/version.json を自動更新 +``` + +### E2Eテスト(`run_cypress.yml`) + +``` +トリガー: master以外の全ブランチへのpush +SSHテストサーバー: naoso5/openpbs(GitHub Actionsサービス, port 4000:22) + +Dockerコンテナ(4台, ネットワーク: wheel-e2e-net): + wheel (port 8089): WHEELアプリ本体 + wheel_auth (port 8090): 認証機能が有効なWHEEL + mock (port 3101/3102): Socket.IOモック + HTTPモック + gateway (port 3001): リバースプロキシ + +失敗時のアーティファクト: + container-logs : コンテナログ + cypress-screenshots: 失敗時スクリーンショット + cypress-videos : テスト実行動画 +``` + +--- + +## 5. トラブルシューティング + +### UT: Docker が起動しない + +```bash +docker info +``` + +### UT: SSHテストサーバーへの接続失敗 + +```bash +ssh-keygen -R 127.0.0.1 +ssh-keygen -R '[127.0.0.1]:4000' +``` + +### E2E: モックサーバーが起動しない + +```bash +cd test +docker compose down +lsof -i :3001 -i :3101 -i :3102 +``` + +### E2E: Chromeが見つからない + +```bash +cd test +npx cypress install +``` diff --git a/documentMD/design/webhook.md b/documentMD/design/webhook.md new file mode 100644 index 000000000..732d0273b --- /dev/null +++ b/documentMD/design/webhook.md @@ -0,0 +1,113 @@ +# Webhook連携 + +## 1. 概要 + +WHEELのWebhook機能を使用すると、プロジェクトやコンポーネントの状態変化を +外部のURLにHTTP POSTで通知できる。 +これにより、CIツールや外部システムとのインテグレーションが可能になる。 + +## 2. Webhook設定 + +### 設定方法 + +Webhookの設定はワークフローエディタのプロジェクト設定画面から行う。 + +### webhook オブジェクトの構造 + +Webhook設定は `prj.wheel.json`(プロジェクトメタデータ)内の `webhook` フィールドに保存される。 + +```json +{ + "webhook": { + "URL": "https://example.com/webhook", + "project": true, + "component": false + } +} +``` + +| フィールド | 型 | 説明 | +|-----------|-----|------| +| `URL` | `string` | 通知先のURL | +| `project` | `boolean` | プロジェクト状態変化時に通知するか | +| `component` | `boolean` | コンポーネント状態変化時に通知するか | + +## 3. 通知の仕組み + +### トリガーとペイロード + +Webhookはプロジェクト実行開始時に `EventEmitter`(`ee`)のリスナーとして登録される。 + +| トリガー | 通知の有効条件 | ペイロード | +|---------|------------|---------| +| `projectStateChanged` | `webhook.project === true` | プロジェクトJSON全体 | +| `componentStateChanged` | `webhook.component === true` | 変化したコンポーネントのオブジェクト | + +### 通知の実装(projectController.js) + +```js +if (typeof webhook !== "undefined" && typeof webhook.URL === "string") { + if (webhook.project) { + ee.on("projectStateChanged", async (projectJson) => { + const response = await axios.post(webhook.URL, projectJson); + }); + } + if (webhook.component) { + ee.on("componentStateChanged", async (component) => { + const response = await axios.post(webhook.URL, component); + }); + } +} +``` + +通知には `axios` ライブラリを使用。HTTPレスポンスはデバッグログに記録される。 + +## 4. Webhook設定の更新(webhook.js) + +### `replaceWebhook()` 関数 + +```js +async function replaceWebhook(projectRootDir, newWebhook) +``` + +- `just-diff` / `just-diff-apply` を使って差分更新(differential update)を行う +- 完全な上書きではなく差分を適用するため、部分的な更新が可能 +- 戻り値は更新後の `webhook` オブジェクト + +#### 差分更新の流れ + +``` +1. プロジェクトJSONを読み込む +2. 現在の webhook と新しい webhook の差分(patch)を計算 + → just-diff による JSON Patch 形式 +3. 差分を既存の webhook オブジェクトに適用 + → diffApply(in-place更新) +4. プロジェクトJSONを書き戻す +5. 更新後の webhook を返す +``` + +### Socket.IO ハンドラ + +クライアントからの更新は `workflowEditor.js` の `onUpdateWebhook` で処理される。 + +```js +export async function onUpdateWebhook(projectRootDir, webhook, parentID, cb) { + return generalHandler(replaceWebhook.bind(null, projectRootDir, webhook), ...); +} +``` + +## 5. Web API機能(enableWebApi) + +`enableWebApi: true` に設定すると、リモートホストの認証(OAuth)サポートが有効になる。 +これはWebhookとは独立した機能であり、OAuth 2.0によるリモートホスト認証フローを提供する。 + +### 有効化された場合のルート + +- `GET /webAPIauth` — OAuth認証コールバック処理 +- `GET /` のOAuthクエリパラメータ(`code`, `state`)の処理が有効化される + +## 6. 注意事項 + +- Webhook URLは実行前に設定する必要がある(実行中の変更は反映されない) +- HTTPSを使用しない場合でも通知可能だが、セキュリティ上HTTPS推奨 +- 通知失敗(HTTP 4xx/5xx等)はエラーログに記録されるが、プロジェクト実行は継続する diff --git a/documentMD/readme.md b/documentMD/readme.md index 31dbb0c52..5537deacb 100644 --- a/documentMD/readme.md +++ b/documentMD/readme.md @@ -1,13 +1,36 @@ # 本ディレクトリに含まれるドキュメント -## ユーザ向け -- [Cloud(AWS)インスタンス利用方法](./Cloud.md) -- [パラメータスタディ解説](./PS.md) -## 管理者向け -- [管理者向けドキュメント](./AdminGuide.md) +## ユーザ向け・管理者向け +- [ユーザーガイド](./user_guide/) -## 開発者向け +## 開発者向け(design/ディレクトリ) +### アーキテクチャ・設計 +- [システムアーキテクチャ概要](./design/architecture.md) +- [コンポーネント実行・ディスパッチアルゴリズム](./design/dispatching.md) +- [認証・認可](./design/authentication.md) +- [ファイル転送・リモートホスト管理](./design/file_transfer.md) +- [エラーハンドリングと回復](./design/error_handling.md) - [詳細設計書](./design/design.md) -- [API guilde](./APIGuide.md) -- [JobScheduler.jsonファイル仕様](./JS.md) + +### 設定・運用 +- [設定システムリファレンス](./design/configuration.md) +- [デプロイ・運用手順](./design/deployment.md) +- [パフォーマンスチューニング](./design/performance.md) +- [クラウド (AWS) インスタンス利用方法](./design/Cloud.md) +- [自己証明書作成ガイド](./design/self-signed_certification.md) + +### 開発者向けガイド +- [開発者向けオンボーディングガイド](./design/developer_guide.md) +- [テスト戦略](./design/testing.md) +- [モックサーバガイド](./design/mock_guide.md) + +### 連携機能 +- [GFarm連携](./design/gfarm.md) +- [Webhook連携](./design/webhook.md) + +### API・仕様 +- [API ガイド (Socket.IO)](./design/APIGuide.md) +- [JobScheduler.json ファイル仕様](./design/JS.md) +- [JobScriptEditor データフォーマット](./design/JobScriptEditor.md) +- [パラメータスタディ仕様](./design/PS.md) diff --git a/documentMD/user_guide/_data/en/navigation.yml b/documentMD/user_guide/_data/en/navigation.yml index 09969fe94..2c6a766f3 100644 --- a/documentMD/user_guide/_data/en/navigation.yml +++ b/documentMD/user_guide/_data/en/navigation.yml @@ -41,6 +41,9 @@ refs: url: /reference/1_home_screen/ - title: 2. Remote host settings screen url: /reference/2_remotehost_screen/ + children: + - title: SSH Authentication + url: /reference/2_remotehost_screen/ssh_auth/ - title: 3. Workflow Screen children: - title: Graph View diff --git a/documentMD/user_guide/_data/navigation.yml b/documentMD/user_guide/_data/navigation.yml index f5323533c..2771b465c 100644 --- a/documentMD/user_guide/_data/navigation.yml +++ b/documentMD/user_guide/_data/navigation.yml @@ -41,6 +41,9 @@ refs: url: /reference/1_home_screen/ - title: 2. リモートホスト設定画面 url: /reference/2_remotehost_screen/ + children: + - title: SSH認証の設定 + url: /reference/2_remotehost_screen/ssh_auth/ - title: 3. ワークフロー画面 children: - title: グラフビュー diff --git a/documentMD/user_guide/_for_admins/how_to_boot/index.en.md b/documentMD/user_guide/_for_admins/how_to_boot/index.en.md index 2e82722c1..9d53226e0 100644 --- a/documentMD/user_guide/_for_admins/how_to_boot/index.en.md +++ b/documentMD/user_guide/_for_admins/how_to_boot/index.en.md @@ -243,3 +243,74 @@ However, the number of jobs submitted without using WHEEL is not counted. Theref -------- [Return to home page]({{ site.baseurl }}/) + +## Customizing server settings + +WHEEL loads all its config files (`server.json`, `jobScheduler.json`) using the same priority order (highest first): + +1. **Environment variables** (e.g. `WHEEL_PORT`) — overrides all config files +2. **`WHEEL_CONFIG_DIR/{file}`** — set `WHEEL_CONFIG_DIR` env var to point at a directory +3. **`~/.wheel/{file}`** — the easy way: place files in your home directory, no env var needed +4. Built-in package defaults + +### Using ~/.wheel/ (recommended) + +Create files under `~/.wheel/` and set only the values you want to override. WHEEL will deep-merge them with the built-in defaults automatically, so you only need to specify the values that differ. + +For example, to change the port number, create `~/.wheel/server.json`: + +```json +{ "port": 9000 } +``` + +To override a single field in a batch scheduler, create `~/.wheel/jobScheduler.json`: + +```json +{ "PBSPro": { "submit": "my-qsub" } } +``` + +#### Docker users + +If you start WHEEL with Docker using the standard command, `${HOME}` on the host is already mounted to `/root` inside the container: + +``` +docker run -d -v ${HOME}:/root ... +``` + +This means `~/.wheel/` inside the container is `${HOME}/.wheel/` on the host. No extra volume mounts are needed — just create the files on the host and WHEEL will pick them up automatically. + +### Available server.json settings + +| Key | Default | Env var | Description | +|-----|---------|---------|-------------| +| `port` | `8089` | `WHEEL_PORT` | Port number WHEEL listens on | +| `numLogFiles` | `5` | — | Number of log files to keep | +| `withLogin` | `false` | — | Require login (use `WHEEL_ENABLE_AUTH` env var instead) | +| `numLocalJob` | `1` | `WHEEL_NUM_LOCAL_JOB` | Max concurrent local task executions | +| `baseURL` | `""` | `WHEEL_BASE_URL` | Base URL when WHEEL is behind a reverse proxy | +| `useHttp` | `false` | `WHEEL_USE_HTTP` | Disable TLS and serve over plain HTTP | +| `acceptAddress` | `null` | `WHEEL_ACCEPT_ADDRESS` | Allowed client IP address (`null` = allow all) | +| `logLevel` | `"debug"` | `WHEEL_LOG_LEVEL` | Log level (`trace`/`debug`/`info`/`warn`/`error`/`fatal`) | +| `verboseSsh` | `false` | `WHEEL_VERBOSE_SSH` | Enable SSH verbose logging (`-vvv` flag) | +| `enableWebApi` | `false` | `WHEEL_ENABLE_WEB_API` | Enable Web API endpoints | +| `enableAuth` | `false` | `WHEEL_ENABLE_AUTH` | Enable the authentication mechanism | + +> **Note:** `WHEEL_LOGLEVEL` was renamed to `WHEEL_LOG_LEVEL`. If you have an existing configuration using the old name, please update it. + +## Configuration migration + +When WHEEL starts, it automatically checks user configuration files (`~/.wheel/server.json` and `$WHEEL_CONFIG_DIR/server.json`) and rewrites any old property names to their new equivalents. If a rewrite occurs, a warning is printed to the console at startup. + +### Automatically migrated property names + +| Old property name | New property name | +|-------------------|-------------------| +| `numJobOnLocal` | `numLocalJob` | + +### Deprecated environment variables + +| Deprecated variable | Replacement | +|---------------------|-------------| +| `WHEEL_LOGLEVEL` | `WHEEL_LOG_LEVEL` | + +If a deprecated environment variable is detected at startup, WHEEL will print a `console.warn` message. The deprecated variable has no effect — it will not map to any configuration property, so your intended setting will be silently ignored unless you update to the new name. diff --git a/documentMD/user_guide/_for_admins/how_to_boot/index.md b/documentMD/user_guide/_for_admins/how_to_boot/index.md index f063cf375..ec2c66378 100644 --- a/documentMD/user_guide/_for_admins/how_to_boot/index.md +++ b/documentMD/user_guide/_for_admins/how_to_boot/index.md @@ -205,6 +205,8 @@ __リモートホストへの接続に公開鍵認証を使用する場合__ その他の詳細な設定内容は [リファレンスマニュアル]({{ site.baseurl }}/reference/2_remotehost_screen/ "remotehost設定") をご参照ください。 +SSH認証方式(公開鍵認証・ssh-agent・`~/.ssh/config` の活用方法など)については [SSH認証の設定]({{ site.baseurl }}/reference/2_remotehost_screen/ssh_auth/) をご参照ください。 + ### バッチシステムがある場合の追加設定 ここでは、計算サーバにバッチシステムがある場合に必要な追加のリモートホスト設定について説明します。 本手順を実施する際は、事前に[バッチシステムがない場合](#バッチシステムがない場合)の手順を実施してください。 @@ -245,3 +247,74 @@ __バッチシステムの設定について__ -------- [トップページに戻る]({{ site.baseurl }}/) + +## サーバー設定のカスタマイズ + +WHEELはすべての設定ファイル(`server.json`、`jobScheduler.json`)を以下の優先順位で読み込みます(上位が優先)。 + +1. **環境変数**(例: `WHEEL_PORT`) — すべての設定ファイルより優先されます +2. **`WHEEL_CONFIG_DIR/{ファイル名}`** — `WHEEL_CONFIG_DIR` 環境変数でディレクトリを指定します +3. **`~/.wheel/{ファイル名}`** — 簡単な方法: ホームディレクトリにファイルを置くだけで、環境変数は不要です +4. パッケージ組み込みのデフォルト値 + +### ~/.wheel/ を使う(推奨) + +`~/.wheel/` 以下にファイルを作成し、変更したい設定項目のみ記述してください。WHEELがデフォルト値と自動的にディープマージします。 + +例えばポート番号を変更する場合は `~/.wheel/server.json` を作成します。 + +```json +{ "port": 9000 } +``` + +バッチスケジューラーの設定を一部だけ変更する場合は `~/.wheel/jobScheduler.json` を作成します。 + +```json +{ "PBSPro": { "submit": "my-qsub" } } +``` + +#### Dockerをご利用の方 + +標準の `docker run` コマンドでWHEELを起動する場合、ホスト側の `${HOME}` はコンテナ内の `/root` にマウントされています。 + +``` +docker run -d -v ${HOME}:/root ... +``` + +そのため、コンテナ内の `~/.wheel/` はホスト側の `${HOME}/.wheel/` と同じディレクトリです。追加のボリュームマウントは不要で、ホスト側にファイルを作成するだけで自動的に読み込まれます。 + +### server.json の設定項目 + +| キー | デフォルト値 | 対応する環境変数 | 説明 | +|------|------------|-----------------|------| +| `port` | `8089` | `WHEEL_PORT` | WHEELが待ち受けるポート番号 | +| `numLogFiles` | `5` | — | 保持するログファイルの数 | +| `withLogin` | `false` | — | ログイン必須にする(代わりに `WHEEL_ENABLE_AUTH` 環境変数を使用してください) | +| `numLocalJob` | `1` | `WHEEL_NUM_LOCAL_JOB` | localhostで実行するtaskの同時実行本数 | +| `baseURL` | `""` | `WHEEL_BASE_URL` | WHEELのベースURL(リバースプロキシ配下で使用) | +| `useHttp` | `false` | `WHEEL_USE_HTTP` | TLSを無効にしてHTTPで起動する | +| `acceptAddress` | `null` | `WHEEL_ACCEPT_ADDRESS` | 接続を許可するクライアントのIPアドレス(nullは全て許可) | +| `logLevel` | `"debug"` | `WHEEL_LOG_LEVEL` | ログレベル(`trace`/`debug`/`info`/`warn`/`error`/`fatal`) | +| `verboseSsh` | `false` | `WHEEL_VERBOSE_SSH` | SSH接続時に詳細ログを出力する(`-vvv`オプション) | +| `enableWebApi` | `false` | `WHEEL_ENABLE_WEB_API` | Web APIエンドポイントを有効にする | +| `enableAuth` | `false` | `WHEEL_ENABLE_AUTH` | 認証機構を有効にする | + +> **注意:** `WHEEL_LOGLEVEL` は `WHEEL_LOG_LEVEL` に名称変更されました。既存の設定をお使いの場合は更新が必要です。 + +## 設定の移行(マイグレーション) + +WHEELは起動時に、ユーザーの設定ファイル(`~/.wheel/server.json`、`$WHEEL_CONFIG_DIR/server.json`)を自動的に確認し、古いプロパティ名を新しい名前に書き換えます。書き換えが発生した場合は、起動時に `console.warn` メッセージが表示されます。 + +### 自動変換されるプロパティ名 + +| 旧プロパティ名 | 新プロパティ名 | +|--------------|--------------| +| `numJobOnLocal` | `numLocalJob` | + +### 廃止された環境変数 + +| 廃止された環境変数 | 代替の環境変数 | +|------------------|--------------| +| `WHEEL_LOGLEVEL` | `WHEEL_LOG_LEVEL` | + +廃止された環境変数が設定されている場合、WHEELは起動時に警告を表示します。廃止された環境変数は設定値として認識されないため、意図した設定が有効にならない場合があります。古い名前から新しい名前への更新をお願いします。 diff --git a/documentMD/user_guide/_reference/2_remotehost_screen/index.md b/documentMD/user_guide/_reference/2_remotehost_screen/index.md index 7891b58e8..32c71a851 100644 --- a/documentMD/user_guide/_reference/2_remotehost_screen/index.md +++ b/documentMD/user_guide/_reference/2_remotehost_screen/index.md @@ -153,4 +153,6 @@ __shared with localhost__ を使用すると、ファイル転送は以下のよ -------- +[SSH認証の詳細設定]({{ site.baseurl }}/reference/2_remotehost_screen/ssh_auth/) + [リファレンスマニュアルのトップページに戻る]({{ site.baseurl }}/reference/) diff --git a/documentMD/user_guide/_reference/2_remotehost_screen/ssh_auth.md b/documentMD/user_guide/_reference/2_remotehost_screen/ssh_auth.md new file mode 100644 index 000000000..506457d94 --- /dev/null +++ b/documentMD/user_guide/_reference/2_remotehost_screen/ssh_auth.md @@ -0,0 +1,270 @@ +--- +title: SSH認証の設定 +lang: ja +permalink: /reference/2_remotehost_screen/ssh_auth/ +toc: true +toc_sticky: true +--- + +本ドキュメントでは、WHEELからリモートホストへSSH接続する際の認証設定について説明します。 + +## 1. WHEELのSSH接続の仕組み + +WHEELは `ssh-client-wrapper` ライブラリを使用してリモートホストへ接続します。 +このライブラリはシステムにインストールされたOpenSSHクライアント(`ssh`コマンド)を内部で呼び出します。 +そのため、OpenSSHの設定(`~/.ssh/config`)をそのまま活用することができます。 + +WHEELがSSH接続を確立する際には、ControlMaster機能を使用して +マスター接続を1本維持し、以後の接続はそのマスター接続を再利用します。 +これにより、繰り返されるファイル転送やコマンド実行のたびに +認証処理を行う必要がなくなります。 + +## 2. 認証方式 + +WHEELは以下の認証方式をサポートしています。 + +### 2.1 パスワード認証 + +リモートホスト設定の `use public key authentication` スイッチを **無効** にすると、 +パスワード認証が使用されます。 + +プロジェクト実行時に、パスワード入力ダイアログが表示されます。 + +入力されたパスワードはメモリ上に保持され、同一プロジェクト実行中は再入力不要です。 +ただし、接続が切断・再接続された場合は再度入力が求められます。 + +### 2.2 公開鍵認証(秘密鍵ファイル指定) + +リモートホスト設定の `use public key authentication` スイッチを **有効** にすると、 +公開鍵認証が使用されます。 + +`private key path` フィールドに秘密鍵ファイルのパスを指定します。 +(WHEELサーバが動作しているマシン上のパスを指定してください。) + +秘密鍵にパスフレーズが設定されている場合は、プロジェクト実行時に +パスフレーズ入力ダイアログが表示されます。 + +公開鍵認証を使用する場合、WHEELは自動的にSSHエージェントフォワーディング(`-A`オプション)を有効にします。 +これにより、リモート→リモート間のファイル転送が可能になります。 + +### 2.3 ssh-agent を使用した認証(パスフレーズ省略) + +`ssh-agent` を使用することで、パスフレーズの繰り返し入力を省略できます。 + +#### 手順 + +1. SSHエージェントを起動します(通常はログインシェル起動時に自動起動されます)。 + + ```bash + eval "$(ssh-agent -s)" + ``` + +2. 秘密鍵をエージェントに登録します。 + + ```bash + ssh-add ~/.ssh/id_ed25519 + ``` + +3. `~/.ssh/config` に以下を追加します。 + + ``` + Host * + AddKeysToAgent yes + ``` + + `AddKeysToAgent yes` を設定すると、初回接続時に秘密鍵がエージェントに自動登録されます。 + 以後はパスフレーズを入力することなくSSH接続が行われます。 + +{% capture notice-ssh-agent %} +__Dockerコンテナ上でWHEELを使用する場合__ + +コンテナ内のWHEELプロセスがホストのssh-agentを使用するには、 +SSHエージェントのソケットをコンテナにマウントする必要があります。 + +```bash +docker run -d \ + -v "${SSH_AUTH_SOCK}:/ssh-agent" \ + -e SSH_AUTH_SOCK=/ssh-agent \ + ... \ + tmkawanabe/wheel:latest +``` + +このようにすることで、コンテナ内のWHEELからホストのssh-agentが使用できるようになります。 +{% endcapture %} +
{{ notice-ssh-agent | markdownify }}
+ +## 3. `~/.ssh/config` の活用 + +WHEELはOpenSSHクライアントを使用するため、`~/.ssh/config` で設定した内容が反映されます。 + +### 3.1 基本的な設定例 + +``` +# リモートホストの共通設定 +Host hpc-cluster + HostName hpc.example.ac.jp + User yamada + IdentityFile ~/.ssh/id_ed25519_hpc + AddKeysToAgent yes +``` + +リモートホスト設定の `Hostname` フィールドに `hpc.example.ac.jp` を指定する代わりに、 +`~/.ssh/config` で設定したエイリアス(上記例では `hpc-cluster`)をそのまま使用できます。 + +### 3.2 ProxyJump(多段SSH接続) + +踏み台サーバ経由でリモートホストに接続する場合は、`ProxyJump` を使用します。 + +``` +# 踏み台サーバの設定 +Host bastion + HostName bastion.example.ac.jp + User yamada + IdentityFile ~/.ssh/id_ed25519 + +# 踏み台経由でHPCに接続 +Host hpc-internal + HostName hpc-internal.example.ac.jp + User yamada + IdentityFile ~/.ssh/id_ed25519_hpc + ProxyJump bastion +``` + +WHEELのリモートホスト設定の `Hostname` フィールドに `hpc-internal` を指定するだけで、 +踏み台サーバを経由した接続が自動的に行われます。 + +### 3.3 よく使用する設定項目 + +| 設定項目 | 説明 | +|---------|------| +| `HostName` | 実際のホスト名またはIPアドレス | +| `User` | ログインユーザ名 | +| `Port` | 接続先ポート番号(デフォルト: 22) | +| `IdentityFile` | 使用する秘密鍵ファイルのパス | +| `ProxyJump` | 踏み台サーバの指定(多段SSH) | +| `AddKeysToAgent` | 初回接続時にssh-agentへ鍵を自動登録するか(`yes`/`no`) | +| `ServerAliveInterval` | 接続維持のためのKeepAlive間隔(秒) | +| `ServerAliveCountMax` | KeepAliveの最大試行回数 | +| `StrictHostKeyChecking` | ホスト鍵の確認方式(`yes`/`no`/`accept-new`) | + +### 3.4 `StrictHostKeyChecking` について + +初回接続時に `~/.ssh/known_hosts` にホスト鍵が登録されていない場合、 +SSH接続が失敗することがあります。 + +事前にSSH接続を試みて `known_hosts` に登録しておくか、以下の設定を使用してください。 + +``` +Host hpc.example.ac.jp + StrictHostKeyChecking accept-new +``` + +`accept-new` は初回接続時に自動的に鍵を登録し、それ以後は変更を検出した場合に警告します。 + +{% capture notice-strict %} +__注意__ + +`StrictHostKeyChecking no` はホスト鍵の検証を完全に無効にするため、 +セキュリティ上のリスクがあります。本番環境での使用は推奨しません。 +{% endcapture %} +
{{ notice-strict | markdownify }}
+ +## 4. ControlMaster / ControlPersist の動作 + +WHEELは内部的にOpenSSHの **ControlMaster** 機能を使用しています。 +最初のSSH接続(マスター接続)を確立した後、以降の接続はそのマスター接続を再利用します。 + +### ControlPersist の設定 + +マスター接続の維持時間は、リモートホスト設定の +`connection renewal interval (min.)` フィールドで制御します。 + +| 設定値 | 動作 | +|-------|------| +| `0`(デフォルト) | 接続を切断しない(プロジェクト終了まで維持) | +| `N`(分) | 最後のクライアント接続終了後 N 分でマスター接続を切断 | + +### ControlPath ファイルの保存先 + +ControlPath(マスター接続のソケットファイル)はデフォルトで `~/.ssh/` に保存されます。 + +保存先を変更するには、環境変数 `SSH_CONTROL_PERSIST_DIR` に +書き込み可能なディレクトリのパスを設定します。 + +```bash +export SSH_CONTROL_PERSIST_DIR=/tmp/ssh-sockets +mkdir -p /tmp/ssh-sockets +``` + +{% capture notice-control-socket %} +__Control socket creation failed エラーについて__ + +NFS等のネットワークファイルシステム上に `~/.ssh/` がある場合、 +ControlPath ファイルの作成が失敗することがあります。 + +このような場合は `SSH_CONTROL_PERSIST_DIR` をローカルファイルシステム上の +ディレクトリ(`/tmp` 配下など)に設定してください。 + +```bash +export SSH_CONTROL_PERSIST_DIR=/tmp/wheel-ssh-sockets +``` +{% endcapture %} +
{{ notice-control-socket | markdownify }}
+ +## 5. よくある問題と対処法 + +### 5.1 接続テストが失敗する + +リモートホスト設定ダイアログの **TEST** ボタンで接続確認が行えます。 +失敗する場合は以下を確認してください。 + +1. **ホスト名・ポート番号・ユーザ名** が正しいか確認する +2. リモートホスト側で **公開鍵が登録されているか** 確認する(`~/.ssh/authorized_keys`) +3. ターミナルで直接 `ssh` コマンドで接続を試みる + + ```bash + ssh -i /path/to/key user@hostname + ``` + +4. `known_hosts` にホスト鍵が登録されているか確認する + + ```bash + ssh-keygen -F hostname + ``` + +### 5.2 パスフレーズ入力ダイアログが毎回表示される + +ssh-agent を使用することでパスフレーズ入力を省略できます。 +[2.3 ssh-agent を使用した認証](#23-ssh-agent-を使用した認証パスフレーズ省略) を参照してください。 + +また、`~/.ssh/config` に `AddKeysToAgent yes` を設定することで、 +初回接続後は自動的にエージェントが鍵を保持します。 + +### 5.3 多段SSH接続でタイムアウトする + +踏み台サーバの設定で `ServerAliveInterval` を設定して接続を維持してください。 + +``` +Host bastion + ServerAliveInterval 60 + ServerAliveCountMax 3 +``` + +### 5.4 SSH接続のデバッグログを出力する + +`server.json` の `verboseSsh` を `true` に設定(または環境変数 `WHEEL_VERBOSE_SSH=true`)すると、 +SSH接続時に詳細ログ(`-vvv` オプション相当)が出力されます。 + +```json +{ + "verboseSsh": true +} +``` + +ログはWHEELのログファイル(`wheel.log`)に記録されます。 +問題解決後は `false` に戻すことを推奨します(ログ量が大幅に増加するため)。 + +-------- +[リモートホスト設定ダイアログに戻る]({{ site.baseurl }}/reference/2_remotehost_screen/) + +[リファレンスマニュアルのトップページに戻る]({{ site.baseurl }}/reference/) diff --git a/documentMD/user_guide/_reference/4_component/00_common.en.md b/documentMD/user_guide/_reference/4_component/00_common.en.md index e0d465926..997ecfe2b 100644 --- a/documentMD/user_guide/_reference/4_component/00_common.en.md +++ b/documentMD/user_guide/_reference/4_component/00_common.en.md @@ -7,6 +7,7 @@ This section describes the specifications common to all components. ## Viewing Properties When you single-click a component displayed in the workflow creation area, an area for editing the settings (properties) of that component appears. +Settings are saved when the area is closed, except for some items. The contents of this area differ for each type of component. @@ -14,8 +15,8 @@ The contents of this area differ for each type of component. || Component | Description | |----------|----------|---------------------------------| -|1|close button | Closes the property display | -|2|clean button | Rewind the state of the component (and any subcomponents) to the most recent saved state | +|1|close button | Discards changes and closes the property display | +|2|disable switch | Disables the component (and subcomponents if any) | |3| Details button | Shows or hides property settings for each group | ### Component Right-Click Menu @@ -47,7 +48,7 @@ To export a component: - The archive contains the component and all its descendant components __What is Exported__ -- Component configuration (component.json files) +- Component configuration (component.wheel.json files) - All files and directories within the component - All descendant (child) components recursively - Component states are reset to "not-started" @@ -196,6 +197,7 @@ At the top of the file operation area are buttons for file operations. |5|upload file button | Uploads a file to the displayed hierarchy | |6| Download button | Downloads the selected file or directory | |7|share file button | Displays the path of the currently selected file or directory | +|8|edit file button | Opens the selected file in a text editor | __About buttons for working with files and directories during selection__ If the selected file or directory is not supported, the button is disabled. diff --git a/documentMD/user_guide/_reference/4_component/03_For.en.md b/documentMD/user_guide/_reference/4_component/03_For.en.md index 25bb24ac1..736bf52f1 100644 --- a/documentMD/user_guide/_reference/4_component/03_For.en.md +++ b/documentMD/user_guide/_reference/4_component/03_For.en.md @@ -14,7 +14,7 @@ You can set the following properties for the For component: Sets the starting value of the index. ### end -Sets the closing price of the index. +Sets the ending value of the index. ### step Sets the update width for index updates. @@ -29,24 +29,34 @@ If unspecified, all directories are saved. For details, see [For Component Run-time Behavior](#for-component-run-time-behavior) below. +### skip copy + +Sets the list of files, directories, or glob patterns to exclude from the copy operation between loop iterations. + +Files and directories matching the specified patterns are not copied when WHEEL creates a new iteration directory from the previous one. +This is useful for excluding large output files or temporary files generated during previous iterations. + +Enter the desired pattern in the input field and click the + button to add it. +Glob patterns (e.g., `*.log`, `output_*`, `results/`) are supported. + ### For Component Run-time Behavior When the For component runs for the first time, the component directory is copied with the index value appended. When all the subcomponents in the copied directory have finished executing, a new index value is calculated and further directories are copied based on that value. -This process is repeated sequentially until the index value exceeds the closing price. -When the closing price is exceeded, the directory is copied to the original directory, and processing of the For component ends. -Note that even if you set a negative value for step, if the opening price is > closing price, the operation will be successful. -In this case, execution ends when the index falls below the closing price. +This process is repeated sequentially until the index value exceeds the end value. +When the end value is exceeded, the directory is copied to the original directory, and processing of the For component ends. +Note that even if you set a negative value for step, if the start value > end value, the operation will be successful. +In this case, execution ends when the index falls below the end value. For example, a `for` component with start=1, end=3, step=2 is processed as follows: 1. Copy `for` directory as `for_1` directory 2. Sequentially execute components in the `for_1` directory -3. index Calculation 1 +2 = 3 => equal to the closing price of 3, run the next loop +3. index Calculation 1 +2 = 3 => equal to the end value of 3, run the next loop 4. Copy `for_1` directory as `for_3` directory 5. Sequentially execute components in the `for_3` directory -6. index Calculation 3 +2 = 5 => Since the closing price has exceeded 3, the closing process is performed. +6. index Calculation 3 +2 = 5 => Since the end value of 3 has been exceeded, the ending process is performed. 7. Copy `for_3` directory as `for` directory If the number of instance to keep value is set to nonzero, delete the old directories (such as `for_1` and `for_3`) that exceed the number set after the 4, 7 operation. diff --git a/documentMD/user_guide/_reference/4_component/03_For.md b/documentMD/user_guide/_reference/4_component/03_For.md index 1c83e47b3..34f8b08c1 100644 --- a/documentMD/user_guide/_reference/4_component/03_For.md +++ b/documentMD/user_guide/_reference/4_component/03_For.md @@ -29,6 +29,16 @@ __インデックス値の参照方法について__ 詳しくは後述の[Forコンポーネント実行時の挙動](#forコンポーネント実行時の挙動)で説明します。 +### skip copy + +各イテレーション間のコピー操作から除外するファイル、ディレクトリ、または glob パターンのリストを設定します。 + +指定したパターンにマッチするファイル・ディレクトリは、WHEEL が前のイテレーションのディレクトリから新しいイテレーションディレクトリを作成する際にコピーされません。 +前のイテレーションで生成された大容量の出力ファイルや一時ファイルを除外するのに便利です。 + +入力欄に任意のパターンを入力し、+ボタンをクリックで追加します。 +glob パターン(例:`*.log`、`output_*`、`results/`)も使用できます。 + ### Forコンポーネント実行時の挙動 Forコンポーネントが初めて実行されるとき、コンポーネントのディレクトリはインデックスの値を末尾につけた名前でコピーされます。 コピーされたディレクトリ内の下位コンポーネントの実行が全て終了すると、新しいインデックス値が計算されその値に基いてさらにディレクトリがコピーされます。 diff --git a/documentMD/user_guide/_reference/4_component/04_while.en.md b/documentMD/user_guide/_reference/4_component/04_while.en.md index 679d77f73..6e8a164f5 100644 --- a/documentMD/user_guide/_reference/4_component/04_while.en.md +++ b/documentMD/user_guide/_reference/4_component/04_while.en.md @@ -30,6 +30,16 @@ If unspecified, all directories are saved. For details, see [While Component Run-time Behavior](#while-component-run-time-behavior) below. +### skip copy + +Sets the list of files, directories, or glob patterns to exclude from the copy operation between loop iterations. + +Files and directories matching the specified patterns are not copied when WHEEL creates a new iteration directory from the previous one. +This is useful for excluding large output files or temporary files generated during previous iterations. + +Enter the desired pattern in the input field and click the + button to add it. +Glob patterns (e.g., `*.log`, `output_*`, `results/`) are supported. + ### While Component Run-time Behavior The While component behaves similarly to the For component, but uses a zero-based number at the end of the directory name instead of an index value. diff --git a/documentMD/user_guide/_reference/4_component/04_while.md b/documentMD/user_guide/_reference/4_component/04_while.md index 5d1d5fa26..055a85596 100644 --- a/documentMD/user_guide/_reference/4_component/04_while.md +++ b/documentMD/user_guide/_reference/4_component/04_while.md @@ -30,6 +30,16 @@ __インデックス値の参照方法について__ 詳しくは後述の[Whileコンポーネント実行時の挙動](#whileコンポーネント実行時の挙動)で説明します。 +### skip copy + +各イテレーション間のコピー操作から除外するファイル、ディレクトリ、または glob パターンのリストを設定します。 + +指定したパターンにマッチするファイル・ディレクトリは、WHEEL が前のイテレーションのディレクトリから新しいイテレーションディレクトリを作成する際にコピーされません。 +前のイテレーションで生成された大容量の出力ファイルや一時ファイルを除外するのに便利です。 + +入力欄に任意のパターンを入力し、+ボタンをクリックで追加します。 +glob パターン(例:`*.log`、`output_*`、`results/`)も使用できます。 + ### Whileコンポーネント実行時の挙動 Whileコンポーネントも、Forコンポーネントと同様の挙動をしますがディレクトリ名の末尾にはインデックス値の代わりに、0から始まる数字を1刻みで使用します。 diff --git a/documentMD/user_guide/_reference/4_component/05_Foreach.en.md b/documentMD/user_guide/_reference/4_component/05_Foreach.en.md index 9a95214c1..752cd8a8a 100644 --- a/documentMD/user_guide/_reference/4_component/05_Foreach.en.md +++ b/documentMD/user_guide/_reference/4_component/05_Foreach.en.md @@ -27,6 +27,16 @@ If unspecified, all directories are saved. For details, see [Foreach Component Run-time Behavior](#foreach-component-run-time-behavior) below. +### skip copy + +Sets the list of files, directories, or glob patterns to exclude from the copy operation between loop iterations. + +Files and directories matching the specified patterns are not copied when WHEEL creates a new iteration directory from the previous one. +This is useful for excluding large output files or temporary files generated during previous iterations. + +Enter the desired pattern in the input field and click the + button to add it. +Glob patterns (e.g., `*.log`, `output_*`, `results/`) are supported. + ### Foreach Component Run-time Behavior The Foreach component behaves the same way as the For component, but the index value is not calculated; instead, the values set in indexList are used starting from the beginning of the list. Terminates execution of the entire component when it reaches the end of the list. diff --git a/documentMD/user_guide/_reference/4_component/05_Foreach.md b/documentMD/user_guide/_reference/4_component/05_Foreach.md index 013a7d422..10e609213 100644 --- a/documentMD/user_guide/_reference/4_component/05_Foreach.md +++ b/documentMD/user_guide/_reference/4_component/05_Foreach.md @@ -27,6 +27,16 @@ __インデックス値の参照方法について__ 詳しくは後述の[Foreachコンポーネント実行時の挙動](#foreachコンポーネント実行時の挙動)で説明します。 +### skip copy + +各イテレーション間のコピー操作から除外するファイル、ディレクトリ、または glob パターンのリストを設定します。 + +指定したパターンにマッチするファイル・ディレクトリは、WHEEL が前のイテレーションのディレクトリから新しいイテレーションディレクトリを作成する際にコピーされません。 +前のイテレーションで生成された大容量の出力ファイルや一時ファイルを除外するのに便利です。 + +入力欄に任意のパターンを入力し、+ボタンをクリックで追加します。 +glob パターン(例:`*.log`、`output_*`、`results/`)も使用できます。 + ### Foreachコンポーネント実行時の挙動 ForeachコンポーネントもForコンポーネントと同様の挙動をしますがインデックス値は計算によって求められるのではなく、indexListに設定された値がリストの先頭から順に使われます。 リストの終端まで実行されるとコンポーネント全体の実行を終了します。 diff --git a/documentMD/user_guide/_reference/4_component/13_Continue.en.md b/documentMD/user_guide/_reference/4_component/13_Continue.en.md new file mode 100644 index 000000000..c1b45cf5b --- /dev/null +++ b/documentMD/user_guide/_reference/4_component/13_Continue.en.md @@ -0,0 +1,39 @@ +--- +title: Continue +lang: en +permalink: /reference/4_component/13_Continue.html +--- + +![img](./img/continue.png "continue") + +The Continue component can only be created directly under a for, while, or foreach component, and can have a condition expression set, similar to the If component. + +When the condition set on this component is met, it advances the parent component's loop to the next index and continues executing the project. + +When the result of the condition check is false, this component does nothing. + +Among the components at the same level as the Continue component, the execution order (before or after the condition check) of components that have no dependency on the Continue component cannot be specified. +If you want to control whether a component runs when Continue is triggered, set a dependency with the Continue component. +Even if there is no direct dependency, it is fine if they are connected through other components. + +The properties you can set for the Continue component are as follows. + +### condition setting +Configure the settings for condition evaluation. + +#### use javascript expression for condition check +Similar to the retry decision of the Task component, specifies whether to use a JavaScript expression or a shell script as the condition expression for evaluating true / false. + + - When disabled + ![img](./img/task_retry_expression_disable.png "task_retry_expression_disable")
+When disabled, a dropdown list for selecting a shell script is displayed. +The specified shell script is used as the condition expression to determine true / false. + + - When enabled +![img](./img/task_retry_expression_enable.png "task_retry_expression_enable")
+When enabled, a JavaScript expression can be entered. +The entered expression is used as the condition expression to determine true / false. + + +-------- +[Return to Component Details]({{site.baseurl}}/reference/4_component/) diff --git a/documentMD/user_guide/_reference/4_component/14_Break.en.md b/documentMD/user_guide/_reference/4_component/14_Break.en.md new file mode 100644 index 000000000..6ddf078aa --- /dev/null +++ b/documentMD/user_guide/_reference/4_component/14_Break.en.md @@ -0,0 +1,39 @@ +--- +title: Break +lang: en +permalink: /reference/4_component/14_Break.html +--- + +![img](./img/break.png "break") + +The Break component can only be created directly under a for, while, or foreach component, and can have a condition expression set, similar to the If component. + +When the condition set on this component is met, it interrupts the parent component's loop and continues executing the project as if all loops had finished. + +When the result of the condition check is false, this component does nothing. + +Among the components at the same level as the Break component, the execution order (before or after the condition check) of components that have no dependency on the Break component cannot be specified. +If you want to control whether a component runs when Break is triggered, set a dependency with the Break component. +Even if there is no direct dependency, it is fine if they are connected through other components. + +The properties you can set for the Break component are as follows. + +### condition setting +Configure the settings for condition evaluation. + +#### use javascript expression for condition check +Similar to the retry decision of the Task component, specifies whether to use a JavaScript expression or a shell script as the condition expression for evaluating true / false. + + - When disabled + ![img](./img/task_retry_expression_disable.png "task_retry_expression_disable")
+When disabled, a dropdown list for selecting a shell script is displayed. +The specified shell script is used as the condition expression to determine true / false. + + - When enabled +![img](./img/task_retry_expression_enable.png "task_retry_expression_enable")
+When enabled, a JavaScript expression can be entered. +The entered expression is used as the condition expression to determine true / false. + + +-------- +[Return to Component Details]({{site.baseurl}}/reference/4_component/) diff --git a/documentMD/user_guide/_reference/4_component/15_HPCISS.en.md b/documentMD/user_guide/_reference/4_component/15_HPCISS.en.md new file mode 100644 index 000000000..4a10da377 --- /dev/null +++ b/documentMD/user_guide/_reference/4_component/15_HPCISS.en.md @@ -0,0 +1,33 @@ +--- +title: HPCI-SS +lang: en +permalink: /reference/4_component/15_HPCISS.html +--- + +![img](./img/hpciss.png "hpciss") + +The HPCI-SS component is a variant of the Storage component that uses HPCI shared storage as the file storage location. + +The properties you can set for the HPCI-SS component are as follows. + +### host +You can set the host where files are actually stored. +However, only hosts that have the `use gfarm` option checked in the remotehost settings can be set as the host. + +Additionally, only hosts that can transfer files to HPCI shared storage using gfarm commands (gfcp, gfpcopy, etc.) are available. + +### directory path +![img](./img/storage_path.png "storage_path") + +Similar to the Storage component, this is the path where files are actually stored. +However, you must specify the path on HPCI shared storage, not a path on the host. + +### Constraints +HPCI shared storage does not support overwriting copies to directories that already exist. +For this reason, when the HPCI-SS component receives a `foo` directory from a preceding component: +- On the first execution, a `foo` directory is created directly under the path specified in directory path, and the contents of the received `foo` are copied under the `foo` directory. +- On subsequent executions, a directory named `WHEEL_TMP_XXXXXX` (where XXXXXX is a random string) is created directly under directory path, and the `foo` directory is copied under it. + + +-------- +[Return to Component Details]({{site.baseurl}}/reference/4_component/) diff --git a/documentMD/user_guide/_reference/4_component/16_HPCISStar.en.md b/documentMD/user_guide/_reference/4_component/16_HPCISStar.en.md new file mode 100644 index 000000000..b930c7ec1 --- /dev/null +++ b/documentMD/user_guide/_reference/4_component/16_HPCISStar.en.md @@ -0,0 +1,37 @@ +--- +title: HPCI-SS-tar +lang: en +permalink: /reference/4_component/16_HPCISStar.html +--- + +![img](./img/hpcisstar.png "hpciss-tar") + +The HPCI-SS-tar component, like the HPCI-SS component, is a variant of the Storage component for storing files in HPCI shared storage. + +Unlike the HPCI-SS component, the HPCI-SS-tar component uses the gfptar command to save files in tar format (gzip compressed) when storing them. + +For this reason, unlike the HPCI-SS component, you cannot delete or rename files/directories in the destination HPCI shared storage. + +The properties you can set for the HPCI-SS-tar component are as follows. + +### host +You can set the host where files are actually stored. +However, only hosts that have the `use gfarm` option checked in the remotehost settings can be set as the host. + +Additionally, only hosts that can transfer files to HPCI shared storage using the gfptar command are available. + +### directory path +![img](./img/storage_path.png "storage_path") + +Similar to the Storage component, this is the path where files are actually stored. +However, you must specify the path on HPCI shared storage, not a path on the host. + +### Constraints +The HPCI-SS-tar component creates a tar archive with the path name specified in `directory path`. +Therefore, if a file or directory already exists at directory path, an error will occur. +When running a project containing this component multiple times, either rewrite directory path or click the `remove storage directory` button on the component property screen to delete the archive directory before running. + +![img](./img/remove_storage_button_hpciss_tar.png) + +-------- +[Return to Component Details]({{site.baseurl}}/reference/4_component/) diff --git a/documentMD/user_guide/_reference/4_component/index.en.md b/documentMD/user_guide/_reference/4_component/index.en.md index af1465750..e6163cc70 100644 --- a/documentMD/user_guide/_reference/4_component/index.en.md +++ b/documentMD/user_guide/_reference/4_component/index.en.md @@ -13,9 +13,13 @@ Learn more about each component. * [For](03_For.html) * [While](04_while.html) * [Foreach](05_Foreach.html) +* [Continue](13_Continue.html) +* [Break](14_Break.html) * [PS](06_PS.html) * [Workflow](07_Workflow.html) * [Storage](08_Storage.html) +* [HPCI-SS](15_HPCISS.html) +* [HPCI-SS-tar](16_HPCISStar.html) * [Source](09_Source.html) * [Viewer](10_Viewer.html) * [Stepjob](11_Stepjob.html) diff --git a/documentMD/user_guide/_reference/index.md b/documentMD/user_guide/_reference/index.md index 9f7e725e5..c7d5c70a8 100644 --- a/documentMD/user_guide/_reference/index.md +++ b/documentMD/user_guide/_reference/index.md @@ -10,6 +10,8 @@ permalink: /reference/ [2. リモートホスト設定画面](2_remotehost_screen/) + * [SSH認証の設定](2_remotehost_screen/ssh_auth/) + __3. ワークフロー画面__ * [グラフビュー](3_workflow_screen/1_graphview.html) diff --git a/package-lock.json b/package-lock.json index 1e3c7d3c9..2ed89d9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7054,6 +7054,62 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -7964,6 +8020,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, "node_modules/configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", @@ -9011,6 +9073,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -9050,6 +9118,12 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -9107,10 +9181,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -10547,6 +10620,12 @@ "node": ">=6.6.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11444,6 +11523,15 @@ "assert-plus": "^1.0.0" } }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -13449,6 +13537,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joi": { "version": "18.1.2", "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", @@ -16642,7 +16739,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true, "license": "MIT" }, "node_modules/on-finished": { @@ -17380,7 +17476,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pause": { @@ -17525,6 +17620,17 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/plist": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/plist/-/plist-1.2.0.tgz", @@ -17983,6 +18089,16 @@ "node": ">=0.10.0" } }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -22719,6 +22835,7 @@ "ajv-formats": "^3.0.1", "axios": "^1.13.1", "body-parser": "^2.2.0", + "c12": "^3.3.4", "connect-ensure-login": "^0.1.1", "connect-sqlite3": "^0.9.15", "cookie-parser": "^1.4.7", diff --git a/server/app/core/deliverFile.js b/server/app/core/deliverFile.js index 67aa02727..35076d29e 100644 --- a/server/app/core/deliverFile.js +++ b/server/app/core/deliverFile.js @@ -188,12 +188,17 @@ async function deliverFilesBetweenRemotes(recipe) { logger.debug("direct remote to remote copy from", recipe.srcRemotehostID, "to", recipe.dstRemotehostID); - await srcSsh.remoteToRemoteCopy( + const rt = await srcSsh.remoteToRemoteCopy( [`${recipe.srcRoot}/${recipe.srcName}`], dstHostinfo, `${recipe.dstRoot}/${recipe.dstName}`, ["-vv", ...rsyncExcludeOptionOfWheelSystemFiles] ); + if (rt !== 0) { + const err = new Error(`direct remote to remote copy failed with exit code ${rt}`); + err.rt = rt; + return Promise.reject(err); + } return { type: "direct-remote-copy", src: `${recipe.srcRoot}/${recipe.srcName}`, dst: `${recipe.dstRoot}/${recipe.dstName}` }; } diff --git a/server/app/core/dispatcher.js b/server/app/core/dispatcher.js index 8fd1751c7..9fc4859fd 100644 --- a/server/app/core/dispatcher.js +++ b/server/app/core/dispatcher.js @@ -31,7 +31,8 @@ import { logDebug, logInfo, logWarn, - logError + logError, + _internal } from "../logSettings.js"; import { cancelDispatchedTasks } from "./taskUtil.js"; import { eventEmitters } from "./global.js"; @@ -78,22 +79,26 @@ const taskDB = new Map(); * @param {string} target - absolute path to check * @param {string} baseDir - base directory path * @param {Array} skipCopyPatterns - array of glob patterns - * @returns {boolean} - true if target matches any pattern + * @returns {Promise} - true if target matches any pattern */ -function shouldSkipCopy(target, baseDir, skipCopyPatterns) { +async function shouldSkipCopy(target, baseDir, skipCopyPatterns) { if (!skipCopyPatterns || skipCopyPatterns.length === 0) { return false; } const relativePath = path.relative(baseDir, target); - return skipCopyPatterns.some((pattern)=>{ + for (const pattern of skipCopyPatterns) { if (hasMagic(pattern)) { - const matched = glob.sync(pattern, { cwd: baseDir }); - return matched.some((matchedPath)=>{ + const matched = await glob(pattern, { cwd: baseDir }); + if (matched.some((matchedPath)=>{ return relativePath === matchedPath || relativePath.startsWith(`${matchedPath}${path.sep}`); - }); + })) { + return true; + } + } else if (relativePath === pattern || relativePath.startsWith(`${pattern}${path.sep}`)) { + return true; } - return relativePath === pattern || relativePath.startsWith(`${pattern}${path.sep}`); - }); + } + return false; } /** @@ -327,6 +332,7 @@ class Dispatcher extends EventEmitter { continue; } await this._getInputFiles(target); + await this._warnMissingInputFiles(target); if (!await this._checkMandatoryInputFilesExist(target)) { await this._setComponentState(target, "failed"); this.hasFailedComponent = true; @@ -810,7 +816,7 @@ class Dispatcher extends EventEmitter { if (path.basename(target) === statusFilename) { return false; } - if (shouldSkipCopy(target, srcDir, skipCopyPatterns)) { + if (await shouldSkipCopy(target, srcDir, skipCopyPatterns)) { logTrace(this.projectRootDir, `${this.cwfDir}/${component.name}`, "skipping copy due to skipCopy pattern:", target); return false; } @@ -1390,6 +1396,49 @@ class Dispatcher extends EventEmitter { ee.emit("componentStateChanged", component); } + /** + * Check non-mandatory inputFiles and warn if any are missing after file staging. + * Unlike mandatory inputFiles, missing non-mandatory files do not fail the component — + * a warning is logged and a toast message is sent to the client. + * @param {object} component - component to check + * @returns {Promise} + */ + async _warnMissingInputFiles(component) { + if (!component.inputFiles) { + return; + } + const componentDir = this._getComponentDir(component.ID); + const remotehostID = isLocal(component) ? null : remoteHost.getID("name", component.host); + const ssh = remotehostID ? getSsh(this.projectRootDir, remotehostID) : null; + const remoteWorkingDir = remotehostID + ? getRemoteWorkingDir(this.projectRootDir, this.projectStartTime, path.resolve(this.cwfDir, component.name), component) + : null; + + for (const inputFile of component.inputFiles) { + if (inputFile.mandatory === true) { + continue; + } + const renderedName = nunjucks.renderString(inputFile.name, this.env); + let missing = false; + try { + if (isLocal(component)) { + await fs.stat(path.join(componentDir, renderedName)); + } else { + const rt = await ssh.exec(`test -e ${path.join(remoteWorkingDir, renderedName)}`, 0, logTrace.bind(null, this.projectRootDir, `${this.cwfDir}/${component.name}`)); + if (rt !== 0) { + missing = true; + } + } + } catch (e) { + missing = true; + } + if (missing) { + logWarn(this.projectRootDir, `${this.cwfDir}/${component.name}`, "inputFile not found:", renderedName); + await _internal.emitAll(this.projectRootDir, "showMessage", `[${component.name}] inputFile not found: ${renderedName}`); + } + } + } + /** * check if all mandatory inputFiles exist on the host (local or remote) after file staging * @param {object} component - component to check @@ -1429,6 +1478,13 @@ class Dispatcher extends EventEmitter { return true; } + /** + * Fetch all inputFiles for a component, delivering them from their sources. + * Non-mandatory transfer failures are logged as warnings and ignored. + * Mandatory transfer failures cause all remaining transfers to complete before the component is failed. + * @param {object} component - the component whose inputFiles should be fetched + * @returns {Promise} - array of delivery results from successful transfers + */ async _getInputFiles(component) { if (component.type === "source") { return; @@ -1438,6 +1494,7 @@ class Dispatcher extends EventEmitter { const tmpDeliverRecipes = []; for (const inputFile of component.inputFiles) { const dstName = nunjucks.renderString(inputFile.name, this.env); + const mandatory = inputFile.mandatory === true; //resolve real src for (const src of inputFile.src) { const srcComponent = await this._getComponent(src.srcNode); @@ -1477,7 +1534,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, srcRemotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } } else if (onSameRemote) { @@ -1523,7 +1581,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, dstRemotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } else if (!srcIsLocal && dstIsLocal) { //Remote → Localhost via shared storage @@ -1562,7 +1621,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, srcRemotehostID: srcRemotehostIDForRecipe, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } else { //Remote → Remote @@ -1584,7 +1644,8 @@ class Dispatcher extends EventEmitter { srcRemotehostID, dstRemotehostID: remotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } } else if (!isLocal(srcComponent) && !["task", "stepjobTask", "bulkjobtask", "hpciss", "hpcisstar"].includes(srcComponent.type)) { @@ -1603,7 +1664,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, srcRemotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } else { //deliver files under component directory even if destination component is storage @@ -1634,7 +1696,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, srcRemotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } } else { @@ -1653,7 +1716,8 @@ class Dispatcher extends EventEmitter { projectRootDir: this.projectRootDir, srcRemotehostID, fromHPCISS, - fromHPCISStar + fromHPCISStar, + mandatory }); } }) @@ -1664,13 +1728,17 @@ class Dispatcher extends EventEmitter { await Promise.all(promises); const deliverRecipes = []; for (const recipe of tmpDeliverRecipes) { - if (!deliverRecipes.some((e)=>{ + const existingIdx = deliverRecipes.findIndex((e)=>{ return e.dstRoot === recipe.dstRoot && e.dstName === recipe.dstName && e.srcRoot === recipe.srcRoot && e.srcName === recipe.srcName; - })) { + }); + if (existingIdx === -1) { deliverRecipes.push(recipe); + } else if (recipe.mandatory) { + //if any source marks this transfer as mandatory, keep it mandatory + deliverRecipes[existingIdx].mandatory = true; } } @@ -1678,17 +1746,17 @@ class Dispatcher extends EventEmitter { const p2 = []; for (const recipe of deliverRecipes) { if (recipe.fromHPCISS || recipe.fromHPCISStar) { - p2.push(deliverFilesFromHPCISS(recipe, this.projectRootDir)); + p2.push({ promise: deliverFilesFromHPCISS(recipe, this.projectRootDir), mandatory: recipe.mandatory }); } else if (recipe.onSameRemote) { - p2.push(deliverFilesOnRemote(recipe)); + p2.push({ promise: deliverFilesOnRemote(recipe), mandatory: recipe.mandatory }); } else if (recipe.betweenRemotes) { - p2.push(deliverFilesBetweenRemotes(recipe)); + p2.push({ promise: deliverFilesBetweenRemotes(recipe), mandatory: recipe.mandatory }); } else if (recipe.localToRemoteShared) { - p2.push(deliverFilesLocalToRemoteShared(recipe)); + p2.push({ promise: deliverFilesLocalToRemoteShared(recipe), mandatory: recipe.mandatory }); } else if (recipe.remoteToLocalShared) { - p2.push(deliverFilesRemoteToLocalShared(recipe)); + p2.push({ promise: deliverFilesRemoteToLocalShared(recipe), mandatory: recipe.mandatory }); } else if (recipe.remoteToLocal) { - p2.push(deliverFilesFromRemote(recipe)); + p2.push({ promise: deliverFilesFromRemote(recipe), mandatory: recipe.mandatory }); } else { const srces = await glob(recipe.srcName, { cwd: recipe.srcRoot }); const hasGlob = hasMagic(recipe.srcName); @@ -1703,13 +1771,30 @@ class Dispatcher extends EventEmitter { if (hasGlob || recipe.dstName.endsWith(path.posix.sep) || recipe.dstName.endsWith(path.win32.sep)) { newPath = path.resolve(newPath, srcFile); } - p2.push(deliverFile(oldPath, newPath, recipe.forceCopy)); + p2.push({ promise: deliverFile(oldPath, newPath, recipe.forceCopy), mandatory: recipe.mandatory }); } } } - const results = await Promise.all(p2); - for (const result of results) { - logTrace(this.projectRootDir, `${this.cwfDir}/${component.name}`, "make", result.type, "from ", result.src, "to", result.dst); + const settled = await Promise.allSettled(p2.map((e)=>{ + return e.promise; + })); + const results = []; + const mandatoryErrors = []; + for (const [i, outcome] of settled.entries()) { + if (outcome.status === "fulfilled") { + logTrace(this.projectRootDir, `${this.cwfDir}/${component.name}`, "make", outcome.value.type, "from ", outcome.value.src, "to", outcome.value.dst); + results.push(outcome.value); + } else if (p2[i].mandatory) { + logWarn(this.projectRootDir, `${this.cwfDir}/${component.name}`, "mandatory inputFile transfer failed:", outcome.reason); + mandatoryErrors.push(outcome.reason); + } else { + logWarn(this.projectRootDir, `${this.cwfDir}/${component.name}`, "non-mandatory inputFile transfer failed (ignored):", outcome.reason); + } + } + if (mandatoryErrors.length > 0) { + throw new Error(`mandatory inputFile transfer failed: ${mandatoryErrors.map((e)=>{ + return e.message; + }).join(", ")}`); } if (component.type === "viewer") { component.files = results; diff --git a/server/app/core/executerManager.js b/server/app/core/executerManager.js index dddedb0c8..cd617045e 100644 --- a/server/app/core/executerManager.js +++ b/server/app/core/executerManager.js @@ -8,7 +8,7 @@ import childProcess from "child_process"; import axios from "axios"; import { getAccessToken } from "./webAPI.js"; import SBS from "simple-batch-system"; -import { remoteHost, jobScheduler, numJobOnLocal, defaultTaskRetryCount } from "../db/db.js"; +import { remoteHost, jobScheduler, numLocalJob, defaultTaskRetryCount } from "../db/db.js"; import { addX } from "./fileUtils.js"; import { evalCondition } from "./dispatchUtils.js"; import { getDateString } from "../lib/utility.js"; @@ -24,7 +24,7 @@ const _internal = { executers: new Map(), getSshHostinfo, jobScheduler, - numJobOnLocal, + numLocalJob, remoteHost }; @@ -553,7 +553,7 @@ function getExecutersKey(task) { */ function getMaxNumJob(hostinfo) { if (hostinfo === null) { - return _internal.numJobOnLocal; + return _internal.numLocalJob; } if (!Number.isNaN(parseInt(hostinfo.numJob, 10))) { return Math.max(parseInt(hostinfo.numJob, 10), 1); @@ -686,4 +686,4 @@ export { _internal }; -export { numJobOnLocal }; +export { numLocalJob }; diff --git a/server/app/core/exportComponent.js b/server/app/core/exportComponent.js index 810fb988e..bab76ff2e 100644 --- a/server/app/core/exportComponent.js +++ b/server/app/core/exportComponent.js @@ -11,6 +11,7 @@ import { createTempd } from "./tempd.js"; import { getComponentDir } from "./componentJsonIO.js"; import { gitClone } from "./gitOperator2.js"; import { componentJsonFilename } from "../db/db.js"; +import { baseURL } from "./global.js"; const { create } = tar; @@ -141,7 +142,6 @@ async function exportComponent(projectRootDir, componentID) { [componentBasename] ); - const baseURL = process.env.WHEEL_BASE_URL || ""; //Get path relative to tempdRoot, then prepend /exportComponent/ const relativePath = path.relative(root, archiveFilename); const url = `${baseURL}/exportComponent/${relativePath}`; diff --git a/server/app/core/exportProject.js b/server/app/core/exportProject.js index d8190560e..d9f797c3b 100644 --- a/server/app/core/exportProject.js +++ b/server/app/core/exportProject.js @@ -9,6 +9,7 @@ import * as tar from "tar"; import { createTempd } from "./tempd.js"; import { readJsonGreedy } from "./fileUtils.js"; import { projectJsonFilename } from "../db/db.js"; +import { baseURL } from "./global.js"; import { gitAdd, gitClone, gitCommit, gitConfig, gitRemoveOrigin } from "./gitOperator2.js"; import { setComponentStateR } from "./componentState.js"; @@ -77,7 +78,6 @@ async function exportProject(projectRootDir, name = null, mail = null, memo = nu [`${projectJson.name}.wheel`] ); - const baseURL = process.env.WHEEL_BASE_URL || ""; const url = `${baseURL}/${path.join(path.relative(path.dirname(dir), archiveFilename))}`; return url; } diff --git a/server/app/core/global.js b/server/app/core/global.js index eec345264..8f8e04699 100644 --- a/server/app/core/global.js +++ b/server/app/core/global.js @@ -6,7 +6,7 @@ "use strict"; import debugLib from "debug"; const debug = debugLib("wheel"); -const baseURL = process.env.WHEEL_BASE_URL || "/"; +export { baseURL } from "../db/db.js"; const parentDirs = new Map(); //workflow path which is displayed on graphview const eventEmitters = new Map(); //event emitter object which is used to communicate while running project const watchers = new Map(); //result file watcher @@ -38,6 +38,5 @@ export { watchers, checkWritePermissions, setSio, - getSio, - baseURL + getSio }; diff --git a/server/app/core/migrationHelper.js b/server/app/core/migrationHelper.js new file mode 100644 index 000000000..ac39a144b --- /dev/null +++ b/server/app/core/migrationHelper.js @@ -0,0 +1,107 @@ +/* + * Copyright (c) Center for Computational Science, RIKEN All rights reserved. + * Copyright (c) Research Institute for Information Technology(RIIT), Kyushu University. All rights reserved. + * See License in the project root for the license information. + */ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; + +/** + * Map of old server.json property names to their new names. + * Used by migrateConfigFile to rewrite user config files at startup. + */ +const PROP_RENAMES = { + numJobOnLocal: "numLocalJob" +}; + +/** + * Map of deprecated environment variable names to their replacements. + * Used by warnDeprecatedEnvVars to alert users at startup. + * These variables no longer map to any config property and have no effect. + */ +const DEPRECATED_ENV_VARS = { + WHEEL_LOGLEVEL: "WHEEL_LOG_LEVEL" +}; + +/** + * Migrate old property names in a JSON config file to new names. + * Skips silently if the file does not exist (ENOENT). + * If both the old and new property are present, keeps the new value and removes the old. + * @param {string} filePath - absolute path to the JSON config file + * @returns {Promise} + */ +async function migrateConfigFile(filePath) { + let raw; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (e) { + if (e.code === "ENOENT") { + return; + } + throw e; + } + + let obj; + try { + obj = JSON.parse(raw); + } catch (e) { + console.warn(`[WHEEL migration] Warning: could not parse ${filePath} — skipping migration: ${e.message}`); + return; + } + + let changed = false; + for (const [oldProp, newProp] of Object.entries(PROP_RENAMES)) { + if (Object.prototype.hasOwnProperty.call(obj, oldProp)) { + if (Object.prototype.hasOwnProperty.call(obj, newProp)) { + console.warn(`[WHEEL migration] ${filePath}: both "${oldProp}" and "${newProp}" found. Keeping "${newProp}" and removing "${oldProp}".`); + } else { + console.warn(`[WHEEL migration] ${filePath}: renamed property "${oldProp}" → "${newProp}".`); + obj[newProp] = obj[oldProp]; + } + delete obj[oldProp]; + changed = true; + } + } + + if (changed) { + await fs.writeFile(filePath, `${JSON.stringify(obj, null, 2)}\n`, "utf8"); + } +} + +/** + * Check environment variables for deprecated WHEEL_ names and warn via console.warn. + * These variables are no longer recognized by the auto-mapper and have no effect. + * @returns {void} + */ +function warnDeprecatedEnvVars() { + for (const [oldVar, newVar] of Object.entries(DEPRECATED_ENV_VARS)) { + if (Object.prototype.hasOwnProperty.call(process.env, oldVar)) { + console.warn(`[WHEEL migration] Deprecated environment variable "${oldVar}" is set but has no effect. Use "${newVar}" instead.`); + } + } +} + +/** + * Run all startup migrations: + * 1. Migrate server.json property names in user config directories. + * 2. Warn about deprecated environment variables. + * Looks for server.json in WHEEL_CONFIG_DIR (if set) and ~/.wheel/. + * @returns {Promise} + */ +async function runMigrations() { + const configDirs = [ + path.resolve(os.homedir(), ".wheel") + ]; + if (typeof process.env.WHEEL_CONFIG_DIR === "string") { + configDirs.push(path.resolve(process.env.WHEEL_CONFIG_DIR)); + } + + for (const dir of configDirs) { + await migrateConfigFile(path.join(dir, "server.json")); + } + + warnDeprecatedEnvVars(); +} + +export { migrateConfigFile, warnDeprecatedEnvVars, runMigrations, PROP_RENAMES, DEPRECATED_ENV_VARS }; diff --git a/server/app/core/sshManager.js b/server/app/core/sshManager.js index e74faa79c..08d040567 100644 --- a/server/app/core/sshManager.js +++ b/server/app/core/sshManager.js @@ -5,6 +5,7 @@ */ import SshClientWrapper from "ssh-client-wrapper"; import { emitAll } from "../handlers/commUtils.js"; +import { verboseSsh } from "../db/db.js"; const _internal = { db: new Map(), @@ -204,7 +205,7 @@ async function createSsh(projectRootDir, remoteHostName, hostinfo, clientID, isS if (hostinfo.readyTimeout) { hostinfo.ConnectTimeout = Math.floor(hostinfo.readyTimeout / 1000); } - if (process.env.WHEEL_VERBOSE_SSH) { + if (verboseSsh) { hostinfo.sshOpt = ["-vvv"]; } if (hostinfo.username) { diff --git a/server/app/core/versionInfo.js b/server/app/core/versionInfo.js index 45c330f48..ed9196c42 100644 --- a/server/app/core/versionInfo.js +++ b/server/app/core/versionInfo.js @@ -6,6 +6,7 @@ import versionData from "../db/version.json" with { type: "json" }; const { version } = versionData; import { getLogger } from "../logSettings.js"; +import { port, baseURL, useHttp, acceptAddress, logLevel, verboseSsh, numLocalJob, enableWebApi, enableAuth } from "../db/db.js"; /** * print version and environment variables @@ -13,22 +14,21 @@ import { getLogger } from "../logSettings.js"; */ function aboutWheel(projectRootDir) { const logger = getLogger(projectRootDir); - const baseURL = process.env.WHEEL_BASE_URL || "/"; logger.info(`starting WHEEL server (version ${version})`); logger.info("base URL = ", baseURL); - logger.info("environment variables"); + logger.info("effective configuration"); logger.info(`WHEEL_TEMPD = ${process.env.WHEEL_TEMPD}`); logger.info(`WHEEL_CONFIG_DIR = ${process.env.WHEEL_CONFIG_DIR}`); - logger.info(`WHEEL_BASE_URL= ${process.env.WHEEL_USE_HTTP}`); - logger.info(`WHEEL_USE_HTTP = ${process.env.WHEEL_USE_HTTP}`); - logger.info(`WHEEL_PORT = ${process.env.WHEEL_PORT}`); - logger.info(`WHEEL_ACCEPT_ADDRESS = ${process.env.WHEEL_ACCEPT_ADDRESS}`); - logger.info(`WHEEL_LOGLEVEL = ${process.env.WHEEL_LOGLEVEL}`); - logger.info(`WHEEL_VERBOSE_SSH = ${process.env.WHEEL_VERBOSE_SSH}`); - logger.info(`WHEEL_INTERVAL = ${process.env.WHEEL_INTERVAL}`); - logger.info(`WHEEL_NUM_LOCAL_JOB = ${process.env.WHEEL_NUM_LOCAL_JOB}`); - logger.info(`WHEEL_ENABLE_WEB_API = ${process.env.WHEEL_ENABLE_WEB_API}`); + logger.info(`WHEEL_BASE_URL = ${baseURL}`); + logger.info(`WHEEL_USE_HTTP = ${useHttp}`); + logger.info(`WHEEL_PORT = ${port}`); + logger.info(`WHEEL_ACCEPT_ADDRESS = ${acceptAddress}`); + logger.info(`WHEEL_LOG_LEVEL = ${logLevel}`); + logger.info(`WHEEL_VERBOSE_SSH = ${verboseSsh}`); + logger.info(`WHEEL_NUM_LOCAL_JOB = ${numLocalJob}`); + logger.info(`WHEEL_ENABLE_WEB_API = ${enableWebApi}`); + logger.info(`WHEEL_ENABLE_AUTH = ${enableAuth}`); } export { diff --git a/server/app/db/db.js b/server/app/db/db.js index afa605afc..1597f2901 100644 --- a/server/app/db/db.js +++ b/server/app/db/db.js @@ -6,8 +6,10 @@ import os from "os"; import path from "path"; import fs from "fs-extra"; +import { loadConfig } from "c12"; import JsonArrayManager from "./jsonArrayManager.js"; import { fileURLToPath } from "url"; +import { runMigrations } from "../core/migrationHelper.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -76,7 +78,71 @@ function getConfigFile(filename, failIfNotFound) { } /** - * return value or alternate value if it is nudefined + * convert UPPER_SNAKE_CASE string to camelCase + * @param {string} str - UPPER_SNAKE_CASE string + * @returns {string} - camelCase string + */ +function toCamelCase(str) { + return str.toLowerCase().replace(/_([a-z])/g, (_, c)=>{ + return c.toUpperCase(); + }); +} + +/** + * coerce a string value to boolean, number, string, or undefined as appropriate. + * Returns undefined for empty or whitespace-only strings so that callers can + * fall back to configured defaults instead of overriding them with an empty value. + * @param {string} value - string value from environment variable + * @returns {boolean|number|string|undefined} - coerced value, or undefined for blank strings + */ +function coerce(value) { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + const trimmed = value.trim(); + if (trimmed === "") { + return undefined; + } + if (!Number.isNaN(Number(trimmed))) { + return Number(trimmed); + } + return value; +} + +/** + * env vars excluded from auto-mapping (infrastructure/path settings needed before config loads, + * or vars that conflict with existing TLS cert export names) + */ +const INFRASTRUCTURE_ENV_VARS = new Set([ + "WHEEL_CONFIG_DIR", + "WHEEL_TEMPD", + "WHEEL_USER_DB_DIR", + "WHEEL_SESSION_DB_DIR", + "WHEEL_CLEAR_SESSION_DB", + "WHEEL_CERT_FILENAME", + "WHEEL_CERT_PASSPHRASE" +]); + +/** + * extract WHEEL_ prefixed environment variables and map them to camelCase config overrides. + * UPPER_SNAKE_CASE after stripping WHEEL_ prefix is converted to camelCase. + * String values are auto-coerced to boolean or number where appropriate. + * @returns {object} - config overrides from environment variables + */ +function extractWheelEnvOverrides() { + const overrides = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("WHEEL_") && !INFRASTRUCTURE_ENV_VARS.has(key)) { + overrides[toCamelCase(key.slice(6))] = coerce(value); + } + } + return overrides; +} + +/** * @param {*} target - variable to be checked * @param {*} alt - alternate value * @returns {*} - @@ -106,30 +172,54 @@ function getStringVar(target, alt) { } /** - * read default and userdefined config file and merge them - * @param {string} filename - config file's name - * @returns {Promise} - + * load a WHEEL config file using c12. + * Priority (highest to lowest): + * 1. WHEEL_CONFIG_DIR/{filename} (env-based override) + * 2. ~/.wheel/{filename} (user home directory) + * 3. server/app/db/{filename} (package defaults) + * @param {string} filename - config file's name (e.g. "server.json") + * @returns {Promise} merged config */ -async function readAndMergeConfigFile(filename) { - let userConfigFilename; +async function loadWheelConfig(filename) { + const packageDefaults = JSON.parse(await fs.readFile(path.resolve(__dirname, filename), "utf-8")); + + //load ~/.wheel/{filename} as user defaults + const dotWheelConfigPath = path.resolve(os.homedir(), ".wheel", filename); + let dotWheelConfig = {}; try { - userConfigFilename = getConfigFile(filename, true); + dotWheelConfig = JSON.parse(await fs.readFile(dotWheelConfigPath, "utf-8")); } catch (e) { - if (e.message !== "file not found") { + if (e.code !== "ENOENT") { throw e; } } - const defaultConfigPath = path.resolve(__dirname, filename); - const defaultConfig = JSON.parse(await fs.readFile(defaultConfigPath, "utf-8")); - if (!userConfigFilename) { - return defaultConfig; + + //load WHEEL_CONFIG_DIR/{filename} as highest-priority overrides + let envDirConfig = {}; + if (typeof process.env.WHEEL_CONFIG_DIR === "string") { + const envConfigPath = path.resolve(process.env.WHEEL_CONFIG_DIR, filename); + try { + envDirConfig = JSON.parse(await fs.readFile(envConfigPath, "utf-8")); + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } } - const userConfig = JSON.parse(await fs.readFile(userConfigFilename, "utf-8")); - return { ...defaultConfig, ...userConfig }; + + const { config } = await loadConfig({ + name: "wheel", + rcFile: false, + globalRc: false, + defaults: { ...packageDefaults, ...dotWheelConfig }, + overrides: { ...envDirConfig, ...extractWheelEnvOverrides() } + }); + return config; } -const config = await readAndMergeConfigFile("server.json"); -const jobScheduler = await readAndMergeConfigFile("jobScheduler.json"); +await runMigrations(); +const config = await loadWheelConfig("server.json"); +const jobScheduler = await loadWheelConfig("jobScheduler.json"); const remotehostFilename = getConfigFile(getStringVar(config.remotehostJsonFile, "remotehost.json")); const jobScriptTemplateFilename = getConfigFile(getStringVar(config.jobScriptTemplateJsonFile, "jobScriptTemplate.json")); const projectListFilename = getConfigFile(getStringVar(config.projectListJsonFile, "projectList.json")); @@ -147,8 +237,8 @@ export const defaultPSconfigFilename = "parameterSetting.json"; export const userDBFilename = "user.db"; export const userDBDir = process.env.WHEEL_USER_DB_DIR || __dirname; -export const keyFilename = !process.env.WHEEL_USE_HTTP ? getConfigFile("server.key", true) : undefined; -export const certFilename = !process.env.WHEEL_USE_HTTP ? getConfigFile("server.crt", true) : undefined; +export const keyFilename = !config.useHttp ? getConfigFile("server.key", true) : undefined; +export const certFilename = !config.useHttp ? getConfigFile("server.crt", true) : undefined; export { logFilename }; export { credentialFilename }; @@ -164,15 +254,25 @@ export const rsyncExcludeOptionOfWheelSystemFiles = [ ]; //re-export server settings -export const port = parseInt(process.env.WHEEL_PORT, 10) || config.port; //default var will be calcurated in app/index.js +export const port = getIntVar(config.port, 8089); export const rootDir = getStringVar(config.rootDir, getStringVar(os.homedir(), "/")); export const defaultCleanupRemoteRoot = getVar(config.defaultCleanupRemoteRoot, true); export const numLogFiles = getIntVar(config.numLogFiles, 5); export const maxLogSize = getIntVar(config.maxLogSize, 8388608); export const compressLogFile = getVar(config.compressLogFile, true); -export const numJobOnLocal = parseInt(process.env.WHEEL_NUM_LOCAL_JOB, 10) || getIntVar(config.numJobOnLocal, 1); +export const numLocalJob = getIntVar(config.numLocalJob, 1); export const defaultTaskRetryCount = getIntVar(config.defaultTaskRetryCount, 1); export const gitLFSSize = getIntVar(config.gitLFSSize, 200); +export const baseURL = getStringVar(config.baseURL, "/"); +export const useHttp = Boolean(config.useHttp); +export const acceptAddress = config.acceptAddress || null; +const VALID_LOG_LEVELS = ["ALL", "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "MARK", "OFF"]; +export const logLevel = VALID_LOG_LEVELS.includes(String(config.logLevel).toUpperCase()) + ? String(config.logLevel).toUpperCase() + : "DEBUG"; +export const verboseSsh = Boolean(config.verboseSsh); +export const enableWebApi = Boolean(config.enableWebApi); +export const enableAuth = Boolean(config.enableAuth); //export setting files export { jobScheduler }; @@ -189,3 +289,6 @@ await remoteHost.write(); export const jobScriptTemplate = new JsonArrayManager(jobScriptTemplateFilename); export const projectList = new JsonArrayManager(projectListFilename); + +/**@internal exported for unit testing only */ +export const _internal = { loadWheelConfig, extractWheelEnvOverrides, coerce }; diff --git a/server/app/db/server.json b/server/app/db/server.json index eac8fdd8d..7a3c1a7d3 100644 --- a/server/app/db/server.json +++ b/server/app/db/server.json @@ -5,5 +5,13 @@ "numLogFiles": 5, "withLogin": false, "userid": null, - "enablePassword": true + "enablePassword": true, + "numLocalJob": 1, + "baseURL": "", + "useHttp": false, + "acceptAddress": null, + "logLevel": "debug", + "verboseSsh": false, + "enableWebApi": false, + "enableAuth": false } diff --git a/server/app/handlers/fileManager.js b/server/app/handlers/fileManager.js index b9f68fa17..43b0231de 100644 --- a/server/app/handlers/fileManager.js +++ b/server/app/handlers/fileManager.js @@ -18,6 +18,7 @@ import { escapeRegExp } from "../lib/utility.js"; import fileBrowser from "../core/fileBrowser.js"; import { getLogger } from "../logSettings.js"; import { gitLFSSize, projectJsonFilename, componentJsonFilename, rootDir, remoteHost, logFilename } from "../db/db.js"; +import { baseURL } from "../core/global.js"; import { emitAll } from "./commUtils.js"; import { getTempd, createTempd } from "../core/tempd.js"; import { getSsh } from "../core/sshManager.js"; @@ -356,7 +357,6 @@ export async function onDownload(projectRootDir, target, cb) { } const ext = downloadZip ? ".zip" : ""; - const baseURL = process.env.WHEEL_BASE_URL || ""; const url = `${baseURL}/${path.join(path.relative(downloadRootDir, tmpDir), targetBasename)}${ext}`; getLogger(projectRootDir).debug("Download url is ready", url); cb(url); @@ -453,7 +453,6 @@ export async function onDownloadFullLog(projectRootDir, cb) { //remove temporary directory await fs.remove(archiveDir); - const baseURL = process.env.WHEEL_BASE_URL || ""; const url = `${baseURL}/${path.join(path.relative(downloadRootDir, tmpDir), archiveName)}.zip`; getLogger(projectRootDir).info("Debug log archive is ready for download", url); cb(url); diff --git a/server/app/handlers/tryToConnect.js b/server/app/handlers/tryToConnect.js index 40e184334..6275df5a8 100644 --- a/server/app/handlers/tryToConnect.js +++ b/server/app/handlers/tryToConnect.js @@ -7,7 +7,7 @@ import SshClientWrapper from "ssh-client-wrapper"; import { getLogger } from "../logSettings.js"; const logger = getLogger(); -import { remoteHost } from "../db/db.js"; +import { remoteHost, verboseSsh } from "../db/db.js"; import { askPassword } from "../core/sshManager.js"; /** @@ -19,7 +19,7 @@ import { askPassword } from "../core/sshManager.js"; async function onTryToConnect(clientID, hostInfo, cb) { hostInfo.password = askPassword.bind(null, clientID, hostInfo.name, "password", null); hostInfo.passphrase = askPassword.bind(null, clientID, hostInfo.name, "passphrase", null); - if (process.env.WHEEL_VERBOSE_SSH) { + if (verboseSsh) { hostInfo.sshOpt = ["-vvv"]; } const ssh = new SshClientWrapper(hostInfo); diff --git a/server/app/index.js b/server/app/index.js index 7778eaae4..17434025d 100644 --- a/server/app/index.js +++ b/server/app/index.js @@ -18,7 +18,7 @@ import Siofu from "socketio-file-upload"; import { createServer as createHTTPServer } from "http"; import { createServer as createHTTPSServer } from "https"; import { Server as SocketIOServer } from "socket.io"; -import { port, projectList, keyFilename, certFilename } from "./db/db.js"; +import { port, projectList, keyFilename, certFilename, useHttp, acceptAddress, enableAuth, enableWebApi } from "./db/db.js"; import { setProjectState } from "./core/projectJsonFileOperator.js"; import { checkRunningJobs } from "./core/checkRunningJobs.js"; import { getLogger } from "./logSettings.js"; @@ -61,9 +61,9 @@ if (process.env.WHEEL_CLEAR_SESSION_DB) { */ const app = express(); -const address = process.env.WHEEL_ACCEPT_ADDRESS; +const address = acceptAddress; -const server = process.env.WHEEL_USE_HTTP +const server = useHttp ? createHTTPServer(app) : createHTTPSServer({ key: fs.readFileSync(keyFilename), @@ -79,7 +79,7 @@ setSio(sio); aboutWheel(); //port number -const defaultPort = process.env.WHEEL_USE_HTTP ? 80 : 443; +const defaultPort = useHttp ? 80 : 443; let portNumber = port || defaultPort; portNumber = portNumber > 0 ? portNumber : defaultPort; //middlewares @@ -100,7 +100,7 @@ app.use(session({ store: new SQLiteStore({ db: sessionDBFilename, dir: sessionDBDir }) })); -if (process.env.WHEEL_ENABLE_AUTH) { +if (enableAuth) { app.use(passport.initialize()); app.use(passport.session()); app.use(passport.authenticate("session")); @@ -141,7 +141,7 @@ router.use(express.static(path.resolve(tempdRoot, "download"), { index: false }) router.use(express.static(path.resolve(tempdRoot, "exportProject"), { index: false })); router.use("/exportComponent", express.static(path.resolve(tempdRoot, "exportComponent"), { index: false })); logger.info("DEBUG: Static routes configured, setting up web API routes..."); -if (process.env.WHEEL_ENABLE_WEB_API) { +if (enableWebApi) { router.use(asyncHandler(async (req, res, next)=>{ if (!req.query.code) { if (req.query.error) { @@ -189,7 +189,7 @@ let checkLoggedIn = (req, res, next)=>{ next(); }; -if (process.env.WHEEL_ENABLE_AUTH) { +if (enableAuth) { checkLoggedIn = ensureLoggedIn ("/login"); router.route("/login").get(routes.login.get) .post(routes.login.post); @@ -207,7 +207,7 @@ router.route("/editor").get(checkLoggedIn, routes.workflow.get) router.route("/viewer").get(checkLoggedIn, routes.viewer.get) .post(checkLoggedIn, routes.viewer.post); -if (process.env.WHEEL_ENABLE_WEB_API) { +if (enableWebApi) { router.get("/webAPIauth", asyncHandler(async (req, res)=>{ const projectRootDir = req.cookies.rootDir; if (!projectRootDir) { diff --git a/server/app/logSettings.js b/server/app/logSettings.js index f168f2a2c..7300f79b4 100644 --- a/server/app/logSettings.js +++ b/server/app/logSettings.js @@ -7,22 +7,13 @@ import path from "path"; import { promisify } from "util"; import log4js from "log4js"; const logger = log4js.getLogger(); -import { logFilename, numLogFiles, maxLogSize, compressLogFile } from "./db/db.js"; +import { logFilename, numLogFiles, maxLogSize, compressLogFile, logLevel } from "./db/db.js"; import { emitAll } from "./handlers/commUtils.js"; export const _internal = { emitAll }; -function getLoglevel(ignoreEnv = false) { - const wheelLoglevel = process.env.WHEEL_LOGLEVEL; - const defaultLevel = "debug"; - if (ignoreEnv || typeof wheelLoglevel !== "string") { - return defaultLevel; - } - return ["ALL", "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "MARK", "OFF"].includes(wheelLoglevel.toUpperCase()) ? wheelLoglevel.toUpperCase() : defaultLevel; -} - const eventNameTable = { DEBUG: null, INFO: "logINFO", @@ -96,17 +87,17 @@ export const logSettings = { filterdConsole: { type: "logLevelFilter", appender: "console", - level: getLoglevel() + level: logLevel }, filterdFile: { type: "logLevelFilter", appender: "multi", - level: getLoglevel() + level: logLevel }, log2client: { type: "logLevelFilter", appender: "socketIO", - level: getLoglevel() + level: logLevel } }, categories: { diff --git a/server/app/routes/home.js b/server/app/routes/home.js index 44a880332..2557803d7 100644 --- a/server/app/routes/home.js +++ b/server/app/routes/home.js @@ -5,6 +5,7 @@ */ import path from "path"; import { rootDir } from "../db/db.js"; +import { baseURL } from "../core/global.js"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -13,7 +14,6 @@ const __dirname = path.dirname(__filename); export default (req, res)=>{ res.cookie("home", rootDir); res.cookie("pathSep", path.sep); - const baseURL = process.env.WHEEL_BASE_URL || "/"; res.cookie("socketIOPath", baseURL); res.sendFile(path.join(__dirname, "../public/home.html")); diff --git a/server/app/routes/viewer.js b/server/app/routes/viewer.js index 46e36dede..05ba8c3c9 100644 --- a/server/app/routes/viewer.js +++ b/server/app/routes/viewer.js @@ -5,6 +5,7 @@ */ import path from "path"; import fs from "fs-extra"; +import { baseURL } from "../core/global.js"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -23,7 +24,6 @@ export async function post(req, res) { if (!await fs.pathExists(dir)) { return; } - const baseURL = process.env.WHEEL_BASE_URL || "/"; res.cookie("socketIOPath", baseURL); res.cookie("dir", dir); res.cookie("rootDir", projectRootDir); diff --git a/server/app/routes/workflow.js b/server/app/routes/workflow.js index 9f9f508e7..75b1c883d 100644 --- a/server/app/routes/workflow.js +++ b/server/app/routes/workflow.js @@ -7,6 +7,7 @@ import path from "path"; import { projectJsonFilename } from "../db/db.js"; import { readProject } from "../core/projectOperations.js"; import { readComponentJson } from "../core/componentJsonIO.js"; +import { baseURL } from "../core/global.js"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -17,7 +18,6 @@ export async function get(req, res) { if (!req.cookies || !req.cookies.rootDir) { return; } - const baseURL = process.env.WHEEL_BASE_URL || "/"; res.cookie("socketIOPath", baseURL); res.sendFile(path.resolve(__dirname, "../public/workflow.html")); } @@ -31,7 +31,6 @@ export async function post(req, res) { res.cookie("root", ID); res.cookie("rootDir", newProjectRootDir); res.cookie("project", path.resolve(newProjectRootDir, projectJsonFilename)); - const baseURL = process.env.WHEEL_BASE_URL || "/"; res.cookie("socketIOPath", baseURL); res.sendFile(path.resolve(__dirname, "../public/workflow.html")); } diff --git a/server/package.json b/server/package.json index 2041a64bc..1c6ec823e 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "ajv-formats": "^3.0.1", "axios": "^1.13.1", "body-parser": "^2.2.0", + "c12": "^3.3.4", "connect-ensure-login": "^0.1.1", "connect-sqlite3": "^0.9.15", "cookie-parser": "^1.4.7", diff --git a/server/test/app/core/deliverFile.js b/server/test/app/core/deliverFile.js index b94ae6b87..97ff267a1 100644 --- a/server/test/app/core/deliverFile.js +++ b/server/test/app/core/deliverFile.js @@ -687,7 +687,7 @@ describe("#deliverFilesBetweenRemotes", ()=>{ error: sinon.stub() }; mockSsh = { - remoteToRemoteCopy: sinon.stub().resolves() + remoteToRemoteCopy: sinon.stub().resolves(0) }; mockDstHostinfo = { host: "remote-dst.example.com", @@ -744,4 +744,26 @@ describe("#deliverFilesBetweenRemotes", ()=>{ dst: "/remote/dst/path/file.txt" }); }); + + it("should reject if remoteToRemoteCopy returns non-zero exit code", async ()=>{ + mockSsh.remoteToRemoteCopy = sinon.stub().resolves(1); + const recipe = { + betweenRemotes: true, + projectRootDir: "/dummy/project", + srcRemotehostID: "srchost-id", + dstRemotehostID: "dsthost-id", + srcRoot: "/remote/src/path", + srcName: "file.txt", + dstRoot: "/remote/dst/path", + dstName: "file.txt" + }; + + try { + await deliverFilesBetweenRemotes(recipe); + expect.fail("Expected deliverFilesBetweenRemotes to reject, but it resolved"); + } catch (err) { + expect(err.message).to.match(/exit code 1/); + expect(err.rt).to.equal(1); + } + }); }); diff --git a/server/test/app/core/dispatcher.js b/server/test/app/core/dispatcher.js index 6f3e81b7d..21b8d98ca 100644 --- a/server/test/app/core/dispatcher.js +++ b/server/test/app/core/dispatcher.js @@ -13,6 +13,8 @@ const expect = chai.expect; import sinon from "sinon"; import sinonChai from "sinon-chai"; chai.use(sinonChai); +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); import Ajv from "ajv"; const ajv = new Ajv({ strict: false }); @@ -30,7 +32,7 @@ import { updateComponentProperty } from "../../testUtil.js"; import { createNewComponent } from "../../../app/core/componentOperations.js"; import { removeExecuters } from "../../../app/core/executerManager.js"; import { removeTransferrers } from "../../../app/core/transferManager.js"; -import { addInputFile, addOutputFile, renameOutputFile } from "../../../app/core/componentFiles.js"; +import { addInputFile, addOutputFile, renameOutputFile, toggleInputFileMandatory } from "../../../app/core/componentFiles.js"; import { addLink, addFileLink } from "../../../app/core/componentLinks.js"; import { scriptName, pwdCmd, scriptHeader } from "../../testScript.js"; const scriptPwd = `${scriptHeader}\n${pwdCmd}`; @@ -42,6 +44,8 @@ const wait = ()=>{ import { remoteHost } from "../../../app/db/db.js"; import { addSsh } from "../../../app/core/sshManager.js"; +import { _internal } from "../../../app/logSettings.js"; +import { _internal as deliverFileInternal } from "../../../app/core/deliverFile.js"; describe("UT for Dispatcher class", function () { this.timeout(0); @@ -1340,4 +1344,141 @@ describe("UT for Dispatcher class", function () { }); }); }); + describe("#_warnMissingInputFiles", ()=>{ + let task; + let emitAllStub; + beforeEach(async ()=>{ + emitAllStub = sinon.stub(_internal, "emitAll").resolves(); + task = await createNewComponent(projectRootDir, projectRootDir, "task", { x: 10, y: 10 }); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + }); + afterEach(()=>{ + sinon.restore(); + }); + it("should emit showMessage when non-mandatory inputFile is missing", async ()=>{ + await addInputFile(projectRootDir, task.ID, "missing.txt"); + const updatedTask = await fs.readJson(path.resolve(projectRootDir, task.name, componentJsonFilename)); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._warnMissingInputFiles(updatedTask); + expect(emitAllStub).to.have.been.calledOnce; + expect(emitAllStub).to.have.been.calledWith(projectRootDir, "showMessage", sinon.match(/missing\.txt/)); + }); + it("should not emit showMessage when non-mandatory inputFile exists", async ()=>{ + await addInputFile(projectRootDir, task.ID, "present.txt"); + await fs.outputFile(path.resolve(projectRootDir, task.name, "present.txt"), "content"); + const updatedTask = await fs.readJson(path.resolve(projectRootDir, task.name, componentJsonFilename)); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._warnMissingInputFiles(updatedTask); + expect(emitAllStub).to.not.have.been.called; + }); + it("should not emit showMessage for mandatory inputFile even if missing", async ()=>{ + await addInputFile(projectRootDir, task.ID, "mandatory.txt"); + await toggleInputFileMandatory(projectRootDir, task.ID, 0, true); + const updatedTask = await fs.readJson(path.resolve(projectRootDir, task.name, componentJsonFilename)); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._warnMissingInputFiles(updatedTask); + expect(emitAllStub).to.not.have.been.called; + }); + it("should do nothing when component has no inputFiles", async ()=>{ + const updatedTask = await fs.readJson(path.resolve(projectRootDir, task.name, componentJsonFilename)); + updatedTask.inputFiles = null; + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._warnMissingInputFiles(updatedTask); + expect(emitAllStub).to.not.have.been.called; + }); + it("should emit showMessage for each missing non-mandatory inputFile", async ()=>{ + await addInputFile(projectRootDir, task.ID, "fileA.txt"); + await addInputFile(projectRootDir, task.ID, "fileB.txt"); + const updatedTask = await fs.readJson(path.resolve(projectRootDir, task.name, componentJsonFilename)); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._warnMissingInputFiles(updatedTask); + expect(emitAllStub).to.have.been.calledTwice; + expect(emitAllStub).to.have.been.calledWith(projectRootDir, "showMessage", sinon.match(/fileA\.txt/)); + expect(emitAllStub).to.have.been.calledWith(projectRootDir, "showMessage", sinon.match(/fileB\.txt/)); + }); + }); + + describe("#_getInputFiles mandatory-aware error handling", ()=>{ + let previous; + let next; + let ensureSymlinkStub; + + beforeEach(async ()=>{ + previous = await createNewComponent(projectRootDir, projectRootDir, "task", { x: 0, y: 0 }); + next = await createNewComponent(projectRootDir, projectRootDir, "task", { x: 100, y: 0 }); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + }); + + afterEach(()=>{ + if (ensureSymlinkStub) { + ensureSymlinkStub.restore(); + ensureSymlinkStub = null; + } + }); + + it("should succeed when all mandatory inputFiles are delivered", async ()=>{ + await addOutputFile(projectRootDir, previous.ID, "out.txt"); + await addInputFile(projectRootDir, next.ID, "out.txt"); + await toggleInputFileMandatory(projectRootDir, next.ID, 0, true); + await addFileLink(projectRootDir, previous.ID, "out.txt", next.ID, "out.txt"); + const srcFile = path.resolve(projectRootDir, previous.name, "out.txt"); + await fs.outputFile(srcFile, "content"); + const updatedNext = await fs.readJson(path.resolve(projectRootDir, next.name, componentJsonFilename)); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + await DP._getInputFiles(updatedNext); + const delivered = path.resolve(projectRootDir, next.name, "out.txt"); + expect(await fs.pathExists(delivered)).to.be.true; + }); + + it("should not throw when non-mandatory inputFile delivery fails", async ()=>{ + await addOutputFile(projectRootDir, previous.ID, "out.txt"); + await addInputFile(projectRootDir, next.ID, "out.txt"); + await addFileLink(projectRootDir, previous.ID, "out.txt", next.ID, "out.txt"); + const srcFile = path.resolve(projectRootDir, previous.name, "out.txt"); + await fs.outputFile(srcFile, "content"); + const updatedNext = await fs.readJson(path.resolve(projectRootDir, next.name, componentJsonFilename)); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + //Stub ensureSymlink to force delivery failure without corrupting the component directory + ensureSymlinkStub = sinon.stub(deliverFileInternal.fs, "ensureSymlink").rejects(new Error("forced delivery failure")); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + //non-mandatory (default): should NOT throw + await DP._getInputFiles(updatedNext); + }); + + it("should throw when mandatory inputFile delivery fails", async ()=>{ + await addOutputFile(projectRootDir, previous.ID, "out.txt"); + await addInputFile(projectRootDir, next.ID, "out.txt"); + await toggleInputFileMandatory(projectRootDir, next.ID, 0, true); + await addFileLink(projectRootDir, previous.ID, "out.txt", next.ID, "out.txt"); + const srcFile = path.resolve(projectRootDir, previous.name, "out.txt"); + await fs.outputFile(srcFile, "content"); + const updatedNext = await fs.readJson(path.resolve(projectRootDir, next.name, componentJsonFilename)); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + //Stub ensureSymlink to force delivery failure without corrupting the component directory + ensureSymlinkStub = sinon.stub(deliverFileInternal.fs, "ensureSymlink").rejects(new Error("forced delivery failure")); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + //mandatory: should throw + await expect(DP._getInputFiles(updatedNext)).to.be.rejectedWith(/mandatory inputFile transfer failed/); + }); + + it("should throw only due to mandatory failures when both mandatory and non-mandatory deliveries fail", async ()=>{ + await addOutputFile(projectRootDir, previous.ID, "mand.txt"); + await addOutputFile(projectRootDir, previous.ID, "opt.txt"); + await addInputFile(projectRootDir, next.ID, "mand.txt"); + await addInputFile(projectRootDir, next.ID, "opt.txt"); + await toggleInputFileMandatory(projectRootDir, next.ID, 0, true); + await addFileLink(projectRootDir, previous.ID, "mand.txt", next.ID, "mand.txt"); + await addFileLink(projectRootDir, previous.ID, "opt.txt", next.ID, "opt.txt"); + await fs.outputFile(path.resolve(projectRootDir, previous.name, "mand.txt"), "mandatory content"); + await fs.outputFile(path.resolve(projectRootDir, previous.name, "opt.txt"), "optional content"); + const updatedNext = await fs.readJson(path.resolve(projectRootDir, next.name, componentJsonFilename)); + projectJson = await fs.readJson(path.resolve(projectRootDir, projectJsonFilename)); + //Stub ensureSymlink to force both deliveries to fail + ensureSymlinkStub = sinon.stub(deliverFileInternal.fs, "ensureSymlink").rejects(new Error("forced delivery failure")); + const DP = new Dispatcher(projectRootDir, rootWF.ID, projectRootDir, "dummy start time", projectJson.componentPath, {}, ""); + //Should throw (because mandatory failed), not silent + await expect(DP._getInputFiles(updatedNext)).to.be.rejectedWith(/mandatory inputFile transfer failed/); + }); + }); }); diff --git a/server/test/app/core/executerManager.js b/server/test/app/core/executerManager.js index 1e07e246a..0c75dd1b0 100644 --- a/server/test/app/core/executerManager.js +++ b/server/test/app/core/executerManager.js @@ -488,14 +488,14 @@ describe("UT for executerManager class", function () { }); describe("getMaxNumJob", function () { //eslint-disable-next-line no-unused-vars - let numJobOnLocalStub; + let numLocalJobStub; beforeEach(()=>{ - numJobOnLocalStub = sinon.stub(_internal, "numJobOnLocal").value(5); + numLocalJobStub = sinon.stub(_internal, "numLocalJob").value(5); }); afterEach(()=>{ sinon.restore(); }); - it("should return numJobOnLocal if hostinfo is null", function () { + it("should return numLocalJob if hostinfo is null", function () { const result = getMaxNumJob(null); expect(result).to.equal(5); }); diff --git a/server/test/app/core/migrationHelper.js b/server/test/app/core/migrationHelper.js new file mode 100644 index 000000000..9c8bbd318 --- /dev/null +++ b/server/test/app/core/migrationHelper.js @@ -0,0 +1,167 @@ +/* + * Copyright (c) Center for Computational Science, RIKEN All rights reserved. + * Copyright (c) Research Institute for Information Technology(RIIT), Kyushu University. All rights reserved. + * See License in the project root for the license information. + */ + +import path from "path"; +import os from "os"; +import fs from "fs-extra"; +import sinon from "sinon"; +import * as chai from "chai"; +import sinonChai from "sinon-chai"; +chai.use(sinonChai); +const { expect } = chai; + +import { migrateConfigFile, warnDeprecatedEnvVars, runMigrations, PROP_RENAMES, DEPRECATED_ENV_VARS } from "../../../app/core/migrationHelper.js"; + +const testDirRoot = "WHEEL_MIGRATION_TEST_TMP"; + +describe("migrationHelper", function () { + let warnStub; + + beforeEach(function () { + warnStub = sinon.stub(console, "warn"); + }); + + afterEach(function () { + sinon.restore(); + }); + + after(async function () { + if (!process.env.WHEEL_KEEP_FILES_AFTER_LAST_TEST) { + await fs.remove(testDirRoot); + } + }); + + describe("PROP_RENAMES and DEPRECATED_ENV_VARS", function () { + it("should have numJobOnLocal → numLocalJob in PROP_RENAMES", function () { + expect(PROP_RENAMES).to.have.property("numJobOnLocal", "numLocalJob"); + }); + it("should have WHEEL_LOGLEVEL → WHEEL_LOG_LEVEL in DEPRECATED_ENV_VARS", function () { + expect(DEPRECATED_ENV_VARS).to.have.property("WHEEL_LOGLEVEL", "WHEEL_LOG_LEVEL"); + }); + }); + + describe("migrateConfigFile", function () { + it("should silently skip if the file does not exist", async function () { + const filePath = path.resolve(testDirRoot, "nonexistent.json"); + await migrateConfigFile(filePath); + expect(warnStub).not.to.have.been.called; + }); + + it("should warn and skip if the file contains invalid JSON", async function () { + const filePath = path.resolve(testDirRoot, "invalid.json"); + await fs.outputFile(filePath, "not json", "utf8"); + await migrateConfigFile(filePath); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.include("could not parse"); + }); + + it("should rename old property to new property and rewrite the file", async function () { + const filePath = path.resolve(testDirRoot, "rename.json"); + await fs.outputFile(filePath, JSON.stringify({ numJobOnLocal: 4, port: 8080 }), "utf8"); + await migrateConfigFile(filePath); + const result = await fs.readJson(filePath); + expect(result).to.have.property("numLocalJob", 4); + expect(result).not.to.have.property("numJobOnLocal"); + expect(result).to.have.property("port", 8080); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.include("numJobOnLocal"); + expect(warnStub.firstCall.args[0]).to.include("numLocalJob"); + }); + + it("should not modify file if no old properties are present", async function () { + const filePath = path.resolve(testDirRoot, "nochange.json"); + const original = { numLocalJob: 2, port: 9090 }; + await fs.outputFile(filePath, JSON.stringify(original), "utf8"); + const mtime1 = (await fs.stat(filePath)).mtimeMs; + await migrateConfigFile(filePath); + const mtime2 = (await fs.stat(filePath)).mtimeMs; + expect(mtime1).to.equal(mtime2); + expect(warnStub).not.to.have.been.called; + }); + + it("should keep new property value and remove old property when both are present", async function () { + const filePath = path.resolve(testDirRoot, "conflict.json"); + await fs.outputFile(filePath, JSON.stringify({ numJobOnLocal: 4, numLocalJob: 8 }), "utf8"); + await migrateConfigFile(filePath); + const result = await fs.readJson(filePath); + expect(result).to.have.property("numLocalJob", 8); + expect(result).not.to.have.property("numJobOnLocal"); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.include("numJobOnLocal"); + expect(warnStub.firstCall.args[0]).to.include("numLocalJob"); + }); + }); + + describe("warnDeprecatedEnvVars", function () { + it("should warn when a deprecated env var is set", function () { + process.env.WHEEL_LOGLEVEL = "info"; + + try { + warnDeprecatedEnvVars(); + } finally { + delete process.env.WHEEL_LOGLEVEL; + } + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.include("WHEEL_LOGLEVEL"); + expect(warnStub.firstCall.args[0]).to.include("WHEEL_LOG_LEVEL"); + }); + + it("should not warn when no deprecated env vars are set", function () { + delete process.env.WHEEL_LOGLEVEL; + warnDeprecatedEnvVars(); + expect(warnStub).not.to.have.been.called; + }); + }); + + describe("runMigrations", function () { + it("should migrate server.json in ~/.wheel and WHEEL_CONFIG_DIR", async function () { + const homeDir = path.resolve(testDirRoot, "home"); + const configDir = path.resolve(testDirRoot, "configdir"); + const homeServerJson = path.join(homeDir, ".wheel", "server.json"); + const configDirServerJson = path.join(configDir, "server.json"); + + await fs.outputFile(homeServerJson, JSON.stringify({ numJobOnLocal: 2 }), "utf8"); + await fs.outputFile(configDirServerJson, JSON.stringify({ numJobOnLocal: 3 }), "utf8"); + + const origHome = os.homedir; + os.homedir = ()=>{ + return homeDir; + }; + process.env.WHEEL_CONFIG_DIR = configDir; + + try { + await runMigrations(); + } finally { + os.homedir = origHome; + delete process.env.WHEEL_CONFIG_DIR; + } + + const homeResult = await fs.readJson(homeServerJson); + expect(homeResult).to.have.property("numLocalJob", 2); + expect(homeResult).not.to.have.property("numJobOnLocal"); + + const configResult = await fs.readJson(configDirServerJson); + expect(configResult).to.have.property("numLocalJob", 3); + expect(configResult).not.to.have.property("numJobOnLocal"); + }); + + it("should warn about deprecated env vars during runMigrations", async function () { + process.env.WHEEL_LOGLEVEL = "warn"; + + try { + await runMigrations(); + } finally { + delete process.env.WHEEL_LOGLEVEL; + } + const calls = warnStub.args.map((a)=>{ + return a[0]; + }); + expect(calls.some((msg)=>{ + return msg.includes("WHEEL_LOGLEVEL"); + })).to.be.true; + }); + }); +}); diff --git a/server/test/app/db/db.js b/server/test/app/db/db.js new file mode 100644 index 000000000..8017d75e1 --- /dev/null +++ b/server/test/app/db/db.js @@ -0,0 +1,207 @@ +/* + * Copyright (c) Center for Computational Science, RIKEN All rights reserved. + * Copyright (c) Research Institute for Information Technology(RIIT), Kyushu University. All rights reserved. + * See License in the project root for the license information. + */ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; + +import * as chai from "chai"; +const expect = chai.expect; + +import { _internal } from "../../../app/db/db.js"; +const { loadWheelConfig, coerce } = _internal; + +/** + * Write a JSON file to a temp path and return the path. + * @param {string} dir - directory to write to + * @param {string} filename - file name + * @param {object} data - data to serialize + * @returns {string} full file path + */ +async function writeJson(dir, filename, data) { + await fs.ensureDir(dir); + const filepath = path.join(dir, filename); + await fs.writeJson(filepath, data); + return filepath; +} + +describe("loadWheelConfig", function () { + let origWheelConfigDir; + let tmpDir; + + beforeEach(async function () { + origWheelConfigDir = process.env.WHEEL_CONFIG_DIR; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "wheel-db-test-")); + + //Clear env vars used by loadWheelConfig + delete process.env.WHEEL_CONFIG_DIR; + }); + + afterEach(async function () { + //Restore env vars + if (typeof origWheelConfigDir !== "undefined") { + process.env.WHEEL_CONFIG_DIR = origWheelConfigDir; + } else { + delete process.env.WHEEL_CONFIG_DIR; + } + await fs.remove(tmpDir); + }); + + it("returns server.json package defaults when no config files exist", async function () { + const config = await loadWheelConfig("server.json"); + expect(config).to.be.an("object"); + expect(config.port).to.equal(8089); + }); + + it("applies WHEEL_CONFIG_DIR/server.json as highest priority override", async function () { + const configDir = path.join(tmpDir, "config"); + await writeJson(configDir, "server.json", { port: 7777 }); + process.env.WHEEL_CONFIG_DIR = configDir; + + const config = await loadWheelConfig("server.json"); + expect(config.port).to.equal(7777); + }); + + it("WHEEL_CONFIG_DIR/server.json overrides ~/.wheel/server.json", async function () { + const dotWheelDir = path.join(os.homedir(), ".wheel"); + const dotWheelConfig = path.join(dotWheelDir, "server.json"); + + let existed = false; + let originalContent; + try { + originalContent = await fs.readFile(dotWheelConfig, "utf-8"); + existed = true; + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } + + try { + await fs.ensureDir(dotWheelDir); + await fs.writeJson(dotWheelConfig, { port: 5555 }); + + const configDir = path.join(tmpDir, "envconfig"); + await writeJson(configDir, "server.json", { port: 6543 }); + process.env.WHEEL_CONFIG_DIR = configDir; + + const config = await loadWheelConfig("server.json"); + expect(config.port).to.equal(6543); + } finally { + if (existed) { + await fs.writeFile(dotWheelConfig, originalContent); + } else { + await fs.remove(dotWheelConfig); + } + } + }); + + it("uses ~/.wheel/server.json as defaults when no WHEEL_CONFIG_DIR is set", async function () { + const dotWheelDir = path.join(os.homedir(), ".wheel"); + const dotWheelConfig = path.join(dotWheelDir, "server.json"); + + let existed = false; + let originalContent; + try { + originalContent = await fs.readFile(dotWheelConfig, "utf-8"); + existed = true; + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } + + try { + await fs.ensureDir(dotWheelDir); + await fs.writeJson(dotWheelConfig, { port: 5432 }); + + const config = await loadWheelConfig("server.json"); + expect(config.port).to.equal(5432); + } finally { + if (existed) { + await fs.writeFile(dotWheelConfig, originalContent); + } else { + await fs.remove(dotWheelConfig); + } + } + }); + + it("non-port fields from server.json package defaults are preserved", async function () { + const config = await loadWheelConfig("server.json"); + expect(config).to.include.keys("numLogFiles"); + }); + + it("returns jobScheduler.json package defaults when no config files exist", async function () { + const config = await loadWheelConfig("jobScheduler.json"); + expect(config).to.be.an("object"); + expect(config).to.include.keys("PBSPro", "SLURM"); + }); + + it("applies WHEEL_CONFIG_DIR/jobScheduler.json as highest priority override", async function () { + const configDir = path.join(tmpDir, "config"); + await writeJson(configDir, "jobScheduler.json", { PBSPro: { submit: "custom-qsub" } }); + process.env.WHEEL_CONFIG_DIR = configDir; + + const config = await loadWheelConfig("jobScheduler.json"); + expect(config.PBSPro.submit).to.equal("custom-qsub"); + }); + + it("uses ~/.wheel/jobScheduler.json as defaults when no WHEEL_CONFIG_DIR is set", async function () { + const dotWheelDir = path.join(os.homedir(), ".wheel"); + const dotWheelConfig = path.join(dotWheelDir, "jobScheduler.json"); + + let existed = false; + let originalContent; + try { + originalContent = await fs.readFile(dotWheelConfig, "utf-8"); + existed = true; + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } + + try { + await fs.ensureDir(dotWheelDir); + await fs.writeJson(dotWheelConfig, { PBSPro: { submit: "user-qsub" } }); + + const config = await loadWheelConfig("jobScheduler.json"); + expect(config.PBSPro.submit).to.equal("user-qsub"); + } finally { + if (existed) { + await fs.writeFile(dotWheelConfig, originalContent); + } else { + await fs.remove(dotWheelConfig); + } + } + }); +}); + +describe("coerce", function () { + it("should return true for string 'true'", function () { + expect(coerce("true")).to.equal(true); + }); + it("should return false for string 'false'", function () { + expect(coerce("false")).to.equal(false); + }); + it("should return a number for a numeric string", function () { + expect(coerce("8089")).to.equal(8089); + }); + it("should return 0 for string '0'", function () { + expect(coerce("0")).to.equal(0); + }); + it("should return undefined for an empty string", function () { + expect(coerce("")).to.be.undefined; + }); + it("should return undefined for a whitespace-only string", function () { + expect(coerce(" ")).to.be.undefined; + }); + it("should return the original string for a non-boolean, non-numeric string", function () { + expect(coerce("hello")).to.equal("hello"); + }); + it("should return a number for a string with leading/trailing spaces", function () { + expect(coerce(" 42 ")).to.equal(42); + }); +}); diff --git a/server/test/compose.yml b/server/test/compose.yml index 8c54c7602..97b4f4f83 100644 --- a/server/test/compose.yml +++ b/server/test/compose.yml @@ -22,11 +22,11 @@ services: - "8089:8089" environment: WHEEL_USE_HTTP: 1 - WHEEL_USE_PORT: 8089 + WHEEL_PORT: 8089 WHEEL_TEST_REMOTEHOST: testServer WHEEL_TEST_REMOTE_PASSWORD: passw0rd WHEEL_RUNNING_TEST: 1 - WHEEL_LOGLEVEL: OFF + WHEEL_LOG_LEVEL: OFF NODE_ENV: test volumes: diff --git a/server/test/setup.sh b/server/test/setup.sh index 155517fb3..4f4247246 100755 --- a/server/test/setup.sh +++ b/server/test/setup.sh @@ -42,7 +42,7 @@ echo remove entry from known_hosts to avoid error if the entry already exists ssh-keygen -R ${KNOWN_HOSTS} 2>/dev/null echo NODE_ENV=test > .env -echo WHEEL_LOGLEVEL=OFF >> .env +echo WHEEL_LOG_LEVEL=OFF >> .env echo NODE_NO_WARNINGS=1 >> .env echo WHEEL_CONFIG_DIR=${TEST_DIR}/${WHEEL_CONFIG_DIR} >> .env echo SETTING_FILE=${SETTING_FILE} >> .env diff --git a/test/compose.yml b/test/compose.yml index 41167c7e3..b50d63bda 100644 --- a/test/compose.yml +++ b/test/compose.yml @@ -28,10 +28,10 @@ services: - "8089:8089" environment: WHEEL_USE_HTTP: 1 - WHEEL_USE_PORT: 8089 + WHEEL_PORT: 8089 WHEEL_TEST_REMOTEHOST: testServer WHEEL_TEST_REMOTE_PASSWORD: passw0rd - WHEEL_LOGLEVEL: OFF + WHEEL_LOG_LEVEL: OFF volumes: - "./wheel_config:/root/.wheel" - /tmp/WHEEL_TEST/shared:/mnt/shared @@ -44,12 +44,10 @@ services: - "8090:8089" environment: WHEEL_USE_HTTP: 1 - WHEEL_USE_PORT: 8089 + WHEEL_PORT: 8089 WHEEL_ANONYMOUS_LOGIN: YES WHEEL_ANONYMOUS_PASSWORD: WheelTest123! - WHEEL_LOGLEVEL: OFF - volumes: - - "./wheel_config_auth:/root/.wheel" + WHEEL_LOG_LEVEL: OFF depends_on: - wheel_release_test diff --git a/test/cypress/e2e/cleanProject.cy.js b/test/cypress/e2e/cleanProject.cy.js new file mode 100644 index 000000000..43b87dcf9 --- /dev/null +++ b/test/cypress/e2e/cleanProject.cy.js @@ -0,0 +1,113 @@ +/* + * Copyright (c) Center for Computational Science, RIKEN All rights reserved. + * Copyright (c) Research Institute for Information Technology(RIIT), Kyushu University. All rights reserved. + * See License in the project root for the license information. + */ +/** + * cleanup project ボタン操作テスト + */ +describe("cleanProject", ()=>{ + before(()=>{ + return cy.removeAllProjects(); + }); + + beforeEach(()=>{ + cy.viewport("macbook-16"); + return cy.createAndOpenProject(); + }); + + afterEach(()=>{ + return cy.goHome(); + }); + + after(()=>{ + return cy.removeAllProjects(); + }); + + /** + * cleanup project 実行時にトーストメッセージがクリアされることを確認 + * 試験手順: + * 1. プロジェクト状態を finished に設定してcleanupボタンを有効化 + * 2. スナックバーにテストメッセージを表示 + * 3. cleanup project ボタンをクリックして確認ダイアログを承認 + * 試験確認内容:スナックバーが非表示になること + */ + it("cleanup project実行時-トーストメッセージがクリアされることを確認", ()=>{ + //wait for workflow initialization: SIO sends projectState "not-started" after getProjectJson + cy.get("[data-cy=\"workflow-project_state-btn\"]").should("contain", "not-started"); + + //enable the cleanup button by setting project state to finished + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + store.commit("projectState", "finished"); + }); + + //show a snackbar toast message + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + store.dispatch("showSnackbar", { message: "test toast message for clean", timeout: -1 }); + }); + + //verify snackbar is visible before clicking clean + cy.contains("div.v-snackbar__content", "test toast message for clean") + .should("be.visible"); + + //wait for the button to be enabled (Vue re-render after state commit) then click + cy.get("[data-cy=\"workflow-cleanup_project-btn\"]") + .should("not.be.disabled") + .click(); + cy.get("[data-cy=\"versatile_dialog_cleanProject-ok-btn\"]").click(); + + //verify snackbar is dismissed via store state (Vuetify removes the DOM element when closed) + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + expect(store.state.openSnackbar).to.be.false; + expect(store.state.snackbarMessage).to.equal(""); + }); + }); + + /** + * cleanup project 実行時にトーストキューがクリアされることを確認 + * 試験手順: + * 1. プロジェクト状態を finished に設定してcleanupボタンを有効化 + * 2. 複数のトーストメッセージをキューに追加 + * 3. cleanup project ボタンをクリックして確認ダイアログを承認 + * 試験確認内容:クリーン後に次のトーストが表示されないこと + */ + it("cleanup project実行時-キュー内のトーストメッセージがクリアされることを確認", ()=>{ + //wait for workflow initialization: SIO sends projectState "not-started" after getProjectJson + cy.get("[data-cy=\"workflow-project_state-btn\"]").should("contain", "not-started"); + + //enable the cleanup button + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + store.commit("projectState", "finished"); + }); + + //show a first snackbar and enqueue a second one directly into the queue + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + store.dispatch("showSnackbar", { message: "first toast message", timeout: -1 }); + //push additional messages directly into the queue to simulate a backlog + store.state.snackbarQueue.push({ message: "queued toast message 1", timeout: -1 }); + store.state.snackbarQueue.push({ message: "queued toast message 2", timeout: -1 }); + }); + + //verify first snackbar is visible + cy.contains("div.v-snackbar__content", "first toast message") + .should("be.visible"); + + //click cleanup project button and confirm + cy.get("[data-cy=\"workflow-cleanup_project-btn\"]") + .should("not.be.disabled") + .click(); + cy.get("[data-cy=\"versatile_dialog_cleanProject-ok-btn\"]").click(); + + //verify snackbar and queue are cleared via store state + cy.window().then((win)=>{ + const store = win.document.querySelector("#app").__vue_app__.config.globalProperties.$store; + expect(store.state.snackbarQueue).to.have.length(0); + expect(store.state.openSnackbar).to.be.false; + }); + }); +});