-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdeploy.ps1
More file actions
515 lines (428 loc) · 17.9 KB
/
Copy pathdeploy.ps1
File metadata and controls
515 lines (428 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
<#
.SYNOPSIS
Deploy script for the GaryEatsFloyd application.
.DESCRIPTION
Handles bootstrapping AWS backend resources, building Lambda packages,
and deploying all infrastructure via Terraform.
.PARAMETER Action
The deployment action to perform:
bootstrap - Create Terraform backend (S3 state bucket + DynamoDB lock table)
plan - Build everything + run terraform plan
apply - Build everything + terraform apply + deploy website to S3
destroy - Tear down all resources
build - Build Lambda packages + React website
full - bootstrap + build + apply + deploy website (first-time deploy)
output - Show terraform outputs
.PARAMETER AutoApprove
Skip interactive approval on apply/destroy.
.PARAMETER YouTubeApiKey
YouTube Data API key (required for plan/apply/full). Can also be set via
environment variable YOUTUBE_API_KEY.
.EXAMPLE
.\deploy.ps1 -Action full -YouTubeApiKey "your-key-here"
.EXAMPLE
.\deploy.ps1 -Action plan
.EXAMPLE
.\deploy.ps1 -Action apply -AutoApprove
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("bootstrap", "plan", "apply", "destroy", "build", "full", "output")]
[string]$Action,
[switch]$AutoApprove,
[string]$YouTubeApiKey
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ── Configuration ──────────────────────────────────────────────────────────────
$ProjectRoot = $PSScriptRoot
$TerraformDir = Join-Path $ProjectRoot "terraform"
$SrcDir = Join-Path $ProjectRoot "src"
$DistDir = Join-Path $ProjectRoot "dist"
$LayerDir = Join-Path $SrcDir "layer"
$WebsiteDir = Join-Path $ProjectRoot "website"
$AwsRegion = "us-east-1"
$StateBucket = "garyeatsfloyd-terraform-state"
$LockTable = "garyeatsfloyd-terraform-locks"
$LambdaFunctions = @(
"youtube_scanner",
"video_downloader",
"video_processor",
"api_handler",
"website_publisher"
)
# ── Helpers ────────────────────────────────────────────────────────────────────
function Write-Banner {
param([string]$Message)
$line = "=" * 60
Write-Host ""
Write-Host $line -ForegroundColor Cyan
Write-Host " $Message" -ForegroundColor Cyan
Write-Host $line -ForegroundColor Cyan
Write-Host ""
}
function Write-Step {
param([string]$Message)
Write-Host ">> $Message" -ForegroundColor Yellow
}
function Assert-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
Write-Error "Required command '$Name' not found. Please install it and ensure it is on your PATH."
}
}
function Resolve-YouTubeApiKey {
if ($YouTubeApiKey) { return $YouTubeApiKey }
if ($env:YOUTUBE_API_KEY) { return $env:YOUTUBE_API_KEY }
# Check for .env file
$envFile = Join-Path $ProjectRoot ".env"
if (Test-Path $envFile) {
Get-Content $envFile | ForEach-Object {
if ($_ -match '^\s*YOUTUBE_API_KEY\s*=\s*(.+)$') {
return $Matches[1].Trim().Trim('"').Trim("'")
}
}
}
return $null
}
# ── Bootstrap ──────────────────────────────────────────────────────────────────
function Invoke-Bootstrap {
Write-Banner "Bootstrapping Terraform Backend"
Assert-Command "aws"
# ── S3 state bucket ──
Write-Step "Checking S3 state bucket: $StateBucket"
$bucketExists = $false
try {
aws s3api head-bucket --bucket $StateBucket --region $AwsRegion 2>$null
$bucketExists = $true
} catch { }
if ($bucketExists) {
Write-Host " Bucket '$StateBucket' already exists. Skipping." -ForegroundColor Green
} else {
Write-Step "Creating S3 bucket: $StateBucket"
aws s3api create-bucket `
--bucket $StateBucket `
--region $AwsRegion | Out-Null
Write-Step "Enabling versioning"
aws s3api put-bucket-versioning `
--bucket $StateBucket `
--versioning-configuration Status=Enabled `
--region $AwsRegion
Write-Step "Enabling server-side encryption"
aws s3api put-bucket-encryption `
--bucket $StateBucket `
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}' `
--region $AwsRegion
Write-Step "Blocking public access"
aws s3api put-public-access-block `
--bucket $StateBucket `
--public-access-block-configuration `
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" `
--region $AwsRegion
Write-Host " Bucket created successfully." -ForegroundColor Green
}
# ── DynamoDB lock table ──
Write-Step "Checking DynamoDB lock table: $LockTable"
$tableExists = $false
try {
aws dynamodb describe-table --table-name $LockTable --region $AwsRegion 2>$null | Out-Null
$tableExists = $true
} catch { }
if ($tableExists) {
Write-Host " Table '$LockTable' already exists. Skipping." -ForegroundColor Green
} else {
Write-Step "Creating DynamoDB lock table: $LockTable"
aws dynamodb create-table `
--table-name $LockTable `
--attribute-definitions AttributeName=LockID,AttributeType=S `
--key-schema AttributeName=LockID,KeyType=HASH `
--billing-mode PAY_PER_REQUEST `
--region $AwsRegion | Out-Null
Write-Step "Waiting for table to become active..."
aws dynamodb wait table-exists --table-name $LockTable --region $AwsRegion
Write-Host " Lock table created successfully." -ForegroundColor Green
}
Write-Host ""
Write-Host "Backend bootstrap complete!" -ForegroundColor Green
}
# ── Build ──────────────────────────────────────────────────────────────────────
function Invoke-Build {
Write-Banner "Building Lambda Packages"
Assert-Command "pip"
# Create dist directory
if (-not (Test-Path $DistDir)) {
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
}
# ── Install layer dependencies ──
$layerPythonDir = Join-Path $LayerDir "python"
$requirementsFile = Join-Path $SrcDir "requirements.txt"
if (Test-Path $requirementsFile) {
Write-Step "Installing layer dependencies from requirements.txt"
if (Test-Path $layerPythonDir) {
Remove-Item -Recurse -Force $layerPythonDir
}
New-Item -ItemType Directory -Path $layerPythonDir -Force | Out-Null
pip install -r $requirementsFile -t $layerPythonDir --quiet --upgrade
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install layer dependencies."
}
Write-Host " Layer dependencies installed." -ForegroundColor Green
} else {
Write-Host " No requirements.txt found at $requirementsFile -- skipping layer build." -ForegroundColor DarkYellow
# Ensure the layer dir exists so archive_file doesn't fail
if (-not (Test-Path $layerPythonDir)) {
New-Item -ItemType Directory -Path $layerPythonDir -Force | Out-Null
}
}
# ── Download ffmpeg binary for Lambda (Linux x86_64) ──
$ffmpegLayerDir = Join-Path $SrcDir "ffmpeg-layer"
$ffmpegBinDir = Join-Path $ffmpegLayerDir "bin"
$ffmpegBin = Join-Path $ffmpegBinDir "ffmpeg"
if (-not (Test-Path $ffmpegBin)) {
Write-Step "Downloading ffmpeg binary for Lambda (Linux x86_64)"
New-Item -ItemType Directory -Path $ffmpegBinDir -Force | Out-Null
$tempDir = Join-Path $env:TEMP "ffmpeg-download-$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
# Download the manylinux wheel which bundles a static Linux ffmpeg binary
Write-Host " Downloading imageio-ffmpeg manylinux wheel..." -ForegroundColor DarkGray
pip download imageio-ffmpeg --platform manylinux2014_x86_64 --python-version 311 --only-binary=:all: -d $tempDir --no-deps --quiet
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to download imageio-ffmpeg wheel" }
$whlFile = Get-ChildItem $tempDir -Filter "imageio_ffmpeg*.whl" | Select-Object -First 1
if (-not $whlFile) { Write-Error "No imageio-ffmpeg wheel found in $tempDir" }
# Use Python to extract the ffmpeg binary from the wheel (zipfile)
Write-Host " Extracting ffmpeg binary from wheel..." -ForegroundColor DarkGray
$pyScript = @"
import zipfile, shutil, os
whl = r'$($whlFile.FullName)'
out = r'$ffmpegBin'
z = zipfile.ZipFile(whl)
bins = [n for n in z.namelist() if 'binaries/ffmpeg' in n and z.getinfo(n).file_size > 1000000]
if not bins:
raise RuntimeError('No ffmpeg binary found in wheel')
with z.open(bins[0]) as f_in, open(out, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
print(f'Extracted {bins[0]} ({os.path.getsize(out) / 1048576:.1f} MB)')
"@
python -c $pyScript
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to extract ffmpeg from wheel" }
Write-Host " ffmpeg downloaded to src/ffmpeg-layer/bin/ffmpeg" -ForegroundColor Green
} finally {
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
}
} else {
Write-Host " ffmpeg binary already present. Skipping download." -ForegroundColor Green
}
# ── Validate function source dirs exist ──
foreach ($fn in $LambdaFunctions) {
$fnDir = Join-Path $SrcDir $fn
if (-not (Test-Path $fnDir)) {
Write-Error "Lambda source directory missing: $fnDir"
}
$handlerFile = Join-Path $fnDir "handler.py"
if (-not (Test-Path $handlerFile)) {
Write-Error "Handler file missing: $handlerFile"
}
Write-Host " Validated: src/$fn/handler.py" -ForegroundColor Green
}
Write-Host ""
Write-Host "Build complete! Terraform archive_file will create the zips." -ForegroundColor Green
}
function Invoke-WebsiteBuild {
Write-Banner "Building React Website"
Assert-Command "npm"
if (-not (Test-Path (Join-Path $WebsiteDir "package.json"))) {
Write-Error "Website package.json not found at $WebsiteDir"
}
Write-Step "Installing website dependencies"
Push-Location $WebsiteDir
try {
npm ci --silent
if ($LASTEXITCODE -ne 0) { Write-Error "npm ci failed" }
Write-Step "Building production bundle"
npm run build
if ($LASTEXITCODE -ne 0) { Write-Error "npm run build failed" }
Write-Host " Website built to website/dist/" -ForegroundColor Green
} finally {
Pop-Location
}
}
function Invoke-WebsiteDeploy {
Write-Banner "Deploying Website to S3"
Assert-Command "aws"
$websiteDist = Join-Path $WebsiteDir "dist"
if (-not (Test-Path $websiteDist)) {
Write-Error "Website dist folder not found. Run build first."
}
# Get the website bucket name from terraform output
Push-Location $TerraformDir
try {
$websiteBucket = (terraform output -raw website_bucket 2>$null)
$cfDistId = (terraform output -raw cloudfront_distribution_id 2>$null)
} finally {
Pop-Location
}
if (-not $websiteBucket) {
Write-Error "Could not read website_bucket from terraform output. Has infrastructure been deployed?"
}
Write-Step "Syncing website files to s3://$websiteBucket"
aws s3 sync $websiteDist "s3://$websiteBucket" `
--delete `
--cache-control "public, max-age=3600" `
--region $AwsRegion
# Set long cache on static assets
aws s3 sync $websiteDist "s3://$websiteBucket" `
--exclude "*" `
--include "*.js" --include "*.css" --include "*.woff2" --include "*.svg" --include "*.png" `
--cache-control "public, max-age=31536000, immutable" `
--region $AwsRegion
# Set no-cache on index.html so SPA updates propagate immediately
aws s3 cp "$websiteDist/index.html" "s3://$websiteBucket/index.html" `
--cache-control "no-cache, no-store, must-revalidate" `
--content-type "text/html" `
--region $AwsRegion
if ($cfDistId) {
Write-Step "Invalidating CloudFront cache ($cfDistId)"
aws cloudfront create-invalidation `
--distribution-id $cfDistId `
--paths "/*" `
--region $AwsRegion | Out-Null
Write-Host " CloudFront invalidation submitted." -ForegroundColor Green
}
Write-Host " Website deployed successfully!" -ForegroundColor Green
}
# ── Terraform helpers ──────────────────────────────────────────────────────────
function Invoke-TerraformInit {
Write-Step "Running terraform init"
Push-Location $TerraformDir
try {
terraform init -input=false
if ($LASTEXITCODE -ne 0) { Write-Error "terraform init failed" }
} finally {
Pop-Location
}
}
function Set-TerraformEnvVars {
# Pass sensitive variables via TF_VAR_ environment variables instead of
# CLI -var args. Environment variables are NOT visible in process listings,
# shell history, or CI/CD logs -- unlike -var which exposes the value.
$apiKey = Resolve-YouTubeApiKey
if (-not $apiKey) {
Write-Error @"
YouTube API key not found. Provide it via one of:
-YouTubeApiKey parameter
YOUTUBE_API_KEY environment variable
YOUTUBE_API_KEY=... in .env file at project root
"@
}
$env:TF_VAR_youtube_api_key = $apiKey
Write-Step "Sensitive variables loaded into environment (not visible in process list)"
}
function Invoke-TerraformPlan {
Write-Banner "Terraform Plan"
Invoke-TerraformInit
Set-TerraformEnvVars
Write-Step "Running terraform plan"
Push-Location $TerraformDir
try {
terraform plan -out=tfplan
if ($LASTEXITCODE -ne 0) { Write-Error "terraform plan failed" }
} finally {
Pop-Location
}
}
function Invoke-TerraformApply {
Write-Banner "Terraform Apply"
Invoke-TerraformInit
Set-TerraformEnvVars
$approveFlag = @()
if ($AutoApprove) { $approveFlag = @("-auto-approve") }
Write-Step "Running terraform apply"
Push-Location $TerraformDir
try {
terraform apply @approveFlag
if ($LASTEXITCODE -ne 0) { Write-Error "terraform apply failed" }
Write-Host ""
Write-Banner "Deployment Outputs"
terraform output
} finally {
Pop-Location
}
}
function Invoke-TerraformDestroy {
Write-Banner "Terraform Destroy"
Invoke-TerraformInit
Set-TerraformEnvVars
$approveFlag = @()
if ($AutoApprove) { $approveFlag = @("-auto-approve") }
Write-Host "WARNING: This will destroy ALL GaryEatsFloyd infrastructure!" -ForegroundColor Red
if (-not $AutoApprove) {
$confirm = Read-Host "Type 'yes' to continue"
if ($confirm -ne "yes") {
Write-Host "Destroy cancelled." -ForegroundColor Yellow
return
}
}
Write-Step "Running terraform destroy"
Push-Location $TerraformDir
try {
terraform destroy @approveFlag
if ($LASTEXITCODE -ne 0) { Write-Error "terraform destroy failed" }
} finally {
Pop-Location
}
}
function Invoke-TerraformOutput {
Write-Banner "Terraform Outputs"
Push-Location $TerraformDir
try {
terraform output
} finally {
Pop-Location
}
}
# ── Full Deploy ────────────────────────────────────────────────────────────────
function Invoke-FullDeploy {
Write-Banner "Full Deployment -- GaryEatsFloyd"
# Pre-flight checks
Assert-Command "aws"
Assert-Command "terraform"
Assert-Command "pip"
# Validate API key is available (without logging it)
$apiKey = Resolve-YouTubeApiKey
if (-not $apiKey) {
Write-Error @"
YouTube API key is required for deployment.
Provide it via -YouTubeApiKey parameter, YOUTUBE_API_KEY env var, or .env file.
"@
}
# Clear from local variable immediately -- Set-TerraformEnvVars will re-resolve
$apiKey = $null
Invoke-Bootstrap
Invoke-Build
Invoke-WebsiteBuild
Invoke-TerraformApply
Invoke-WebsiteDeploy
# Scrub sensitive env vars from session after deployment
Remove-Item Env:TF_VAR_youtube_api_key -ErrorAction SilentlyContinue
Write-Step "Sensitive environment variables cleared from session"
}
# ── Main ───────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host " GaryEatsFloyd Deploy" -ForegroundColor Magenta
Write-Host " In the style of Keith Floyd -- with a glass of red wine" -ForegroundColor DarkMagenta
Write-Host ""
switch ($Action) {
"bootstrap" { Invoke-Bootstrap }
"build" { Invoke-Build; Invoke-WebsiteBuild }
"plan" { Invoke-Build; Invoke-WebsiteBuild; Invoke-TerraformPlan }
"apply" { Invoke-Build; Invoke-WebsiteBuild; Invoke-TerraformApply; Invoke-WebsiteDeploy }
"destroy" { Invoke-TerraformDestroy }
"full" { Invoke-FullDeploy }
"output" { Invoke-TerraformOutput }
}
Write-Host ""
Write-Host "Done." -ForegroundColor Green