feat: deploy to vercel & use ALB on ecs

This commit is contained in:
2026-01-21 23:03:22 -08:00
parent cfd2e0d2c2
commit d9622ea451
11 changed files with 89 additions and 62 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.env*.local

6
.vercelignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.svelte-kit
.git
.vercel
api
cdk

28
api/Cargo.lock generated
View File

@@ -1396,6 +1396,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -1416,6 +1427,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr", "memchr",
@@ -2932,6 +2944,7 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util",
"h2 0.4.13", "h2 0.4.13",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
@@ -2954,12 +2967,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
] ]
@@ -4395,6 +4410,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.83" version = "0.3.83"

View File

@@ -11,7 +11,7 @@ aws-sdk-dynamodb = "1.102.0"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
mime_guess = "2.0.5" mime_guess = "2.0.5"
reqwest = { version = "0.13.1", features = ["json", "query"] } reqwest = { version = "0.13.1", features = ["json", "query", "stream"] }
sentry = { version = "0.46.1", features = ["actix", "tracing"] } sentry = { version = "0.46.1", features = ["actix", "tracing"] }
serde = "1.0.228" serde = "1.0.228"
serde_dynamo = { version = "4.3.0", features = ["aws-sdk-dynamodb+1"] } serde_dynamo = { version = "4.3.0", features = ["aws-sdk-dynamodb+1"] }

View File

@@ -37,7 +37,7 @@ pub async fn proxy_file(
.error_for_status_ref() .error_for_status_ref()
.map_err(|_| crate::error::Error::NotFound)?; .map_err(|_| crate::error::Error::NotFound)?;
let bytes = response.bytes().await?; let stream = response.bytes_stream();
let mime = mime_guess::from_path(&path.file) let mime = mime_guess::from_path(&path.file)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
@@ -45,5 +45,5 @@ pub async fn proxy_file(
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok()
.content_type(mime) .content_type(mime)
.insert_header(("Cache-Control", "public, max-age=3600")) .insert_header(("Cache-Control", "public, max-age=3600"))
.body(bytes)) .streaming(stream))
} }

View File

@@ -98,7 +98,7 @@ async fn run() -> std::io::Result<()> {
), ),
) )
}) })
.bind(("0.0.0.0", 8080))? .bind((env::var("ADDRESS").unwrap_or("0.0.0.0".to_string()), 8080))?
.run() .run()
.await .await
} }

View File

@@ -169,7 +169,7 @@ impl UserRepository {
.item("gsi1pk", AttributeValue::S("REPOS".into())) .item("gsi1pk", AttributeValue::S("REPOS".into()))
.item("gsi1sk", AttributeValue::S(now.clone())) .item("gsi1sk", AttributeValue::S(now.clone()))
.item("imported_at", AttributeValue::S(now)) .item("imported_at", AttributeValue::S(now))
.item("approved", AttributeValue::Bool(false)) .item("approved", AttributeValue::Bool(true))
.item("owner_id", AttributeValue::S(user_id.into())) .item("owner_id", AttributeValue::S(user_id.into()))
.item("description", AttributeValue::S(repo.description.clone())) .item("description", AttributeValue::S(repo.description.clone()))
.send() .send()

View File

@@ -3,5 +3,9 @@
"ca-central-1a", "ca-central-1a",
"ca-central-1b", "ca-central-1b",
"ca-central-1d" "ca-central-1d"
] ],
"hosted-zone:account=585061171043:domainName=lucalise.ca:region=ca-central-1": {
"Id": "/hostedzone/Z0948300LINP3SX1WI4O",
"Name": "lucalise.ca."
}
} }

View File

@@ -4,8 +4,10 @@ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs"; import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ssm from "aws-cdk-lib/aws-ssm" import * as ssm from "aws-cdk-lib/aws-ssm"
import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery"; import * as acm from "aws-cdk-lib/aws-certificatemanager"
import * as apigatewayv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as route53 from "aws-cdk-lib/aws-route53"
import * as route53Targets from "aws-cdk-lib/aws-route53-targets"
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"
import * as path from "path"; import * as path from "path";
interface StackProps extends cdk.StackProps { interface StackProps extends cdk.StackProps {
@@ -26,6 +28,9 @@ export class GhostV2Stack extends cdk.Stack {
partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING }, partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING }, sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
}); });
if (props.environment !== "prod") {
return;
}
const vpc = new ec2.Vpc(this, "ghostv2 vpc", { const vpc = new ec2.Vpc(this, "ghostv2 vpc", {
maxAzs: 2, maxAzs: 2,
@@ -45,22 +50,24 @@ export class GhostV2Stack extends cdk.Stack {
allowAllOutbound: true, allowAllOutbound: true,
}); });
ecsSecurityGroup.addIngressRule( ecsSecurityGroup.addIngressRule(
ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
"Allow HTTP"
);
ecsSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443),
"Allow HTTPS"
);
ecsSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(8080), ec2.Port.tcp(8080),
"Allow traffic from VPC" "Allow API"
); );
const cluster = new ecs.Cluster(this, "ghostv2 cluster", { const cluster = new ecs.Cluster(this, "ghostv2 cluster", {
vpc, vpc,
}); });
const namespace = new servicediscovery.PrivateDnsNamespace(
this,
"ghostv2 namespace",
{
name: "ghostv2.local",
vpc,
}
);
const taskDefinition = new ecs.FargateTaskDefinition( const taskDefinition = new ecs.FargateTaskDefinition(
this, this,
"ghostv2 api", "ghostv2 api",
@@ -73,9 +80,7 @@ export class GhostV2Stack extends cdk.Stack {
}, },
} }
); );
table.grantReadWriteData(taskDefinition.taskRole); const secretImports = ["DATABASE_URL", "GH_CLIENT_ID", "GH_CLIENT_SECRET", "REPOS_TABLE_NAME", "SENTRY_DSN", "FRONTEND_BASE_URL"];
const secretImports = ["DATABASE_URL", "GH_CLIENT_ID", "GH_CLIENT_SECRET", "REPOS_TABLE_NAME", "SENTRY_DSN"];
const secrets = secretImports.reduce<Record<string, ecs.Secret>>((acc, name) => { const secrets = secretImports.reduce<Record<string, ecs.Secret>>((acc, name) => {
acc[name] = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, `param${name}`, { acc[name] = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, `param${name}`, {
parameterName: `/ghostv2/${props.environment}/${name}` parameterName: `/ghostv2/${props.environment}/${name}`
@@ -95,7 +100,7 @@ export class GhostV2Stack extends cdk.Stack {
], ],
secrets secrets
}); });
table.grantReadWriteData(taskDefinition.taskRole);
const service = new ecs.FargateService(this, "ghostv2 service", { const service = new ecs.FargateService(this, "ghostv2 service", {
cluster, cluster,
taskDefinition, taskDefinition,
@@ -111,46 +116,21 @@ export class GhostV2Stack extends cdk.Stack {
weight: 1, weight: 1,
}, },
], ],
cloudMapOptions: {
name: "api",
cloudMapNamespace: namespace,
dnsRecordType: servicediscovery.DnsRecordType.SRV,
containerPort: 8080,
dnsTtl: cdk.Duration.seconds(10),
},
}); });
const hostedZone = route53.HostedZone.fromLookup(this, "hosted zone", { domainName: "lucalise.ca" })
const vpcLink = new apigatewayv2.CfnVpcLink(this, "ghostv2 vpc link", { const certificate = new acm.Certificate(this, "ghostv2 api cert", {
name: "ghostv2-vpc-link", domainName: "api.ghostv2.lucalise.ca",
subnetIds: vpc.publicSubnets.map((s) => s.subnetId), validation: acm.CertificateValidation.fromDns(hostedZone)
securityGroupIds: [ecsSecurityGroup.securityGroupId], })
}); const alb = new elbv2.ApplicationLoadBalancer(this, "ghostv2 alb", {
vpc,
const httpApi = new apigatewayv2.CfnApi(this, "ghostv2 http-api", { internetFacing: true,
name: "ghostv2-api", securityGroup: ecsSecurityGroup,
protocolType: "HTTP",
});
const integration = new apigatewayv2.CfnIntegration(this, "Integration", {
apiId: httpApi.ref,
integrationType: "HTTP_PROXY",
integrationMethod: "ANY",
integrationUri: service.cloudMapService!.serviceArn,
connectionType: "VPC_LINK",
connectionId: vpcLink.ref,
payloadFormatVersion: "1.0",
});
new apigatewayv2.CfnRoute(this, "DefaultRoute", {
apiId: httpApi.ref,
routeKey: "$default",
target: `integrations/${integration.ref}`,
});
new apigatewayv2.CfnStage(this, "Stage", {
apiId: httpApi.ref,
stageName: "$default",
autoDeploy: true,
}); });
alb.setAttribute("idle_timeout.timeout_seconds", "120")
alb.addRedirect({ sourcePort: 80, sourceProtocol: elbv2.ApplicationProtocol.HTTP, targetPort: 443, targetProtocol: elbv2.ApplicationProtocol.HTTPS })
const listener = alb.addListener("https listener", { port: 443, open: true, certificates: [certificate] })
listener.addTargets("ECSTargets", { port: 8080, protocol: elbv2.ApplicationProtocol.HTTP, targets: [service], healthCheck: { path: "/", port: "8080" } })
new route53.ARecord(this, "alb record", { zone: hostedZone, recordName: "api.ghostv2.lucalise.ca", target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(alb)) })
} }
} }

View File

@@ -2,6 +2,6 @@ import { jwtClient, adminClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/svelte'; import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: 'http://localhost:5173', baseURL: process.env.BETTER_AUTH_URL!,
plugins: [jwtClient(), adminClient()] plugins: [jwtClient(), adminClient()]
}); });

8
vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/api/v0/:path*",
"destination": "https://s8do6ipb07.execute-api.ca-central-1.amazonaws.com/api/v0/:path*"
}
]
}