﻿# 如何正確使用 Azure Pipelines 內建的 FTP Upload Task 上傳與刪除檔案

這幾天在 Review 公司內一個 ASP.NET Core 專案的 Azure Pipelines，我發現網站是透過 FTP 進行更新的。然而自動化部署的過程，則是先上傳一個 `app_offline.htm` 檔案，然後在部署成功後刪除 `app_offline.htm` 檔案。也因為這個專案，我發現了一些 Azure Pipelines 內建的 [FTP Upload task](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/ftp-upload?view=azure-devops&WT.mc_id=DT-MVP-4015686) (`FtpUpload@2`) 有些魔鬼般的細節，實測之後發現有太多地雷，便花了點時間研究一下。

> 本篇文章都是以 [FtpUpload@2](https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/FtpUploadV2/) 為主，也是目前的最新版本。

### 地雷 1 - 忘記勾選 Preserve file paths 選項

當你只想上傳 **單一檔案** 或 **只有一個資料夾下的多個檔案** 時，其實不用特別勾選 **Preserve file paths** 選項。

但是如果你想上傳一整個資料夾，不勾選這一項，就會把所有來源任意子目錄下的檔案，全部上傳到遠端的「目標目錄」下，超雷！

假設你想上傳 `/home/user/source/` 資料夾到遠端，而來源檔案與路徑如下：

```bash
foo/bar/foobar.txt
```

如果你想上傳到遠端的 `/uploads/` 目錄下，那麼當你沒勾選 **Preserve file paths** 選項的時候，會上傳到如下的路徑：

```log
/uploads/foobar.txt
```

如果有勾選 **Preserve file paths** 選項，就會上傳到以下路徑：

```log
/uploads/foo/bar/foobar.txt
```

### 地雷 2 - 無法正確比對排除上傳的檔名路徑

我非常認真的看了 [File matching patterns reference](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/file-matching-patterns?view=azure-devops&viewFallbackFrom=vsts&WT.mc_id=DT-MVP-4015686)，但我發現 [FtpUpload@2](https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/FtpUploadV2/) 根本認不得 `!` 這種 Pattern，他會直接把 `!` 符號當成檔名或目錄名稱的一部份，浪費了我好多時間查這個問題，原來是個存在很久很久的 Bug 啊！

> 錯誤的文件，比 Bug 還可怕，不支援就不支援，文件幹嘛說有支援啊！(怒)

如果你在 **File patterns** 欄位輸入以下內容：

```log
**
!.git
```

或是

```log
**
!.git\**
```

或是

```log
**
!.git
!.git\**
```

通通無效！他一樣會把你的 `.git` 資料夾與完整的內容全部上傳上去！

結論是：你只能**正面表列**要上傳的檔案或目錄清單！

### 地雷 3 - 當沒有檔案上傳時不會執行 FTP Commands 命令

如果要透過 FTP Upload task 刪除遠端的檔案，必須送出低階的 FTP 命令，這時你可以在 **FTP Commands** 欄位這樣寫：

```bash
CWD /site/wwwroot
DELE App_Offline.htm
```

這時有個地方要特別注意，這個動作會在建立 FTP 連線後就先執行，而不是等到 FTP 上傳作業結束後才執行！

也因為這個限制，我們要在上傳完檔案後，就要刪除這個 `app_offline.htm` 檔案，勢必就要在多新增一個 [FTP Upload task](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/ftp-upload?view=azure-devops&WT.mc_id=DT-MVP-4015686) 來用，但我們檔案明明就上傳完畢了，現在不是要上傳，而是要透過 FTP 命令**刪除**一個檔案，但又不需要上傳任何新的檔案。

如果你這樣設定：

-   Root folder: `$(Build.SourcesDirectory)`
-   File patterns: `NOTHING*`

你的 **File patterns** 寫成 `NOTHING*` 是因為故意讓 FTP Upload task 找不到檔案，這樣就不會上傳檔案了。但悲劇的是，只要沒有任何檔案需要上傳，FTP 也完全就不會建立連線，這也意味著你的 **FTP Commands** 設定了也沒用，根本不會執行，雷阿～～

所以結論是，你必須重新上傳一個**小檔案**上去，只為了讓 **FTP Commands** 可以被順利執行而已。有點髒，但有用！

### 正確使用 Azure Pipelines 內建的 FTP Upload Task 發行 ASP.NET Core 應用程式

我們專案的需求是這樣的，當 ASP.NET Core 透過 `dotnet publish` 發行完畢後，要透過 FTP 上傳到目標網站進行自動化更版，但是在正式更版之前，要先上傳一份 `app_offline.htm` 將網站暫時封閉，然後才能正確正常發行檔案。

> 以下步驟我直接忽略所有 ASP.NET Core 相關的發行步驟，僅呈現 FTP 上傳部分的設定。

1.  使用 PowerShell 工作，建立 `app_offline.htm` 檔案
    
    ```powershell
    echo "" > App_Offline.htm
    ```
    
2.  使用 FTP Upload v2 工作，上傳 `App_Offline.htm` 檔案
    
    Root folder: `$(Build.SourcesDirectory)`
    
    File patterns: `App_Offline.htm`
    
3.  使用 PowerShell 工作，等待 15 秒讓網站確實關閉
    
    ```powershell
    Start-Sleep -s 15
    ```
    
4.  使用 FTP Upload v2 工作，上傳所有需要發布的檔案檔案
    
    Root folder: `$(Build.ArtifactStagingDirectory)`
    
    File patterns: `**`
    
5.  使用 PowerShell 工作，將 `app_offline.htm` 更名為 `App_Offline_OFF.htm` 檔案
    
    ```powershell
    if (Test-Path -Path .\App_Offline_OFF.htm)
    {
        Remove-Item App_Offline_OFF.htm
    }
    Rename-Item .\App_Offline.htm -NewName App_Offline_OFF.htm
    ```
    
6.  使用 FTP Upload v2 工作，刪除 `App_Offline.htm` 並上傳 `App_Offline_OFF.htm` 檔案
    
    Root folder: `$(Build.SourcesDirectory)`
    
    File patterns: `App_Offline_OFF.htm`
    
    FTP Commands:
    
    ```bash
    CWD /site/wwwroot
    DELE App_Offline.htm
    ```
    

以下是完整的 `azure-pipeline.yml` 範例：

```yaml
pool:
  name: Azure Pipelines
steps:
- powershell: 'echo "" > App_Offline.htm'
  displayName: 'PowerShell: Create App_Offline.htm'

- task: FtpUpload@2
  displayName: 'FTP Upload: Add App_Offline.htm'
  inputs:
    serverEndpoint: mocky.azurewebsites.net
    rootDirectory: '$(Build.SourcesDirectory)'
    filePatterns: 'App_Offline.htm'
    remoteDirectory: /site/wwwroot

- powershell: 'Start-Sleep -s 15'
  displayName: 'PowerShell: Sleep for 15 seconds'

- task: FtpUpload@2
  displayName: 'FTP Upload: Publish'
  inputs:
    serverEndpoint: mocky.azurewebsites.net
    rootDirectory: '$(Build.SourcesDirectory)'
    remoteDirectory: /site/wwwroot
    preservePaths: true

- powershell: |
   if (Test-Path -Path .\App_Offline_OFF.htm)
   {
       Remove-Item App_Offline_OFF.htm
   }
   Rename-Item .\App_Offline.htm -NewName App_Offline_OFF.htm
  displayName: 'PowerShell: Rename App_Offline.htm to App_Offline_OFF.htm'

- task: FtpUpload@2
  displayName: 'FTP Upload: Remove App_Offline.htm'
  inputs:
    serverEndpoint: mocky.azurewebsites.net
    rootDirectory: '$(Build.SourcesDirectory)'
    filePatterns: 'App_Offline_OFF.htm'
    remoteDirectory: /site/wwwroot
    enableUtf8: true
    customCmds: |
     CWD /site/wwwroot
     DELE App_offline.htm
```

### 相關連結

-   [FTP Upload task - Azure Pipelines | Microsoft Docs](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/ftp-upload?view=azure-devops&WT.mc_id=DT-MVP-4015686)
-   [File matching patterns reference - Azure Pipelines | Microsoft Docs](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/file-matching-patterns?view=azure-devops&WT.mc_id=DT-MVP-4015686)
-   [azure-pipelines-tasks/ftpuploadtask.ts at master · microsoft/azure-pipelines-tasks](https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/FtpUploadV2/ftpuploadtask.ts)
-   [Predefined variables - Azure Pipelines | Microsoft Docs](https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&WT.mc_id=DT-MVP-4015686&tabs=yaml)
-   [Start-Sleep (Microsoft.PowerShell.Utility) - PowerShell | Microsoft Docs](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/start-sleep?view=powershell-7.1&WT.mc_id=DT-MVP-4015686)
-   [YAML 結構描述 - Azure Pipelines | Microsoft Docs](https://learn.microsoft.com/zh-tw/azure/devops/pipelines/yaml-schema?view=azure-devops&WT.mc_id=DT-MVP-4015686&tabs=schema%2Cparameter-schema)
