CentOS 7 と CentOS 8 で Cron ジョブに /etc/environment の環境変数が渡るかどうかが異なる

TL;DR

  • CentOS 7 なら /etc/environment の環境変数が渡る
  • CentOS 8 で root でジョブを実行すると /etc/environment の環境変数は渡らない
  • CentOS 8 で root 以外でジョブを実行すると /etc/environment の環境変数が渡る

理由

cron のジョブで /etc/environment が有効になるのは crond が直接なにかしているわけではなく pam_env.so によるものだったと思うので、なにか違いがあるかと思って見てみたのですが・・

### CentOS 7

cat /etc/pam.d/crond
#=> account    required   pam_access.so
#=> account    include    system-auth
#=> session    required   pam_loginuid.so
#=> session    include    system-auth
#=> auth       include    system-auth

cat /etc/pam.d/system-auth | grep env
#=> auth        required      pam_env.so

### CentOS 8

cat /etc/pam.d/crond
#=> auth       include    password-auth
#=> account    required   pam_access.so
#=> account    include    password-auth
#=> session    required   pam_loginuid.so
#=> session    include    password-auth

cat /etc/pam.d/password-auth | grep env
#=> auth        required      pam_env.so

微妙に違いはあるもののどちらも pam_env.so は有効なようです。

ただ、Cron ジョブの開始時に CentOS 7 または CentOS 8 でも root 以外で実行するときは /var/log/messages に次のようなものが出力されますが、

Started Session 50 of user root.

CentOS 8 で root で実行するときはなにも出力されません。

CentOS 8 だと root で実行するときだけ pam がバイパスされているのでしょうか。

crond のバージョンはそれぞれ次のとおりでした。

  • CentOS 7 cronie-1.4.11-23.el7
  • CentOS 8 cronie-1.5.2-4.el8

GitHub の以下のリポジトリでホストされているようです。

差分を見てみたところ・・

このあたりが非常に怪しいです。

 #if defined(WITH_PAM)
-    if (cron_start_pam(pw) != PAM_SUCCESS) {
+    if (getuid() != 0 && cron_start_pam(pw) != PAM_SUCCESS) {
         fprintf(stderr,
             "You (%s) are not allowed to access to (%s) because of pam configuration.\n",
             User, ProgramName);
 #ifdef WITH_PAM
-    if ((ret = cron_start_pam(e->pwd)) != 0) {
+    /* PAM is called only for non-root users or non-system crontab */
+    if ((!u->system || e->pwd->pw_uid != 0) && (ret = cron_start_pam(e->pwd)) != 0) {
         log_it(e->pwd->pw_name, getpid(), "FAILED to authorize user with PAM",
             pam_strerror(pamh, ret), 0);
         return -1;
     }
 #endif

以下のコミットで変更されていました。

Call PAM only when it makes sense.

  • do not check PAM in crontab when uid is 0
  • do not call PAM at all in crond for system cron jobs that are run as uid 0

さいごに

CentOS というか cronie のバージョンの違いによるものでした。

Cron ジョブで pam_env.so によって /etc/environment が有効になっていたのは意図していない副作用のようなものだったということですね。

環境固有の設定値を /etc/environment に記述していて、かつ、root で実行される cron ジョブでその環境変数を参照している場合、CentOS 8 だと /etc/environment が有効にならないので注意が必要です。

実行するジョブの側で↓のように対策する必要があります。

set -a
source /etc/environment
set +a

PHP 7.4 で xhprof/xhgui プロファイリング

だいぶ前に xhgui 使ったときは、アプリ側にも xhgui のソースを入れて xhgui/external/header.php みたいなファイルを auto_prepend_file とかに設定していたと思うのですが、最新版だとだいぶ変わっていました。

xhprof

とりあえず tideways_xhprof 拡張 が必要です。Docker なら次のような感じでインストールできます。

RUN mkdir -p /tmp/tideways_xhprof &&\
    curl -fsSL https://github.com/tideways/php-xhprof-extension/archive/v5.0.2.tar.gz |\
        tar xzf - --strip-components=1 -C /tmp/tideways_xhprof &&\
    docker-php-ext-install /tmp/tideways_xhprof &&\
    rm -fr /tmp/tideways_xhprof

と思ったら xhprof 拡張も PHP 7 対応でメンテ続いていたんですね。こっちでも良いかも。

RUN apk add --no-cache --virtual .build-deps autoconf gcc g++ make &&\
    pecl install xhprof &&\
    apk del .build-deps &&\
    rm -fr /tmp/pear &&\
    docker-php-ext-enable xhprof

php-profiler

アプリ側には xhgui は必要無く、代わりに perftools/php-profiler が必要です。プロファイル結果をアプリ側から MongoDB に直接保存しようとすると xhgui-collector も必要です。

php-profiler の設定の save.handlerSAVER_UPLOAD を指定すればプロファイル結果を HTTP で xhgui へポストするようになるので楽ちんです。アプリ側に mongodb 拡張も必要ありません。

アプリケーションの index.php とか composer.json の autoload.files とかあるいは auto_prepend_file とかで次のようにプロファイラを開始します。

<?php
$config = [
    'profiler.enable' => function () {
        return true;
    },

    // xhprof や tideways_xhprof で有効にするフラグ
    'profiler.flags' => [
        // 実行時間以外に収集するメトリクス
        \Xhgui\Profiler\ProfilingFlags::CPU,
        \Xhgui\Profiler\ProfilingFlags::MEMORY,

        // ビルトイン関数をプロファイル結果煮含めない
        \Xhgui\Profiler\ProfilingFlags::NO_BUILTINS,

        // xhprof や tideways_xhprof では無意味(tideways 拡張ではサポートされているらしい)
        \Xhgui\Profiler\ProfilingFlags::NO_SPANS,
    ],

    // プロファイル結果の保存ハンドラ
    'save.handler' => \Xhgui\Profiler\Profiler::SAVER_UPLOAD,

    // プロファイル結果のアップロード先
    'save.handler.upload' => [
        // xhgui の URL を指定する
        'uri' => 'http://xhgui/run/import',
    ],
];

$profiler = new \Xhgui\Profiler\Profiler($config);
$profiler->start();

$profiler->start() でプロファイルが開始されつつ、プロファイルを停止して結果を保存するためのシャットダウンハンドラが登録されます。

シャットダウン関数の中で fastcgi_finish_request でリクエストを終了させたうえで保存ハンドラを呼ぶため、保存に時間が掛かったとしてもページの表示が遅延することはありません。ただ、別の用途でシャットダウンハンドラを利用していてシャットダウンハンドラからレスポンスを返している場合、それが機能しなくなります。その場合、$profiler->start(false) のようにプロファイルを開始すればシャットダウン関数で fastcgi_finish_request は実行されなくなります。

xhgui

xhgui は edyan/xhgui がオールインワンの Docker イメージなので楽です。docker-compose ならこれだけです。

version: '3.7'
services:
  xhgui:
    image: edyan/xhgui
    ports:
      - '8142:80'

xhgui/xhgui というイメージもありますが、これは nginx や mongodb を別に用意する必要があります。あとなぜか /var/www/xhgui/config に謎のコンフィグファイルが置かれているため、別にコンフィルファイルを用意してマウントするか、あるいは削除しないと環境変数で設定を指定できません。。。

xhgui: document to insert contains invalid key: keys cannot contain "."

追記 2020-12-16 アップストリームで 0.16 で対応されたものの edyan/xhgui は 0.14.0 なのでまだ必要

xhprof 拡張を使ったときだけ下記の問題で xhgui で mongodb への保存が失敗するようになりました。

tideways_xhprof 拡張では発生しないようです。たまたまかもしれません。

プロファイル結果のオブジェクトキーに "." が含まれていることが原因のようですが・・次のようにシャットダウン関数で修正して保存すればとりあえず大丈夫です。

<?php
$profiler = new \Xhgui\Profiler\Profiler([
    'profiler.enable' => function () {
        return true;
    },

    'profiler.flags' => [
        \Xhgui\Profiler\ProfilingFlags::CPU,
        \Xhgui\Profiler\ProfilingFlags::MEMORY,
        \Xhgui\Profiler\ProfilingFlags::NO_BUILTINS,
        \Xhgui\Profiler\ProfilingFlags::NO_SPANS,
    ],

    'save.handler' => \Xhgui\Profiler\Profiler::SAVER_UPLOAD,

    'save.handler.upload' => [
        'uri' => 'http://xhgui/run/import',
    ],
]);

$profiler->enable();

register_shutdown_function(function () use ($profiler) {
    ignore_user_abort(true);
    session_write_close();
    flush();
    fastcgi_finish_request();

    $data = $profiler->disable();
    $profile = [];
    foreach($data['profile'] as $key => $value) {
        $profile[strtr($key, ['.' => '_'])] = $value;
    }
    $data['profile'] = $profile;
    $profiler->save($data);
});

さいごに

どうやら xhgui に最近になってかなり大きな変更が入っているようです。そのため、まだちょいちょいおかしなところがあるようです。プロファイル結果のストレージに mongodb 以外に PDO も指定できるようになっているようなのですが PDO だとまともに動作しない・・とか。

ただ、以前のアプリ側にも mongodb 拡張が必要、とかと比べるとだいぶ仕込みやすくなっていると思います。

Terraform の AWS プロバイダのクレデンシャルの優先順が AWS CLI や AWS SDK と異なる

環境とか。

Terraform v0.13.4
+ provider registry.terraform.io/hashicorp/aws v3.9.0

Terraform でデプロイ対象の AWS アカウントが MFA 必須だったので aws-vault を使う前提で provider aws にはクレデンシャルの指定なし、一方で tfstate のバックエンドは MFA 無しの別の AWS アカウントの S3 バケットを利用するために profile を指定していました。

terraform {
  backend "s3" {
    profile = "ore"
    region  = "ap-northeast-1"
    bucket  = "ore-no-terraform"
    key     = "are.tfstate"
  }
}

provider "aws" {
  region  = "ap-northeast-1"
}

こんな変な構成にしていることが圧倒的に悪い気がしますが、これは期待通りにはなりません。次のように aws-vault で実行すると tfstate のバックエンドの S3 へも are のクレデンシャルでアクセスしてしまいます。

aws-vault exec are -- terraform plan

aws-vault が AWS STS から取得した一時的なクレデンシャルが AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY などの環境変数に設定されたうえで Terraform が実行されるのですが、環境変数のクレデンシャルが tf ファイルで指定している profile よりも優先されるためです。

原因

Terraform の AWS プロバイダは hashicorp/aws-sdk-go-base で AWS に接続します。

import (
    // ...snip...
    awsbase "github.com/hashicorp/aws-sdk-go-base"
    // ...snip...
)

// ...snip...

// Client configures and returns a fully initialized AWSClient
func (c *Config) Client() (interface{}, error) {
    // ...snip...
    sess, accountID, partition, err := awsbase.GetSessionWithAccountIDAndPartition(awsbaseConfig)
    // ...snip...
}

そして hashicorp/aws-sdk-go-base のこの辺りで profile よりも環境変数が優先されています。

   // build a chain provider, lazy-evaluated by aws-sdk
    providers := []awsCredentials.Provider{
        &awsCredentials.StaticProvider{Value: awsCredentials.Value{
            AccessKeyID:     c.AccessKey,
            SecretAccessKey: c.SecretKey,
            SessionToken:    c.Token,
        }},
        &awsCredentials.EnvProvider{},
        &awsCredentials.SharedCredentialsProvider{
            Filename: sharedCredentialsFilename,
            Profile:  c.Profile,
        },
    }

ちなみに環境変数 AWS_PROFILESharedCredentialsProviderprofile が指定されていないときのフォールバックになっているので、環境変数 AWS_PROFILE よりも直接指定された profile が優先されます。

// profile returns the AWS shared credentials profile.  If empty will read
// environment variable "AWS_PROFILE". If that is not set profile will
// return "default".
func (p *SharedCredentialsProvider) profile() string {
    if p.Profile == "" {
        p.Profile = os.Getenv("AWS_PROFILE")
    }
    if p.Profile == "" {
        p.Profile = "default"
    }

    return p.Profile
}

整理すると、次のような優先順になっています。

  • provider aws で指定した access_keysecret_key
  • 環境変数 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
  • provider aws で指定した profile
  • 環境変数 AWS_PROFILE

AWS CLI はそうではありません。次のように実行すると ore のクレデンシャルが使用されていることがわかります。

aws-vault exec are -- aws sts --profile ore get-caller-identity --query Account

AWS SDK for Go も同じです。次のようなコードで確認できます。

package main

import (
    "flag"
    "fmt"
    "io/ioutil"
    "log"

    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/iam"
    "github.com/aws/aws-sdk-go/service/sts"
    awsbase "github.com/hashicorp/aws-sdk-go-base"
)

func printAccountIdAndAlias(sess *session.Session) {
    stsClient := sts.New(sess)
    identity, err := stsClient.GetCallerIdentity(&sts.GetCallerIdentityInput{})
    if err != nil {
        panic(err)
    }
    iamClient := iam.New(sess)
    aliases, err := iamClient.ListAccountAliases(&iam.ListAccountAliasesInput{})
    if err != nil && len(aliases.AccountAliases) == 0 {
        fmt.Println(*identity.Account)
    } else {
        fmt.Println(*identity.Account, *aliases.AccountAliases[0])
    }
}

func main() {
    log.SetOutput(ioutil.Discard)
    profile := flag.String("profile", "default", "profile")
    flag.Parse()

    fmt.Print("aws/aws-sdk-go: ")
    printAccountIdAndAlias(session.Must(session.NewSessionWithOptions(session.Options{
        Profile: *profile,
    })))

    fmt.Print("hashicorp/aws-sdk-go-base: ")
    printAccountIdAndAlias(session.Must(awsbase.GetSession(&awsbase.Config{
        Profile: *profile,
    })))
}

次のように実行すると違いがわかります。

aws-vault exec are -- go run main.go -profile ore

さいごに

下記の記述のよると意図されたもののようです(AWS CLI と異なっているのが意図されたものかどうかはさておき)。

Linuxbrew(Homebrew) で入れた docker-compose で ModuleNotFoundError: No module named 'invoke' と言われたときにやること

環境

まあバージョンはあまり関係ないと思いますが。

  • Fedora 31 (WSL1)
  • Homebrew 2.5.2
  • Python 3.8.5
  • docker-compose 1.27.3

問題発生

Homebrew、Macは手元にないので Linuxbrew ですけど、で入れた docker-compose で次のエラーがでました。

ModuleNotFoundError: No module named 'invoke'

これは ~/.ssh/config の Match で exec が使用されていると発生します。

原因

paramiko でいくつかの依存パッケージがオプショナルになっており Match exec を使うためには invoke が必要なためです。

Linuxbrew で pyinvoke を入れれば大丈夫なのかと思ったのですが、ダメでした。

brew install pyinvoke

Python のことはよくわからないですが、それぞれ別の virtualenv だから、とかなのでしょうか。たぶん。

解決

docker-compose をインストールしたときに依存で入った python3 の pip3 で invoke をインストールすれば大丈夫です。

念のため python3 や pip3 が Linuxbrew のものになっているか確認します。

which python3 pip3

もしこれが /usr/bin/python3/usr/bin/pip3 になるなら invoke のインストールための pip コマンドは絶対パスで指定すると良いでしょう。

/home/linuxbrew/.linuxbrew/bin/pip3 install invoke

さいごに

~/.ssh/config で Match の exec とか使う人あまりいないのでしょうか。。。ちなみに Github の Releases からバイナリをダウンロードしたときは発生しません。これは pyinstaller というものでワンバイナリになっているようなのですが、何が違うんでしょう? 謎。

NodeJS とかでファイル更新を監視してプロセスを再起動しつつ Browsersync でリロード

NodeJS や Golang で Web アプリを作るとき、サーバサイドのコードを変更したときはプロセスの再起動が必要です。毎回手でやるのはさすがに煩雑なのでファイルの更新を監視して自動的に再起動してくれる系のツールがいろいろあります。

NodeJS なら下記などがよく使われるようです。

nodemon は実行コマンドを node から変更できるので NodeJS 以外でも使おうと思えば使えます。

node-dev は require() をフックすることで必要なファイルだけを監視、みたいな動作になるようです。なので監視対象のディレクトリやファイルの設定などしなくてもうまく動作します。その仕組的に NodeJS 以外では使えません。

ts-node-dev は node-dev の TypeScript 版です。TypeScript のコンパイラプロセスが毎回起動しなくて良いためリスタートが速いらしいです。

Golang なら oxequa/realize が鉄板なのでしょうか? ファイルの更新を監視して go run をリスタートする以外にもいろいろ出来るようです。

うーん? Go Modules に対応していないので cosmtrek/air のほうが良いの? 単にファイルの更新を監視してプロセスを再起動するだけではないんですかね。。。

watchexec

この種のツール、NodeJS や Golang に特化することで便利になっているところもあるようなのですが、基本的にはファイルの更新を監視してプロセスをリスタートするだけのものなので、それならもっと汎用的なものを使ってもよいのでは、と思います。

普段ファイルの更新を監視してなんやかんやするときに watchexec を使っているのですが -r オプションでプロセスのリスタートもできます。使い慣れているのでこれを使うことにします。

watchexec 自体は Homebrew で簡単にインストールできます。

brew install watchexec

次のようにすると src/ ディレクトリの *.ts ファイルを監視して、更新があると ts-node コマンドをリスタートしてくれます。

watchexec -r -w src/ -f '*.ts' -- env PORT=9876 ts-node -T src/app.ts

ファイル監視からのプロセスのリスタートはこれだけで十分です。

browser-sync

次に、サーバが HTML を返す系の Web アプリの場合、見た目の確認のためにいわゆる livereload も出来ると便利です。livereload にはいつも Browsersync を使ってます。便利です。

browser-sync start --port 3000 -p localhost:9876 --open -w -f '**/*.ts' -f '**/*.html' -f '**/*.css'

前述の watchexec と同時に実行すればブラウザで http://localhost:3000 を開けば Browsersync 経由で localhost:9876 でリッスンしている NodeJS のアプリにアクセスできます。

そして html や css を更新すると Browsersync でブラウザのリロード、サーバサイドのコードを更新したときは NodeJS のプロセスをリスタートした上で Browsersync でブラウザがリロードされ・・

ませんでした。サーバサイドのコードを更新したとき、NodeJS のプロセスが再起動しつつ Browsersync がブラウザで表示しているページをリロードするわけですが、NodeJS のプロセスが再起動してポートのリッスンが開始するより、ブラウザがリロードされる方が速いため、リロード後に Browsersync がプロキシ先のポートにアクセスできなくてエラーになります。

Makefile

試行錯誤のうえで出来上がったのがこれ。NodeJS のプロセスがリスタートするとき、NodeJS によってポートがリッスンされるのを待ってから Browsersync でリロードします。

APP_PORT = 9876
BS_PORT = 3000

.PHONY: all
all:
    $(MAKE) -j watch bs

.PHONY: app
app:
    env PORT="$(APP_PORT)" ts-node -T src/app.ts

.PHONY: watch
watch:
    watchexec -r -w src/ -f '*.ts' -- $(MAKE) -j app reload

.PHONY: bs
bs:
    browser-sync start --port "$(BS_PORT)" -p localhost:"$(APP_PORT)" --open -w \
        -f '**/*.html' \
        -f '**/*.css'

.PHONY: reload
reload:
    @while ! nc -z localhost "$(APP_PORT)"; do sleep 1; done
    browser-sync reload

Makefile を使っていますがなにかをメイクするわけではなくタスクランナーとしてだけ使っています。タスクを直列に実行したり並列に実行したりの制御が Makefile なら簡単なので。。。

watchexec からは $(MAKE) -j app reload で、アプリを開始するタスクと、ページをリロードするタスクを同時に実行します。リロードのタスクはアプリのポートがリッスンを待ってから browser-sync reload します。make-j はタスクの並列数を指定するオプションです。値を省略しているので無制限になります。

また、これら2つを同時に実行するためにデフォルトのタスクで $(MAKE) -j watch bs としています。

リッスンを開始したログをトリガにする版

試行錯誤中にできた、アプリが標準出力に出すログをトリガにする版(↑の方がスマートです)。

まず、アプリでリッスンを開始したときに適当なログを出力するようにしておきます。

const port = parseInt(process.env.PORT || '9876');
app.listen(port, () => {
    console.log(`Listening on :${port}`);
});

そしてこのメッセージが標準出力に流れてきたのをトリガーに browser-sync reload を実行します。

.PHONY: watch
watch:
    watchexec -r -w src/ -f '*.ts' -- $(MAKE) app \
        | tee /dev/stderr \
        | grep --line-buffered '^Listening on :' \
        | xargs -i browser-sync reload

watchexec で実行したアプリの標準出力を tee/dev/stderr に流しています。これはなくても動きますが、その場合アプリの標準出力の内容が失われます。

次に grep で標準出力にポートがリッスンしたときのメッセージが流れるのを待ちます。--line-buffered を付けて後段のパイプに行ごとにフラッシュされるようにします。

最後に xargs で標準入力になにか来るたびに browser-sync reload を実行します。-i を付けてコマンドにに余計な引数が付与されないようにするとともに、標準入力から1行読むたびにコマンドが実行されるようにします。

npm だけで実現する

↑の方法は自分ひとりで使う分には良いですが、チームで使うのであれば NodeJS ならやっぱり npm だけで実行できたほうが良いでしょうか。

npm でも npm-run-all で並列実行は出来ます。ポートのリッスンを待つのは wait-on で出来そうです。watchexecnodemon で問題ありません。node-devts-node-dev は実行コマンドを変更できないのでダメです。

次のようになりました。

{
"scripts": {
  "dev": "npm-run-all -p -r bs watch",
  "app": "ts-node -T src/app.ts",
  "watch": "nodemon -w src/ -e ts -x npm-run-all -- -p app wait-reload",
  "bs": "browser-sync start --port 3000 -p localhost:9876 --open -w -f \"**/*.ejs\" -f \"**/*.css\"",
  "wait-reload": "npm-run-all -s wait reload",
  "wait": "wait-on -v tcp:9876",
  "reload": "browser-sync reload"
}

npm run dev で開始できます。

npm run dev

さいごに

ググると gulp とかで同じようなことをやっている例がでてきました。きめ細かにやるならその方が良いのかもしれませんが、自分専用なら雑にありもののツールを組み合わせてこういうのもアリじゃないでしょうか。

VSCode で PHPUnit を実行する拡張を使ってみた

VSCode の Marketplace で PHPUnit で検索して出てきた中からダウンロード数が多い 3 つの拡張を使ってみました。

なお、普段は↓のようなめんどくさい環境でコードを書いています。

  • Windows で PhpStorm を実行している
  • PHP は WSL 上の docker-compse コマンドを使って Docker 環境で実行している
  • direnv で環境変数をいろいろ設定している

ので同様に VSCode も Windows 上から WSL 上で direnv を適用しつつ docker-compse を使いたいです。

emallin.phpunit

デフォで vendor/bin/phpunit とか vendor/phpunit/phpunit/phpunit とか phpunit*.phar とかを再帰的に検索して php コマンドで実行してくれます。vendor/bin/phpunit 決め打ちじゃないのと Windows の場合に vendor/phpunit/phpunit/phpunit を探してくれるのは便利です。Phar もプロジェクトディレクトリ内に置いとけば自動で見つけてくれます。

リモート実行する場合、次のように任意のコマンドが指定できるので、Windows 上から WSL の docker-compose を実行すれば OK です。テストファイルを指定して実行するときのために phpunit.paths も指定する必要があります。

{
    "phpunit.command": "wsl direnv exec . docker-compose run --rm app",
    "phpunit.phpunit": "vendor/bin/phpunit",
    "phpunit.paths": {
        "${workspaceFolder}": "/app",
    }
}

SSH や Docker で実行するための別の設定 phpunit.ssh とか phpunit.docker.container とか docker.image とかもありますが、phpunit.command だけで指定するほうが簡単なんじゃないかと思います。

メソッドを指定して実行するときにメソッド名が単純に --filter に渡されるので、例えば hoge というメソッドのテストを実行するときに hogehoge も一緒に実行されてしまいます。変に頑張られると Windows と Linux でのシェルのクオートやエスケープの違いでうまく動かなくなったりするので、これでも十分かなと言う気もします。

phpunit.args 設定で PHPUnit のコマンドラインオプションを配列で指定できます。配列なので適切なエスケープが施されるか、あるいはシェルを経由しないのかと思いきや、実際には単純に join でスペース区切りで繋げられてシェル経由で実行されます。これ配列である必要全然無いような。。。むしろ phpunit.command phpunit.phpunit phpunit.args をまとめて1つの文字列の設定でも十分な気もします。実際のところ次のように適当にバラけさせても動きます。

{
    "phpunit.command": "wsl direnv",
    "phpunit.phpunit": "exec .",
    "phpunit.args": ["docker-compose", "run", "--rm", "app", "vendor/bin/phpunit"],
    "phpunit.paths": {
        "${workspaceFolder}": "/app",
    },
}

リモート実行のために phpunit.paths でパスのマッピングをしているとき、テストがコケたときに赤線がついたり問題タブに表示されたりするのが機能しなくなることがあります。下記で /app がハードコードされているためなので Docker 内では /app にソースを配置すれば大丈夫です。

PHPUnit は拡張が直接実行しているわけではなく、VSCode の ShellExecution タスクとして実行するようになっています。problemMatchers という VSCode の拡張ポイントを使って実行結果から正規表現でコケたテストケースのファイルや行番号やメッセージを抜き出すようになっているようです。この problemMatchers は動的にはできなさそうなのでこのような動作になっているようです。

calebporzio.better-phpunit

デフォだと vendor/bin/phpunit が(Windows なら vendor/bin/phpunit.bat)が決め打ちで実行されます。 better-phpunit.phpunitBinary でコマンドは変更できます。これはシェルで解釈されるので Windows なら php vendor/phpunit/phpunit/phpunit とかでも OK です。

Docker などでリモート実行するときは次のようにコマンドを指定します。ローカルとリモートのファイル名のマッピングで ${workspaceFolder} は使用できないので次のように絶対パスで指定する必要があります。なお、Windows でもパス区切りは \\ ではなく / で指定します。

{
    "better-phpunit.phpunitBinary": "vendor/bin/phpunit",
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl direnv exec . docker-compose run --rm app",
    "better-phpunit.docker.paths": {
        "c:/Users/oreore/path/to/project": "/app"
    },
}

better-phpunit.docker.commandbetter-phpunit.phpunitBinary は単にスペース区切りで連結されるだけです。なので次のように適当にバラしても通ります。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl direnv exec .",
    "better-phpunit.phpunitBinary": "docker-compose run --rm app vendor/bin/phpunit",
    "better-phpunit.docker.paths": {
        "c:/Users/oreore/path/to/project": "/app"
    },
}

設定の項目名に docker と付いていますが Docker でなくても動きます。例えば次のようにすれば Windows 上の VSCode から WSL 上の PHPUnit を実行できます。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl php",
    "better-phpunit.phpunitBinary": "vendor/bin/phpunit",
    "better-phpunit.docker.paths": {
        "c:/Users": "/c/Users"
    },
}

次のようにすればローカルの Windows 上の PHP が実行されます。つまり最初の better-phpunit.phpunitBinary だけ指定したときと同じです。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "php",
    "better-phpunit.phpunitBinary": "vendor/phpunit/phpunit/phpunit",
}

コケたテストに赤線をつけたり問題タブに表示したりする機能が機能していないようです。たぶん package.jsonproblemMatchers のパターンが間違っているためだと思います。また、仮に problemMatchers が修正されたとしても「リモートの実行結果のパス → ローカルのパス」のマッピングはサポートしてなさそうなので、リモート実行だと赤線や問題タブには表示されません。

なお、emallin.phpunit と同様で PHPUnit は拡張が直接実行しているわけではなく、VSCode の ShellExecution タスクとして実行するようになっています。problemMatchers という拡張ポイントを使って実行結果から正規表現でコケたテストケースのファイルや行番号やメッセージを抜き出すようになっているようです。

recca0120.vscode-phpunit

サイドバーにテストのエクスプローラーが表示できたり、CodeLenses でコード上からサクッと実行できたり、多機能です。

前述の 2 つと比べるとだいぶ複雑です。内部では LanguageServer を実行していて PHPUnit は LanguageServer から child_process.spawn で直接実行されています。コケたテストのファイル名や行番号やメッセージは PHPUnit で出力した JUnit XML から抜き出しているようです。

素のままだとデフォで vendor/bin/phpunit (Windows なら vendor/bin/phpunit.bat)を実行します。設定の phpunit.phpunit で変更できますが、絶対パスで指定する必要があります。相対パスで指定しても頭に / が付けられてしまいます。

phpunit.php で PHP のバイナリを指定すれば PHPUnit のコマンドを直接ではなく明示的に PHP コマンドで実行されるようになります。Windows で Phar を指定するときに使えそうです。なお、これも絶対パスで指定する必要があります。パスが通ったところにあるコマンドを指定しても頭に / が付けられるのでダメです。

phpunit.args で PHPUnit のコマンドラインオプションが指定できます。phpunit.args-c が含まれないときは phpunit.xml.dist または phpunit.xml が検索され、見つかればコマンドラインに自動で追加されます。これも絶対パスで追加されます。

テストファイルを指定して実行するとき、phpunit.relativeFilePath: true に設定されていると相対パスで指定されるようになります。リモート実行するときはローカルの絶対パスが指定されてもダメなのでこのオプションが必須です。ただし前述の phpunit.xml が自動で追加されるときは絶対パスのままなので、リモート実行させるなら phpunit.args-c オプションを指定する必要があります。

Windows で実行するとき、パス区切り文字はバックスラッシュのままです。なので Windows から Docker などでリモート実行させるのは難しいです。

phpunit.shell でシェルを経由するかどうかが指定できます。これはそのまま child_process.spawn のオプションになります。

カーソル位置のテストを実行や、テストエクスプローラーや CodeLenses で単一のメソッドのテストを実行する機能が機能していません。たぶん下記の問題だと思いますが・・

WSL 上で VSCode を実行しているならこれで解決します。

{
    "phpunit.shell": "bash"
}

Windows 上で VSCode を実行しているときはどうしようもありません。不便なので治しておきました。

もうマージされてリリースされているようなのでこの問題は解決していると思います。

さいごに

recca0120.vscode-phpunit が高機能で IDE っぽい感じで良さそうですが、Windows 上の VSCode からのリモート実行が絶望的です。Remote Development とかで Docker 内で VSCode を動かせばいいんでしょうけど。

emallin.phpunit は Docker 内で /app にコードを置く必要がある、という問題はありますが・・それさえ目をつぶれば一番安定して使えそうです。

calebporzio.better-phpunit はコケたテストの赤線や問題タブが機能しないので微妙です。それ以外は動作原理は emallin.phpunit とほぼほぼ同じで違いがあまりありません。

NodeJS で AsyncLocalStorage を使って横断的なトランザクション

NodeJS で Async hooks を使うトランザクションの実用的な実装 - ngyukiの日記 ですが、Twitter で AsyncLocalStorage なるものの存在を教えてもらいました。

AsyncLocalStorage を使うほうがスッキリできました。transaction の方はあまり変わっていませんが connection のほうがスッキリしています。

// conext.ts

import { AsyncLocalStorage } from 'async_hooks'

interface Pool<T extends Connection> {
    getConnection(): Promise<T>,
    end(): Promise<void>;
}

interface Connection {
    beginTransaction(): Promise<void>,
    commit(): Promise<void>,
    rollback(): Promise<void>,
    release(): void,
}

type PromiseValue<T> = T extends Promise<infer V> ? V : never;

type PoolConnection<TPool extends Pool<Connection>> = PromiseValue<ReturnType<TPool["getConnection"]>>;

export class Conext<
    TPool extends Pool<TConnection>,
    TConnection extends Connection = PoolConnection<TPool>
> {
    private asyncLocalStorage: AsyncLocalStorage<{conn: TConnection, trx: number}>;

    constructor(private pool: TPool) {
        this.asyncLocalStorage = new AsyncLocalStorage();
    }

    async connection<T>(callback: (conn: TConnection) => Promise<T>) {
        const context = this.asyncLocalStorage.getStore();
        if (context) {
            return await callback(context.conn);
        }
        const conn = await this.pool.getConnection();
        try {
            const context = { conn: conn, trx: 0 };
            return await this.asyncLocalStorage.run(context, async () => {
                return await callback(conn);
            });
        } finally {
            conn.release();
        }
    }

    async transaction<T>(callback: (conn: TConnection) => Promise<T>) {
        return await this.connection(async (conn) => {
            const context = this.asyncLocalStorage.getStore()!;
            if (context.trx === 0) {
                context.trx++;
                await conn.beginTransaction();
                try {
                    const ret = await callback(conn);
                    await conn.commit();
                    return ret;
                } catch (err) {
                    await conn.rollback();
                    throw err;
                } finally {
                    context.trx--;
                }
            } else {
                context.trx++;
                try {
                    return await callback(conn);
                } finally {
                    context.trx--;
                }
            }
        });
    }

    async end() {
        try {
            return await this.pool.end();
        } finally {
            this.asyncLocalStorage.disable();
        }
    }
}

これ、async_hooks モジュールに含まれていて同じページのリファレンスにあるじゃないですか・・Async hooks の存在を知って、これ超便利じゃーん、と高まったテンションでそのまま試行錯誤していたので一番下まで読んでませんでした・・

asyncHook を直で使うとぱっと見なにをやっているのか判りにくいですが、AsyncLocalStorage ならなるほど他言語でいうところの TLS のようなものかと理解できますね(スレッドじゃないけど)。