summaryrefslogtreecommitdiff
path: root/deploy.ps1
diff options
context:
space:
mode:
Diffstat (limited to 'deploy.ps1')
-rw-r--r--deploy.ps1314
1 files changed, 314 insertions, 0 deletions
diff --git a/deploy.ps1 b/deploy.ps1
new file mode 100644
index 0000000..970bacd
--- /dev/null
+++ b/deploy.ps1
@@ -0,0 +1,314 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$siteRoot = $PSScriptRoot
+$configPath = $null
+$localArchive = $null
+$remoteArchive = $null
+$remoteArchiveMayExist = $false
+$deploymentSucceeded = $false
+$failureMessage = $null
+$locationPushed = $false
+$nativeCommands = @{}
+$sshOptions = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
+
+function Get-NativeCommandPath {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ $command = Get-Command -Name $Name -CommandType Application -ErrorAction Stop
+ return $command.Source
+}
+
+function Invoke-CheckedNativeCommand {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Command,
+
+ [Parameter()]
+ [string[]]$Arguments = @(),
+
+ [Parameter(Mandatory)]
+ [string]$Operation
+ )
+
+ & $Command @Arguments
+ $exitCode = $LASTEXITCODE
+ if ($exitCode -ne 0) {
+ throw "$Operation failed with exit code $exitCode."
+ }
+}
+
+function Invoke-CheckedSshScript {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Command,
+
+ [Parameter(Mandatory)]
+ [string[]]$Options,
+
+ [Parameter(Mandatory)]
+ [string]$Target,
+
+ [Parameter(Mandatory)]
+ [string]$Script
+ )
+
+ $Script | & $Command @Options $Target "sh -s"
+ $exitCode = $LASTEXITCODE
+ if ($exitCode -ne 0) {
+ throw "Remote release activation failed with exit code $exitCode."
+ }
+}
+
+try {
+ if ($args.Count -gt 0) {
+ throw "deploy.ps1 does not accept arguments. Edit deploy.config.psd1 instead."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($siteRoot)) {
+ throw "Unable to determine the site root. Run deploy.ps1 from a saved file."
+ }
+
+ $siteRoot = (Resolve-Path -LiteralPath $siteRoot).Path
+ if (-not (Test-Path -LiteralPath (Join-Path $siteRoot "zola.toml") -PathType Leaf)) {
+ throw "zola.toml was not found next to deploy.ps1."
+ }
+
+ $configPath = Join-Path $siteRoot "deploy.config.psd1"
+ if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
+ throw (
+ "deploy.config.psd1 was not found. " +
+ "Copy deploy.config.example.psd1 and enter the local deployment settings."
+ )
+ }
+
+ $deployConfig = Import-PowerShellDataFile -LiteralPath $configPath
+ if ($deployConfig -isnot [System.Collections.IDictionary]) {
+ throw "deploy.config.psd1 must contain a PowerShell data hashtable."
+ }
+
+ $requiredConfigKeys = @(
+ "DeployHost",
+ "DeployUser",
+ "RemoteBase",
+ "IdentityFile",
+ "SiteUrl"
+ )
+ foreach ($key in $requiredConfigKeys) {
+ if (-not $deployConfig.Contains($key)) {
+ throw "deploy.config.psd1 is missing the required key: $key"
+ }
+ }
+
+ $DeployHost = ([string]$deployConfig["DeployHost"]).Trim()
+ $DeployUser = ([string]$deployConfig["DeployUser"]).Trim()
+ $RemoteBase = ([string]$deployConfig["RemoteBase"]).Trim()
+ $IdentityFile = ([string]$deployConfig["IdentityFile"]).Trim()
+ $siteUrl = ([string]$deployConfig["SiteUrl"]).Trim()
+
+ if ($DeployHost -notmatch '^[A-Za-z0-9._-]+$') {
+ throw "DeployHost contains unsupported characters."
+ }
+ if ($DeployUser -notmatch '^[A-Za-z_][A-Za-z0-9_-]*$') {
+ throw "DeployUser contains unsupported characters."
+ }
+
+ $RemoteBase = $RemoteBase.TrimEnd('/')
+ if (
+ $RemoteBase.Length -le 1 -or
+ $RemoteBase -notmatch '^/[A-Za-z0-9._/-]+$' -or
+ $RemoteBase -match '(^|/)\.\.?(/|$)' -or
+ $RemoteBase.Contains('//')
+ ) {
+ throw "RemoteBase must be a normalized absolute Unix path other than '/'."
+ }
+ if (-not $RemoteBase.StartsWith('/srv/www/', [System.StringComparison]::Ordinal)) {
+ throw "RemoteBase must be located below /srv/www/."
+ }
+
+ $parsedSiteUrl = $null
+ if (
+ -not [System.Uri]::TryCreate(
+ $siteUrl,
+ [System.UriKind]::Absolute,
+ [ref]$parsedSiteUrl
+ ) -or
+ $parsedSiteUrl.Scheme -ne "https"
+ ) {
+ throw "SiteUrl must be an absolute HTTPS URL."
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) {
+ $identityCandidate = $IdentityFile
+ if (-not [System.IO.Path]::IsPathRooted($identityCandidate)) {
+ $identityCandidate = Join-Path $siteRoot $identityCandidate
+ }
+ $identityPath = (Resolve-Path -LiteralPath $identityCandidate -ErrorAction Stop).Path
+ if (-not (Test-Path -LiteralPath $identityPath -PathType Leaf)) {
+ throw "IdentityFile must point to a file."
+ }
+ $sshOptions += @("-i", $identityPath)
+ }
+
+ foreach ($name in @("zola", "tar", "ssh", "scp")) {
+ $nativeCommands[$name] = Get-NativeCommandPath -Name $name
+ }
+
+ Push-Location -LiteralPath $siteRoot
+ $locationPushed = $true
+
+ Write-Host "[1/6] Checking site..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["zola"] `
+ -Arguments @("check") `
+ -Operation "zola check"
+
+ Write-Host "[2/6] Building site..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["zola"] `
+ -Arguments @("build") `
+ -Operation "zola build"
+
+ $publicDir = Join-Path $siteRoot "public"
+ $publicIndex = Join-Path $publicDir "index.html"
+ if (-not (Test-Path -LiteralPath $publicIndex -PathType Leaf)) {
+ throw "Build validation failed: public/index.html was not generated."
+ }
+
+ $resolvedPublicDir = (Resolve-Path -LiteralPath $publicDir).Path
+ $siteRootPrefix = $siteRoot.TrimEnd(
+ [System.IO.Path]::DirectorySeparatorChar,
+ [System.IO.Path]::AltDirectorySeparatorChar
+ ) + [System.IO.Path]::DirectorySeparatorChar
+ if (-not $resolvedPublicDir.StartsWith(
+ $siteRootPrefix,
+ [System.StringComparison]::OrdinalIgnoreCase
+ )) {
+ throw "Resolved public directory is outside the site root."
+ }
+
+ $releaseId = Get-Date -Format "yyyyMMdd-HHmmss"
+ $archiveNonce = [System.Guid]::NewGuid().ToString("N")
+ $archiveName = "takashi-2521-$releaseId-$archiveNonce.tar.gz"
+ $localArchive = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
+ $remoteArchive = "/tmp/$archiveName"
+ $remoteTarget = "$DeployUser@$DeployHost"
+
+ Write-Host "[3/6] Creating archive..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["tar"] `
+ -Arguments @("-czf", $localArchive, "-C", $resolvedPublicDir, ".") `
+ -Operation "tar archive creation"
+
+ if (
+ -not (Test-Path -LiteralPath $localArchive -PathType Leaf) -or
+ (Get-Item -LiteralPath $localArchive).Length -le 0
+ ) {
+ throw "Archive validation failed: the local archive is missing or empty."
+ }
+
+ Write-Host "[4/6] Uploading release..."
+ $scpArguments = @($sshOptions) + @(
+ $localArchive,
+ "${remoteTarget}:$remoteArchive"
+ )
+ $remoteArchiveMayExist = $true
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["scp"] `
+ -Arguments $scpArguments `
+ -Operation "SCP upload"
+
+ $remoteScript = @'
+set -eu
+umask 022
+
+base='__REMOTE_BASE__'
+release_id='__RELEASE_ID__'
+archive='__REMOTE_ARCHIVE__'
+release="$base/releases/$release_id"
+new_link="$base/current.new"
+release_created=0
+
+cleanup() {
+ status=$?
+ trap - 0
+ rm -f -- "$archive" || true
+ rm -f -- "$new_link" || true
+ if [ "$status" -ne 0 ] && [ "$release_created" -eq 1 ]; then
+ rm -rf -- "$release" || true
+ fi
+ exit "$status"
+}
+trap cleanup 0
+
+test -d "$base/releases"
+test -L "$base/current"
+test -f "$base/current/index.html"
+test -f "$archive"
+
+if [ -e "$release" ] || [ -L "$release" ]; then
+ echo "Release already exists: $release_id" >&2
+ exit 1
+fi
+
+if [ -e "$new_link" ] || [ -L "$new_link" ]; then
+ rm -f -- "$new_link"
+fi
+
+mkdir -- "$release"
+release_created=1
+tar -xzf "$archive" -C "$release"
+test -f "$release/index.html"
+
+ln -s "releases/$release_id" "$new_link"
+mv -Tf "$new_link" "$base/current"
+release_created=0
+'@
+ $remoteScript = $remoteScript.Replace("__REMOTE_BASE__", $RemoteBase)
+ $remoteScript = $remoteScript.Replace("__RELEASE_ID__", $releaseId)
+ $remoteScript = $remoteScript.Replace("__REMOTE_ARCHIVE__", $remoteArchive)
+
+ Write-Host "[5/6] Switching release..."
+ Invoke-CheckedSshScript `
+ -Command $nativeCommands["ssh"] `
+ -Options $sshOptions `
+ -Target $remoteTarget `
+ -Script $remoteScript
+
+ $deploymentSucceeded = $true
+}
+catch {
+ $failureMessage = $_.Exception.Message
+}
+finally {
+ if ($locationPushed) {
+ Pop-Location
+ }
+
+ if ($null -ne $localArchive -and (Test-Path -LiteralPath $localArchive)) {
+ Remove-Item -LiteralPath $localArchive -Force -ErrorAction SilentlyContinue
+ }
+
+ if (
+ $remoteArchiveMayExist -and
+ -not $deploymentSucceeded -and
+ $null -ne $remoteArchive
+ ) {
+ $cleanupCommand = "rm -f -- '$remoteArchive'"
+ & $nativeCommands["ssh"] @sshOptions $remoteTarget $cleanupCommand 2>$null | Out-Null
+ }
+}
+
+if (-not $deploymentSucceeded) {
+ [System.Console]::Error.WriteLine("Deployment failed: $failureMessage")
+ exit 1
+}
+
+Write-Host "[6/6] Done."
+Write-Host ""
+Write-Host "Release: $releaseId"
+Write-Host "URL: $siteUrl"
+exit 0