lake.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import { domain } from "./stage"
  2. const current = aws.getCallerIdentityOutput({})
  3. const partition = aws.getPartitionOutput({})
  4. const region = aws.getRegionOutput({})
  5. const tableBucketName = `opencode-${$app.stage}-lake`
  6. const glueCatalogName = "s3tablescatalog"
  7. const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
  8. const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
  9. const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
  10. const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
  11. const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
  12. const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
  13. export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
  14. name: tableBucketName,
  15. forceDestroy: $app.stage !== "production",
  16. })
  17. const s3TablesCatalog = new aws.cloudcontrol.Resource(
  18. "LakeS3TablesCatalog",
  19. {
  20. typeName: "AWS::Glue::Catalog",
  21. desiredState: $jsonStringify({
  22. Name: glueCatalogName,
  23. Description: "Federated catalog for S3 Tables",
  24. FederatedCatalog: {
  25. Identifier: s3TablesBucketWildcardArn,
  26. ConnectionName: "aws:s3tables",
  27. },
  28. CreateDatabaseDefaultPermissions: [
  29. {
  30. Principal: {
  31. DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
  32. },
  33. Permissions: ["ALL"],
  34. },
  35. ],
  36. CreateTableDefaultPermissions: [
  37. {
  38. Principal: {
  39. DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
  40. },
  41. Permissions: ["ALL"],
  42. },
  43. ],
  44. AllowFullTableExternalDataAccess: "True",
  45. }),
  46. },
  47. { dependsOn: [tableBucket] },
  48. )
  49. const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
  50. bucket: `opencode-${$app.stage}-lake-athena-results`,
  51. forceDestroy: $app.stage !== "production",
  52. })
  53. const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
  54. bucket: `opencode-${$app.stage}-lake-firehose-errors`,
  55. forceDestroy: $app.stage !== "production",
  56. })
  57. const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
  58. name: `opencode-${$app.stage}-lake-workgroup`,
  59. forceDestroy: $app.stage !== "production",
  60. configuration: {
  61. enforceWorkgroupConfiguration: true,
  62. publishCloudwatchMetricsEnabled: true,
  63. resultConfiguration: {
  64. outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
  65. },
  66. },
  67. })
  68. const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
  69. assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
  70. statements: [
  71. {
  72. effect: "Allow",
  73. actions: ["sts:AssumeRole"],
  74. principals: [
  75. {
  76. type: "Service",
  77. identifiers: ["firehose.amazonaws.com"],
  78. },
  79. ],
  80. },
  81. ],
  82. }).json,
  83. })
  84. const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
  85. role: firehoseRole.id,
  86. policy: aws.iam.getPolicyDocumentOutput({
  87. statements: [
  88. {
  89. effect: "Allow",
  90. actions: [
  91. "s3tables:ListTableBuckets",
  92. "s3tables:GetTableBucket",
  93. "s3tables:GetNamespace",
  94. "s3tables:GetTable",
  95. "s3tables:GetTableData",
  96. "s3tables:GetTableMetadataLocation",
  97. "s3tables:ListNamespaces",
  98. "s3tables:ListTables",
  99. "s3tables:PutTableData",
  100. "s3tables:UpdateTableMetadataLocation",
  101. ],
  102. resources: ["*"],
  103. },
  104. {
  105. effect: "Allow",
  106. actions: [
  107. "glue:GetCatalog",
  108. "glue:GetCatalogs",
  109. "glue:GetDatabase",
  110. "glue:GetDatabases",
  111. "glue:GetTable",
  112. "glue:GetTables",
  113. "glue:UpdateTable",
  114. ],
  115. resources: [
  116. glueCatalogArn,
  117. glueS3TablesCatalogArn,
  118. $interpolate`${glueS3TablesCatalogArn}/*`,
  119. glueS3TablesDatabaseWildcardArn,
  120. glueS3TablesTableWildcardArn,
  121. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
  122. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
  123. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
  124. ],
  125. },
  126. {
  127. effect: "Allow",
  128. actions: [
  129. "s3:AbortMultipartUpload",
  130. "s3:GetBucketLocation",
  131. "s3:GetObject",
  132. "s3:ListBucket",
  133. "s3:ListBucketMultipartUploads",
  134. "s3:PutObject",
  135. ],
  136. resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
  137. },
  138. {
  139. effect: "Allow",
  140. actions: ["lakeformation:GetDataAccess"],
  141. resources: ["*"],
  142. },
  143. ],
  144. }).json,
  145. })
  146. const firehose = new aws.kinesis.FirehoseDeliveryStream(
  147. "LakeFirehose",
  148. {
  149. name: `opencode-${$app.stage}-lake-ingest`,
  150. destination: "iceberg",
  151. icebergConfiguration: {
  152. appendOnly: true,
  153. bufferingInterval: 60,
  154. bufferingSize: 1,
  155. catalogArn: glueS3TablesChildCatalogArn,
  156. processingConfiguration: {
  157. enabled: true,
  158. processors: [
  159. {
  160. type: "MetadataExtraction",
  161. parameters: [
  162. { parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
  163. {
  164. parameterName: "MetadataExtractionQuery",
  165. parameterValue:
  166. '{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
  167. },
  168. ],
  169. },
  170. ],
  171. },
  172. roleArn: firehoseRole.arn,
  173. s3BackupMode: "FailedDataOnly",
  174. s3Configuration: {
  175. roleArn: firehoseRole.arn,
  176. bucketArn: firehoseErrorBucket.arn,
  177. errorOutputPrefix: "errors/!{firehose:error-output-type}/",
  178. },
  179. },
  180. },
  181. { dependsOn: [s3TablesCatalog, firehosePolicy] },
  182. )
  183. export const lakeVpc = new sst.aws.Vpc("LakeVpc")
  184. export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
  185. export const lakeRegion = region.region
  186. export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
  187. export const lakeAthenaWorkgroup = athenaWorkgroup
  188. const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
  189. export const ingestSecretSsm = new aws.ssm.Parameter("LakeIngestSecretSsm", {
  190. name: $interpolate`/${$app.name}/${$app.stage}/lake/ingest/secret`,
  191. type: "SecureString",
  192. value: ingestSecret.result,
  193. })
  194. const ingestConfig = new sst.Linkable("LakeIngestConfig", {
  195. properties: {
  196. streamName: firehose.name,
  197. secret: ingestSecret.result,
  198. },
  199. })
  200. const ingestService = new sst.aws.Service("LakeIngestService", {
  201. cluster: lakeCluster,
  202. architecture: "arm64",
  203. cpu: "1 vCPU",
  204. memory: "4 GB",
  205. image: {
  206. context: ".",
  207. dockerfile: "packages/stats/server/Dockerfile",
  208. },
  209. link: [ingestConfig],
  210. permissions: [
  211. {
  212. actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
  213. resources: [firehose.arn],
  214. },
  215. ],
  216. scaling: {
  217. min: $app.stage === "production" ? 2 : 1,
  218. max: $app.stage === "production" ? 32 : 4,
  219. cpuUtilization: 60,
  220. memoryUtilization: 70,
  221. },
  222. loadBalancer: {
  223. domain: {
  224. name: `lake.${domain}`,
  225. dns: sst.cloudflare.dns(),
  226. },
  227. rules: [
  228. { listen: "80/http", redirect: "443/https" },
  229. { listen: "443/https", forward: "3000/http" },
  230. ],
  231. health: {
  232. "3000/http": {
  233. path: "/ready",
  234. successCodes: "200-299",
  235. },
  236. },
  237. },
  238. health: {
  239. command: [
  240. "CMD-SHELL",
  241. "bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
  242. ],
  243. interval: "30 seconds",
  244. retries: 3,
  245. startPeriod: "30 seconds",
  246. timeout: "5 seconds",
  247. },
  248. dev: {
  249. command: "bun run start",
  250. directory: "packages/stats/server",
  251. url: "http://localhost:3000",
  252. },
  253. wait: $app.stage === "production",
  254. })
  255. export const lakeIngest = new sst.Linkable("LakeIngest", {
  256. properties: {
  257. url: ingestService.url,
  258. secret: ingestSecret.result,
  259. },
  260. })
  261. export const lakeQueryPermissions = [
  262. {
  263. actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
  264. resources: [athenaWorkgroup.arn],
  265. },
  266. {
  267. actions: [
  268. "glue:GetCatalog",
  269. "glue:GetCatalogs",
  270. "glue:GetDatabase",
  271. "glue:GetDatabases",
  272. "glue:GetTable",
  273. "glue:GetTables",
  274. "glue:GetPartitions",
  275. ],
  276. resources: [
  277. glueCatalogArn,
  278. glueS3TablesCatalogArn,
  279. $interpolate`${glueS3TablesCatalogArn}/*`,
  280. glueS3TablesDatabaseWildcardArn,
  281. glueS3TablesTableWildcardArn,
  282. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
  283. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
  284. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
  285. ],
  286. },
  287. {
  288. actions: ["s3:GetBucketLocation", "s3:ListBucket"],
  289. resources: [athenaResultsBucket.arn],
  290. },
  291. {
  292. actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
  293. resources: [$interpolate`${athenaResultsBucket.arn}/*`],
  294. },
  295. {
  296. actions: [
  297. "s3tables:GetTableBucket",
  298. "s3tables:GetNamespace",
  299. "s3tables:GetTable",
  300. "s3tables:GetTableData",
  301. "s3tables:GetTableMetadataLocation",
  302. "s3tables:ListNamespaces",
  303. "s3tables:ListTables",
  304. ],
  305. resources: ["*"],
  306. },
  307. {
  308. actions: ["lakeformation:GetDataAccess"],
  309. resources: ["*"],
  310. },
  311. ]
  312. ////////////////
  313. // S3 Tables
  314. ////////////////
  315. const modelsNamespace = new aws.s3tables.Namespace("LakeModelsNamespace", {
  316. namespace: "models",
  317. tableBucketArn: tableBucket.arn,
  318. })
  319. new aws.s3tables.Table(
  320. "LakeModelsEventTable",
  321. {
  322. name: "hit",
  323. namespace: modelsNamespace.namespace,
  324. tableBucketArn: modelsNamespace.tableBucketArn,
  325. format: "ICEBERG",
  326. metadata: {
  327. iceberg: {
  328. schema: {
  329. fields: [
  330. { name: "event_timestamp", type: "string", required: false },
  331. { name: "event_date", type: "string", required: false },
  332. { name: "event_type", type: "string", required: false },
  333. { name: "country", type: "string", required: false },
  334. { name: "user_agent", type: "string", required: false },
  335. { name: "ip", type: "string", required: false },
  336. { name: "ip_prefix", type: "string", required: false },
  337. { name: "path", type: "string", required: false },
  338. ],
  339. },
  340. },
  341. },
  342. },
  343. { deleteBeforeReplace: $app.stage !== "production" },
  344. )