Skip to content

Commit 85882fc

Browse files
committed
Add AWS native functionality
* Based on the configuration variable AWS_S3_DIRECT_ACCESS read build logs, documentation and readme files directly from S3, rather than the S3 web frontend. * Add handling of root urls to map to index.html * Based on AWS_USE_IAM_ROLE use instance roles to access S3 rather than access key/secret * Based on ENABLE_PACKAGE_UPLOAD_PRESIGNED_URLS and ENABLE_BUILD_LOGS_PRESIGNED_URLS, create presigned URLs to pass to builders for logs and docs upload. * Add functionality to the Dockerfile to be able to build spi in debug vs release * Do not add back cookies in responses, the site does not use cookies. * Make the gitlab project id for build triggers configurable via GITLAB_PROJECT_ID *
1 parent 50c14b7 commit 85882fc

17 files changed

Lines changed: 709 additions & 53 deletions

Dockerfile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
# Build image
2020
# ================================
2121
FROM registry.gitlab.com/finestructure/spi-base:2.2.1 AS build
22+
ARG COMPILATION_MODE="release"
2223

2324
# Set up a build area
2425
WORKDIR /build
@@ -39,7 +40,7 @@ RUN mkdir /staging
3940

4041
# Build everything, with optimizations, with static linking, and using jemalloc
4142
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
42-
RUN swift build -c release \
43+
RUN swift build -c ${COMPILATION_MODE} \
4344
--enable-experimental-prebuilts \
4445
--static-swift-stdlib \
4546
-Xlinker -ljemalloc
@@ -48,20 +49,19 @@ RUN swift build -c release \
4849
WORKDIR /staging
4950

5051
# Copy main executable to staging area
51-
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./
52+
RUN cp "$(swift build --package-path /build -c $COMPILATION_MODE --show-bin-path)/Run" ./
5253

5354
# Copy static swift backtracer binary to staging area
5455
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
5556

5657
# Copy resources bundled by SPM to staging area
57-
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
58+
RUN find -L "$(swift build --package-path /build -c $COMPILATION_MODE --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
5859

5960
# Copy any resources from the public directory and views directory if the directories exist
6061
# Ensure that by default, neither the directory nor any of its contents are writable.
6162
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
6263
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
6364

64-
6565
# ================================
6666
# Run image
6767
# ================================
@@ -75,6 +75,9 @@ FROM registry.gitlab.com/finestructure/spi-base:2.2.1
7575
# Create a vapor user and group with /app as its home directory
7676
# RUN useradd --user-group --create-home --system --home-dir /app vapor
7777

78+
# Download the AWS global certificate for SSL connections and add it to the cert store
79+
RUN curl -L -o /usr/local/share/ca-certificates/aws-global-bundle.crt https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem && update-ca-certificates
80+
7881
# Switch to the new home directory
7982
WORKDIR /app
8083

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ let package = Package(
7979
.product(name: "SwiftPMPackageCollections", package: "swift-package-manager"),
8080
.product(name: "Vapor", package: "vapor"),
8181
.product(name: "SotoCognitoAuthentication", package: "soto-cognito-authentication"),
82+
.product(name: "SotoS3", package: "soto"),
8283
.product(name: "JWTKit", package: "jwt-kit")
8384
],
8485
swiftSettings: swiftSettings,

Sources/App/Controllers/PackageController+routes.swift

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,95 @@ enum PackageController {
154154
).encodeResponse(for: req)
155155
}
156156

157+
// Remove cookies from response headers to prevent "upstream sent too big header" errors
158+
var headers = req.headers
159+
headers.remove(name: .cookie)
160+
headers.replaceOrAdd(name: .contentType, value: route.contentType)
161+
157162
return try await processor.processedPage.encodeResponse(
158163
status: .ok,
159-
headers: req.headers.replacingOrAdding(name: .contentType,
160-
value: route.contentType),
164+
headers: headers,
161165
for: req
162166
)
163167
}
164168

165169
static func awsResponse(client: Client, route: DocRoute) async throws -> HTTPClient.Response {
166170
@Dependency(\.httpClient) var httpClient
171+
@Dependency(\.environment) var environment
172+
@Dependency(\.s3) var s3
173+
174+
// Check if direct S3 access is enabled
175+
if environment.awsDirectS3Access() {
176+
guard let bucket = environment.awsDocsBucket() else {
177+
throw AppError.envVariableNotSet("AWS_DOCS_BUCKET")
178+
}
179+
180+
// Construct the S3 key path - try exact path first
181+
let primaryS3Key: String
182+
if route.path.isEmpty {
183+
primaryS3Key = "\(route.baseURL)/\(route.fragment.urlFragment)"
184+
} else {
185+
primaryS3Key = "\(route.baseURL)/\(route.fragment.urlFragment)/\(route.path)"
186+
}
187+
do {
188+
let data = try await s3.fetchDocumentation(bucket, primaryS3Key)
189+
190+
// Create a mock HTTPClient.Response with the S3 data
191+
let response = HTTPClient.Response(
192+
host: bucket,
193+
status: .ok,
194+
version: .http1_1,
195+
headers: HTTPHeaders([
196+
("content-type", route.fragment.contentType),
197+
("content-length", "\(data.count)")
198+
]),
199+
body: ByteBuffer(data: data)
200+
)
201+
return response
202+
} catch {
203+
// If the primary key fails and this is a documentation/tutorials request,
204+
// try appending /index.html as a fallback
205+
if (route.fragment == .documentation || route.fragment == .tutorials) {
206+
let fallbackS3Key = route.path.isEmpty ?
207+
"\(route.baseURL)/index.html" :
208+
"\(route.baseURL)/\(route.fragment.urlFragment)/\(route.path)/index.html"
209+
do {
210+
let data = try await s3.fetchDocumentation(bucket, fallbackS3Key)
211+
212+
let response = HTTPClient.Response(
213+
host: bucket,
214+
status: .ok,
215+
version: .http1_1,
216+
headers: HTTPHeaders([
217+
("content-type", route.fragment.contentType),
218+
("content-length", "\(data.count)")
219+
]),
220+
body: ByteBuffer(data: data)
221+
)
222+
return response
223+
} catch let fallbackError {
224+
throw Abort(.notFound)
225+
}
226+
} else {
227+
throw Abort(.notFound)
228+
}
229+
}
230+
}
167231

232+
// Fallback to existing HTTP-based approach
168233
let url = try Self.awsDocumentationURL(route: route)
169-
guard let response = try? await httpClient.fetchDocumentation(url) else {
170-
throw Abort(.notFound)
234+
235+
let response: HTTPClient.Response
236+
if environment.awsUseIamRole() {
237+
guard let iamResponse = try? await httpClient.fetchDocumentationWithIAM(url) else {
238+
throw Abort(.notFound)
239+
}
240+
response = iamResponse
241+
} else {
242+
guard let standardResponse = try? await httpClient.fetchDocumentation(url) else {
243+
throw Abort(.notFound)
244+
}
245+
response = standardResponse
171246
}
172247
guard (200..<399).contains(response.status.code) else {
173248
// Convert anything that isn't a 2xx or 3xx from AWS into a 404 from us.
@@ -307,7 +382,7 @@ enum PackageController {
307382

308383
do {
309384
@Dependency(\.s3) var s3
310-
let readme = try await s3.fetchReadme(owner, repository)
385+
let readme = try await s3.fetchReadme(owner.lowercased(), repository.lowercased())
311386
guard let branch = pkg.repository?.defaultBranch else {
312387
return PackageReadme.View(model: .cacheLookupFailed(url: readmeHtmlUrl)).document()
313388
}
@@ -443,12 +518,17 @@ extension PackageController {
443518
extension PackageController {
444519
static func awsDocumentationURL(route: DocRoute) throws -> URI {
445520
@Dependency(\.environment) var environment
521+
446522
guard let bucket = environment.awsDocsBucket() else {
447523
throw AppError.envVariableNotSet("AWS_DOCS_BUCKET")
448524
}
449525

450-
let baseURLHost = "\(bucket).s3-website.us-east-2.amazonaws.com"
451-
let baseURL = "http://\(baseURLHost)/\(route.baseURL)"
526+
// Use the correct region for the docs bucket
527+
let region = environment.awsDocsBucketRegion() ?? environment.awsRegion() ?? "us-east-2"
528+
let baseURLHost = "\(bucket).s3-website.\(region).amazonaws.com"
529+
let urlScheme = "http"
530+
531+
let baseURL = "\(urlScheme)://\(baseURLHost)/\(route.baseURL)"
452532
let path = route.path
453533

454534
switch route.fragment {

Sources/App/Core/Dependencies/EnvironmentClient.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,22 @@ struct EnvironmentClient {
2929
var apiSigningKey: @Sendable () -> String?
3030
var appVersion: @Sendable () -> String?
3131
var awsAccessKeyId: @Sendable () -> String?
32+
var awsBuildLogsBucket: @Sendable () -> String?
33+
var awsBuildLogsBucketRegion: @Sendable () -> String?
3234
var awsDocsBucket: @Sendable () -> String?
35+
var awsDocsBucketRegion: @Sendable () -> String?
36+
var awsDocsInboxBucket: @Sendable () -> String?
37+
var awsDocsInboxBucketRegion: @Sendable () -> String?
3338
var awsReadmeBucket: @Sendable () -> String?
39+
var awsReadmeBucketRegion: @Sendable () -> String?
40+
var awsRegion: @Sendable () -> String?
3441
var awsSecretAccessKey: @Sendable () -> String?
42+
var awsUseIamRole: @Sendable () -> Bool = { XCTFail("awsUseIamRole"); return false }
43+
var awsDirectS3Access: @Sendable () -> Bool = { XCTFail("awsDirectS3Access"); return false }
3544
var builderToken: @Sendable () -> String?
45+
var enableBuildLogsPreSignedURLs: @Sendable () -> Bool = { XCTFail("enableBuildLogsPreSignedURLs"); return false }
46+
var enablePackageUploadPreSignedURLs: @Sendable () -> Bool = { XCTFail("enablePackageUploadPreSignedURLs"); return false }
47+
var packageUploadPreSignedURLExpiration: @Sendable () -> Int = { XCTFail("packageUploadPreSignedURLExpiration"); return 86400 }
3648
var buildTimeout: @Sendable () -> Int = { XCTFail("buildTimeout"); return 10 }
3749
var buildTriggerAllowList: @Sendable () -> [Package.Id] = { XCTFail("buildTriggerAllowList"); return [] }
3850
var buildTriggerDownscaling: @Sendable () -> Double = { XCTFail("buildTriggerDownscaling"); return 1 }
@@ -44,6 +56,7 @@ struct EnvironmentClient {
4456
var gitlabApiToken: @Sendable () -> String?
4557
var gitlabPipelineLimit: @Sendable () -> Int = { XCTFail("gitlabPipelineLimit"); return 100 }
4658
var gitlabPipelineToken: @Sendable () -> String?
59+
var gitlabProjectId: @Sendable () -> Int = { XCTFail("gitlabProjectId"); return 19564054 }
4760
var hideStagingBanner: @Sendable () -> Bool = { XCTFail("hideStagingBanner"); return Constants.defaultHideStagingBanner }
4861
var loadSPIManifest: @Sendable (String) -> SPIManifest.Manifest?
4962
var maintenanceMessage: @Sendable () -> String?
@@ -82,10 +95,32 @@ extension EnvironmentClient: DependencyKey {
8295
apiSigningKey: { Environment.get("API_SIGNING_KEY") },
8396
appVersion: { App.appVersion },
8497
awsAccessKeyId: { Environment.get("AWS_ACCESS_KEY_ID") },
98+
awsBuildLogsBucket: { Environment.get("AWS_BUILD_LOGS_BUCKET") },
99+
awsBuildLogsBucketRegion: { Environment.get("AWS_BUILD_LOGS_BUCKET_REGION") },
85100
awsDocsBucket: { Environment.get("AWS_DOCS_BUCKET") },
101+
awsDocsBucketRegion: { Environment.get("AWS_DOCS_BUCKET_REGION") },
102+
awsDocsInboxBucket: { Environment.get("AWS_DOCS_INBOX_BUCKET") },
103+
awsDocsInboxBucketRegion: { Environment.get("AWS_DOCS_INBOX_BUCKET_REGION") },
86104
awsReadmeBucket: { Environment.get("AWS_README_BUCKET") },
105+
awsReadmeBucketRegion: { Environment.get("AWS_README_BUCKET_REGION") },
106+
awsRegion: { Environment.get("AWS_REGION") },
87107
awsSecretAccessKey: { Environment.get("AWS_SECRET_ACCESS_KEY") },
108+
awsUseIamRole: {
109+
Environment.get("AWS_USE_IAM_ROLE").flatMap(\.asBool) ?? false
110+
},
111+
awsDirectS3Access: {
112+
Environment.get("AWS_S3_DIRECT_ACCESS").flatMap(\.asBool) ?? false
113+
},
88114
builderToken: { Environment.get("BUILDER_TOKEN") },
115+
enableBuildLogsPreSignedURLs: {
116+
Environment.get("ENABLE_BUILD_LOGS_PRESIGNED_URLS").flatMap(\.asBool) ?? false
117+
},
118+
enablePackageUploadPreSignedURLs: {
119+
Environment.get("ENABLE_PACKAGE_UPLOAD_PRESIGNED_URLS").flatMap(\.asBool) ?? false
120+
},
121+
packageUploadPreSignedURLExpiration: {
122+
Environment.get("PACKAGE_UPLOAD_PRESIGNED_URL_EXPIRATION").flatMap(Int.init) ?? 86400
123+
},
89124
buildTimeout: { Environment.get("BUILD_TIMEOUT").flatMap(Int.init) ?? 10 },
90125
buildTriggerAllowList: {
91126
Environment.decode("BUILD_TRIGGER_ALLOW_LIST", as: [Package.Id].self) ?? []
@@ -118,6 +153,7 @@ extension EnvironmentClient: DependencyKey {
118153
?? Constants.defaultGitlabPipelineLimit
119154
},
120155
gitlabPipelineToken: { Environment.get("GITLAB_PIPELINE_TOKEN") },
156+
gitlabProjectId: { Environment.get("GITLAB_PROJECT_ID").flatMap(Int.init) ?? 19564054 },
121157
hideStagingBanner: {
122158
Environment.get("HIDE_STAGING_BANNER").flatMap(\.asBool)
123159
?? Constants.defaultHideStagingBanner

Sources/App/Core/Dependencies/HTTPClient.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import AsyncHTTPClient
1616
import Dependencies
1717
import DependenciesMacros
1818
import Vapor
19+
import Foundation
20+
import SotoS3
21+
import SotoCore
22+
import SotoSignerV4
1923

2024

2125
@DependencyClient
@@ -27,6 +31,7 @@ struct HTTPClient {
2731
var post: @Sendable (_ url: String, _ headers: HTTPHeaders, _ body: Data?) async throws -> Response
2832

2933
var fetchDocumentation: @Sendable (_ url: URI) async throws -> Response
34+
var fetchDocumentationWithIAM: @Sendable (_ url: URI) async throws -> Response
3035
var fetchHTTPStatusCode: @Sendable (_ url: String) async throws -> HTTPStatus
3136
var mastodonPost: @Sendable (_ message: String) async throws -> Void
3237
var postAnalyticsEvent: @Sendable (_ kind: Analytics.Event.Kind, _ path: Analytics.Path, _ user: User?) async throws -> Void
@@ -46,6 +51,9 @@ extension HTTPClient: DependencyKey {
4651
fetchDocumentation: { url in
4752
try await Vapor.HTTPClient.shared.get(url: url.string).get()
4853
},
54+
fetchDocumentationWithIAM: { url in
55+
try await Self.fetchWithIAMAuth(url: url)
56+
},
4957
fetchHTTPStatusCode: { url in
5058
var config = Vapor.HTTPClient.Configuration()
5159
// We're forcing HTTP/1 due to a bug in Github's HEAD request handling
@@ -104,6 +112,84 @@ private let globallySharedHTTPClient: Vapor.HTTPClient = {
104112
}()
105113

106114

115+
// MARK: - AWS IAM Authentication
116+
extension HTTPClient {
117+
static func fetchWithIAMAuth(url: URI) async throws -> Response {
118+
// Create AWS client with default credential provider
119+
let awsClient = AWSClient(
120+
credentialProvider: .default,
121+
httpClientProvider: .createNew
122+
)
123+
124+
defer {
125+
Task {
126+
try await awsClient.shutdown()
127+
}
128+
}
129+
130+
// Extract region from URL
131+
let urlComponents = URLComponents(string: url.string)
132+
guard let host = urlComponents?.host else {
133+
throw AppError.genericError(nil, "Invalid URL: \(url.string)")
134+
}
135+
136+
let region = extractRegionFromHost(host) ?? .useast2
137+
138+
// Get credentials from the credential provider
139+
let credentials: Credential
140+
do {
141+
credentials = try await awsClient.credentialProvider.getCredential(
142+
on: awsClient.eventLoopGroup.next(),
143+
logger: Logger(label: "aws-credentials")
144+
).get()
145+
} catch {
146+
throw AppError.genericError(nil, "Failed to retrieve AWS credentials: \(error)")
147+
}
148+
149+
// Use Soto's AWSSigner to sign the request headers
150+
let signer = AWSSigner(
151+
credentials: credentials,
152+
name: "execute-api",
153+
region: region.rawValue
154+
)
155+
156+
// Create the request URL
157+
guard let requestURL = URL(string: url.string) else {
158+
throw AppError.genericError(nil, "Invalid URL: \(url.string)")
159+
}
160+
161+
// Sign the headers for the request
162+
let signedHeaders: HTTPHeaders
163+
do {
164+
signedHeaders = try await signer.signHeaders(
165+
url: requestURL,
166+
method: HTTPMethod.GET,
167+
headers: HTTPHeaders(),
168+
body: nil
169+
)
170+
} catch {
171+
throw AppError.genericError(nil, "Failed to sign request: \(error)")
172+
}
173+
174+
// Create request with signed headers
175+
let request = try Request(url: url.string, method: .GET, headers: signedHeaders)
176+
177+
// Execute the request
178+
return try await shared.execute(request: request).get()
179+
}
180+
181+
private static func extractRegionFromHost(_ host: String) -> Region? {
182+
// Extract region from API Gateway URL format: {api-id}.execute-api.{region}.amazonaws.com
183+
let components = host.split(separator: ".")
184+
if components.count >= 4 && components[1] == "execute-api" && components[3] == "amazonaws" {
185+
let regionString = String(components[2])
186+
return Region(rawValue: regionString)
187+
}
188+
return nil
189+
}
190+
}
191+
192+
107193
#if DEBUG
108194
// Convenience initialisers to make testing easier
109195

0 commit comments

Comments
 (0)