Mert Özen Her Satırda Daha İleri
DevOps PowerShell Scripting — #06

Gerçek İşler: Dosya Sistemi, REST API, JSON/CSV, Scheduled Task, Windows Service, IIS ve Windows'ta Network Teşhisi

Mert Özen 15 Kas 2026 16 dk 6 görüntülenme
Gerçek İşler: Dosya Sistemi, REST API, JSON/CSV, Scheduled Task, Windows Service, IIS ve Windows'ta Network Teşhisi

ConvertTo-Json varsayılan olarak yalnız 2 seviye derine iner; API'ye gönderilen iç içe gövde sessizce "System.Collections.Hashtable" string'ine dönüşür, bu yüzden her gövdeye -Depth ver. Bearer token, -MaximumRetryCount'un 401'de bile POST'u tekrarlaması, $PSScriptRoot ve CSV encoding, Windows Service, scheduled task, IIS ve network komutları, sonunda Bookstore.Api için yedek alan ve geri dönebilen bir deploy script'i.

Sürüm notu: Bu yazıdaki örnekler PowerShell 7.6.10 (LTS) ve PowerShell 7.7.0 ile, Ubuntu 24.04 üzerinde .NET 10 SDK (10.0.404) ve EF (Entity Framework; .NET'in veritabanı erişim library'si) Core 10.0.15 ile denenmiştir; çıktılar iki sürümde aynı, yalnız .NET 11 üzerinde çalışan 7.7'de bulunamayan dosyanın hata metni "Could not find file" biçiminde. Windows Service, scheduled task, IIS (Windows'un web sunucusu) ve Windows'a özgü network komutları Microsoft Learn dokümanlarından aktarıldı, çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) back office'inde fiyat güncellemesi bir admin endpoint'iyle yapılıyor. Script, isteği bir hashtable'dan JSON'a çevirip bearer token ile gönderiyor:

$body = @{
    updates = @(
        @{ isbn = '9780441172719'; price = @{ amount = 13.49; currency = 'TRY' } }
    )
}
$json = $body | ConvertTo-Json
$json

$token = ConvertTo-SecureString -String $env:BOOKSTORE_ADMIN_TOKEN -AsPlainText -Force
Invoke-RestMethod -Uri http://127.0.0.1:5080/api/admin/prices -Method Post -Body $json `
    -ContentType 'application/json' -Authentication Bearer -Token $token -AllowUnencryptedAuthentication

Hashtable'da her şey yerinde: ISBN (kitabın uluslararası standart numarası), tutar ve para birimi. API gövdede ne görür, istek başarılı olur mu?

Önceki yazıda Bookstore.Ops module'ünü kurduk. Bu yazı script'lerin günlük işini ele alıyor: REST (HTTP üzerinde kaynak odaklı API tarzı) API'ler ve JSON, retry, dosya yolları ve CSV (virgülle ayrılmış metin dosyası), Windows Service'leri, scheduled task, IIS ve Windows'ta network teşhisi. Sonunda hepsini Bookstore.Api için yedek alan, app_offline ile siteyi kapatan, migration çalıştıran, health check yapan ve başarısızlıkta geri dönen bir deploy script'inde birleştiriyoruz.

REST ve JSON: -Depth, Bearer Token ve Hata Gövdesi

Tahminin cevabı:

PS> ./lab06/update-prices.ps1
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{
  "updates": [
    {
      "price": "System.Collections.Hashtable",
      "isbn": "9780441172719"
    }
  ]
}
Invoke-RestMethod: /home/user/bookstore/lab06/update-prices.ps1:10
Line |
  10 |  Invoke-RestMethod -Uri http://127.0.0.1:5080/api/admin/prices -Method …
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Response status code does not indicate success: 400 (Bad Request).

ConvertTo-Json'un varsayılan derinliği 2. Üçüncü seviyedeki price hashtable'ı JSON object'i yerine ToString() sonucuna, yani "System.Collections.Hashtable" string'ine dönüştü. PowerShell 7 bunu bir uyarıyla söylüyor, ama uyarı CI log'unda kolayca kaybolur; API ise beklediği object yerine string gördüğü için 400 döndü. Düzeltme -Depth vermek:

PS> $body = @{ updates = @( @{ isbn = '9780441172719'; price = @{ amount = 13.49; currency = 'TRY' } } ) }
PS> $body | ConvertTo-Json -Depth 5 -Compress
{"updates":[{"isbn":"9780441172719","price":{"currency":"TRY","amount":13.49}}]}
PS> $request = @{ Uri = 'http://127.0.0.1:5080/api/admin/prices'; Method = 'Post'; ContentType = 'application/json' }
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -Authentication Bearer -Token $token -AllowUnencryptedAuthentication

updated currency
------- --------
      1 {TRY}
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -Authentication Bearer -Token $token
Invoke-RestMethod: The cmdlet cannot protect plain text secrets sent over unencrypted connections. To suppress this warning and send plain text secrets over unencrypted networks, reissue the command specifying the AllowUnencryptedAuthentication parameter.
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5)
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Missing or invalid bearer token","status":401}
PS> try { Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) } catch { $_.Exception.Response.StatusCode; ($_.ErrorDetails.Message | ConvertFrom-Json).title }
Unauthorized
Missing or invalid bearer token
PS> $response = Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -SkipHttpErrorCheck -StatusCodeVariable status; "$status $($response.title)"
401 Missing or invalid bearer token

Birkaç şey birlikte görünüyor. -Depth 5 ile gövde doğru gitti ve API güncellemeyi kabul etti. Hashtable'daki key'lerin sırası garanti değil: aynı komutu üç ayrı oturumda çalıştırınca JSON'daki alan sırası her seferinde değişti; sıra önemliyse [ordered] kullan. Token, -Authentication Bearer -Token ile SecureString olarak veriliyor; Authorization header'ını elle kurmuyoruz. Adres https:// ile başlamıyorsa PowerShell token'ı göndermeyi reddediyor, lab'daki -AllowUnencryptedAuthentication yalnız 127.0.0.1 için. Token'sız istek 401 aldı ve hata mesajında API'nin döndürdüğü problem details gövdesi var. Bu gövdeyi catch içinde $_.ErrorDetails.Message'dan, durum kodunu $_.Exception.Response.StatusCode'dan okuyabilirsin. Exception yerine sonucu kendin değerlendirmek istiyorsan, birinci yazıdaki gibi -SkipHttpErrorCheck ve -StatusCodeVariable daha okunur.

Retry: -MaximumRetryCount Neyi Tekrar Dener?

Bookstore'un günlük raporu ilk isteklerde 503 dönüyor, rapor hazırlanınca 200. Invoke-RestMethod'un kendi retry parametreleri var:

PS> Invoke-RestMethod http://127.0.0.1:5080/api/reports/daily
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Report is being generated","status":503}
PS> Invoke-RestMethod http://127.0.0.1:5080/api/reports/daily -MaximumRetryCount 3 -RetryIntervalSec 2 -Verbose
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5080/api/reports/daily
VERBOSE: WebResponse: 503 Service Unavailable with application/problem+json payload
VERBOSE: Retrying after interval of 2 seconds. Status code for previous attempt: ServiceUnavailable
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5080/api/reports/daily
VERBOSE: WebResponse: 200 OK with application/json payload

date       orders revenue
----       ------ -------
2026-11-15     42 1234.50

İlk istek 503 ile düştü, ikincisi bir kez tekrar deneyip raporu aldı. Dokümana göre tekrar deneme 400 ile 599 arasındaki her durum kodunda ve 304'te yapılıyor; 429 cevabında Retry-After header'ı varsa bekleme süresi oradan alınıyor. "Her durum kodunda" kısmını lab'da denedim:

PS> $sw = [System.Diagnostics.Stopwatch]::StartNew()
PS> Invoke-RestMethod http://127.0.0.1:5080/api/admin/prices -Method Post -Body '{"updates":[]}' -ContentType application/json -MaximumRetryCount 2 -RetryIntervalSec 1 -Verbose
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
VERBOSE: Retrying after interval of 1 seconds. Status code for previous attempt: Unauthorized
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
VERBOSE: Retrying after interval of 1 seconds. Status code for previous attempt: Unauthorized
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Missing or invalid bearer token","status":401}
PS> Invoke-RestMethod http://127.0.0.1:5081/health -MaximumRetryCount 2 -RetryIntervalSec 1 -Verbose
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5081/health
Invoke-RestMethod: Connection refused
PS> '{0:N1} s' -f $sw.Elapsed.TotalSeconds
2.2 s

401, geçici bir hata değil, ama POST üç kez gönderildi. Bağlantı hiç kurulamadığında ise (connection refused) retry yapılmadı ve komut hemen hata verdi. Yani bu parametreler, geçici hataları ayırt eden bir retry politikası değil. Idempotent olmayan bir POST'ta retry açmak, 500 dönen ama işi aslında yapmış bir API'de aynı güncellemeyi iki kez uygulatabilir. Retry'ı GET ve idempotent işlerde kullan; bağlantı hataları ve belirli durum kodları için kendi döngünü yaz. Bu yazının deploy script'indeki health check döngüsü böyle bir örnek.

Dosya Yolları ve CSV: $PSScriptRoot ve Encoding

İlk yazıda $PSScriptRoot'tan kısaca söz etmiştik. Relative bir path, script'in bulunduğu klasöre göre değil, çalışma dizinine göre çözülür:

param([string] $Path = './data/books.csv')
$books = Import-Csv -Path $Path
"$($books.Count) titles, $(($books | Measure-Object -Property Stock -Sum).Sum) books in stock"
param([string] $Path = (Join-Path -Path $PSScriptRoot -ChildPath '..', 'data', 'books.csv'))
$books = Import-Csv -Path $Path
"$($books.Count) titles, $(($books | Measure-Object -Property Stock -Sum).Sum) books in stock"
PS> ./lab06/stock-count.ps1
12 titles, 100 books in stock
PS> Set-Location ~
PS> ./bookstore/lab06/stock-count.ps1
Import-Csv: /home/user/bookstore/lab06/stock-count.ps1:2
Line |
   2 |  $books = Import-Csv -Path $Path
     |           ~~~~~~~~~~~~~~~~~~~~~~
     | Could not find a part of the path '/home/user/data/books.csv'.
0 titles,  books in stock
PS> ./bookstore/lab06/stock-count-fixed.ps1
12 titles, 100 books in stock
PS> Set-Location ~/bookstore
PS> Import-Csv ./lab06/warehouse-stock.csv -Delimiter ';'

Warehouse Isbn          Quantity
--------- ----          --------
�stanbul  9780441172719 14
�zmir     9780141439587 3
PS> Import-Csv ./lab06/warehouse-stock.csv -Delimiter ';' -Encoding 1254

Warehouse Isbn          Quantity
--------- ----          --------
İstanbul  9780441172719 14
İzmir     9780141439587 3
PS> Get-Service
Get-Service: The term 'Get-Service' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

Script kendi klasöründen çalışınca sorun yok. Ev klasöründen çalışınca ./data/books.csv /home/user/data'ya çözüldü; üstelik script hatadan sonra devam edip "0 titles" yazdı, çünkü $ErrorActionPreference burada Stop değil. $PSScriptRoot ile kurulan path her yerden çalıştı. Scheduled task ve CI, script'i senin beklediğin klasörden çalıştırmaz; script'teki her path'i $PSScriptRoot'tan kur.

Aynı çıktıdaki CSV örneği, eski bir Windows aracının depo sayım dosyası. Dosya Windows-1254 (Türkçe Windows kod sayfası) ile yazılmış; PowerShell 7 varsayılan olarak Unicode (UTF-8) okuduğu için İ harfleri bozuldu. -Encoding 1254 ile doğru okundu. Ters yönü, Türkçe Excel'in açabileceği noktalı virgüllü ve BOM'lu (dosya başındaki kodlama işareti) CSV üretmeyi ikinci yazıda yapmıştık; konunun ayrıntısı .NET'te I/O İşlemleri serisinin "CSV ile Çalışmak" yazısında.

Windows Service ve Scheduled Task

Bu bölümdeki komutlar yalnız Windows'ta var; lab'daki Linux'ta Get-Service bile tanımlı değil (yukarıdaki çıktının son satırı). Microsoft'un PowerShell 7 module uyumluluk listesinde ScheduledTasks "Natively Compatible" olarak geçiyor, yani PowerShell 7'de doğrudan çalışıyor:

# Yalnız Windows, çalıştırılmadı: Bookstore'un bildirim worker'ı bir Windows Service olarak çalışıyor
Get-Service -Name BookstoreNotifications | Select-Object Name, Status, StartType
Restart-Service -Name BookstoreNotifications -Force
(Get-Service -Name BookstoreNotifications).WaitForStatus('Running', [timespan]::FromSeconds(30))

# Her gece 02:30'da SYSTEM olarak yedek; module -Scope AllUsers ile kurulmalı
$command = 'Import-Module Bookstore.Ops -RequiredVersion 0.2.0; ' +
    'Invoke-BookstoreBackup -Path C:\inetpub\bookstore-api -Destination D:\backups\bookstore-api -KeepLast 14 -ErrorAction Stop'
$task = @{
    TaskName  = 'Bookstore nightly backup'
    TaskPath  = '\Bookstore\'
    Action    = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument "-NoProfile -NonInteractive -Command `"$command`""
    Trigger   = New-ScheduledTaskTrigger -Daily -At '02:30'
    Principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
    Settings  = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) -StartWhenAvailable
}
Register-ScheduledTask @task
Start-ScheduledTask -TaskPath '\Bookstore\' -TaskName 'Bookstore nightly backup'
Get-ScheduledTaskInfo -TaskPath '\Bookstore\' -TaskName 'Bookstore nightly backup' | Select-Object LastRunTime, LastTaskResult

Bookstore'un bildirim gönderen worker'ı bir Windows Service; deploy sonrası yeniden başlatıp Running olmasını beklemek WaitForStatus ile tek satır. Gece yedeği için action, trigger, principal ve settings ayrı ayrı kuruluyor ve splatting ile Register-ScheduledTask'a veriliyor. İki ayrıntı önemli: task SYSTEM hesabıyla çalıştığı için module'ün -Scope CurrentUser ile değil, bütün kullanıcılar için kurulmuş olması gerekiyor; ve -Command ile çalışan pwsh, birinci yazıda gördüğümüz gibi 0 ve 1 dışındaki exit code'ları 1'e çeviriyor. Sonucu Get-ScheduledTaskInfo'daki LastTaskResult'tan izle.

IIS'i Script'le Yönetmek

IIS yönetimi için iki yol var. IISAdministration module'ü Windows PowerShell 5.1 için yazıldı ve uyumluluk listesinde "Untested with Compatibility Layer" olarak geçiyor. PowerShell 7'de Import-Module IISAdministration -UseWindowsPowerShell ile arka planda bir 5.1 oturumunda çalışabiliyor, ama o zaman dönen object'ler deserialize edilmiş kopyalar oluyor ve method'ları kalmıyor; remoting yazısında bu davranışı ayrıntılı göreceğiz. Microsoft'un IIS destek blogu PowerShell 7 için Microsoft.Web.Administration API'sini doğrudan yüklemeyi öneriyor; önceki yazıdaki Restart-BookstorePool da bu yolu kullanıyor:

# Yalnız Windows, yönetici olarak, çalıştırılmadı
Add-Type -Path "$env:SystemRoot\System32\inetsrv\Microsoft.Web.Administration.dll"
$manager = [Microsoft.Web.Administration.ServerManager]::new()
$manager.Sites['bookstore-api'] | Select-Object Name, State
$manager.ApplicationPools['BookstoreApi'] | Select-Object Name, State, ManagedRuntimeVersion
$manager.Sites['bookstore-api'].Stop()
$manager.Sites['bookstore-api'].Start()
$manager.Dispose()

Hosting Bundle, ASP.NET Core Module ve app pool ayarlarının kendisi Web Server'lar ve Hosting serisinin 9. ve 10. yazılarında. Burada önemli olan bir davranış: ASP.NET Core Module, site klasöründe app_offline.htm gördüğünde uygulamayı durdurur ve gelen isteklere bu dosyayı döndürür; dosya silinince ilk istek uygulamayı yeniden başlatır. Deploy için app pool'u durdurmak gerekmez.

Windows'ta Network Teşhisi

Linux ve Shell serisinin "Network Komutları" yazısındaki "port açık mı, kim dinliyor" sorularının Windows karşılıkları NetTCPIP ve DnsClient module'lerinde; ikisi de uyumluluk listesinde PowerShell 7 ile doğrudan çalışıyor:

# Yalnız Windows, çalıştırılmadı
Test-NetConnection -ComputerName sql1 -Port 1433
Resolve-DnsName -Name api.bookstore.test -Type A
Get-NetTCPConnection -LocalPort 5080 -State Listen | Select-Object LocalAddress, LocalPort, OwningProcess
Get-Process -Id (Get-NetTCPConnection -LocalPort 5080 -State Listen).OwningProcess

Test-NetConnection sonucundaki TcpTestSucceeded port'un açık olup olmadığını, Get-NetTCPConnection bir port'u hangi process'in dinlediğini söylüyor. Bu üç komut Linux'ta yok; script'in iki platformda da çalışması gerekiyorsa PowerShell 7'nin kendi Test-Connection -TcpPort'u ve .NET'in Dns class'ı var:

PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5080
True
PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5081
False
PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5080 -Detailed

   Target: 127.0.0.1

  Id Source           Address                      Port Latency Connected  Status
                                                           (ms)
  -- ------           -------                      ---- ------- ---------  ------
   1 lab              127.0.0.1                    5080       0 True       Success
PS> [System.Net.Dns]::GetHostAddresses('localhost').IPAddressToString
::1
127.0.0.1
PS> Get-Command Test-NetConnection, Resolve-DnsName, Get-NetTCPConnection -ErrorAction SilentlyContinue

Bookstore: Yedekli ve Geri Dönebilen Deploy Script'i

Dördüncü yazıdaki build script'i artifacts/api'yi üretiyor. Deploy script'i bu klasörü bir IIS sitesine kuruyor: önce mevcut siteyi yedekliyor, app_offline.htm ile siteyi kapatıyor, dosyaları kopyalıyor, EF Core migration bundle'ını çalıştırıyor, siteyi açıyor ve health check yapıyor. Herhangi bir adım başarısız olursa dosyaları yedekten geri yüklüyor. Health check için module'e yeni bir function ekledik, module sürümü de 0.2.0 oldu:

function Wait-BookstoreHealthy {
    [CmdletBinding(PositionalBinding = $false)]
    [OutputType('Bookstore.Health')]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^https?://', ErrorMessage = "'{0}' is not an http or https address.")]
        [string] $BaseUrl,

        [ValidateRange(1, 3600)]
        [int] $TimeoutSec = 60,

        [ValidateRange(1, 60)]
        [int] $IntervalSec = 2
    )

    $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSec)
    $attempt = 0
    do {
        $attempt++
        $result = Get-BookstoreHealth -BaseUrl $BaseUrl -TimeoutSec 5
        $detail = if ($result.Error) { $result.Error } else { "HTTP $($result.StatusCode) $($result.Status)" }
        Write-Information "    health attempt ${attempt}: $detail"
        if ($result.Healthy) { return $result }
        Start-Sleep -Seconds $IntervalSec
    } while ([datetime]::UtcNow -lt $deadline)
    $result
}
#!/usr/bin/env pwsh
#Requires -Version 7.6

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -Path $_ -PathType Container }, ErrorMessage = "Package folder '{0}' does not exist.")]
    [string] $PackagePath,

    [Parameter(Mandatory)]
    [string] $SitePath,

    [ValidateScript({ Test-Path -Path $_ -PathType Leaf }, ErrorMessage = "Migration bundle '{0}' does not exist.")]
    [string] $MigrationBundle,

    [string] $BackupPath = (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'backups'),

    [ValidatePattern('^https?://')]
    [string] $HealthUrl = 'http://127.0.0.1:5080',

    [ValidateRange(5, 600)]
    [int] $HealthTimeoutSec = 60
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..', 'modules', 'Bookstore.Ops') -Force

$offlineFile = Join-Path -Path $SitePath -ChildPath 'app_offline.htm'
$backup = $null
$step = 'backup'

try {
    if (Test-Path -Path (Join-Path -Path $SitePath -ChildPath '*')) {
        # Module function'ları bu script'in -WhatIf'ini görmez: açıkça aktar
        $backup = Invoke-BookstoreBackup -Path $SitePath -Destination $BackupPath -KeepLast 5 -WhatIf:$WhatIfPreference
        if ($backup) { Write-Output "==> backup: $($backup.Name)" }
    }

    $step = 'offline'
    if ($PSCmdlet.ShouldProcess($SitePath, 'Take the site offline (app_offline.htm)')) {
        Set-Content -Path $offlineFile -Value '<!doctype html><title>Bookstore</title><p>Bookstore is being updated. Please try again in a minute.</p>'
        Write-Output '==> app_offline.htm written'
    }

    $step = 'copy'
    if ($PSCmdlet.ShouldProcess($SitePath, "Copy files from $PackagePath")) {
        Copy-Item -Path (Join-Path -Path $PackagePath -ChildPath '*') -Destination $SitePath -Recurse -Force
        Write-Output "==> copied $((Get-ChildItem -Path $PackagePath -Recurse -File).Count) files"
    }

    $step = 'migrate'
    if ($MigrationBundle -and $PSCmdlet.ShouldProcess($MigrationBundle, 'Apply EF Core migrations')) {
        if (-not $env:BOOKSTORE_DB_CONNECTION) { throw 'BOOKSTORE_DB_CONNECTION is not set.' }
        Write-Output '==> migrations'
        & $MigrationBundle --connection $env:BOOKSTORE_DB_CONNECTION
    }

    $step = 'online'
    if ($PSCmdlet.ShouldProcess($SitePath, 'Bring the site online (remove app_offline.htm)')) {
        Remove-Item -Path $offlineFile
        Write-Output '==> app_offline.htm removed'
    }

    $step = 'health'
    if (-not $WhatIfPreference) {
        $health = Wait-BookstoreHealthy -BaseUrl $HealthUrl -TimeoutSec $HealthTimeoutSec -InformationAction Continue
        if (-not $health.Healthy) { throw "Health check failed for $($health.Url)" }
    }
    Write-Output ($WhatIfPreference ? 'WhatIf: nothing was changed' : 'Deploy OK')
}
catch {
    Write-Output "FAILED at step '$step': $($_.Exception.Message)"
    # Dosyalar geri yüklenir; çalışmış bir migration veritabanında kalır
    if ($backup -and $step -ne 'offline') {
        Set-Content -Path $offlineFile -Value 'Bookstore is being restored.'
        Expand-Archive -Path $backup.FullName -DestinationPath $SitePath -Force
        Remove-Item -Path $offlineFile -ErrorAction Ignore
        Write-Output "Restored $SitePath from $($backup.Name)"
    }
    exit 1
}

Her değişiklik adımı ShouldProcess ile korunuyor, böylece deploy önce -WhatIf ile log'a yazılabiliyor. $step variable'ı, hatanın hangi adımda olduğunu ve geri yükleme gerekip gerekmediğini söylüyor. Migration bundle'ın connection string'i parametreyle değil environment variable ile geliyor; komut satırına yazılan bir parametre process listesinde ve CI log'unda görünür. Lab'da IIS yok: app_offline.htm'i görüp uygulamayı durduran ASP.NET Core Module burada çalışmıyor, bu yüzden API'yi site klasöründen kendim başlattım. Bookstore'un veritabanı SQL Server; lab'da migration bundle'ı SQLite ile ürettim, script tarafı aynı. Önce -WhatIf, sonra gerçek deploy:

$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082 -WhatIf
What if: Performing the operation "Back up /home/user/site" on target "/home/user/bookstore/backups/bookstore-20261115-061137.zip".
What if: Performing the operation "Take the site offline (app_offline.htm)" on target "/home/user/site".
What if: Performing the operation "Copy files from ./artifacts/api" on target "/home/user/site".
What if: Performing the operation "Apply EF Core migrations" on target "./artifacts/efbundle".
What if: Performing the operation "Bring the site online (remove app_offline.htm)" on target "/home/user/site".
WhatIf: nothing was changed
$ ls backups ~/site/app_offline.htm
ls: cannot access 'backups': No such file or directory
ls: cannot access '/home/user/site/app_offline.htm': No such file or directory
$ export BOOKSTORE_DB_CONNECTION="Data Source=$HOME/data/bookstore.db"
$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082; echo "exit=$?"
==> backup: bookstore-20261115-061138.zip
==> app_offline.htm written
==> copied 9 files
==> migrations
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261115060804_Initial'.
Done.
==> app_offline.htm removed
    health attempt 1: HTTP 200 Healthy
Deploy OK
exit=0

-WhatIf çalıştırmasında yedek alınmadı ve app_offline.htm oluşmadı. Gerçek deploy'da migration uygulandı ve health check ilk denemede geçti. Şimdi API'yi sağlıksız yapıp deploy'u tekrarlıyorum:

$ touch /tmp/bookstore-unhealthy
$ export BOOKSTORE_DB_CONNECTION="Data Source=$HOME/data/bookstore.db"
$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082 -HealthTimeoutSec 6; echo "exit=$?"
==> backup: bookstore-20261115-061140.zip
==> app_offline.htm written
==> copied 9 files
==> migrations
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
No migrations were applied. The database is already up to date.
Done.
==> app_offline.htm removed
    health attempt 1: HTTP 503 Unhealthy
    health attempt 2: HTTP 503 Unhealthy
    health attempt 3: HTTP 503 Unhealthy
FAILED at step 'health': Health check failed for http://127.0.0.1:5082/health
Restored /home/user/site from bookstore-20261115-061140.zip
exit=1
$ rm /tmp/bookstore-unhealthy
$ ls backups
bookstore-20261115-061138.zip
bookstore-20261115-061140.zip

Health check altı saniye boyunca 503 aldı, script dosyaları deploy öncesinde aldığı yedekten geri yükledi ve 1 ile çıktı. Migration bundle bu sefer "already up to date" dedi; bir migration uygulanmış olsaydı geri alınmayacaktı. Bu yüzden migration'ların eski kodla da çalışacak biçimde, yani önce genişletip sonra daraltarak yazılması gerekiyor. Sunucuları sırayla güncellemeyi Deployment Stratejileri serisinin "Rolling Deployment" yazısında anlatmıştık; yedinci yazıda aynı script'i iki sunucuya remoting ile çalıştıracağız.

Üretimde Dikkat

Deploy script'i ne kadar kısa sürüyorsa site o kadar kısa kapalı kalır. Kopyalanacak dosyaları ve migration bundle'ı önceden sunucuya getir, app_offline.htm ile açılan pencerede yalnız kopyalama ve migration olsun. IIS'te kilitli dosyalar ve Application Initialization için Web Server'lar ve Hosting serisinin 10. yazısına bak.

Health check, deploy'un parçası. Script'in exit code'u yalnız "dosyalar kopyalandı" demek değil, "yeni sürüm cevap veriyor" demek olmalı. Health endpoint'in veritabanına ve bağımlı servislere gerçekten dokunuyorsa bu kontrol daha anlamlı olur.

Secret'ları komut satırına koyma. Bearer token ve connection string environment variable ya da bir secret store'dan gelmeli; -Token bir SecureString istiyor ve düz HTTP'de token göndermeyi reddediyor. Credential'ları script'e gömmeden uzak sunucuya taşımayı yedinci yazıda göreceğiz.

Sık Düşülen Tuzaklar

ConvertTo-Json'un varsayılan derinliği. Varsayılan derinlik 2: daha derindeki her object, uyarıyla birlikte string'e dönüşür. API'ye gövde gönderen her ConvertTo-Json'a -Depth ver; uyarıları -WarningAction Stop ile hataya da çevirebilirsin.

Module function'ının script'in -WhatIf'ini görmesini beklemek. Önceki yazıdaki preference tuzağı burada da geçerli. Deploy script'inin ilk taslağında backup çağrısına -WhatIf:$WhatIfPreference yazmamıştım; konsoldan -WhatIf ile çalıştırınca:

PS> ./lab06/Deploy-Draft.ps1 -PackagePath ./artifacts/api -SitePath ~/site -WhatIf
==> backup: bookstore-20261115-061202.zip
What if: Performing the operation "Take the site offline (app_offline.htm)" on target "~/site".
What if: Performing the operation "Copy files from ./artifacts/api" on target "~/site".
What if: Performing the operation "Bring the site online (remove app_offline.htm)" on target "~/site".
WhatIf: nothing was changed
PS> (Get-ChildItem ./backups).Name
bookstore-20261115-061202.zip

Script "nothing was changed" dedi ama gerçek bir yedek aldı. Copy-Item ve Remove-Item gibi binary cmdlet'ler -WhatIf'i görüyor, script module'deki function'lar görmüyor. Module function'larına -WhatIf:$WhatIfPreference ve gerekirse -Confirm:$ConfirmPreference değerlerini açıkça geçir.

Relative path. Script'teki ./data çalışma dizinine göre çözülür ve scheduled task'ın çalışma dizini çoğu zaman C:\Windows\System32'dir. Her path'i $PSScriptRoot'tan kur.

Küçük Bir Deneme

★☆☆ Fiyat güncelleme gövdesine her güncelleme için bir history array'i ekle (@(@{ from = 12.99; to = 13.49 })). -Depth 5 hâlâ yetiyor mu? ConvertTo-Json'un uyarısını -WarningAction Stop ile hataya çevirip dene.

★★☆ Günlük raporu çeken bir Get-BookstoreDailyReport function'ı yaz: yalnız 503 ve 429'da, en fazla 5 kez, her seferinde bekleme süresini iki katına çıkararak tekrar denesin; 401 ve 404'te hemen hata versin. -MaximumRetryCount kullanmadan, -SkipHttpErrorCheck ile.

★★★ Deploy script'ine bir -KeepOffline switch'i ekle: health check başarısız olursa dosyaları geri yüklemek yerine app_offline.htm'i bırakıp çıksın. Hangi durumda geri yüklemek, hangi durumda siteyi kapalı tutmak daha doğru? Migration'ı geri alınamaz bir şema değişikliği yapan bir sürümle ikisini karşılaştır.

Deploy script'i tek sunucuda çalışıyor. Bir sonraki yazıda aynı işi uzaktaki sunuculara taşıyoruz: SSH (şifreli uzak bağlantı protokolü) tabanlı remoting, Invoke-Command, session'lar, Copy-Item -ToSession ve credential'ları script'e gömmeden SecretManagement ile kullanmak.