parser-service/terraform.js (314 lines of code) (raw):
/**
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* terraform.js manages tasks related to Terraform such as downloading the
* terraform state from a GCP bucket and making changes to existing terraform
* files based on recommendations received.
*/
import { Storage } from '@google-cloud/storage';
import { Resource } from '@google-cloud/resource';
import fs from 'fs-extra';
import path from 'path';
const GIT_WORK_DIR_PATH = '/repo';
const TERRAFORM_STATE_BUCKET = process.env.TERRAFORM_STATE_BUCKET;
const storage = new Storage();
const stateBucket = storage.bucket(TERRAFORM_STATE_BUCKET);
const resource = new Resource();
/**
* Fetches the Terraform state from the bucket and returns it as a JSON object.
* @return {Object} The Terraform state in JSON format.
*/
const getTFState = async () => {
const result = await stateBucket
.file('terraform/state/default.tfstate')
.download();
const state = JSON.parse(result[0].toString());
return state;
};
/**
* Finds and returns virtual machine resources by their IDs from the Terraform state.
* @param {Object} state The Terraform state.
* @param {Array} vmList The list of VM identifiers.
* @return {Array} List of VM resources found.
*/
const getVMResourcesByIdFromState = (state, vmList) => {
const instancesFound = [];
const instanceIdPrefixToRemove = '//compute.googleapis.com/';
for (const resource of state.resources) {
if (resource.type === 'google_compute_instance') {
resource.instances.forEach(instance => {
vmList.forEach(vm => {
if (
vm.instanceID.substring(instanceIdPrefixToRemove.length) ===
instance.attributes.id
) {
instancesFound.push({
...vm,
tfResourceName: resource.name,
});
}
});
});
}
}
return instancesFound;
};
/**
* Gets the project number from the given project ID.
* @param {string} projectID The Google Cloud Project ID.
* @return {Promise<string>} The project number.
*/
const getProjectNumberFromProjectID = async projectID => {
const project = resource.project(projectID);
const projectInfo = await project.get();
return projectInfo[0].metadata.projectNumber;
};
/**
* Retrieves IAM bindings based on recommendations.
* @param {Object} state The Terraform state.
* @param {Array} iamRecommendations The list of IAM recommendations.
* @param {boolean} isStub Flag to indicate if it's a stub.
* @return {Promise<Array>} The list of resources to remove.
*/
const getIAMBindingsFromState = async (state, iamRecommendations, isStub) => {
const removeResourcesFound = [];
const projectMapping = new Map();
for (const { instances } of state.resources) {
for (const { attributes } of instances) {
let projectNumber = projectMapping.get(attributes.project);
if (!projectNumber) {
projectNumber = isStub
? attributes.project
: await getProjectNumberFromProjectID(attributes.project);
projectMapping.set(attributes.project, projectNumber);
}
}
}
state.resources.forEach(({ type, instances, name }) => {
if (type === 'google_project_iam_binding') {
instances.forEach(({ attributes }) => {
iamRecommendations.forEach(recommendation => {
const projectNumber = projectMapping.get(attributes.project);
if (
projectNumber === recommendation.project &&
attributes.role === recommendation.role &&
attributes.members.includes(recommendation.member)
) {
removeResourcesFound.push({
...recommendation,
resourceName: name,
project: attributes.project,
});
}
});
});
}
});
return removeResourcesFound;
};
/**
* Finds and modifies instance sizes based on recommendations.
* @param {string} repoPath The repository path.
* @param {Array} resources The resources to find and modify.
* @param {string} [destPath] Optional destination path for modified files.
* @return {Promise<Array>} List of recommendations claimed.
*/
const findAndModifyInstances = async (repoPath, resources, destPath) => {
const recommendationsToClaim = [];
const writePath = destPath || repoPath;
let originalFiles = await readAllTFFiles(repoPath);
const tfFiles = await replaceVariableValuesInTFFiles(repoPath, originalFiles);
resources.forEach(resource => {
originalFiles = tfFiles.map((file, index) => {
const expr = new RegExp(
`(resource\\s+"google_compute_instance"\\s+"${resource.tfResourceName}".+?machine_type\\s+=\\s+)"([\\w -]+)"`,
'gs'
);
const matches = expr.exec(file.contents);
if (matches) {
recommendationsToClaim.push({
id: resource.recommendationID,
etag: resource.recommendationETAG,
});
const lineNumToReplace = findLineNumbersFromCharacters(
file.contents,
matches.index + matches[0].indexOf('machine_type')
);
const contents = replaceLine(
originalFiles[index].contents,
lineNumToReplace,
` machine_type = "${resource.size}"`
);
return { ...file, contents };
}
return originalFiles[index];
});
});
await Promise.all(
originalFiles.map(async ({ path, contents }) => {
const filePath = path.replace(repoPath, writePath);
await fs.writeFile(filePath, contents);
})
);
return recommendationsToClaim;
};
/**
* Checks if a given file path corresponds to a Terraform file.
* @param {string} filePath The full path to the file.
* @return {boolean} True if the file is a Terraform file, otherwise false.
*/
const isTerraformFile = filePath => {
return path.extname(filePath).toLowerCase() === '.tf';
};
/**
* Reads files from a given directory and filters them based on a filtering function.
* @param {string} repoDir The directory where the files are located.
* @param {function} filterFn A filtering function to apply to each file.
* @return {Promise<Array>} A promise resolving to an array of filtered files.
*/
const readFilteredFiles = async (repoDir, filterFn) => {
const allFiles = await fs.readdir(repoDir);
return await Promise.all(
allFiles
.filter(file => filterFn(path.join(repoDir, file)))
.map(async file => {
const fullPath = path.join(repoDir, file);
const contents = await fs.readFile(fullPath, 'utf-8');
return { path: fullPath, contents };
})
);
};
/**
* Reads all Terraform files from a given directory.
* @param {string} repoDir The directory where the Terraform files are located.
* @return {Promise<Array>} A promise resolving to an array of Terraform files.
*/
const readAllTFFiles = async repoDir => {
return readFilteredFiles(repoDir, filePath => {
return !fs.statSync(filePath).isDirectory() && isTerraformFile(filePath);
});
};
/**
* Reads and parses the Terraform variable file.
* @param {string} variableFilePath The full path to the variable file.
* @return {Promise<Object>} A promise resolving to an object containing the parsed variables.
*/
const getTFVariables = async variableFilePath => {
const variables = {};
try {
const contents = await fs.readFile(variableFilePath, 'utf-8');
contents.split('\n').forEach(line => {
const [key, value] = line.split('=');
if (key && value) {
variables[key.trim()] = value.replace(/"/g, '').trim();
}
});
} catch (err) {
console.error(`Could not read ${variableFilePath}: ${err}`);
}
return variables;
};
/**
* Replaces placeholders in the file contents based on the provided variables and regex builder.
* @param {Object} file The file object containing path and contents.
* @param {Object} variables The variables to use for replacement.
* @param {function} regexBuilder A function to build regex patterns for replacement.
* @return {Object} The file object with replaced contents.
*/
const replaceInContents = (file, variables, regexBuilder) => {
let contents = file.contents;
Object.keys(variables).forEach(variable => {
const reg = regexBuilder(variable);
contents = contents.replace(reg, variables[variable]);
});
return { ...file, contents };
};
/**
* Replaces variable values in the contents of Terraform files.
* @param {string} repoDir The directory where the Terraform files are located.
* @param {Array} tfContents An array containing the contents of the Terraform files.
* @return {Promise<Array>} A promise resolving to an array of files with replaced contents.
*/
const replaceVariableValuesInTFFiles = async (repoDir, tfContents) => {
const variableFilePath = path.join(repoDir, 'terraform.tfvars');
const variables = await getTFVariables(variableFilePath);
return tfContents.map(file => replaceInContents(file, variables,
variable => new RegExp(`\\$\\{\\s*var\\.${variable}\\s*}`, 'gs'))
);
};
/**
* Replaces Service Account values in TF contents using regular expressions
*
* @param tfContents the contents of each file
* @return list of new contents
*/const replaceServiceAccountValues = tfContents => {
// Find all service accounts and create map serviceacccountname -> account_id
const serviceAccounts = {};
tfContents.forEach(file => {
const reg = new RegExp(
'resource\\s*"google_service_account"\\s*"(\\w+)"\\s*{.+?account_id\\s*=\\s*"(\\w+)".+?}', 'gs'
);
const groups = reg.exec(file.contents);
if (groups) {
serviceAccounts[groups[1]] = groups[2];
}
});
if (Object.keys(serviceAccounts).length > 0) {
return tfContents.map(file => {
let contents = file.contents;
Object.keys(serviceAccounts).forEach(sa => {
const reg = new RegExp(
`\\$\\{\\s*google_service_account\\.${sa}\\.account_id\\s*}`, 'gs'
);
contents = contents.replace(reg, serviceAccounts[sa]);
});
return {
path: file.path,
contents
};
});
}
return tfContents;
};
/**
* Goes through the cloned repo and iterates through each TF manifest to see
* if the IAM member for which role is changed needs to be updated
*
* @param repoPath the path to the terraform repository
* @param resources a list of resources to apply
* @param destPath is used for writing to a destination path for tests
* @return list of recommendation (ids and etags) which have been applied in
* the repository
*/
const findAndModifyIAMRoleBindings = async (repoPath, resources, destPath) => {
const recommendationsToClaim = [];
const writePath = destPath || repoPath;
let originalFiles = await readAllTFFiles(repoPath);
let tfFiles = await replaceVariableValuesInTFFiles(repoPath, originalFiles);
tfFiles = replaceServiceAccountValues(tfFiles);
resources.forEach(resource => {
originalFiles = tfFiles.map((file, index) => {
const expr = `resource\\s+"google_project_iam_binding"\\s+"${resource.resourceName}"\\s+{.+?project\\s*=\\s*"${resource.project}".+?role\\s*=\\s*"${resource.role}".+?members\\s*=\\s*(\\[.+?"${resource.member}".+?\\]).+?}`;
const reg = new RegExp(expr, 'gs');
const matches = reg.exec(file.contents);
if (matches) {
recommendationsToClaim.push({
id: resource.recommendationID,
etag: resource.recommendationETAG,
});
let members = JSON.parse(matches[1]);
members = members.filter(mem => mem !== resource.member);
if (members.length > 0) {
const indexOfMember = matches[0].indexOf(`"${resource.member}"`);
const lineNumToComment = findLineNumbersFromCharacters(
file.contents,
matches.index + indexOfMember
);
const contents = commentLines(
originalFiles[index].contents,
lineNumToComment,
lineNumToComment
);
return { ...file, contents };
} else {
const startLine = findLineNumbersFromCharacters(
file.contents,
matches.index
);
const endLine = findLineNumbersFromCharacters(
file.contents,
matches.index + matches[0].length
);
let contents = commentLines(
originalFiles[index].contents,
startLine,
endLine
);
let additionalLines = '';
if (resource.add) {
additionalLines = `\n\n${copyLines(
originalFiles[index].contents,
startLine,
endLine
)}`;
additionalLines = additionalLines.replace(
/(.+role\s*=\s*").+?(".+)/gs,
`$1${resource.add}$2`
);
}
return { ...file, contents: contents + additionalLines };
}
} else {
return originalFiles[index];
}
});
});
await Promise.all(
originalFiles.map(async file => {
const filePath = file.path.replace(repoPath, writePath);
await fs.writeFile(filePath, file.contents);
})
);
return recommendationsToClaim;
};
/**
* Finds the line number given the character count
*
* @param text to search through
* @param index of the character
* @return line number
*/
const findLineNumbersFromCharacters = (text, index) => {
let num = 1;
for (let i = 0; i <= index; i++) {
if (text[i] === '\n') {
num += 1;
}
}
return num;
};
/**
* Comments out lines of text between specified start and end lines.
* @param {string} text The original text.
* @param {number} startLine The 1-based line number where commenting starts.
* @param {number} endLine The 1-based line number where commenting ends.
* @return {string} The modified text with lines commented out.
*/
const commentLines = (text, startLine, endLine) => {
const allLines = text.split('\n');
allLines[startLine - 1] = `/* ${allLines[startLine - 1]}`;
allLines[endLine - 1] = `${allLines[endLine - 1]} */`;
return allLines.join('\n');
};
/**
* Copies lines of text between specified start and end lines.
* @param {string} text The original text.
* @param {number} startLine The 1-based line number where copying starts.
* @param {number} endLine The 1-based line number where copying ends.
* @return {string} The lines of text that were copied.
*/
const copyLines = (text, startLine, endLine) => {
const allLines = text.split('\n');
return allLines.slice(startLine - 1, endLine).join('\n');
};
/**
* Replaces a line of text at a given line number.
* @param {string} contents The original text.
* @param {number} lineNum The 1-based line number to replace.
* @param {string} text The new text to insert.
* @return {string} The modified text with the line replaced.
*/
const replaceLine = (contents, lineNum, text) => {
const allLines = contents.split('\n');
allLines[lineNum - 1] = text;
return allLines.join('\n');
};
/**
* Applies VM resize recommendations to a Terraform repo.
* @param {string} repoName The name of the repo where Terraform files are located.
* @param {Array} vmResizeRecommendations An array of VM resize recommendations.
* @param {boolean} isStub A flag to indicate if the function should run in stub mode.
* @return {Promise<Array>} A promise resolving to an array of claimed recommendations.
*/
const applyVMResizeRecommendations = async (repoName, vmResizeRecommendations, isStub) => {
let recommendationsToClaim = [];
const tfState = await getTFState();
const resourceNames = getVMResourcesByIdFromState(tfState, vmResizeRecommendations);
if (resourceNames.length > 0) {
recommendationsToClaim = await findAndModifyInstances(`/repo/${repoName}`, resourceNames);
}
return recommendationsToClaim;
};
/**
* Applies IAM role recommendations to a Terraform repo.
* @param {string} repoName The name of the repo where Terraform files are located.
* @param {Array} iamRecommendations An array of IAM recommendations.
* @param {boolean} isStub A flag to indicate if the function should run in stub mode.
* @return {Promise<Array>} A promise resolving to an array of claimed recommendations.
*/
const applyIAMRecommendations = async (repoName, iamRecommendations, isStub) => {
let recommendationsToClaim = [];
const tfState = await getTFState();
const resourceNames = await getIAMBindingsFromState(tfState, iamRecommendations, isStub);
if (resourceNames.length > 0) {
recommendationsToClaim = await findAndModifyIAMRoleBindings(`/repo/${repoName}`, resourceNames);
}
return recommendationsToClaim;
};
export {
GIT_WORK_DIR_PATH,
applyVMResizeRecommendations,
applyIAMRecommendations
};