547 lines
20 KiB
JavaScript
547 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 };
|