@meyicloud/meyi-cost-server 1.8.3 → 1.8.5
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/package.json +1 -1
- package/src/controllers/cost.controller.js +3 -2
- package/src/cur-discovery/cur-discovery.repository.js +21 -14
- package/src/repositories/aws-onboarding.repository.js +6 -3
- package/src/repositories/cost-analysis-report.repository.js +17 -16
- package/src/schema/cost-analysis-report.schema.js +47 -4
- package/src/services/cost-analyser.service.js +2 -2
- package/src/services/cost-analysis-report.service.js +28 -7
- package/src/workers/cost-analysis-report.worker.js +5 -4
package/package.json
CHANGED
|
@@ -117,11 +117,12 @@ export class CostController {
|
|
|
117
117
|
try {
|
|
118
118
|
const ctx = await this.contextService.resolve(req);
|
|
119
119
|
if (!this.discoveryRepository) return res.json({ tenantId: ctx.tenant, jobs: [], logs: [] });
|
|
120
|
-
const
|
|
120
|
+
const managementAccountId = String(req.query.managementAccountId || "").trim();
|
|
121
|
+
const { jobs, logs } = await this.discoveryRepository.statusForTenant(ctx.tenant, req.query.logLimit, managementAccountId || undefined);
|
|
121
122
|
return res.json({
|
|
122
123
|
tenantId: ctx.tenant,
|
|
123
124
|
jobs: jobs.map((job) => ({
|
|
124
|
-
id: String(job.id), connectionId: job.connection_id, status: job.status,
|
|
125
|
+
id: String(job.id), connectionId: job.connection_id, managementAccountId: job.management_account_id, status: job.status,
|
|
125
126
|
attemptCount: Number(job.attempt_count || 0), bucket: job.cur_bucket,
|
|
126
127
|
prefix: job.cur_prefix, region: job.cur_region, tenantPartition: job.tenant_partition,
|
|
127
128
|
database: job.glue_database, table: job.glue_table, tableLocation: job.table_location,
|
|
@@ -154,22 +154,29 @@ export class CurDiscoveryRepository {
|
|
|
154
154
|
* Discovery jobs for a tenant with their most recent log lines, for the
|
|
155
155
|
* Cost > CUR Discovery screen. Read-only: the scheduler owns every write.
|
|
156
156
|
*/
|
|
157
|
-
async statusForTenant(tenant, logLimit = 50) {
|
|
158
|
-
const jobs = rows(await this.db.execute(sql`
|
|
159
|
-
SELECT id, tenant_id, connection_id, cur_bucket, cur_prefix, cur_region,
|
|
160
|
-
tenant_partition, status, attempt_count, next_run_at, last_started_at,
|
|
161
|
-
last_finished_at, glue_database, glue_table, table_location, cur_s3_uri,
|
|
162
|
-
sample_object_key, last_data_at, last_error, created_at, updated_at
|
|
163
|
-
FROM ${sql.raw(this.jobsTable)}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
async statusForTenant(tenant, logLimit = 50, managementAccountId = undefined) {
|
|
158
|
+
const jobs = rows(await this.db.execute(sql`
|
|
159
|
+
SELECT j.id, j.tenant_id, j.connection_id, a.management_account_id, j.cur_bucket, j.cur_prefix, j.cur_region,
|
|
160
|
+
j.tenant_partition, j.status, j.attempt_count, j.next_run_at, j.last_started_at,
|
|
161
|
+
j.last_finished_at, j.glue_database, j.glue_table, j.table_location, j.cur_s3_uri,
|
|
162
|
+
j.sample_object_key, j.last_data_at, j.last_error, j.created_at, j.updated_at
|
|
163
|
+
FROM ${sql.raw(this.jobsTable)} j
|
|
164
|
+
JOIN ${sql.raw(this.connectionsTable)} a
|
|
165
|
+
ON a.tenant_id::text = j.tenant_id AND a.connection_id = j.connection_id
|
|
166
|
+
WHERE j.tenant_id = ${tenant}
|
|
167
|
+
AND (${managementAccountId || null}::text IS NULL OR a.management_account_id = ${managementAccountId || null})
|
|
168
|
+
ORDER BY j.updated_at DESC
|
|
169
|
+
`));
|
|
167
170
|
if (!jobs.length) return { jobs: [], logs: [] };
|
|
168
171
|
const logs = rows(await this.db.execute(sql`
|
|
169
|
-
SELECT id, job_id, level, event, message, details, created_at
|
|
170
|
-
FROM ${sql.raw(this.logsTable)}
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
SELECT l.id, l.job_id, l.level, l.event, l.message, l.details, l.created_at
|
|
173
|
+
FROM ${sql.raw(this.logsTable)} l
|
|
174
|
+
JOIN ${sql.raw(this.jobsTable)} j ON j.id = l.job_id AND j.tenant_id = l.tenant_id
|
|
175
|
+
JOIN ${sql.raw(this.connectionsTable)} a
|
|
176
|
+
ON a.tenant_id::text = j.tenant_id AND a.connection_id = j.connection_id
|
|
177
|
+
WHERE l.tenant_id = ${tenant}
|
|
178
|
+
AND (${managementAccountId || null}::text IS NULL OR a.management_account_id = ${managementAccountId || null})
|
|
179
|
+
ORDER BY l.created_at DESC, l.id DESC
|
|
173
180
|
LIMIT ${Math.min(Math.max(Number(logLimit) || 50, 1), 500)}
|
|
174
181
|
`));
|
|
175
182
|
return { jobs, logs };
|
|
@@ -40,25 +40,28 @@ export class AwsOnboardingRepository {
|
|
|
40
40
|
if (!connections.length) return { accounts: [], meta: {}, sources: [] };
|
|
41
41
|
|
|
42
42
|
const connectionIds = connections.map((connection) => connection.connection_id);
|
|
43
|
+
// Bind every connection ID independently. Passing an untyped JavaScript array
|
|
44
|
+
// to PostgreSQL ANY() fails for the varchar connection_id column.
|
|
45
|
+
const connectionIdList = sql.join(connectionIds.map((connectionId) => sql`${connectionId}`), sql`, `);
|
|
43
46
|
const curConfigs = relations.cur_config_table ? rows(await this.db.execute(sql`
|
|
44
47
|
SELECT export_arn, bucket, prefix, region, tenant_partition, status, last_data_at
|
|
45
48
|
, connection_id
|
|
46
49
|
FROM ${sql.raw(this.curConfigTable)}
|
|
47
|
-
|
|
50
|
+
WHERE tenant_id::text = ${tenant} AND connection_id IN (${connectionIdList})
|
|
48
51
|
`)) : [];
|
|
49
52
|
|
|
50
53
|
const discoveries = relations.cur_discovery_table ? rows(await this.db.execute(sql`
|
|
51
54
|
SELECT id, status, attempt_count, glue_database, glue_table, table_location,
|
|
52
55
|
cur_s3_uri, last_data_at, last_error, last_started_at, last_finished_at, next_run_at, connection_id
|
|
53
56
|
FROM ${sql.raw(this.curDiscoveryTable)}
|
|
54
|
-
|
|
57
|
+
WHERE tenant_id = ${tenant} AND connection_id IN (${connectionIdList})
|
|
55
58
|
`)) : [];
|
|
56
59
|
|
|
57
60
|
const allAccounts = relations.accounts_table ? rows(await this.db.execute(sql`
|
|
58
61
|
SELECT connection_id, account_id, account_name, status
|
|
59
62
|
FROM ${sql.raw(this.accountsTable)}
|
|
60
63
|
WHERE tenant_id::text = ${tenant}
|
|
61
|
-
AND connection_id
|
|
64
|
+
AND connection_id IN (${connectionIdList})
|
|
62
65
|
AND LOWER(status) = 'active'
|
|
63
66
|
ORDER BY account_name
|
|
64
67
|
`)) : [];
|
|
@@ -9,16 +9,16 @@ export class CostAnalysisReportRepository {
|
|
|
9
9
|
this.reports = `${qSchema}.cost_ai_reports`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
async getSchedule(tenantId) {
|
|
13
|
-
return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.schedules)} WHERE tenant_id = ${tenantId}`))[0] || null;
|
|
12
|
+
async getSchedule(tenantId, managementAccountId) {
|
|
13
|
+
return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.schedules)} WHERE tenant_id = ${tenantId} AND management_account_id = ${managementAccountId}`))[0] || null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
async saveSchedule(tenantId, schedule, nextRunAt) {
|
|
16
|
+
async saveSchedule(tenantId, managementAccountId, schedule, nextRunAt) {
|
|
17
17
|
return rows(await this.db.execute(sql`
|
|
18
18
|
INSERT INTO ${sql.raw(this.schedules)}
|
|
19
|
-
(tenant_id, enabled, frequency, time_of_day, timezone, day_of_week, day_of_month, next_run_at)
|
|
20
|
-
VALUES (${tenantId}, ${schedule.enabled}, ${schedule.frequency}, ${schedule.time}, ${schedule.timezone}, ${schedule.dayOfWeek}, ${schedule.dayOfMonth}, ${nextRunAt})
|
|
21
|
-
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
19
|
+
(tenant_id, management_account_id, enabled, frequency, time_of_day, timezone, day_of_week, day_of_month, next_run_at)
|
|
20
|
+
VALUES (${tenantId}, ${managementAccountId}, ${schedule.enabled}, ${schedule.frequency}, ${schedule.time}, ${schedule.timezone}, ${schedule.dayOfWeek}, ${schedule.dayOfMonth}, ${nextRunAt})
|
|
21
|
+
ON CONFLICT (tenant_id, management_account_id) DO UPDATE SET
|
|
22
22
|
enabled = EXCLUDED.enabled, frequency = EXCLUDED.frequency,
|
|
23
23
|
time_of_day = EXCLUDED.time_of_day, timezone = EXCLUDED.timezone,
|
|
24
24
|
day_of_week = EXCLUDED.day_of_week, day_of_month = EXCLUDED.day_of_month,
|
|
@@ -32,7 +32,7 @@ export class CostAnalysisReportRepository {
|
|
|
32
32
|
const batchSize = Math.min(Math.max(Number(limit) || 10, 1), 50);
|
|
33
33
|
return rows(await this.db.execute(sql`
|
|
34
34
|
WITH due AS (
|
|
35
|
-
SELECT tenant_id, next_run_at AS scheduled_for
|
|
35
|
+
SELECT tenant_id, management_account_id, next_run_at AS scheduled_for
|
|
36
36
|
FROM ${sql.raw(this.schedules)}
|
|
37
37
|
WHERE enabled = true AND next_run_at IS NOT NULL AND next_run_at <= now()
|
|
38
38
|
ORDER BY next_run_at
|
|
@@ -42,16 +42,17 @@ export class CostAnalysisReportRepository {
|
|
|
42
42
|
UPDATE ${sql.raw(this.schedules)} s
|
|
43
43
|
SET next_run_at = ${leaseUntil}, updated_at = now()
|
|
44
44
|
FROM due
|
|
45
|
-
WHERE s.tenant_id = due.tenant_id
|
|
45
|
+
WHERE s.tenant_id = due.tenant_id AND s.management_account_id = due.management_account_id
|
|
46
46
|
RETURNING s.*, due.scheduled_for
|
|
47
47
|
`));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async setNextRun(tenantId, nextRunAt, completed = false, leaseUntil = null) {
|
|
50
|
+
async setNextRun(tenantId, managementAccountId, nextRunAt, completed = false, leaseUntil = null) {
|
|
51
51
|
await this.db.execute(sql`
|
|
52
52
|
UPDATE ${sql.raw(this.schedules)}
|
|
53
53
|
SET next_run_at = ${nextRunAt}, last_run_at = CASE WHEN ${completed} THEN now() ELSE last_run_at END, updated_at = now()
|
|
54
54
|
WHERE tenant_id = ${tenantId}
|
|
55
|
+
AND management_account_id = ${managementAccountId}
|
|
55
56
|
AND (${leaseUntil}::timestamptz IS NULL OR next_run_at = ${leaseUntil})
|
|
56
57
|
`);
|
|
57
58
|
}
|
|
@@ -59,9 +60,9 @@ export class CostAnalysisReportRepository {
|
|
|
59
60
|
async createReport(schedule, range) {
|
|
60
61
|
const inserted = rows(await this.db.execute(sql`
|
|
61
62
|
INSERT INTO ${sql.raw(this.reports)}
|
|
62
|
-
(id, tenant_id, schedule_frequency, scheduled_for, period_start, period_end)
|
|
63
|
-
VALUES (${randomUUID()}, ${schedule.tenant_id}, ${schedule.frequency}, ${schedule.scheduled_for}, ${range.Start}, ${range.End})
|
|
64
|
-
ON CONFLICT (tenant_id, scheduled_for) DO NOTHING
|
|
63
|
+
(id, tenant_id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end)
|
|
64
|
+
VALUES (${randomUUID()}, ${schedule.tenant_id}, ${schedule.management_account_id}, ${schedule.frequency}, ${schedule.scheduled_for}, ${range.Start}, ${range.End})
|
|
65
|
+
ON CONFLICT (tenant_id, management_account_id, scheduled_for) DO NOTHING
|
|
65
66
|
RETURNING *
|
|
66
67
|
`));
|
|
67
68
|
return inserted[0] || null;
|
|
@@ -132,14 +133,14 @@ export class CostAnalysisReportRepository {
|
|
|
132
133
|
await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', ecs_task_arn = COALESCE(${error?.taskArn || null}, ecs_task_arn), error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
async list(tenantId, limit = 100) {
|
|
136
|
+
async list(tenantId, managementAccountId, limit = 100) {
|
|
136
137
|
return rows(await this.db.execute(sql`
|
|
137
|
-
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
138
|
+
SELECT id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
138
139
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
139
140
|
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
140
141
|
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
141
142
|
started_at, completed_at, created_at
|
|
142
|
-
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
|
|
143
|
+
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND management_account_id = ${managementAccountId}
|
|
143
144
|
ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
|
|
144
145
|
`));
|
|
145
146
|
}
|
|
@@ -149,7 +150,7 @@ export class CostAnalysisReportRepository {
|
|
|
149
150
|
return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1`))[0] || null;
|
|
150
151
|
}
|
|
151
152
|
return rows(await this.db.execute(sql`
|
|
152
|
-
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
153
|
+
SELECT id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
153
154
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
154
155
|
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
155
156
|
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
+
import { rows } from "../lib/cost-utils.js";
|
|
2
3
|
|
|
3
4
|
export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
4
5
|
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_report_schedules (
|
|
5
|
-
tenant_id text
|
|
6
|
+
tenant_id text NOT NULL,
|
|
7
|
+
management_account_id text NOT NULL DEFAULT '',
|
|
6
8
|
enabled boolean NOT NULL DEFAULT false,
|
|
7
9
|
frequency text NOT NULL DEFAULT 'weekly' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
|
8
10
|
time_of_day time NOT NULL DEFAULT '09:00:00',
|
|
@@ -12,13 +14,39 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
12
14
|
next_run_at timestamptz,
|
|
13
15
|
last_run_at timestamptz,
|
|
14
16
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
17
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
18
|
+
PRIMARY KEY (tenant_id, management_account_id)
|
|
16
19
|
)`));
|
|
20
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_report_schedules ADD COLUMN IF NOT EXISTS management_account_id text NOT NULL DEFAULT ''`));
|
|
21
|
+
const schemaName = qSchema.replace(/"/g, "");
|
|
22
|
+
const relations = rows(await db.execute(sql.raw(`SELECT to_regclass('${schemaName}.aws_connections') AS connections_table`)))[0] || {};
|
|
23
|
+
if (relations.connections_table) {
|
|
24
|
+
await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_report_schedules s
|
|
25
|
+
SET management_account_id = COALESCE((
|
|
26
|
+
SELECT c.management_account_id FROM ${qSchema}.aws_connections c
|
|
27
|
+
WHERE c.tenant_id::text = s.tenant_id AND c.status = 'CONNECTED'
|
|
28
|
+
AND c.plugins ? 'Cost' AND c.management_account_id IS NOT NULL
|
|
29
|
+
ORDER BY c.connected_at DESC NULLS LAST, c.created_at DESC LIMIT 1
|
|
30
|
+
), '')
|
|
31
|
+
WHERE s.management_account_id = ''`));
|
|
32
|
+
}
|
|
33
|
+
await db.execute(sql.raw(`DO $migration$
|
|
34
|
+
BEGIN
|
|
35
|
+
IF NOT EXISTS (
|
|
36
|
+
SELECT 1 FROM pg_constraint
|
|
37
|
+
WHERE conrelid = '${schemaName}.cost_ai_report_schedules'::regclass
|
|
38
|
+
AND contype = 'p' AND pg_get_constraintdef(oid) LIKE '%management_account_id%'
|
|
39
|
+
) THEN
|
|
40
|
+
ALTER TABLE ${qSchema}.cost_ai_report_schedules DROP CONSTRAINT IF EXISTS cost_ai_report_schedules_pkey;
|
|
41
|
+
ALTER TABLE ${qSchema}.cost_ai_report_schedules ADD CONSTRAINT cost_ai_report_schedules_pkey PRIMARY KEY (tenant_id, management_account_id);
|
|
42
|
+
END IF;
|
|
43
|
+
END $migration$`));
|
|
17
44
|
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_report_schedules_due_idx ON ${qSchema}.cost_ai_report_schedules (enabled, next_run_at)`));
|
|
18
45
|
|
|
19
46
|
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_reports (
|
|
20
47
|
id text PRIMARY KEY,
|
|
21
48
|
tenant_id text NOT NULL,
|
|
49
|
+
management_account_id text NOT NULL DEFAULT '',
|
|
22
50
|
schedule_frequency text NOT NULL CHECK (schedule_frequency IN ('daily', 'weekly', 'monthly')),
|
|
23
51
|
scheduled_for timestamptz NOT NULL,
|
|
24
52
|
period_start date NOT NULL,
|
|
@@ -47,9 +75,24 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
47
75
|
started_at timestamptz,
|
|
48
76
|
completed_at timestamptz,
|
|
49
77
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
51
|
-
UNIQUE (tenant_id, scheduled_for)
|
|
78
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
52
79
|
)`));
|
|
80
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS management_account_id text NOT NULL DEFAULT ''`));
|
|
81
|
+
if (relations.connections_table) {
|
|
82
|
+
await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_reports r
|
|
83
|
+
SET management_account_id = COALESCE(
|
|
84
|
+
(SELECT s.management_account_id FROM ${qSchema}.cost_ai_report_schedules s
|
|
85
|
+
WHERE s.tenant_id = r.tenant_id AND s.management_account_id <> '' LIMIT 1),
|
|
86
|
+
(SELECT c.management_account_id FROM ${qSchema}.aws_connections c
|
|
87
|
+
WHERE c.tenant_id::text = r.tenant_id AND c.status = 'CONNECTED'
|
|
88
|
+
AND c.plugins ? 'Cost' AND c.management_account_id IS NOT NULL
|
|
89
|
+
ORDER BY c.connected_at DESC NULLS LAST, c.created_at DESC LIMIT 1),
|
|
90
|
+
''
|
|
91
|
+
)
|
|
92
|
+
WHERE r.management_account_id = ''`));
|
|
93
|
+
}
|
|
94
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports DROP CONSTRAINT IF EXISTS cost_ai_reports_tenant_id_scheduled_for_key`));
|
|
95
|
+
await db.execute(sql.raw(`CREATE UNIQUE INDEX IF NOT EXISTS cost_ai_reports_tenant_account_scheduled_idx ON ${qSchema}.cost_ai_reports (tenant_id, management_account_id, scheduled_for)`));
|
|
53
96
|
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_tenant_created_idx ON ${qSchema}.cost_ai_reports (tenant_id, created_at DESC)`));
|
|
54
97
|
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_status_idx ON ${qSchema}.cost_ai_reports (status, created_at)`));
|
|
55
98
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS ecs_task_arn text`));
|
|
@@ -167,10 +167,10 @@ export class CostAnalyserService {
|
|
|
167
167
|
return JSON.parse(await bodyText(response.Body));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
async execute({ tenantId, reportId, range, frequency, onStarted = null }) {
|
|
170
|
+
async execute({ tenantId, managementAccountId, reportId, range, frequency, onStarted = null }) {
|
|
171
171
|
this.validate();
|
|
172
172
|
const provider = await this.resolveProvider(tenantId);
|
|
173
|
-
const customer = await this.contextService.resolve({ headers: {}, user: { tenant_id: tenantId, tenantId } });
|
|
173
|
+
const customer = await this.contextService.resolve({ headers: {}, query: { managementAccountId }, user: { tenant_id: tenantId, tenantId } });
|
|
174
174
|
const athena = this.athenaContextService.resolve(customer).config;
|
|
175
175
|
if (!athena.enabled) throw Object.assign(new Error("CUR discovery has not produced a queryable Athena table for this tenant"), { name: "CurDataNotReadyError", statusCode: 503 });
|
|
176
176
|
const targetRoleArn = required(customer.meta?.payerRoleArn, "customer TARGET_ROLE_ARN");
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { nextScheduleRun, reportRange, validateSchedule } from "../lib/cost-analysis-schedule.js";
|
|
2
2
|
|
|
3
|
-
function scheduleDto(row) {
|
|
4
|
-
if (!row) return { enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
|
|
5
|
-
return { enabled: row.enabled, frequency: row.frequency, time: String(row.time_of_day).slice(0, 5), timezone: row.timezone, dayOfWeek: row.day_of_week, dayOfMonth: row.day_of_month, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at };
|
|
3
|
+
function scheduleDto(row, managementAccountId = "") {
|
|
4
|
+
if (!row) return { managementAccountId, enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
|
|
5
|
+
return { managementAccountId: row.management_account_id, enabled: row.enabled, frequency: row.frequency, time: String(row.time_of_day).slice(0, 5), timezone: row.timezone, dayOfWeek: row.day_of_week, dayOfMonth: row.day_of_month, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at };
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function reportDto(row) {
|
|
9
9
|
return {
|
|
10
|
-
id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
|
|
10
|
+
id: row.id, managementAccountId: row.management_account_id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
|
|
11
11
|
period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
|
|
12
12
|
status: row.status, attempts: Number(row.attempt_count || 0), provider: row.provider_name,
|
|
13
13
|
providerSource: row.provider_source, modelId: row.model_id,
|
|
@@ -27,16 +27,37 @@ export class CostAnalysisReportService {
|
|
|
27
27
|
|
|
28
28
|
tenant(req) { return this.contextService.tenantId(req); }
|
|
29
29
|
|
|
30
|
-
async
|
|
30
|
+
async scope(req, required = false) {
|
|
31
|
+
const context = await this.contextService.resolve(req);
|
|
32
|
+
const requested = String(req.query?.managementAccountId || "").trim();
|
|
33
|
+
const managementAccountId = requested || context.sources?.[0]?.managementAccountId || context.meta?.managementAccountId || "";
|
|
34
|
+
if (requested && !context.sources?.some((source) => source.managementAccountId === requested)) {
|
|
35
|
+
throw Object.assign(new Error("The selected management account is not connected for this tenant"), { statusCode: 404 });
|
|
36
|
+
}
|
|
37
|
+
if (required && !managementAccountId) {
|
|
38
|
+
throw Object.assign(new Error("Select a connected management account before configuring AI reports"), { statusCode: 409 });
|
|
39
|
+
}
|
|
40
|
+
return { tenantId: context.tenant, managementAccountId };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getSchedule(req) {
|
|
44
|
+
const scope = await this.scope(req);
|
|
45
|
+
return scheduleDto(scope.managementAccountId ? await this.repository.getSchedule(scope.tenantId, scope.managementAccountId) : null, scope.managementAccountId);
|
|
46
|
+
}
|
|
31
47
|
|
|
32
48
|
async saveSchedule(req, input) {
|
|
33
49
|
if (String(req.user?.role || "").toLowerCase() !== "admin") throw Object.assign(new Error("Administrator access is required to change the AI report schedule"), { statusCode: 403 });
|
|
50
|
+
const scope = await this.scope(req, true);
|
|
34
51
|
const schedule = validateSchedule(input);
|
|
35
52
|
const nextRunAt = schedule.enabled ? nextScheduleRun(schedule) : null;
|
|
36
|
-
return scheduleDto(await this.repository.saveSchedule(
|
|
53
|
+
return scheduleDto(await this.repository.saveSchedule(scope.tenantId, scope.managementAccountId, schedule, nextRunAt));
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
async list(req, limit) {
|
|
56
|
+
async list(req, limit) {
|
|
57
|
+
const scope = await this.scope(req);
|
|
58
|
+
if (!scope.managementAccountId) return [];
|
|
59
|
+
return (await this.repository.list(scope.tenantId, scope.managementAccountId, limit)).map(reportDto);
|
|
60
|
+
}
|
|
40
61
|
|
|
41
62
|
async latest(req) {
|
|
42
63
|
const reports = await this.list(req, 1);
|
|
@@ -19,12 +19,13 @@ export class CostAnalysisReportWorker {
|
|
|
19
19
|
const range = reportRange(normalized, schedule.scheduled_for);
|
|
20
20
|
const report = await this.repository.createReport(schedule, range);
|
|
21
21
|
const nextRunAt = nextScheduleRun(normalized, new Date());
|
|
22
|
-
if (!report) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
|
|
22
|
+
if (!report) return this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, false, schedule.next_run_at);
|
|
23
23
|
const claimed = await this.repository.markRunning(report.id);
|
|
24
|
-
if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
|
|
24
|
+
if (!claimed) return this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, false, schedule.next_run_at);
|
|
25
25
|
try {
|
|
26
26
|
const result = await this.analyserService.execute({
|
|
27
27
|
tenantId: schedule.tenant_id,
|
|
28
|
+
managementAccountId: schedule.management_account_id,
|
|
28
29
|
reportId: report.id,
|
|
29
30
|
range,
|
|
30
31
|
frequency: schedule.frequency,
|
|
@@ -32,10 +33,10 @@ export class CostAnalysisReportWorker {
|
|
|
32
33
|
});
|
|
33
34
|
await this.repository.completeExternal(report.id, result);
|
|
34
35
|
await this.incidentSyncService?.syncPending?.();
|
|
35
|
-
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
36
|
+
await this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, true, schedule.next_run_at);
|
|
36
37
|
} catch (error) {
|
|
37
38
|
await this.repository.fail(report.id, error);
|
|
38
|
-
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
39
|
+
await this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, true, schedule.next_run_at);
|
|
39
40
|
this.logger.error?.("[Cost AI Reports] Scheduled analysis failed", { tenantId: schedule.tenant_id, reportId: report.id, message: error.message });
|
|
40
41
|
}
|
|
41
42
|
}
|