Resourcemanagement/server/aws.js
vickytechkey 8760d39ff0
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 14s
feat: add AWS SDK dependencies and database migration for aws_account table
2026-10-23 20:29:22 +05:30

546 lines
20 KiB
JavaScript

const { R } = require("redbean-node");
const { EC2Client, DescribeInstancesCommand, DescribeRegionsCommand } = require("@aws-sdk/client-ec2");
const { CloudWatchClient, GetMetricDataCommand } = require("@aws-sdk/client-cloudwatch");
const { log } = require("../src/util");
/**
* AWS Account and EC2 Resource Management Service
*/
class AWSAccount {
/**
* Build AWS Client configuration with credentials and region
* @param {object} account AWS account record or plain object
* @param {string} [overrideRegion] Optional region override
* @returns {object} AWS client options
*/
static getClientConfig(account, overrideRegion) {
const region = overrideRegion || account.region || "us-east-1";
const config = { region };
if (account.auth_type === "keys" && account.access_key_id && account.secret_access_key) {
config.credentials = {
accessKeyId: account.access_key_id.trim(),
secretAccessKey: account.secret_access_key.trim(),
};
if (account.session_token && account.session_token.trim()) {
config.credentials.sessionToken = account.session_token.trim();
}
}
// If auth_type === 'iam_role', AWS SDK automatically discovers credentials
// via environment variables, ECS metadata, or EC2 instance profile.
return config;
}
/**
* Save or update an AWS account
* @param {object} account Account data
* @param {?number} accountID Existing account ID if updating
* @param {number} userID User ID
* @returns {Promise<object>} Saved account
*/
static async save(account, accountID, userID) {
let bean;
if (accountID) {
bean = await R.findOne("aws_account", " id = ? ", [accountID]);
if (!bean) {
throw new Error("AWS Account not found");
}
} else {
bean = R.dispense("aws_account");
bean.created_date = R.isoDateTime();
}
bean.name = (account.name || "").trim() || "AWS Account";
bean.auth_type = account.authType || account.auth_type || "keys";
bean.region = (account.region || "us-east-1").trim();
bean.user_id = userID;
if (bean.auth_type === "keys") {
bean.access_key_id = (account.accessKeyId || account.access_key_id || "").trim();
// Don't overwrite secret with masked string
const newSecret = account.secretAccessKey || account.secret_access_key;
if (newSecret && !newSecret.startsWith("••••") && !newSecret.startsWith("****")) {
bean.secret_access_key = newSecret.trim();
}
const newToken = account.sessionToken || account.session_token;
if (newToken !== undefined) {
bean.session_token = newToken ? newToken.trim() : null;
}
} else {
bean.access_key_id = null;
bean.secret_access_key = null;
bean.session_token = null;
}
await R.store(bean);
return AWSAccount.sanitizeAccount(bean.export());
}
/**
* Delete an AWS account
* @param {number} accountID Account ID
* @returns {Promise<void>}
*/
static async delete(accountID) {
const bean = await R.findOne("aws_account", " id = ? ", [accountID]);
if (!bean) {
throw new Error("AWS Account not found");
}
await R.trash(bean);
}
/**
* List all AWS accounts (with masked secrets)
* @param {number} [userID] Optional user filter
* @returns {Promise<object[]>}
*/
static async list(userID) {
let beans;
if (userID) {
beans = await R.find("aws_account", " user_id = ? ORDER BY id ASC ", [userID]);
} else {
beans = await R.find("aws_account", " ORDER BY id ASC ");
}
return beans.map((bean) => AWSAccount.sanitizeAccount(bean.export()));
}
/**
* Get account bean including secret for server-side operations
* @param {number} accountID Account ID
* @returns {Promise<object>}
*/
static async getAccountRaw(accountID) {
const bean = await R.findOne("aws_account", " id = ? ", [accountID]);
if (!bean) {
throw new Error(`AWS Account #${accountID} not found`);
}
return bean.export();
}
/**
* Sanitize account object for frontend
* @param {object} account Account object
* @returns {object} Sanitized account
*/
static sanitizeAccount(account) {
const sanitized = { ...account };
if (sanitized.secret_access_key) {
sanitized.hasSecret = true;
sanitized.secret_access_key = "••••••••••••••••";
} else {
sanitized.hasSecret = false;
}
if (sanitized.session_token) {
sanitized.hasToken = true;
sanitized.session_token = "••••••••";
}
return sanitized;
}
/**
* Test connection to AWS
* @param {object} account Account object
* @param {string} [region] Region to test
* @returns {Promise<{ ok: boolean, regionsCount: number, instancesCount: number, message: string }>}
*/
static async testConnection(account, region) {
const testRegion = region || account.region || "us-east-1";
const config = AWSAccount.getClientConfig(account, testRegion);
const ec2 = new EC2Client(config);
const startTime = Date.now();
const describeCmd = new DescribeInstancesCommand({ MaxResults: 10 });
const res = await ec2.send(describeCmd);
let instancesCount = 0;
if (res.Reservations) {
for (const r of res.Reservations) {
instancesCount += (r.Instances || []).length;
}
}
const latency = Date.now() - startTime;
return {
ok: true,
instancesCount,
latencyMs: latency,
message: `Connected successfully (${latency}ms). Found ${instancesCount} instance(s) in sample.`,
};
}
/**
* List all AWS EC2 instances for an account and region
* @param {object} account Account object
* @param {string} [region] Region to query
* @returns {Promise<object[]>} List of instances
*/
static async getEc2Instances(account, region) {
const targetRegion = region || account.region || "us-east-1";
const config = AWSAccount.getClientConfig(account, targetRegion);
const ec2 = new EC2Client(config);
const cmd = new DescribeInstancesCommand({});
const response = await ec2.send(cmd);
const instances = [];
if (response.Reservations) {
for (const res of response.Reservations) {
if (!res.Instances) continue;
for (const inst of res.Instances) {
let name = inst.InstanceId;
const tags = {};
if (inst.Tags) {
for (const tag of inst.Tags) {
tags[tag.Key] = tag.Value;
if (tag.Key === "Name" && tag.Value) {
name = tag.Value;
}
}
}
instances.push({
id: inst.InstanceId,
name,
state: inst.State?.Name || "unknown",
stateCode: inst.State?.Code,
type: inst.InstanceType,
publicIp: inst.PublicIpAddress || null,
privateIp: inst.PrivateIpAddress || null,
az: inst.Placement?.AvailabilityZone || null,
launchTime: inst.LaunchTime ? new Date(inst.LaunchTime).toISOString() : null,
vpcId: inst.VpcId || null,
subnetId: inst.SubnetId || null,
platform: inst.Platform || "linux",
architecture: inst.Architecture || "x86_64",
tags,
});
}
}
}
// Sort: running first, then alphabetically by name
instances.sort((a, b) => {
if (a.state === "running" && b.state !== "running") return -1;
if (a.state !== "running" && b.state === "running") return 1;
return a.name.localeCompare(b.name);
});
return instances;
}
/**
* Fetch CloudWatch metrics for active instances (CPU, Memory via CWAgent, Network, Status)
* @param {object} account Account object
* @param {string} region Region
* @param {string[]} instanceIds List of instance IDs
* @returns {Promise<Record<string, object>>} Map of instanceId -> metrics
*/
static async getLiveInstanceMetrics(account, region, instanceIds) {
if (!instanceIds || instanceIds.length === 0) {
return {};
}
const targetRegion = region || account.region || "us-east-1";
const config = AWSAccount.getClientConfig(account, targetRegion);
const cw = new CloudWatchClient(config);
const now = new Date();
const startTime = new Date(now.getTime() - 20 * 60 * 1000); // last 20 minutes
const queries = [];
instanceIds.forEach((id, idx) => {
// CPU Utilization (AWS/EC2)
queries.push({
Id: `cpu_${idx}`,
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Dimensions: [{ Name: "InstanceId", Value: id }],
},
Period: 300,
Stat: "Average",
},
ReturnData: true,
});
// Memory Used Percent (CWAgent default)
queries.push({
Id: `mem_${idx}`,
MetricStat: {
Metric: {
Namespace: "CWAgent",
MetricName: "mem_used_percent",
Dimensions: [{ Name: "InstanceId", Value: id }],
},
Period: 300,
Stat: "Average",
},
ReturnData: true,
});
// Network In
queries.push({
Id: `net_in_${idx}`,
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "NetworkIn",
Dimensions: [{ Name: "InstanceId", Value: id }],
},
Period: 300,
Stat: "Average",
},
ReturnData: true,
});
// Network Out
queries.push({
Id: `net_out_${idx}`,
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "NetworkOut",
Dimensions: [{ Name: "InstanceId", Value: id }],
},
Period: 300,
Stat: "Average",
},
ReturnData: true,
});
// Status Check Failed
queries.push({
Id: `status_${idx}`,
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "StatusCheckFailed",
Dimensions: [{ Name: "InstanceId", Value: id }],
},
Period: 300,
Stat: "Maximum",
},
ReturnData: true,
});
});
const metricsByInstance = {};
instanceIds.forEach((id) => {
metricsByInstance[id] = {
cpuUtilization: null,
memoryUtilization: null,
hasMemoryAgent: false,
networkInBps: null,
networkOutBps: null,
statusCheckFailed: 0,
};
});
// CloudWatch allows up to 500 queries per call. Chunk if needed.
const chunkSize = 100;
for (let i = 0; i < queries.length; i += chunkSize) {
const chunk = queries.slice(i, i + chunkSize);
try {
const cmd = new GetMetricDataCommand({
StartTime: startTime,
EndTime: now,
MetricDataQueries: chunk,
});
const response = await cw.send(cmd);
if (response.MetricDataResults) {
for (const res of response.MetricDataResults) {
const parts = res.Id.split("_");
const type = parts.slice(0, -1).join("_");
const idx = parseInt(parts[parts.length - 1], 10);
const instId = instanceIds[idx];
if (!instId || !metricsByInstance[instId]) continue;
const val = res.Values && res.Values.length > 0 ? res.Values[0] : null;
if (type === "cpu") {
metricsByInstance[instId].cpuUtilization = val !== null ? Math.round(val * 100) / 100 : null;
} else if (type === "mem") {
if (val !== null) {
metricsByInstance[instId].memoryUtilization = Math.round(val * 100) / 100;
metricsByInstance[instId].hasMemoryAgent = true;
}
} else if (type === "net_in") {
// Convert 300s average sum to Bytes/sec
metricsByInstance[instId].networkInBps = val !== null ? Math.round(val / 300) : null;
} else if (type === "net_out") {
metricsByInstance[instId].networkOutBps = val !== null ? Math.round(val / 300) : null;
} else if (type === "status") {
metricsByInstance[instId].statusCheckFailed = val !== null ? Math.round(val) : 0;
}
}
}
} catch (err) {
log.warn("aws", `Failed to fetch metric chunk: ${err.message}`);
}
}
return metricsByInstance;
}
/**
* Fetch historical time-series metrics for an instance
* @param {object} account Account object
* @param {string} region Region
* @param {string} instanceId Instance ID
* @param {number} [hours=1] Historical period in hours
* @returns {Promise<object>} Time-series data
*/
static async getInstanceHistory(account, region, instanceId, hours = 1) {
const targetRegion = region || account.region || "us-east-1";
const config = AWSAccount.getClientConfig(account, targetRegion);
const cw = new CloudWatchClient(config);
const now = new Date();
const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000);
// Period: 60s for 1h, 300s for >1h
const period = hours <= 2 ? 60 : 300;
const queries = [
{
Id: "cpu",
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
{
Id: "mem",
MetricStat: {
Metric: {
Namespace: "CWAgent",
MetricName: "mem_used_percent",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
{
Id: "net_in",
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "NetworkIn",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
{
Id: "net_out",
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "NetworkOut",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
{
Id: "disk_read",
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "DiskReadBytes",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
{
Id: "disk_write",
MetricStat: {
Metric: {
Namespace: "AWS/EC2",
MetricName: "DiskWriteBytes",
Dimensions: [{ Name: "InstanceId", Value: instanceId }],
},
Period: period,
Stat: "Average",
},
ReturnData: true,
},
];
const cmd = new GetMetricDataCommand({
StartTime: startTime,
EndTime: now,
MetricDataQueries: queries,
});
const response = await cw.send(cmd);
const result = {
instanceId,
period,
hasMemoryAgent: false,
metrics: {
cpu: [],
mem: [],
netIn: [],
netOut: [],
diskRead: [],
diskWrite: [],
},
};
if (response.MetricDataResults) {
for (const r of response.MetricDataResults) {
const metricKey =
r.Id === "cpu" ? "cpu" :
r.Id === "mem" ? "mem" :
r.Id === "net_in" ? "netIn" :
r.Id === "net_out" ? "netOut" :
r.Id === "disk_read" ? "diskRead" :
r.Id === "disk_write" ? "diskWrite" : null;
if (!metricKey) continue;
const points = [];
const timestamps = r.Timestamps || [];
const values = r.Values || [];
for (let i = 0; i < timestamps.length; i++) {
points.push({
time: new Date(timestamps[i]).toISOString(),
value: Math.round(values[i] * 100) / 100,
});
}
// Sort chronologically ascending
points.sort((a, b) => new Date(a.time) - new Date(b.time));
result.metrics[metricKey] = points;
if (metricKey === "mem" && points.length > 0) {
result.hasMemoryAgent = true;
}
}
}
return result;
}
}
module.exports = { AWSAccount };