@meyicloud/meyi-cost-server 1.7.0 → 1.8.0
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-analysis.controller.js +5 -0
- package/src/plugin.js +4 -2
- package/src/repositories/cost-analysis-report.repository.js +82 -4
- package/src/routes/index.js +1 -0
- package/src/schema/cost-analysis-report.schema.js +27 -0
- package/src/services/cost-analyser.service.js +7 -1
- package/src/services/cost-analysis-report.service.js +19 -0
- package/src/services/cost-incident-sync.service.js +57 -0
- package/src/services/cost-report-artifact.service.js +18 -2
- package/src/workers/cost-analysis-report.worker.js +5 -2
package/package.json
CHANGED
|
@@ -36,6 +36,11 @@ export class CostAnalysisController {
|
|
|
36
36
|
catch (error) { return next(error); }
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
async removeReport(req, res, next) {
|
|
40
|
+
try { return res.json(await this.reportService.remove(req, req.params.id)); }
|
|
41
|
+
catch (error) { return next(error); }
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
async downloadPdf(req, res, next) {
|
|
40
45
|
try {
|
|
41
46
|
const report = await this.reportService.pdf(req, req.params.id);
|
package/src/plugin.js
CHANGED
|
@@ -17,6 +17,7 @@ import { CostAnalyserService } from "./services/cost-analyser.service.js";
|
|
|
17
17
|
import { CostAnalysisReportService } from "./services/cost-analysis-report.service.js";
|
|
18
18
|
import { CostReportArtifactService } from "./services/cost-report-artifact.service.js";
|
|
19
19
|
import { CostAnalysisReportWorker } from "./workers/cost-analysis-report.worker.js";
|
|
20
|
+
import { CostIncidentSyncService } from "./services/cost-incident-sync.service.js";
|
|
20
21
|
import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
|
|
21
22
|
import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
|
|
22
23
|
import { CurDiscoveryAws } from "./cur-discovery/cur-discovery.aws.js";
|
|
@@ -24,7 +25,7 @@ import { CurDiscoveryService } from "./cur-discovery/cur-discovery.service.js";
|
|
|
24
25
|
import { CurDiscoveryWorker } from "./cur-discovery/cur-discovery.worker.js";
|
|
25
26
|
import { safeSchema } from "./lib/cost-utils.js";
|
|
26
27
|
|
|
27
|
-
export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null } = {}) {
|
|
28
|
+
export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null, incidentSink = null } = {}) {
|
|
28
29
|
if (!db) throw new Error("db is required");
|
|
29
30
|
const schema = safeSchema(process.env.DB_SCHEMA || "meyiconnect");
|
|
30
31
|
const qSchema = `"${schema}"`;
|
|
@@ -53,8 +54,9 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
53
54
|
const budgetController = new BudgetController({ contextService, budgetService, budgetAlertService });
|
|
54
55
|
const costAnalyserService = new CostAnalyserService({ contextService, athenaContextService, providerResolver, logger });
|
|
55
56
|
const artifactService = new CostReportArtifactService();
|
|
57
|
+
const incidentSyncService = new CostIncidentSyncService({ repository: costAnalysisReportRepository, artifactService, incidentSink, logger });
|
|
56
58
|
const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, artifactService });
|
|
57
|
-
const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, logger });
|
|
59
|
+
const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, incidentSyncService, logger });
|
|
58
60
|
const costAnalysisController = new CostAnalysisController({ service: costAnalyserService, reportService: costAnalysisReportService });
|
|
59
61
|
const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
|
|
60
62
|
let mounted = false;
|
|
@@ -7,6 +7,7 @@ export class CostAnalysisReportRepository {
|
|
|
7
7
|
this.db = db;
|
|
8
8
|
this.schedules = `${qSchema}.cost_ai_report_schedules`;
|
|
9
9
|
this.reports = `${qSchema}.cost_ai_reports`;
|
|
10
|
+
this.incidentTickets = `${qSchema}.cost_ai_incident_tickets`;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
async getSchedule(tenantId) {
|
|
@@ -71,8 +72,14 @@ export class CostAnalysisReportRepository {
|
|
|
71
72
|
return rows(await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'RUNNING', attempt_count = attempt_count + 1, started_at = now(), error = NULL, updated_at = now() WHERE id = ${id} AND status = 'PENDING' RETURNING *`))[0] || null;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
async
|
|
75
|
-
await this.db.execute(sql`
|
|
75
|
+
async setTaskStarted(id, { taskArn, provider, modelId, source, artifactBucket, markdownKey, pdfKey }) {
|
|
76
|
+
await this.db.execute(sql`
|
|
77
|
+
UPDATE ${sql.raw(this.reports)}
|
|
78
|
+
SET ecs_task_arn = ${taskArn}, provider_name = ${provider}, model_id = ${modelId},
|
|
79
|
+
provider_source = ${source}, artifact_bucket = ${artifactBucket},
|
|
80
|
+
markdown_key = ${markdownKey}, pdf_key = ${pdfKey}, updated_at = now()
|
|
81
|
+
WHERE id = ${id}
|
|
82
|
+
`);
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
async completeExternal(id, result) {
|
|
@@ -83,11 +90,72 @@ export class CostAnalysisReportRepository {
|
|
|
83
90
|
result = ${JSON.stringify(result.summary || {})}::jsonb, artifact_bucket = ${result.artifactBucket},
|
|
84
91
|
markdown_key = ${result.markdownKey}, markdown_size = ${Number(result.markdownSize || 0)},
|
|
85
92
|
pdf_key = ${result.pdfKey}, pdf_size = ${Number(result.pdfSize || 0)}, pdf_data = NULL,
|
|
93
|
+
tickets_key = ${result.ticketsKey || null}, ticket_count = ${Number(result.ticketCount || 0)},
|
|
94
|
+
incident_sync_status = ${Number(result.ticketCount || 0) > 0 ? "PENDING" : "COMPLETED"},
|
|
95
|
+
incident_sync_error = NULL,
|
|
96
|
+
incident_synced_at = ${Number(result.ticketCount || 0) > 0 ? null : new Date()},
|
|
86
97
|
completed_at = now(), updated_at = now()
|
|
87
98
|
WHERE id = ${id}
|
|
88
99
|
`);
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
async findIncidentSyncCandidates(limit = 10) {
|
|
103
|
+
return rows(await this.db.execute(sql`
|
|
104
|
+
SELECT * FROM ${sql.raw(this.reports)}
|
|
105
|
+
WHERE status = 'COMPLETED'
|
|
106
|
+
AND COALESCE(ticket_count, (result->>'ticketCount')::integer, 0) > 0
|
|
107
|
+
AND incident_sync_status IN ('PENDING', 'FAILED')
|
|
108
|
+
ORDER BY completed_at ASC
|
|
109
|
+
LIMIT ${Math.min(Math.max(Number(limit) || 10, 1), 50)}
|
|
110
|
+
`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async stageIncidentTickets(report, tickets) {
|
|
114
|
+
for (const ticket of tickets) {
|
|
115
|
+
await this.db.execute(sql`
|
|
116
|
+
INSERT INTO ${sql.raw(this.incidentTickets)}
|
|
117
|
+
(tenant_id, ticket_id, report_id, payload)
|
|
118
|
+
VALUES (${report.tenant_id}, ${ticket.ticket_id}, ${report.id}, ${JSON.stringify(ticket)}::jsonb)
|
|
119
|
+
ON CONFLICT (tenant_id, ticket_id) DO UPDATE SET
|
|
120
|
+
payload = EXCLUDED.payload, report_id = EXCLUDED.report_id, updated_at = now()
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async listUnsyncedIncidentTickets(reportId) {
|
|
126
|
+
return rows(await this.db.execute(sql`
|
|
127
|
+
SELECT * FROM ${sql.raw(this.incidentTickets)}
|
|
128
|
+
WHERE report_id = ${reportId} AND sync_status IN ('PENDING', 'FAILED')
|
|
129
|
+
ORDER BY created_at ASC
|
|
130
|
+
`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async markIncidentTicketSynced(tenantId, ticketId, incidentId) {
|
|
134
|
+
await this.db.execute(sql`
|
|
135
|
+
UPDATE ${sql.raw(this.incidentTickets)}
|
|
136
|
+
SET incident_id = ${incidentId}, sync_status = 'COMPLETED', sync_error = NULL,
|
|
137
|
+
synced_at = now(), updated_at = now()
|
|
138
|
+
WHERE tenant_id = ${tenantId} AND ticket_id = ${ticketId}
|
|
139
|
+
`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async markIncidentTicketFailed(tenantId, ticketId, error) {
|
|
143
|
+
await this.db.execute(sql`
|
|
144
|
+
UPDATE ${sql.raw(this.incidentTickets)}
|
|
145
|
+
SET sync_status = 'FAILED', sync_error = ${String(error?.message || error).slice(0, 2000)}, updated_at = now()
|
|
146
|
+
WHERE tenant_id = ${tenantId} AND ticket_id = ${ticketId}
|
|
147
|
+
`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async setIncidentSyncState(reportId, status, error = null) {
|
|
151
|
+
await this.db.execute(sql`
|
|
152
|
+
UPDATE ${sql.raw(this.reports)}
|
|
153
|
+
SET incident_sync_status = ${status}, incident_sync_error = ${error ? String(error?.message || error).slice(0, 2000) : null},
|
|
154
|
+
incident_synced_at = ${status === "COMPLETED" ? new Date() : null}, updated_at = now()
|
|
155
|
+
WHERE id = ${reportId}
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
|
|
91
159
|
async complete(id, analysis, pdf) {
|
|
92
160
|
await this.db.execute(sql`
|
|
93
161
|
UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', model_id = ${analysis.modelId}, data_source = ${analysis.dataSource || null},
|
|
@@ -106,7 +174,8 @@ export class CostAnalysisReportRepository {
|
|
|
106
174
|
return rows(await this.db.execute(sql`
|
|
107
175
|
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
108
176
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
109
|
-
markdown_key, markdown_size, pdf_key, pdf_size,
|
|
177
|
+
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
178
|
+
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
110
179
|
started_at, completed_at, created_at
|
|
111
180
|
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
|
|
112
181
|
ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
|
|
@@ -120,9 +189,18 @@ export class CostAnalysisReportRepository {
|
|
|
120
189
|
return rows(await this.db.execute(sql`
|
|
121
190
|
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
122
191
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
123
|
-
markdown_key, markdown_size, pdf_key, pdf_size,
|
|
192
|
+
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
193
|
+
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
124
194
|
started_at, completed_at, created_at
|
|
125
195
|
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
|
|
126
196
|
`))[0] || null;
|
|
127
197
|
}
|
|
198
|
+
|
|
199
|
+
async remove(tenantId, id) {
|
|
200
|
+
return rows(await this.db.execute(sql`
|
|
201
|
+
DELETE FROM ${sql.raw(this.reports)}
|
|
202
|
+
WHERE tenant_id = ${tenantId} AND id = ${id}
|
|
203
|
+
RETURNING id
|
|
204
|
+
`))[0] || null;
|
|
205
|
+
}
|
|
128
206
|
}
|
package/src/routes/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export function createCostRouter({ costController, budgetController, costAnalysi
|
|
|
15
15
|
router.put("/analysis/schedule", costAnalysisController.updateSchedule.bind(costAnalysisController));
|
|
16
16
|
router.get("/analysis/reports", costAnalysisController.reports.bind(costAnalysisController));
|
|
17
17
|
router.get("/analysis/reports/:id", costAnalysisController.report.bind(costAnalysisController));
|
|
18
|
+
router.delete("/analysis/reports/:id", costAnalysisController.removeReport.bind(costAnalysisController));
|
|
18
19
|
router.get("/analysis/reports/:id/pdf", costAnalysisController.downloadPdf.bind(costAnalysisController));
|
|
19
20
|
router.get("/analysis/reports/:id/markdown", costAnalysisController.downloadMarkdown.bind(costAnalysisController));
|
|
20
21
|
router.get("/budgets", budgetController.list.bind(budgetController));
|
|
@@ -38,6 +38,11 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
38
38
|
markdown_key text,
|
|
39
39
|
markdown_size integer,
|
|
40
40
|
pdf_key text,
|
|
41
|
+
tickets_key text,
|
|
42
|
+
ticket_count integer NOT NULL DEFAULT 0,
|
|
43
|
+
incident_sync_status text NOT NULL DEFAULT 'PENDING' CHECK (incident_sync_status IN ('PENDING', 'COMPLETED', 'FAILED')),
|
|
44
|
+
incident_sync_error text,
|
|
45
|
+
incident_synced_at timestamptz,
|
|
41
46
|
error text,
|
|
42
47
|
started_at timestamptz,
|
|
43
48
|
completed_at timestamptz,
|
|
@@ -54,4 +59,26 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
54
59
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS pdf_key text`));
|
|
55
60
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_name text`));
|
|
56
61
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_source text`));
|
|
62
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS tickets_key text`));
|
|
63
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS ticket_count integer NOT NULL DEFAULT 0`));
|
|
64
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_sync_status text NOT NULL DEFAULT 'PENDING'`));
|
|
65
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_sync_error text`));
|
|
66
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_synced_at timestamptz`));
|
|
67
|
+
await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_reports
|
|
68
|
+
SET ticket_count = (result->>'ticketCount')::integer
|
|
69
|
+
WHERE ticket_count = 0 AND result ? 'ticketCount' AND (result->>'ticketCount') ~ '^[0-9]+$'`));
|
|
70
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_incident_tickets (
|
|
71
|
+
tenant_id text NOT NULL,
|
|
72
|
+
ticket_id text NOT NULL,
|
|
73
|
+
report_id text NOT NULL REFERENCES ${qSchema}.cost_ai_reports(id) ON DELETE CASCADE,
|
|
74
|
+
payload jsonb NOT NULL,
|
|
75
|
+
incident_id text,
|
|
76
|
+
sync_status text NOT NULL DEFAULT 'PENDING' CHECK (sync_status IN ('PENDING', 'COMPLETED', 'FAILED')),
|
|
77
|
+
sync_error text,
|
|
78
|
+
synced_at timestamptz,
|
|
79
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
80
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
81
|
+
PRIMARY KEY (tenant_id, ticket_id)
|
|
82
|
+
)`));
|
|
83
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_incident_tickets_report_idx ON ${qSchema}.cost_ai_incident_tickets (report_id, sync_status)`));
|
|
57
84
|
}
|
|
@@ -218,7 +218,13 @@ export class CostAnalyserService {
|
|
|
218
218
|
throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
|
|
219
219
|
}
|
|
220
220
|
const taskArn = launched.tasks[0].taskArn;
|
|
221
|
-
await onStarted?.(
|
|
221
|
+
await onStarted?.({
|
|
222
|
+
taskArn,
|
|
223
|
+
...this.statusSummary(provider),
|
|
224
|
+
artifactBucket: this.reportBucket,
|
|
225
|
+
markdownKey: `${prefix}/report.md`,
|
|
226
|
+
pdfKey: `${prefix}/report.pdf`,
|
|
227
|
+
});
|
|
222
228
|
const deadline = Date.now() + this.timeoutMs;
|
|
223
229
|
while (Date.now() < deadline) {
|
|
224
230
|
const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
|
|
@@ -49,6 +49,25 @@ export class CostAnalysisReportService {
|
|
|
49
49
|
return reportDto(row);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
async remove(req, id) {
|
|
53
|
+
if (String(req.user?.role || "").toLowerCase() !== "admin") {
|
|
54
|
+
throw Object.assign(new Error("Administrator access is required to remove AI report history"), { statusCode: 403 });
|
|
55
|
+
}
|
|
56
|
+
const tenantId = this.tenant(req);
|
|
57
|
+
const row = await this.repository.find(tenantId, id, true);
|
|
58
|
+
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
59
|
+
if (row.status === "PENDING" || row.status === "RUNNING") {
|
|
60
|
+
throw Object.assign(new Error("A running AI cost report cannot be removed"), { statusCode: 409 });
|
|
61
|
+
}
|
|
62
|
+
const artifactKey = row.markdown_key || row.pdf_key;
|
|
63
|
+
if (row.artifact_bucket && artifactKey) {
|
|
64
|
+
const slash = artifactKey.lastIndexOf("/");
|
|
65
|
+
await this.artifactService.removePrefix(row.artifact_bucket, slash >= 0 ? artifactKey.slice(0, slash + 1) : artifactKey);
|
|
66
|
+
}
|
|
67
|
+
await this.repository.remove(tenantId, id);
|
|
68
|
+
return { success: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
async pdf(req, id) {
|
|
53
72
|
const row = await this.repository.find(this.tenant(req), id, true);
|
|
54
73
|
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class CostIncidentSyncService {
|
|
2
|
+
constructor({ repository, artifactService, incidentSink = null, logger = console }) {
|
|
3
|
+
this.repository = repository;
|
|
4
|
+
this.artifactService = artifactService;
|
|
5
|
+
this.incidentSink = incidentSink;
|
|
6
|
+
this.logger = logger;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ticketsKey(report) {
|
|
10
|
+
if (report.tickets_key) return report.tickets_key;
|
|
11
|
+
const artifactKey = report.markdown_key || report.pdf_key || "";
|
|
12
|
+
const slash = artifactKey.lastIndexOf("/");
|
|
13
|
+
return slash >= 0 ? `${artifactKey.slice(0, slash + 1)}tickets.json` : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async syncReport(report) {
|
|
17
|
+
if (!this.incidentSink) return false;
|
|
18
|
+
try {
|
|
19
|
+
const key = this.ticketsKey(report);
|
|
20
|
+
if (!report.artifact_bucket || !key) throw new Error("FinOps ticket artifact location is missing");
|
|
21
|
+
const data = await this.artifactService.get(report.artifact_bucket, key);
|
|
22
|
+
if (!data) throw new Error("FinOps ticket artifact is empty");
|
|
23
|
+
const parsed = JSON.parse(data.toString("utf8"));
|
|
24
|
+
const tickets = Array.isArray(parsed?.tickets) ? parsed.tickets.filter((ticket) => ticket?.ticket_id) : [];
|
|
25
|
+
await this.repository.stageIncidentTickets(report, tickets);
|
|
26
|
+
|
|
27
|
+
for (const row of await this.repository.listUnsyncedIncidentTickets(report.id)) {
|
|
28
|
+
try {
|
|
29
|
+
const result = await this.incidentSink({ tenantId: report.tenant_id, report, ticket: row.payload });
|
|
30
|
+
if (!result?.id) throw new Error("Incident integration did not return an incident ID");
|
|
31
|
+
await this.repository.markIncidentTicketSynced(report.tenant_id, row.ticket_id, result.id);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
await this.repository.markIncidentTicketFailed(report.tenant_id, row.ticket_id, error);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await this.repository.setIncidentSyncState(report.id, "COMPLETED");
|
|
39
|
+
return true;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await this.repository.setIncidentSyncState(report.id, "FAILED", error);
|
|
42
|
+
this.logger.error?.("[Cost AI Reports] Incident synchronization failed", {
|
|
43
|
+
tenantId: report.tenant_id,
|
|
44
|
+
reportId: report.id,
|
|
45
|
+
message: error.message,
|
|
46
|
+
});
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async syncPending(limit = 10) {
|
|
52
|
+
if (!this.incidentSink) return;
|
|
53
|
+
for (const report of await this.repository.findIncidentSyncCandidates(limit)) {
|
|
54
|
+
await this.syncReport(report);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
1
|
+
import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
|
2
2
|
|
|
3
3
|
async function bytes(body) {
|
|
4
4
|
if (!body) return Buffer.alloc(0);
|
|
@@ -18,5 +18,21 @@ export class CostReportArtifactService {
|
|
|
18
18
|
const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
19
19
|
return bytes(response.Body);
|
|
20
20
|
}
|
|
21
|
-
}
|
|
22
21
|
|
|
22
|
+
async removePrefix(bucket, prefix) {
|
|
23
|
+
if (!bucket || !prefix) return;
|
|
24
|
+
let continuationToken;
|
|
25
|
+
do {
|
|
26
|
+
const page = await this.client.send(new ListObjectsV2Command({
|
|
27
|
+
Bucket: bucket,
|
|
28
|
+
Prefix: prefix,
|
|
29
|
+
ContinuationToken: continuationToken,
|
|
30
|
+
}));
|
|
31
|
+
const objects = (page.Contents || []).map(({ Key }) => ({ Key })).filter(({ Key }) => Boolean(Key));
|
|
32
|
+
if (objects.length) {
|
|
33
|
+
await this.client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects, Quiet: true } }));
|
|
34
|
+
}
|
|
35
|
+
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
|
|
36
|
+
} while (continuationToken);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
|
|
2
2
|
export class CostAnalysisReportWorker {
|
|
3
|
-
constructor({ repository, analyserService, logger = console, env = process.env }) {
|
|
3
|
+
constructor({ repository, analyserService, incidentSyncService = null, logger = console, env = process.env }) {
|
|
4
4
|
this.repository = repository;
|
|
5
5
|
this.analyserService = analyserService;
|
|
6
|
+
this.incidentSyncService = incidentSyncService;
|
|
6
7
|
this.logger = logger;
|
|
7
8
|
this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
|
|
8
9
|
this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
|
|
@@ -27,9 +28,10 @@ export class CostAnalysisReportWorker {
|
|
|
27
28
|
reportId: report.id,
|
|
28
29
|
range,
|
|
29
30
|
frequency: schedule.frequency,
|
|
30
|
-
onStarted: (
|
|
31
|
+
onStarted: (metadata) => this.repository.setTaskStarted(report.id, metadata),
|
|
31
32
|
});
|
|
32
33
|
await this.repository.completeExternal(report.id, result);
|
|
34
|
+
await this.incidentSyncService?.syncPending?.();
|
|
33
35
|
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
34
36
|
} catch (error) {
|
|
35
37
|
await this.repository.fail(report.id, error);
|
|
@@ -42,6 +44,7 @@ export class CostAnalysisReportWorker {
|
|
|
42
44
|
if (!this.enabled || this.running) return;
|
|
43
45
|
this.running = true;
|
|
44
46
|
try {
|
|
47
|
+
await this.incidentSyncService?.syncPending?.();
|
|
45
48
|
const schedules = await this.repository.claimDue(this.batchSize, this.leaseMs);
|
|
46
49
|
for (const schedule of schedules) await this.run(schedule);
|
|
47
50
|
} catch (error) {
|