lake.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. const ingestConfig = new sst.Linkable("LakeIngestConfig", {
  190. properties: {
  191. streamName: firehose.name,
  192. secret: ingestSecret.result,
  193. },
  194. })
  195. const ingestService = new sst.aws.Service("LakeIngestService", {
  196. cluster: lakeCluster,
  197. architecture: "arm64",
  198. cpu: "1 vCPU",
  199. memory: "4 GB",
  200. image: {
  201. context: ".",
  202. dockerfile: "packages/stats/server/Dockerfile",
  203. },
  204. link: [ingestConfig],
  205. permissions: [
  206. {
  207. actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
  208. resources: [firehose.arn],
  209. },
  210. ],
  211. scaling: {
  212. min: $app.stage === "production" ? 2 : 1,
  213. max: $app.stage === "production" ? 32 : 4,
  214. cpuUtilization: 60,
  215. memoryUtilization: 70,
  216. },
  217. loadBalancer: {
  218. domain: {
  219. name: `lake.${domain}`,
  220. dns: sst.cloudflare.dns(),
  221. },
  222. rules: [
  223. { listen: "80/http", redirect: "443/https" },
  224. { listen: "443/https", forward: "3000/http" },
  225. ],
  226. health: {
  227. "3000/http": {
  228. path: "/ready",
  229. successCodes: "200-299",
  230. },
  231. },
  232. },
  233. health: {
  234. command: [
  235. "CMD-SHELL",
  236. "bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
  237. ],
  238. interval: "30 seconds",
  239. retries: 3,
  240. startPeriod: "30 seconds",
  241. timeout: "5 seconds",
  242. },
  243. dev: {
  244. command: "bun run start",
  245. directory: "packages/stats/server",
  246. url: "http://localhost:3000",
  247. },
  248. wait: $app.stage === "production",
  249. })
  250. export const lakeIngest = new sst.Linkable("LakeIngest", {
  251. properties: {
  252. url: ingestService.url,
  253. secret: ingestSecret.result,
  254. },
  255. })
  256. export const lakeQueryPermissions = [
  257. {
  258. actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
  259. resources: [athenaWorkgroup.arn],
  260. },
  261. {
  262. actions: [
  263. "glue:GetCatalog",
  264. "glue:GetCatalogs",
  265. "glue:GetDatabase",
  266. "glue:GetDatabases",
  267. "glue:GetTable",
  268. "glue:GetTables",
  269. "glue:GetPartitions",
  270. ],
  271. resources: [
  272. glueCatalogArn,
  273. glueS3TablesCatalogArn,
  274. $interpolate`${glueS3TablesCatalogArn}/*`,
  275. glueS3TablesDatabaseWildcardArn,
  276. glueS3TablesTableWildcardArn,
  277. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
  278. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
  279. $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
  280. ],
  281. },
  282. {
  283. actions: ["s3:GetBucketLocation", "s3:ListBucket"],
  284. resources: [athenaResultsBucket.arn],
  285. },
  286. {
  287. actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
  288. resources: [$interpolate`${athenaResultsBucket.arn}/*`],
  289. },
  290. {
  291. actions: [
  292. "s3tables:GetTableBucket",
  293. "s3tables:GetNamespace",
  294. "s3tables:GetTable",
  295. "s3tables:GetTableData",
  296. "s3tables:GetTableMetadataLocation",
  297. "s3tables:ListNamespaces",
  298. "s3tables:ListTables",
  299. ],
  300. resources: ["*"],
  301. },
  302. {
  303. actions: ["lakeformation:GetDataAccess"],
  304. resources: ["*"],
  305. },
  306. ]