@hasna/uptime 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +0 -1
- package/CHANGELOG.md +35 -1
- package/Dockerfile +2 -1
- package/Dockerfile.package +22 -0
- package/README.md +13 -1
- package/dist/api.d.ts +2 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +81 -12
- package/dist/cli/index.js +148 -51
- package/dist/cloud-plan.d.ts +15 -4
- package/dist/cloud-plan.d.ts.map +1 -1
- package/dist/cloud-plan.js +55 -35
- package/dist/index.js +136 -47
- package/dist/mcp/index.js +38 -8
- package/dist/service.d.ts +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +38 -8
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +40 -9
- package/docs/aws-deployment-runbook.md +44 -22
- package/infra/aws/README.md +27 -6
- package/infra/aws/main.tf +374 -36
- package/infra/aws/outputs.tf +20 -0
- package/infra/aws/terraform.tfvars.example +13 -12
- package/infra/aws/variables.tf +48 -22
- package/package.json +2 -1
package/dist/cloud-plan.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/cloud-plan.ts
|
|
3
|
-
var DEFAULT_ACCOUNT = "
|
|
3
|
+
var DEFAULT_ACCOUNT = "aws-profile";
|
|
4
4
|
var DEFAULT_REGION = "us-east-1";
|
|
5
5
|
var DEFAULT_STAGE = "prod";
|
|
6
6
|
var DEFAULT_PREFIX = "open-uptime";
|
|
7
|
-
var DEFAULT_HOSTNAME = "uptime.
|
|
8
|
-
var DEFAULT_WORKSPACE_ID = "
|
|
9
|
-
var DEFAULT_VPC_ID = "vpc-
|
|
10
|
-
var
|
|
7
|
+
var DEFAULT_HOSTNAME = "uptime.example.com";
|
|
8
|
+
var DEFAULT_WORKSPACE_ID = "workspace-id";
|
|
9
|
+
var DEFAULT_VPC_ID = "vpc-xxxxxxxx";
|
|
10
|
+
var DEFAULT_HOSTED_SQLITE_DB = "/data/uptime/uptime.db";
|
|
11
|
+
var DEFAULT_PROTECTED_ACCESS_MODE = "cloudfront_default_domain";
|
|
11
12
|
function buildAwsDeploymentPlan(options = {}) {
|
|
12
13
|
const region = clean(options.region, DEFAULT_REGION);
|
|
13
14
|
const stage = clean(options.stage, DEFAULT_STAGE);
|
|
@@ -15,37 +16,42 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
15
16
|
const accountName = clean(options.accountName, DEFAULT_ACCOUNT);
|
|
16
17
|
const hostname = clean(options.hostname, DEFAULT_HOSTNAME);
|
|
17
18
|
const workspaceId = clean(options.workspaceId, DEFAULT_WORKSPACE_ID);
|
|
18
|
-
const ecrRepository = clean(options.ecrRepository,
|
|
19
|
+
const ecrRepository = clean(options.ecrRepository, prefix);
|
|
19
20
|
const imageRepositoryUri = `<account-id>.dkr.ecr.${region}.amazonaws.com/${ecrRepository}`;
|
|
20
21
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
21
22
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
23
|
+
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
24
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.8");
|
|
25
|
+
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
26
|
+
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
22
27
|
const cluster = `${prefix}-${stage}`;
|
|
23
28
|
const secrets = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
reporting: clean(options.reportingSecretName, `hasna/xyz/opensource/uptime/${stage}/reporting`)
|
|
29
|
+
appEnv: clean(options.appEnvSecretName, `open-uptime/${stage}/app/env`),
|
|
30
|
+
hostedToken: clean(options.hostedTokenSecretName, `open-uptime/${stage}/hosted-token`),
|
|
31
|
+
publicProbe: clean(options.publicProbeSecretName, `open-uptime/${stage}/probe/public`),
|
|
32
|
+
privateProbe: clean(options.privateProbeSecretName, `open-uptime/${stage}/probe/private`),
|
|
33
|
+
reporting: clean(options.reportingSecretName, `open-uptime/${stage}/reporting`)
|
|
30
34
|
};
|
|
31
35
|
const services = [
|
|
32
|
-
servicePlan(prefix, stage, "web",
|
|
36
|
+
servicePlan(prefix, stage, "web", 1, image, workspaceId, secrets, {
|
|
33
37
|
HASNA_UPTIME_MODE: "hosted",
|
|
38
|
+
HASNA_UPTIME_HOSTED_SQLITE_DB: hostedSqliteDbPath,
|
|
34
39
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
35
|
-
HASNA_UPTIME_HOSTNAME: hostname
|
|
40
|
+
HASNA_UPTIME_HOSTNAME: hostname,
|
|
41
|
+
HASNA_UPTIME_ALLOWED_ORIGINS: protectedAccessUrl
|
|
36
42
|
}),
|
|
37
|
-
servicePlan(prefix, stage, "scheduler",
|
|
43
|
+
servicePlan(prefix, stage, "scheduler", 0, image, workspaceId, secrets, {
|
|
38
44
|
HASNA_UPTIME_MODE: "hosted",
|
|
39
45
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
40
46
|
HASNA_UPTIME_COMPONENT: "scheduler"
|
|
41
47
|
}),
|
|
42
|
-
servicePlan(prefix, stage, "public-probe",
|
|
48
|
+
servicePlan(prefix, stage, "public-probe", 0, image, workspaceId, secrets, {
|
|
43
49
|
HASNA_UPTIME_MODE: "hosted",
|
|
44
50
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
45
51
|
HASNA_UPTIME_COMPONENT: "public-probe",
|
|
46
52
|
HASNA_UPTIME_PROBE_LOCATION: region
|
|
47
53
|
}),
|
|
48
|
-
servicePlan(prefix, stage, "reporter",
|
|
54
|
+
servicePlan(prefix, stage, "reporter", 0, image, workspaceId, secrets, {
|
|
49
55
|
HASNA_UPTIME_MODE: "hosted",
|
|
50
56
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
51
57
|
HASNA_UPTIME_COMPONENT: "reporter"
|
|
@@ -58,7 +64,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
58
64
|
];
|
|
59
65
|
return {
|
|
60
66
|
kind: "open-uptime.aws-deployment-plan",
|
|
61
|
-
version:
|
|
67
|
+
version: 3,
|
|
62
68
|
generatedAt: new Date().toISOString(),
|
|
63
69
|
status: "blocked",
|
|
64
70
|
canApply: false,
|
|
@@ -71,12 +77,18 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
71
77
|
mode: "hosted",
|
|
72
78
|
resources: {
|
|
73
79
|
ecrRepository,
|
|
80
|
+
imageBuilder: `${prefix}-${stage}-image-builder`,
|
|
74
81
|
ecsCluster: cluster,
|
|
75
82
|
services,
|
|
76
83
|
vpcId: clean(options.vpcId, DEFAULT_VPC_ID),
|
|
77
|
-
|
|
84
|
+
efsFileSystem: `${prefix}-${stage}-data`,
|
|
85
|
+
efsAccessPoint: `${prefix}-${stage}-uptime`,
|
|
86
|
+
hostedSqliteDbPath,
|
|
78
87
|
evidenceBucket,
|
|
79
88
|
loadBalancer: `${prefix}-${stage}-alb`,
|
|
89
|
+
protectedAccessMode,
|
|
90
|
+
edgeDistribution: protectedAccessMode === "cloudfront_default_domain" ? `${prefix}-${stage}-edge` : undefined,
|
|
91
|
+
protectedAccessUrl,
|
|
80
92
|
targetGroups: [`${prefix}-${stage}-web-tg`],
|
|
81
93
|
securityGroups: [
|
|
82
94
|
`${prefix}-${stage}-alb-sg`,
|
|
@@ -84,7 +96,8 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
84
96
|
`${prefix}-${stage}-scheduler-sg`,
|
|
85
97
|
`${prefix}-${stage}-public-probe-sg`,
|
|
86
98
|
`${prefix}-${stage}-reporter-sg`,
|
|
87
|
-
`${prefix}-${stage}-migration-sg
|
|
99
|
+
`${prefix}-${stage}-migration-sg`,
|
|
100
|
+
`${prefix}-${stage}-efs-sg`
|
|
88
101
|
],
|
|
89
102
|
secrets,
|
|
90
103
|
logGroups: services.map((service) => service.logGroup),
|
|
@@ -96,10 +109,10 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
96
109
|
image: {
|
|
97
110
|
repository: ecrRepository,
|
|
98
111
|
uri: image,
|
|
99
|
-
dockerfile: "Dockerfile",
|
|
100
|
-
buildCommand: `
|
|
112
|
+
dockerfile: "Dockerfile.package",
|
|
113
|
+
buildCommand: `BLOCKED: after infra approval, AWS CodeBuild builds Dockerfile.package from @hasna/uptime@${runtimePackageVersion} into ${imageRepositoryUri}`,
|
|
101
114
|
pushCommands: [
|
|
102
|
-
|
|
115
|
+
`BLOCKED: start ${prefix}-${stage}-image-builder only through the approved deploy pipeline after @hasna/uptime@${runtimePackageVersion} is published`,
|
|
103
116
|
"BLOCKED: deploy services by immutable image digest, not by mutable tags"
|
|
104
117
|
]
|
|
105
118
|
},
|
|
@@ -114,28 +127,31 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
114
127
|
runbook: {
|
|
115
128
|
preflight: [
|
|
116
129
|
`aws sts get-caller-identity --profile ${accountName}`,
|
|
117
|
-
`aws rds describe-db-instances --db-instance-identifier ${clean(options.rdsInstanceId, DEFAULT_RDS)} --region ${region}`,
|
|
118
130
|
`aws ec2 describe-vpcs --vpc-ids ${clean(options.vpcId, DEFAULT_VPC_ID)} --region ${region}`,
|
|
131
|
+
`aws efs describe-file-systems --region ${region}`,
|
|
119
132
|
"Confirm the infra repository and Terraform/CloudFormation owner before live mutation."
|
|
120
133
|
],
|
|
121
134
|
provision: [
|
|
122
135
|
`Infra PR must declare or update ECR repository ${ecrRepository}.`,
|
|
136
|
+
`Infra PR must declare CodeBuild image builder ${prefix}-${stage}-image-builder for @hasna/uptime@${runtimePackageVersion}.`,
|
|
123
137
|
`Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
|
|
124
|
-
`Infra PR must declare
|
|
138
|
+
`Infra PR must declare encrypted EFS ${prefix}-${stage}-data with access point, mount targets, and AWS Backup plan.`,
|
|
139
|
+
protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
|
|
125
140
|
"Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
|
|
126
141
|
],
|
|
127
142
|
deploy: [
|
|
128
143
|
"Build and publish the image only after the Dockerfile/container target is reviewed.",
|
|
129
|
-
|
|
144
|
+
`Start the AWS image builder for @hasna/uptime@${runtimePackageVersion} and record the pushed image digest.`,
|
|
145
|
+
"For the EFS SQLite bridge, do not run migration, scheduler, public-probe, or reporter tasks; keep them at desired count 0 until Postgres and cloud leases exist.",
|
|
130
146
|
`Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
|
|
131
147
|
`Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
|
|
132
|
-
`Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
|
|
148
|
+
protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
|
|
133
149
|
],
|
|
134
150
|
rollback: [
|
|
135
151
|
"Keep previous task definition ARNs before each service update.",
|
|
136
152
|
"Rollback through the approved deploy pipeline to the previously recorded task definition ARNs.",
|
|
137
153
|
"Disable scheduler/reporter services before data rollback.",
|
|
138
|
-
"Restore
|
|
154
|
+
"Restore EFS backup recovery point only after explicit operator approval and audit record."
|
|
139
155
|
],
|
|
140
156
|
spark01: [
|
|
141
157
|
"Create a private probe identity with a caller-managed public key.",
|
|
@@ -144,18 +160,19 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
144
160
|
]
|
|
145
161
|
},
|
|
146
162
|
blockers: [
|
|
147
|
-
"The
|
|
148
|
-
"
|
|
163
|
+
"The infrastructure owner repository was not found in this workspace.",
|
|
164
|
+
"The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
|
|
149
165
|
"Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
|
|
150
166
|
"Public probe execution still needs DNS, redirect, and rebinding SSRF enforcement plus cloud check-job leases.",
|
|
151
167
|
"Spark01 hosted probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
|
|
152
168
|
],
|
|
153
169
|
requiredEvidence: [
|
|
154
170
|
"Infrastructure PR/synth/plan from the approved infra repository.",
|
|
155
|
-
"
|
|
171
|
+
"CodeBuild image-builder run, container smoke, and immutable image digest.",
|
|
156
172
|
"ECS task definitions using secrets.valueFrom only.",
|
|
157
|
-
"ALB
|
|
158
|
-
"
|
|
173
|
+
"CloudFront-default-domain or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
|
|
174
|
+
"Single-writer ECS evidence: one web task maximum and no scheduler/public-probe/reporter EFS mounts.",
|
|
175
|
+
"EFS encryption, access point, mount-target, AWS Backup, and restore-drill evidence.",
|
|
159
176
|
"S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
|
|
160
177
|
"Spark01 private-probe registration, key-file mode, heartbeat, and revocation evidence."
|
|
161
178
|
],
|
|
@@ -165,7 +182,10 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
165
182
|
hostedLocalSqliteAllowed: false,
|
|
166
183
|
notes: [
|
|
167
184
|
"This plan generator does not call AWS.",
|
|
168
|
-
"
|
|
185
|
+
"Blocked plan output intentionally avoids copy-pastable AWS mutation commands.",
|
|
186
|
+
"Default protected access uses CloudFront's HTTPS default domain so first deploy is not blocked on custom DNS or ACM.",
|
|
187
|
+
"Hosted runtime uses explicit EFS-backed SQLite at HASNA_UPTIME_HOSTED_SQLITE_DB until the async Postgres adapter exists.",
|
|
188
|
+
"Do not set HASNA_UPTIME_DATABASE_URL for hosted tasks until the Postgres adapter is implemented.",
|
|
169
189
|
"Secrets are represented as secret names/refs and must be injected with valueFrom.",
|
|
170
190
|
"Actual deploy belongs in the deploy_release_operate_final goal node after infra review."
|
|
171
191
|
]
|
|
@@ -257,7 +277,7 @@ function servicePlan(prefix, stage, role, desiredCount, image, workspaceId, secr
|
|
|
257
277
|
HASNA_UPTIME_IMAGE: image,
|
|
258
278
|
...environment
|
|
259
279
|
},
|
|
260
|
-
secrets: role === "web" ? {
|
|
280
|
+
secrets: role === "web" ? { APP_ENV: secrets.appEnv, HASNA_UPTIME_HOSTED_TOKEN: secrets.hostedToken } : role === "public-probe" ? { PROBE_CONFIG: secrets.publicProbe } : role === "reporter" ? { REPORTING_CONFIG: secrets.reporting } : { APP_ENV: secrets.appEnv }
|
|
261
281
|
};
|
|
262
282
|
}
|
|
263
283
|
function clean(value, fallback) {
|
package/dist/index.js
CHANGED
|
@@ -820,10 +820,12 @@ function ensureUptimeHome() {
|
|
|
820
820
|
}
|
|
821
821
|
|
|
822
822
|
// src/store.ts
|
|
823
|
-
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statSync } from "fs";
|
|
823
|
+
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statfsSync, statSync } from "fs";
|
|
824
824
|
import { dirname, join as join2 } from "path";
|
|
825
825
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
826
826
|
import { Database } from "bun:sqlite";
|
|
827
|
+
var DEFAULT_HOSTED_SQLITE_DB_PATH = "/data/uptime/uptime.db";
|
|
828
|
+
var NFS_SUPER_MAGIC = 26985;
|
|
827
829
|
var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
828
830
|
var REQUIRED_TABLES = [
|
|
829
831
|
"schema_migrations",
|
|
@@ -860,18 +862,39 @@ class UptimeStore {
|
|
|
860
862
|
this.mode = resolveRuntimeMode(options.mode ?? "local");
|
|
861
863
|
const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
|
|
862
864
|
if (this.mode === "hosted" && cloudDatabaseUrl) {
|
|
863
|
-
throw new Error("hosted
|
|
865
|
+
throw new Error("hosted Postgres adapter is not implemented yet; use HASNA_UPTIME_HOSTED_SQLITE_DB on cloud-mounted storage for the current hosted deployment path");
|
|
864
866
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
+
const hostedSqliteDbPath = options.hostedSqliteDbPath ?? process.env.HASNA_UPTIME_HOSTED_SQLITE_DB;
|
|
868
|
+
if (this.mode === "hosted" && hostedSqliteDbPath) {
|
|
869
|
+
if (hostedSqliteDbPath === ":memory:" || !hostedSqliteDbPath.startsWith("/")) {
|
|
870
|
+
throw new Error("HASNA_UPTIME_HOSTED_SQLITE_DB must be an absolute path on mounted cloud storage");
|
|
871
|
+
}
|
|
872
|
+
const approvedHostedPath = hostedSqliteDbPath === DEFAULT_HOSTED_SQLITE_DB_PATH;
|
|
873
|
+
if (!approvedHostedPath && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
874
|
+
throw new Error(`HASNA_UPTIME_HOSTED_SQLITE_DB must be ${DEFAULT_HOSTED_SQLITE_DB_PATH}; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing`);
|
|
875
|
+
}
|
|
876
|
+
const verifiedCloudMount = approvedHostedPath && isNfsMount(dirname(hostedSqliteDbPath));
|
|
877
|
+
if (approvedHostedPath && !verifiedCloudMount && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
878
|
+
throw new Error(`${DEFAULT_HOSTED_SQLITE_DB_PATH} must be on a mounted EFS/NFS filesystem; refusing to create hosted task-local SQLite`);
|
|
879
|
+
}
|
|
880
|
+
this.dataMode = verifiedCloudMount ? "hosted-efs-sqlite" : "hosted-local-sqlite";
|
|
881
|
+
this.dbPath = hostedSqliteDbPath;
|
|
882
|
+
} else if (this.mode === "hosted") {
|
|
883
|
+
if (!allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
884
|
+
throw new Error("hosted mode requires HASNA_UPTIME_HOSTED_SQLITE_DB on mounted cloud storage; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
|
|
885
|
+
}
|
|
886
|
+
this.dataMode = "hosted-local-sqlite";
|
|
887
|
+
this.dbPath = options.dbPath ?? uptimeHostedFallbackDbPath();
|
|
888
|
+
} else {
|
|
889
|
+
this.dataMode = "local-sqlite";
|
|
890
|
+
this.dbPath = options.dbPath ?? uptimeDbPath();
|
|
867
891
|
}
|
|
868
|
-
|
|
869
|
-
this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
|
|
870
|
-
if (this.dbPath !== ":memory:") {
|
|
892
|
+
if (this.dbPath !== ":memory:" && this.dataMode !== "hosted-efs-sqlite") {
|
|
871
893
|
mkdirSync2(dirname(this.dbPath), { recursive: true });
|
|
872
894
|
}
|
|
873
895
|
this.db = new Database(this.dbPath, { create: true });
|
|
874
|
-
this.db.run("PRAGMA journal_mode = WAL");
|
|
896
|
+
this.db.run(this.dataMode === "hosted-efs-sqlite" ? "PRAGMA journal_mode = DELETE" : "PRAGMA journal_mode = WAL");
|
|
897
|
+
this.db.run("PRAGMA busy_timeout = 5000");
|
|
875
898
|
this.db.run("PRAGMA foreign_keys = ON");
|
|
876
899
|
this.migrate();
|
|
877
900
|
}
|
|
@@ -1751,6 +1774,13 @@ function resolveRuntimeMode(mode) {
|
|
|
1751
1774
|
function allowHostedLocalStore(value) {
|
|
1752
1775
|
return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
|
|
1753
1776
|
}
|
|
1777
|
+
function isNfsMount(path) {
|
|
1778
|
+
try {
|
|
1779
|
+
return statfsSync(path).type === NFS_SUPER_MAGIC;
|
|
1780
|
+
} catch {
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1754
1784
|
function verifyBackupFile(backupPath) {
|
|
1755
1785
|
const db = new Database(backupPath, { readonly: true });
|
|
1756
1786
|
try {
|
|
@@ -3480,6 +3510,7 @@ function serveUptime(options = {}) {
|
|
|
3480
3510
|
apiToken: options.apiToken,
|
|
3481
3511
|
hostedToken: options.hostedToken,
|
|
3482
3512
|
hostedTokens: options.hostedTokens,
|
|
3513
|
+
hostedAllowedOrigins: options.hostedAllowedOrigins,
|
|
3483
3514
|
allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
|
|
3484
3515
|
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1"),
|
|
3485
3516
|
mode
|
|
@@ -3539,13 +3570,23 @@ async function handleHostedRequest(service, request, url, options) {
|
|
|
3539
3570
|
const scope = hostedScopeFor(request.method, apiPath);
|
|
3540
3571
|
requireHostedActor(request, url, options, scope);
|
|
3541
3572
|
if (["POST", "PATCH", "DELETE"].includes(request.method)) {
|
|
3542
|
-
|
|
3543
|
-
if (origin && origin !== `${url.protocol}//${url.host}`) {
|
|
3544
|
-
throw new ApiError("cross-origin mutation rejected", 403);
|
|
3545
|
-
}
|
|
3573
|
+
validateHostedMutationOrigin(request, url, options);
|
|
3546
3574
|
}
|
|
3547
3575
|
return handleApiRoute(service, request, url, apiPath, options, true);
|
|
3548
3576
|
}
|
|
3577
|
+
function validateHostedMutationOrigin(request, url, options) {
|
|
3578
|
+
const rawOrigin = request.headers.get("origin");
|
|
3579
|
+
const origin = normalizeOrigin(rawOrigin);
|
|
3580
|
+
if (rawOrigin && !origin) {
|
|
3581
|
+
throw new ApiError("cross-origin mutation rejected", 403);
|
|
3582
|
+
}
|
|
3583
|
+
if (!origin)
|
|
3584
|
+
return;
|
|
3585
|
+
const allowedOrigins = new Set([`${url.protocol}//${url.host}`, ...resolveHostedAllowedOrigins(options)]);
|
|
3586
|
+
if (!allowedOrigins.has(origin)) {
|
|
3587
|
+
throw new ApiError("cross-origin mutation rejected", 403);
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3549
3590
|
async function handleApiRoute(service, request, url, apiPath, options, hosted) {
|
|
3550
3591
|
if (request.method === "GET" && apiPath === "/api/summary") {
|
|
3551
3592
|
return json(service.summary());
|
|
@@ -3764,6 +3805,34 @@ function resolveHostedTokens(options) {
|
|
|
3764
3805
|
workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
|
|
3765
3806
|
}];
|
|
3766
3807
|
}
|
|
3808
|
+
function resolveHostedAllowedOrigins(options) {
|
|
3809
|
+
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
3810
|
+
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
3811
|
+
}
|
|
3812
|
+
function splitCsv(value) {
|
|
3813
|
+
if (!value)
|
|
3814
|
+
return [];
|
|
3815
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
3816
|
+
}
|
|
3817
|
+
function normalizeAllowedOrigin(value) {
|
|
3818
|
+
const origin = normalizeOrigin(value);
|
|
3819
|
+
if (!origin) {
|
|
3820
|
+
throw new ApiError(`invalid hosted allowed origin: ${value}`, 500);
|
|
3821
|
+
}
|
|
3822
|
+
return origin;
|
|
3823
|
+
}
|
|
3824
|
+
function normalizeOrigin(value) {
|
|
3825
|
+
if (!value?.trim())
|
|
3826
|
+
return;
|
|
3827
|
+
try {
|
|
3828
|
+
const parsed = new URL(value.trim());
|
|
3829
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
3830
|
+
return;
|
|
3831
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
3832
|
+
} catch {
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3767
3836
|
function safeTokenEqual(candidate, expected) {
|
|
3768
3837
|
if (!candidate)
|
|
3769
3838
|
return false;
|
|
@@ -3791,14 +3860,15 @@ class ApiError extends Error {
|
|
|
3791
3860
|
}
|
|
3792
3861
|
|
|
3793
3862
|
// src/cloud-plan.ts
|
|
3794
|
-
var DEFAULT_ACCOUNT = "
|
|
3863
|
+
var DEFAULT_ACCOUNT = "aws-profile";
|
|
3795
3864
|
var DEFAULT_REGION = "us-east-1";
|
|
3796
3865
|
var DEFAULT_STAGE = "prod";
|
|
3797
3866
|
var DEFAULT_PREFIX = "open-uptime";
|
|
3798
|
-
var DEFAULT_HOSTNAME = "uptime.
|
|
3799
|
-
var DEFAULT_WORKSPACE_ID = "
|
|
3800
|
-
var DEFAULT_VPC_ID = "vpc-
|
|
3801
|
-
var
|
|
3867
|
+
var DEFAULT_HOSTNAME = "uptime.example.com";
|
|
3868
|
+
var DEFAULT_WORKSPACE_ID = "workspace-id";
|
|
3869
|
+
var DEFAULT_VPC_ID = "vpc-xxxxxxxx";
|
|
3870
|
+
var DEFAULT_HOSTED_SQLITE_DB = "/data/uptime/uptime.db";
|
|
3871
|
+
var DEFAULT_PROTECTED_ACCESS_MODE = "cloudfront_default_domain";
|
|
3802
3872
|
function buildAwsDeploymentPlan(options = {}) {
|
|
3803
3873
|
const region = clean(options.region, DEFAULT_REGION);
|
|
3804
3874
|
const stage = clean(options.stage, DEFAULT_STAGE);
|
|
@@ -3806,37 +3876,42 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3806
3876
|
const accountName = clean(options.accountName, DEFAULT_ACCOUNT);
|
|
3807
3877
|
const hostname = clean(options.hostname, DEFAULT_HOSTNAME);
|
|
3808
3878
|
const workspaceId = clean(options.workspaceId, DEFAULT_WORKSPACE_ID);
|
|
3809
|
-
const ecrRepository = clean(options.ecrRepository,
|
|
3879
|
+
const ecrRepository = clean(options.ecrRepository, prefix);
|
|
3810
3880
|
const imageRepositoryUri = `<account-id>.dkr.ecr.${region}.amazonaws.com/${ecrRepository}`;
|
|
3811
3881
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
3812
3882
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
3883
|
+
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
3884
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.8");
|
|
3885
|
+
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
3886
|
+
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
3813
3887
|
const cluster = `${prefix}-${stage}`;
|
|
3814
3888
|
const secrets = {
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
reporting: clean(options.reportingSecretName, `hasna/xyz/opensource/uptime/${stage}/reporting`)
|
|
3889
|
+
appEnv: clean(options.appEnvSecretName, `open-uptime/${stage}/app/env`),
|
|
3890
|
+
hostedToken: clean(options.hostedTokenSecretName, `open-uptime/${stage}/hosted-token`),
|
|
3891
|
+
publicProbe: clean(options.publicProbeSecretName, `open-uptime/${stage}/probe/public`),
|
|
3892
|
+
privateProbe: clean(options.privateProbeSecretName, `open-uptime/${stage}/probe/private`),
|
|
3893
|
+
reporting: clean(options.reportingSecretName, `open-uptime/${stage}/reporting`)
|
|
3821
3894
|
};
|
|
3822
3895
|
const services = [
|
|
3823
|
-
servicePlan(prefix, stage, "web",
|
|
3896
|
+
servicePlan(prefix, stage, "web", 1, image, workspaceId, secrets, {
|
|
3824
3897
|
HASNA_UPTIME_MODE: "hosted",
|
|
3898
|
+
HASNA_UPTIME_HOSTED_SQLITE_DB: hostedSqliteDbPath,
|
|
3825
3899
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
3826
|
-
HASNA_UPTIME_HOSTNAME: hostname
|
|
3900
|
+
HASNA_UPTIME_HOSTNAME: hostname,
|
|
3901
|
+
HASNA_UPTIME_ALLOWED_ORIGINS: protectedAccessUrl
|
|
3827
3902
|
}),
|
|
3828
|
-
servicePlan(prefix, stage, "scheduler",
|
|
3903
|
+
servicePlan(prefix, stage, "scheduler", 0, image, workspaceId, secrets, {
|
|
3829
3904
|
HASNA_UPTIME_MODE: "hosted",
|
|
3830
3905
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
3831
3906
|
HASNA_UPTIME_COMPONENT: "scheduler"
|
|
3832
3907
|
}),
|
|
3833
|
-
servicePlan(prefix, stage, "public-probe",
|
|
3908
|
+
servicePlan(prefix, stage, "public-probe", 0, image, workspaceId, secrets, {
|
|
3834
3909
|
HASNA_UPTIME_MODE: "hosted",
|
|
3835
3910
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
3836
3911
|
HASNA_UPTIME_COMPONENT: "public-probe",
|
|
3837
3912
|
HASNA_UPTIME_PROBE_LOCATION: region
|
|
3838
3913
|
}),
|
|
3839
|
-
servicePlan(prefix, stage, "reporter",
|
|
3914
|
+
servicePlan(prefix, stage, "reporter", 0, image, workspaceId, secrets, {
|
|
3840
3915
|
HASNA_UPTIME_MODE: "hosted",
|
|
3841
3916
|
HASNA_UPTIME_WORKSPACE_ID: workspaceId,
|
|
3842
3917
|
HASNA_UPTIME_COMPONENT: "reporter"
|
|
@@ -3849,7 +3924,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3849
3924
|
];
|
|
3850
3925
|
return {
|
|
3851
3926
|
kind: "open-uptime.aws-deployment-plan",
|
|
3852
|
-
version:
|
|
3927
|
+
version: 3,
|
|
3853
3928
|
generatedAt: new Date().toISOString(),
|
|
3854
3929
|
status: "blocked",
|
|
3855
3930
|
canApply: false,
|
|
@@ -3862,12 +3937,18 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3862
3937
|
mode: "hosted",
|
|
3863
3938
|
resources: {
|
|
3864
3939
|
ecrRepository,
|
|
3940
|
+
imageBuilder: `${prefix}-${stage}-image-builder`,
|
|
3865
3941
|
ecsCluster: cluster,
|
|
3866
3942
|
services,
|
|
3867
3943
|
vpcId: clean(options.vpcId, DEFAULT_VPC_ID),
|
|
3868
|
-
|
|
3944
|
+
efsFileSystem: `${prefix}-${stage}-data`,
|
|
3945
|
+
efsAccessPoint: `${prefix}-${stage}-uptime`,
|
|
3946
|
+
hostedSqliteDbPath,
|
|
3869
3947
|
evidenceBucket,
|
|
3870
3948
|
loadBalancer: `${prefix}-${stage}-alb`,
|
|
3949
|
+
protectedAccessMode,
|
|
3950
|
+
edgeDistribution: protectedAccessMode === "cloudfront_default_domain" ? `${prefix}-${stage}-edge` : undefined,
|
|
3951
|
+
protectedAccessUrl,
|
|
3871
3952
|
targetGroups: [`${prefix}-${stage}-web-tg`],
|
|
3872
3953
|
securityGroups: [
|
|
3873
3954
|
`${prefix}-${stage}-alb-sg`,
|
|
@@ -3875,7 +3956,8 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3875
3956
|
`${prefix}-${stage}-scheduler-sg`,
|
|
3876
3957
|
`${prefix}-${stage}-public-probe-sg`,
|
|
3877
3958
|
`${prefix}-${stage}-reporter-sg`,
|
|
3878
|
-
`${prefix}-${stage}-migration-sg
|
|
3959
|
+
`${prefix}-${stage}-migration-sg`,
|
|
3960
|
+
`${prefix}-${stage}-efs-sg`
|
|
3879
3961
|
],
|
|
3880
3962
|
secrets,
|
|
3881
3963
|
logGroups: services.map((service) => service.logGroup),
|
|
@@ -3887,10 +3969,10 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3887
3969
|
image: {
|
|
3888
3970
|
repository: ecrRepository,
|
|
3889
3971
|
uri: image,
|
|
3890
|
-
dockerfile: "Dockerfile",
|
|
3891
|
-
buildCommand: `
|
|
3972
|
+
dockerfile: "Dockerfile.package",
|
|
3973
|
+
buildCommand: `BLOCKED: after infra approval, AWS CodeBuild builds Dockerfile.package from @hasna/uptime@${runtimePackageVersion} into ${imageRepositoryUri}`,
|
|
3892
3974
|
pushCommands: [
|
|
3893
|
-
|
|
3975
|
+
`BLOCKED: start ${prefix}-${stage}-image-builder only through the approved deploy pipeline after @hasna/uptime@${runtimePackageVersion} is published`,
|
|
3894
3976
|
"BLOCKED: deploy services by immutable image digest, not by mutable tags"
|
|
3895
3977
|
]
|
|
3896
3978
|
},
|
|
@@ -3905,28 +3987,31 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3905
3987
|
runbook: {
|
|
3906
3988
|
preflight: [
|
|
3907
3989
|
`aws sts get-caller-identity --profile ${accountName}`,
|
|
3908
|
-
`aws rds describe-db-instances --db-instance-identifier ${clean(options.rdsInstanceId, DEFAULT_RDS)} --region ${region}`,
|
|
3909
3990
|
`aws ec2 describe-vpcs --vpc-ids ${clean(options.vpcId, DEFAULT_VPC_ID)} --region ${region}`,
|
|
3991
|
+
`aws efs describe-file-systems --region ${region}`,
|
|
3910
3992
|
"Confirm the infra repository and Terraform/CloudFormation owner before live mutation."
|
|
3911
3993
|
],
|
|
3912
3994
|
provision: [
|
|
3913
3995
|
`Infra PR must declare or update ECR repository ${ecrRepository}.`,
|
|
3996
|
+
`Infra PR must declare CodeBuild image builder ${prefix}-${stage}-image-builder for @hasna/uptime@${runtimePackageVersion}.`,
|
|
3914
3997
|
`Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
|
|
3915
|
-
`Infra PR must declare
|
|
3998
|
+
`Infra PR must declare encrypted EFS ${prefix}-${stage}-data with access point, mount targets, and AWS Backup plan.`,
|
|
3999
|
+
protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
|
|
3916
4000
|
"Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
|
|
3917
4001
|
],
|
|
3918
4002
|
deploy: [
|
|
3919
4003
|
"Build and publish the image only after the Dockerfile/container target is reviewed.",
|
|
3920
|
-
|
|
4004
|
+
`Start the AWS image builder for @hasna/uptime@${runtimePackageVersion} and record the pushed image digest.`,
|
|
4005
|
+
"For the EFS SQLite bridge, do not run migration, scheduler, public-probe, or reporter tasks; keep them at desired count 0 until Postgres and cloud leases exist.",
|
|
3921
4006
|
`Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
|
|
3922
4007
|
`Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
|
|
3923
|
-
`Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
|
|
4008
|
+
protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
|
|
3924
4009
|
],
|
|
3925
4010
|
rollback: [
|
|
3926
4011
|
"Keep previous task definition ARNs before each service update.",
|
|
3927
4012
|
"Rollback through the approved deploy pipeline to the previously recorded task definition ARNs.",
|
|
3928
4013
|
"Disable scheduler/reporter services before data rollback.",
|
|
3929
|
-
"Restore
|
|
4014
|
+
"Restore EFS backup recovery point only after explicit operator approval and audit record."
|
|
3930
4015
|
],
|
|
3931
4016
|
spark01: [
|
|
3932
4017
|
"Create a private probe identity with a caller-managed public key.",
|
|
@@ -3935,18 +4020,19 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3935
4020
|
]
|
|
3936
4021
|
},
|
|
3937
4022
|
blockers: [
|
|
3938
|
-
"The
|
|
3939
|
-
"
|
|
4023
|
+
"The infrastructure owner repository was not found in this workspace.",
|
|
4024
|
+
"The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
|
|
3940
4025
|
"Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
|
|
3941
4026
|
"Public probe execution still needs DNS, redirect, and rebinding SSRF enforcement plus cloud check-job leases.",
|
|
3942
4027
|
"Spark01 hosted probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
|
|
3943
4028
|
],
|
|
3944
4029
|
requiredEvidence: [
|
|
3945
4030
|
"Infrastructure PR/synth/plan from the approved infra repository.",
|
|
3946
|
-
"
|
|
4031
|
+
"CodeBuild image-builder run, container smoke, and immutable image digest.",
|
|
3947
4032
|
"ECS task definitions using secrets.valueFrom only.",
|
|
3948
|
-
"ALB
|
|
3949
|
-
"
|
|
4033
|
+
"CloudFront-default-domain or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
|
|
4034
|
+
"Single-writer ECS evidence: one web task maximum and no scheduler/public-probe/reporter EFS mounts.",
|
|
4035
|
+
"EFS encryption, access point, mount-target, AWS Backup, and restore-drill evidence.",
|
|
3950
4036
|
"S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
|
|
3951
4037
|
"Spark01 private-probe registration, key-file mode, heartbeat, and revocation evidence."
|
|
3952
4038
|
],
|
|
@@ -3956,7 +4042,10 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3956
4042
|
hostedLocalSqliteAllowed: false,
|
|
3957
4043
|
notes: [
|
|
3958
4044
|
"This plan generator does not call AWS.",
|
|
3959
|
-
"
|
|
4045
|
+
"Blocked plan output intentionally avoids copy-pastable AWS mutation commands.",
|
|
4046
|
+
"Default protected access uses CloudFront's HTTPS default domain so first deploy is not blocked on custom DNS or ACM.",
|
|
4047
|
+
"Hosted runtime uses explicit EFS-backed SQLite at HASNA_UPTIME_HOSTED_SQLITE_DB until the async Postgres adapter exists.",
|
|
4048
|
+
"Do not set HASNA_UPTIME_DATABASE_URL for hosted tasks until the Postgres adapter is implemented.",
|
|
3960
4049
|
"Secrets are represented as secret names/refs and must be injected with valueFrom.",
|
|
3961
4050
|
"Actual deploy belongs in the deploy_release_operate_final goal node after infra review."
|
|
3962
4051
|
]
|
|
@@ -4048,7 +4137,7 @@ function servicePlan(prefix, stage, role, desiredCount, image, workspaceId, secr
|
|
|
4048
4137
|
HASNA_UPTIME_IMAGE: image,
|
|
4049
4138
|
...environment
|
|
4050
4139
|
},
|
|
4051
|
-
secrets: role === "web" ? {
|
|
4140
|
+
secrets: role === "web" ? { APP_ENV: secrets.appEnv, HASNA_UPTIME_HOSTED_TOKEN: secrets.hostedToken } : role === "public-probe" ? { PROBE_CONFIG: secrets.publicProbe } : role === "reporter" ? { REPORTING_CONFIG: secrets.reporting } : { APP_ENV: secrets.appEnv }
|
|
4052
4141
|
};
|
|
4053
4142
|
}
|
|
4054
4143
|
function clean(value, fallback) {
|