こんにちは。テクマトリックスの橘です。

最近、お客様環境にJenkinsおよびCIパイプラインを構築するという案件があり、その中で「ビルドや静的解析でできた成果物を、Jenkins内だけでなく社内のファイルサーバー(Windowsの共有フォルダ)にも保存したい」というご要望をいただきました。

Jenkinsには成果物をジョブに紐付けて保存する「アーカイブ」機能がありますが、これとは別に、チームの誰もが普段どおりエクスプローラー等で開ける共有フォルダにも置いておきたい、というのは現場でよくあるニーズだと思います。

これを実現するのが「Publish Over CIFS」プラグインです。この記事では、その導入から パイプラインでの使い方を紹介します。あわせて、実際に運用する上で見落としがちな「保存先フォルダ名の検証」という観点についても解説していきます。

Jenkinsのアーカイブと共有フォルダ保存の違い

まず、なぜ共有フォルダへの保存が必要になるのかを整理しましょう。

Jenkins標準の「成果物のアーカイブ(archiveArtifacts)」は、成果物をそのビルドのページに紐付けて保存します。履歴管理や、ビルドとの対応付けという観点では非常に便利です。一方で、

  • Jenkinsの画面にログインしないと保存したファイルを取り出せない
  • 開発者が普段使っているファイルサーバーの階層構造とは別管理になる

といった面もあり、「いつもの共有フォルダの、いつもの場所に、ビルド番号ごとのフォルダで置いておいてほしい」という運用要望には、そのままでは応えづらいことがあります。

そこで、アーカイブは従来どおり残しつつ、加えて共有フォルダにも転送する、という構成を取ります。この「共有フォルダへの転送」を担当するのが Publish Over CIFS です。

Publish Over CIFSとは

Publish Over CIFS は、ビルド成果物を CIFS/SMB プロトコルでファイルサーバー(Windowsの共有フォルダなど)へ転送するためのJenkinsプラグインです。

同じ「Publish Over」シリーズには SSH 版(Publish Over SSH)や FTP 版(Publish Over FTP)もあり、CIFS(Common Internet File System)版はその名の通りWindowsの共有フォルダとの相性がよいのが特長です。転送先のサーバー情報(ホスト名・共有名・認証情報)をJenkins側にあらかじめ登録しておき、ジョブからはその登録名を指定して転送する、という使い方になります。

セットアップ:転送先サーバーを登録する

セットアップの流れは大きく2ステップです。

  1. プラグインのインストール
    Jenkins管理画面 → プラグインの管理 から「Publish Over CIFS」をインストールします。
  2. 転送先サーバーの登録
    Jenkinsの管理 → システムの設定 を開くと「Publish over CIFS」のセクションが追加されます。ここで転送先を登録します。主な設定項目は以下です。
  • Name:この転送先を識別する名前(ジョブからはこの名前で参照します)
  • Hostname:ファイルサーバーのホスト名またはIP
  • Username/Password:共有にアクセスできるユーザーとパスワード
  • Share:共有名(例:artifacts)。artifacts\jenkins のように共有内の基準フォルダまで指定することもできます(このフォルダは自動では作成されないため、事前に作成しておきます)
  • 高度な設定:Port、Timeout、Buffer size、SMB Versionなどの設定が可能

ここで付けた「Name」が、後述する パイプライン の configName になります。サーバー情報をジョブ側に持たせず、管理画面に一元登録しておけるのがポイントです。複数の共有フォルダを登録し、ジョブごとに保存先を切り替えるといった使い方も可能ですので、複数プロジェクトでも問題なく利用することができます。

なお、高度な設定にある SMB Version は、初期設定が「SMB v1/CIFS」になっています。最近のWindowsでは SMB1 が既定で無効になっているため、接続先に合わせて「SMB v2」または「SMB v3」を選んでおきましょう。

パイプラインからの使い方

登録が済んだら、パイプラインから cifsPublisher ステップで転送します。最小構成は以下のようなイメージです。

cifsPublisher(publishers: [[
    configName: 'my-file-server',          // 管理画面で登録したName
    transfers: [[
        sourceFiles: 'build/**/*.zip',     // 転送するファイル(ワークスペース相対のグロブ)
        remoteDirectory: 'projectA',       // 転送先(Shareで指定したフォルダからの相対)
        removePrefix: '',                  // 転送時に取り除くパスの接頭辞
        cleanRemote: false,                // 転送前に転送先を空にするか
        flatten: false                     // フォルダ構造を潰して平置きするか
    ]]
]], failOnError: true)                     // 転送失敗時にビルドをFAILUREにする

sourceFiles にワークスペース相対のグロブパターンを指定すると、マッチしたファイルが remoteDirectory 配下に転送されます。flatten を false にしておくと、ワークスペース内のフォルダ構造を保ったまま転送されます。なお、複数種類の成果物(例:ビルド成果物と静的解析レポート)をそれぞれ別のサブフォルダ(上記コードのremoteDirectory)に分けて保存したい場合は、transfers を複数並べるか、サブフォルダ名を変えて複数回呼び出す、といった形で対応できます。

ファイル転送失敗時の挙動

cifsPublisher ステップのパラメータの中で注意したいのが failOnError です。これを指定しない(既定値 false の)場合、転送に失敗してもビルド結果は 不安定 扱いになります。成果物が共有フォルダに保存されていないのに、見逃されてしまう可能性もあるため、保存が必須の運用であれば failOnError: true を指定して ビルドのステータスを 失敗 にしておくのがおすすめです。なお、failOnError: true でもパイプラインはその場では停止せず、後続のステージは実行される点に注意してください。

保存先パスをビルドごとに組み立てるには?

実運用においては「ビルド番号ごとにフォルダを分けて保存したい」という要望が出てくるのは自然かと思います。そういったケースでは、remoteDirectory をビルド番号などから動的に組み立てるのがおすすめです。さらに「リリース」「検証用」といった任意のラベルをフォルダ名に付けられると、後から共有フォルダ上でファイルを探すときに便利です。

def label = params.BUILD_LABEL                 // ジョブ実行時にユーザーが入力
def dirName = label ? "${env.BUILD_NUMBER}_${label}" : "${env.BUILD_NUMBER}" //BUILD_LABELが設定されていれば、"ビルド番号_ラベル"というフォルダに保存
def remoteDir = "projectA/${dirName}"

cifsPublisher(publishers: [[
    configName: 'my-file-server',
    transfers: [[
        sourceFiles: 'build/**/*.zip',
        remoteDirectory: remoteDir,
        removePrefix: '',
        cleanRemote: false,
        flatten: false
    ]]
]], failOnError: true)

これで、BUILD_LABEL に「release」と入力すれば「10_release」、未入力なら「10」 のように、ビルド番号(+ラベル)でフォルダを分けて保存することができます。(上記ケースにおいては、BUILD_LABEL をパラメータ付きビルドの機能を利用して、手動で設定する想定で設計しています。パラメータ付きビルドは初回実行時には反映されず、二回目以降で反映される点に注意してください)

ここまでで「共有フォルダに成果物を自動保存する」という当初の目的は達成できます。しかし、この状態には見逃されている点が1つあります。

保存先フォルダ名がユーザー入力になっている

もう一度、先ほどのコードの remoteDirectory を見てください。フォルダ名の一部に、ジョブ実行時にユーザーが自由入力する BUILD_LABEL が使われています。

つまり、転送先のパスがユーザー入力によって決まるということです。ここで、もし利用者が次のような値を入れたらどうなるでしょうか。

  • ../../ を含む値 → 意図した基準ディレクトリの外に書き出されてしまう(パストラバーサル)
  • \ / : * ? ” < > | などWindowsのフォルダ名に使用できない文字 → 転送がエラーになる、あるいは想定外の階層が作られる

CIFSでの転送先はWindowsのファイルサーバーであるため、Windowsのパス規則に従います。悪意のあるなしに関わらず、ちょっとした入力ミスで「別の場所に成果物が書き込まれる」事故は十分起こり得ます。共有フォルダは複数チームで利用することも多く、書き込み先が想定外の場所にずれるのは避けたいところです。

対策:フォルダ名を検証してから転送する

そこで、ユーザー入力をそのままパスに使う前に、フォルダ名として使ってよい値かを検証するステップを挟みます。

検証方針として、最初は「使ってよい文字だけを許可するホワイトリスト方式」を検討しました。しかし日本語のラベルも使用できた方が便利そうという意見もあり、「安全な文字」を厳密に列挙するのは現実的ではありませんでした。そこで方針を逆にし、「危険な文字だけを拒否するブロックリスト方式」を採用しました。

具体的には、Windowsのフォルダ名で使えない9文字(\ / : * ? ” < > |)と、パストラバーサルの原因になる .. を拒否し、それ以外(日本語を含む)は許可します。

def call(String label) {
    if (label) {
        def forbidden = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
        def bad = label.toList().findAll { forbidden.contains(it) }.unique()
        if (bad) {
            error "ラベルに使用できない文字が含まれています: ${bad.join(' ')}"
        }
        if (label.contains('..')) {
            error "ラベルに '..' は使用できません: ${label}"
        }
    }
}

これを共有ライブラリのカスタムステップ(例:checkBuildLabel)として切り出しておき、次の2か所から呼ぶようにしました。

  • パイプラインの冒頭(ビルドや静的解析を始める前)
  • CIFSへ転送する直前(転送処理をまとめた共通ステップの中)

冒頭で呼ぶ理由は、ラベルの入力ミスがあった場合に、時間のかかるビルドや静的解析を走らせる前に止められるからです。利用者から見ても「実行してしばらく待たされた末に、最後の保存で失敗する」より、開始直後にエラーになった方が親切です。

一方で、転送直前のチェックも実装しています。転送処理は単体で呼び出される可能性もあるため、「最後の砦」として検証を二重に持たせておく、という多層防御の考え方です。検証ロジック自体は1つのカスタムステップに集約してあるので、二重化してもルールがばらつく心配はありません。これはJenkinsの共有ライブラリを使うことの一つのメリットですね。

これにより、ユーザーが問題のあるパス名を保存先フォルダとして指定したとしても、エラーとして検出できるようになりました。

Jenkinsfileのサンプル

@Library('my-shared-library') _

pipeline {
    agent any

    parameters {
        string(name: 'BUILD_LABEL', defaultValue: '',
               description: '保存先フォルダ名に付けるラベル。空欄ならビルド番号のみ')
    }

    stages {
        stage('ラベルチェック') {
            steps {
                // 呼び出し①:時間のかかるビルドの前に入力ミスで止める
                checkBuildLabel(params.BUILD_LABEL)
            }
        }
        stage('ビルド') {
            steps {
                sh './build.sh'   // ビルド処理
            }
        }
        stage('共有フォルダへ保存') {
            steps {
                publishToShare(
                    configName : 'my-file-server',
                    projectDir : 'projectA',
                    sourceFiles: 'build/**/*.zip'
                )
            }
        }
    }
}

ファイル転送処理を担うカスタムステップサンプル

def call(Map args) {
    def label = params.BUILD_LABEL
    // 呼び出し②:このステップが単体で使われても検証される「最後の砦」
    checkBuildLabel(label)

    def dirName = label ? "${env.BUILD_NUMBER}_${label}" : "${env.BUILD_NUMBER}"

    cifsPublisher(publishers: [[
        configName: args.configName,
        transfers: [[
            sourceFiles: args.sourceFiles,
            remoteDirectory: "${args.projectDir}/${dirName}",
            removePrefix: '',
            cleanRemote: false,
            flatten: false
        ]]
    ]], failOnError: true)
}

まとめ

この記事では、Publish Over CIFS を使ってビルド成果物を共有フォルダに自動保存する方法を紹介しました。ポイントは以下の通りです。

  • 転送先サーバーは管理画面に一度登録し、ジョブからは名前(configName)で参照する
  • パイプラインからは cifsPublisher ステップで転送する
  • failOnError: true を指定しないと、転送に失敗してもビルドは 不安定 扱いになる
  • 保存先パスにユーザー入力(ラベルなど)を使う場合は、そのままパスに渡さず検証を挟む
    • 日本語も使いたいなら、危険な文字を拒否するブロックリスト方式が扱いやすい
    • 検証は共有ライブラリのカスタムステップに集約し、パイプライン冒頭と転送直前の両方で呼ぶ

「共有フォルダに置く」という一見シンプルな要望でも、パスの組み立てにユーザー入力が関わるようなケースでは、検証を入れる必要性も出てきます。Publish Over CIFS の導入自体はそれほど難しくはないですが、実際の運用を見据え、考慮しておくべき点はいくつかあることをご理解いただけたのではないでしょうか。

弊社の開発基盤ソリューションチームでは、JenkinsやGitHub Actionsを利用したCI/CD環境の構築を行っています。成果物の共有フォルダ保存や社内ファイルサーバーとの連携なども、現在の開発プロセスをお聞きした上で最適な形をご提案できますので、ご興味頂けましたら、ぜひお問い合わせください。