Repository: chrislewisdev/prettyplan
Branch: master
Commit: 9c4b6c58faac
Files: 22
Total size: 45.6 KB
Directory structure:
gitextract_054_3svt/
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── src/
│ ├── index.html
│ ├── style.css
│ └── ts/
│ ├── parse.ts
│ ├── prettyplan.ts
│ ├── releases.ts
│ ├── render.ts
│ └── ui.ts
├── tests/
│ ├── extractChangeSummary.test.ts
│ ├── extractInvididualChanges.test.ts
│ ├── parseChangeSymbol.test.ts
│ ├── parseId.test.ts
│ ├── parseNewAndOldValueDiffs.test.ts
│ ├── parseSingleValueDiffs.test.ts
│ └── parseWarnings.test.ts
├── tsconfig.json
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
node_modules/
dist/
package-lock.json
yarn.lock
================================================
FILE: .travis.yml
================================================
language: node_js
# Tells Travis to use the latest Node version
# It defaults to an old one otherwise
node_js: node
# By default Travis will run "npm install" and "npm test" for node projects
# So we don't need to specify them here
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Chris Lewis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# prettyplan [](https://travis-ci.com/chrislewisdev/prettyplan)
Prettyplan ([available online here](https://prettyplan.chrislewisdev.com/)) is a small tool to help you view large Terraform plans with ease. By pasting in your plan output, it will be formatted for:
- Expandable/collapsible sections to help you see your plan at a high level and in detail
- Tabular layout for easy comparison of old/new values
- Better display formatting of multi-line strings (such as JSON documents)
## Terraform Version Compatibility
Prettyplan was written to work on Terraform plans from 0.11 and earlier. In 0.12, the plan output was significantly changed, addressing many of the pain points that Prettyplan addresses; for this reason, there are no current plans to update Prettyplan to work with 0.12. In my case, Prettyplan was made unnecessary by Terraform's improvements.
Contributions are still welcome if anyone would like to upgrade the code to handle plans from 0.12 onwards.
## Contributing
You're welcome to submit ideas/bugs (via the Issues section) or improvements (via Pull Requests)!
The code has all been converted from JavaScript to TypeScript and is built by webpack. To work on the project locally, you should be able to get everything up and running just by having `npm`, running `npm install` and then `npm run serve` which will open up Prettyplan in your default browser, ready for any changes you make to the source files.
You can also run `npm run build` to build the project without a dev server.
### Tests
Tests are being run on every commit and Pull Request via Travis, but if you want to run them locally, you'll need to have `npm` on your PC, and run `npm install` followed by `npm test` in the repository.
## Deployment
The project is deployed to Netlify; a slightly older version of the code is also served on GitHub pages which used to be the main way to access Prettyplan.
## Will this steal sensitive data from my Terraform plans?
No. All the parsing/formatting is done directly in your browser, no data is sent to or from another service.
================================================
FILE: jest.config.js
================================================
module.exports = {
"roots": ["tests"],
"transform": {
"\\.ts$": "ts-jest"
},
"testRegex": "\\.test\\.ts$",
"moduleFileExtensions": ["ts", "js"]
}
================================================
FILE: package.json
================================================
{
"name": "prettyplan",
"version": "1.0.0",
"description": "Formatting tool for terraform plan output",
"author": "Chris Lewis",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/chrislewisdev/prettyplan"
},
"scripts": {
"test": "jest",
"build": "webpack",
"serve": "webpack-dev-server --open"
},
"devDependencies": {
"@types/jest": "^24.0.0",
"copy-webpack-plugin": "^4.6.0",
"html-webpack-plugin": "^3.2.0",
"jest": "^23.6.0",
"ts-jest": "^23.10.5",
"ts-loader": "^5.3.3",
"typescript": "^3.3.3",
"webpack": "^4.29.3",
"webpack-cli": "^3.2.3",
"webpack-dev-server": "^3.1.14"
}
}
================================================
FILE: src/index.html
================================================
prettyplan
prettyplan
Just paste in your output from terraform plan (or use the provided example), and hit Prettify!
Prettyplan does not support plans from Terraform 0.12+. For more info, see the project's readme .
Prettify it!
That doesn't look like a Terraform plan. Did you copy the entire output
(without colouring) from the plan command?
================================================
FILE: src/style.css
================================================
body {
font-family: Arial, Helvetica, sans-serif;
text-rendering: optimizeLegibility;
background: #ECF7FE;
color: #000000c0;
font-size: 15px;
margin: 0;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.stripe {
width: 100%;
height: 5px;
background: #5C4CE4;
animation-name: wipe-in;
animation-duration: 1s;
}
@keyframes wipe-in {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
#release-notification {
background: #5C4CE4;
color: white;
font-weight: bold;
text-align: center;
overflow: hidden;
padding: 10px 0 15px 0;
height: 20px;
animation-name: notification-pop-in;
animation-duration: 2s;
}
#release-notification a {
color: white;
}
#release-notification.dismissed {
animation-name: notification-pop-out;
animation-duration: .5s;
height: 0;
padding: 0;
}
@keyframes notification-pop-in {
0% {
height: 0;
padding: 0;
}
50% {
height: 0;
padding: 0;
}
}
@keyframes notification-pop-out {
0% {
height: 20px;
padding: 10px 0 15px 0;
}
100% {
height: 0;
padding: 0;
}
}
#modal-container {
animation-name: fade-in;
animation-duration: .2s;
}
.modal-pane {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #ffffffe6;
z-index: 10;
}
.modal-content {
position: fixed;
width: 60%;
height: 60%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #ffffff;
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
z-index: 20;
}
.modal-close {
position: absolute;
right: 0;
padding: 10px;
}
.modal-close button.text-button {
color: #4526AC;
text-decoration: none;
font-weight: normal;
}
.release-notes {
max-width: 80%;
margin: 0 auto 0 auto;
overflow-y: auto;
max-height: 100%;
}
#branding {
float: right;
padding-top: 10px;
padding-right: 10px;
font-size: 10px;
color: #4526AC;
text-align: right;
}
#branding a {
color: #4526AC;
}
.container {
margin: 10px 10px 0 10px;
animation-name: fade-in;
animation-duration: 1s;
}
@media only screen and (min-width: 600px) {
.container {
max-width: 80%;
margin-left: auto;
margin-right: auto;
}
}
h1, h2 {
text-align: center;
color: #4526AC;
}
#terraform-plan {
width: 100%;
min-height: 300px;
border: none;
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
padding: 10px;
margin-bottom: 10px;
resize: none;
background: #ffffffe6;
}
button {
font-size: 18px;
background: #5C4CE4;
color: #fff;
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
border: none;
border-radius: 2px;
width: 170px;
height: 40px;
}
button:hover {
background: #6567EA;
cursor: pointer;
}
button:active {
background: #5037CA;
}
button.text-button {
background: none;
box-shadow: none;
border-radius: 0;
width: auto;
height: auto;
text-decoration: underline;
font-size: inherit;
font-weight: inherit;
font-family: Arial, Helvetica, sans-serif;
color: inherit;
text-align: inherit;
padding: 0;
}
#parsing-error-message {
background-color: #ffffff;
padding: 10px;
color: #000000c0;
margin: 4px;
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
font-weight: bold;
border-left: 2px solid red;
animation-name: error;
animation-duration: 1s;
}
@keyframes error {
0% {
background-color: red;
}
100% {
background-color: white;
}
}
.prettyplan ul {
padding-left: 0;
font-size: 13px;
}
.prettyplan li {
list-style: none;
background: #ffffffe6;
padding: 10px;
color: #000000c0;
margin: 4px;
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
}
.prettyplan ul.warnings li {
border-left: 3px solid #757575;
}
.prettyplan ul.actions li.update {
border-left: 3px solid #ff8f00;
}
.prettyplan ul.actions li.create {
border-left: 3px solid #2e7d32;
}
.prettyplan ul.actions li.destroy {
border-left: 3px solid #b71c1c;
}
.prettyplan ul.actions li.recreate {
border-left: 3px solid #1565c0;
}
.prettyplan ul.actions li.read {
border-left: 3px solid #519bf0;
}
.badge {
display: inline-block;
text-transform: uppercase;
margin-right: 10px;
padding: 3px;
font-size: 12px;
font-weight: bold;
}
.warnings .badge {
color: #757575;
}
li.update .badge {
color: #ff8f00;
}
li.create .badge {
color: #2e7d32;
}
li.destroy .badge {
color: #b71c1c;
}
li.recreate .badge {
color: #1565c0;
}
li.read .badge {
color: #519bf0;
}
.id-segment:not(:last-child)::after {
content: " > ";
}
.id-segment.name, .id-segment.type {
font-weight: bold;
}
.change-count {
float: right;
}
.summary {
cursor: pointer;
}
.changes {
margin: 5px auto 0 auto;
padding: 5px;
}
.changes table {
width: 100%;
word-break: break-all;
font-size: 13px;
}
.changes table td {
padding: 10px;
width: 40%;
}
pre {
white-space: pre-wrap;
}
.changes table td.property {
width: 20%;
text-align: right;
font-weight: bold;
}
.changes table tr:nth-child(even) {
background-color: #f5f5f5;
}
.forces-new-resource {
color: #b71c1c;
}
.collapsed, .hidden {
display: none;
}
================================================
FILE: src/ts/parse.ts
================================================
export interface ResourceId {
name: string;
type: string;
prefixes: string[];
}
export interface Warning {
id: ResourceId;
detail: string;
}
export enum ChangeType {
Create = 'create',
Read = 'read',
Update = 'update',
Destroy = 'destroy',
Recreate = 'recreate',
Unknown = 'unknown'
}
export interface Diff {
property: string;
old?: string;
new: string;
forcesNewResource?: string;
}
export interface Action {
id: ResourceId;
type: ChangeType;
changes: Diff[];
}
export interface Plan {
warnings: Warning[];
actions: Action[];
}
export function parse(terraformPlan: string): Plan {
var warnings = parseWarnings(terraformPlan);
var changeSummary = extractChangeSummary(terraformPlan);
var changes = extractIndividualChanges(changeSummary);
var plan = { warnings: warnings, actions: [] };
for (var i = 0; i < changes.length; i++) {
plan.actions.push(parseChange(changes[i]));
}
return plan;
}
export function parseWarnings(terraformPlan: string): Warning[] {
let warningRegex: RegExp = new RegExp('Warning: (.*:)(.*)', 'gm');
let warning: RegExpExecArray;
let warnings: Warning[] = [];
do {
warning = warningRegex.exec(terraformPlan);
if (warning) {
warnings.push({ id: parseId(warning[1]), detail: warning[2] });
}
} while (warning);
return warnings;
}
export function extractChangeSummary(terraformPlan: string): string {
var beginActionRegex = new RegExp('Terraform will perform the following actions:', 'gm');
var begin = beginActionRegex.exec(terraformPlan);
if (begin) return terraformPlan.substring(begin.index + 45);
else return terraformPlan;
}
export function extractIndividualChanges(changeSummary: string): string[] {
//TODO: Fix the '-/' in '-/+' getting chopped off
var changeRegex = new RegExp('([~+-]|-\/\+|<=) [\\S\\s]*?((?=-\/\+|[~+-] |<=|Plan:)|$)', 'g');
var change;
var changes = [];
do {
change = changeRegex.exec(changeSummary);
if (change) changes.push(change[0]);
} while (change);
return changes;
}
export function parseChange(change: string): Action {
var changeTypeAndIdRegex = new RegExp('([~+-]|-\/\+|<=) (.*)$', 'gm');
var changeTypeAndId = changeTypeAndIdRegex.exec(change);
var changeTypeSymbol = changeTypeAndId[1];
var resourceId = changeTypeAndId[2];
var type;
type = parseChangeSymbol(changeTypeSymbol);
//Workaround for recreations showing up as '+' changes
if (resourceId.match('(new resource required)')) {
type = 'recreate';
resourceId = resourceId.replace(' (new resource required)', '');
}
var diffs;
if (type === 'create' || type === 'read') {
diffs = parseSingleValueDiffs(change);
}
else {
diffs = parseNewAndOldValueDiffs(change);
}
return {
id: parseId(resourceId),
type: type,
changes: diffs
};
}
export function parseId(resourceId: string): ResourceId {
var idSegments = resourceId.split('.');
var resourceName = idSegments[idSegments.length - 1];
var resourceType = idSegments[idSegments.length - 2] || null;
var resourcePrefixes = idSegments.slice(0, idSegments.length - 2);
return { name: resourceName, type: resourceType, prefixes: resourcePrefixes };
}
export function parseChangeSymbol(changeTypeSymbol): ChangeType {
if (changeTypeSymbol === "-")
return ChangeType.Destroy;
else if (changeTypeSymbol === "+")
return ChangeType.Create;
else if (changeTypeSymbol === "~")
return ChangeType.Update
else if (changeTypeSymbol === "<=")
return ChangeType.Read;
else if (changeTypeSymbol === "-/+")
return ChangeType.Recreate;
else
return ChangeType.Unknown;
}
export function parseSingleValueDiffs(change): Diff[] {
var propertyAndValueRegex = new RegExp('\\s*(.*?): *(?:|"(|[\\S\\s]*?[^\\\\])")', 'gm');
var diff;
var diffs = [];
do {
diff = propertyAndValueRegex.exec(change);
if (diff) {
diffs.push({
property: diff[1].trim(),
new: diff[2] !== undefined ? diff[2] : ""
});
}
} while (diff);
return diffs;
}
export function parseNewAndOldValueDiffs(change): Diff[] {
var propertyAndNewAndOldValueRegex = new RegExp('\\s*(.*?): *(?:"(|[\\S\\s]*?[^\\\\])")[\\S\\s]*?=> *(?:|"(|[\\S\\s]*?[^\\\\])")( \\(forces new resource\\))?', 'gm');
var diff;
var diffs = [];
do {
diff = propertyAndNewAndOldValueRegex.exec(change);
if (diff) {
diffs.push({
property: diff[1].trim(),
old: diff[2],
new: diff[3] !== undefined ? diff[3] : "",
forcesNewResource: diff[4] !== undefined
});
}
} while (diff);
return diffs;
}
================================================
FILE: src/ts/prettyplan.ts
================================================
import { getCurrentVersion, getLastUsedVersion, updateLastUsedVersion } from './releases';
import { expandAll, collapseAll, accordion, closeModal } from './ui';
import { showReleaseNotification, hideReleaseNotification, showReleaseNotes, displayParsingErrorMessage, hideParsingErrorMessage, clearExistingOutput, unHidePlan, render } from './render';
import { parse } from './parse';
window.addEventListener('load', function () {
if (getCurrentVersion() != getLastUsedVersion()) {
showReleaseNotification(getCurrentVersion());
updateLastUsedVersion();
}
});
(window).runPrettyplan = () => {
hideParsingErrorMessage();
clearExistingOutput();
var terraformPlan = (document.getElementById("terraform-plan")).value;
var plan = parse(terraformPlan);
if (plan.warnings.length === 0 && plan.actions.length === 0) {
displayParsingErrorMessage();
}
render(plan);
unHidePlan();
}
(window).showReleaseNotes = showReleaseNotes;
(window).expandAll = expandAll;
(window).collapseAll = collapseAll;
(window).accordion = accordion;
(window).closeModal = closeModal;
(window).hideReleaseNotification = hideReleaseNotification;
================================================
FILE: src/ts/releases.ts
================================================
export interface Release {
version: string;
notes: string[];
}
export function getCurrentVersion(): string {
return releases[0].version;
}
export function getLastUsedVersion(): string {
return window.localStorage.getItem('lastUsedVersion');
}
export function updateLastUsedVersion(): void {
window.localStorage.setItem('lastUsedVersion', getCurrentVersion());
}
export function getReleases(): Release[] {
return releases;
}
//New releases should always go at the top of this list.
let releases: Release[] = [
{
version: 'v1.3',
notes: [
'A command-line version of Prettyplan is now available! Check it out on GitHub '
]
},
{
version: 'v1.2',
notes: [
'<computed> values now display properly instead of being interpreted as HTML (#2 )',
'Resource changes with (forces new resource) now have this highlighted in the table of changes (#3 )',
'Italics for <computed> or ${variable} values to help set them apart from regular values'
]
},
{
version: 'v1.1',
notes: [
'Added handy release notes!',
'Fixed parsing of large AWS IAM policy documents (#10 )'
]
},
{
version: 'v1.0',
notes: [
'See your Terraform plans transformed into a beautiful tabulated format!',
'Support for prettyifying JSON content for easier reading',
'Theming consistent with the Terraform colour scheme',
'Works in Firefox, Chrome, and Edge'
]
}
];
================================================
FILE: src/ts/render.ts
================================================
import { Plan, Action, Diff, Warning, ResourceId } from './parse'
import { removeChildren, addClass, removeClass, createModalContainer } from './ui';
import { Release, getReleases } from './releases';
export function clearExistingOutput(): void {
removeChildren(document.getElementById('errors'));
removeChildren(document.getElementById('warnings'));
removeChildren(document.getElementById('actions'));
}
export function hideParsingErrorMessage(): void {
addClass(document.getElementById('parsing-error-message'), 'hidden');
}
export function displayParsingErrorMessage(): void {
removeClass(document.getElementById('parsing-error-message'), 'hidden');
}
export function unHidePlan(): void {
removeClass(document.getElementById('prettyplan'), 'hidden');
}
export function showReleaseNotification(version: string): void {
const notificationElement = document.getElementById('release-notification');
notificationElement.innerHTML = components.releaseNotification(version);
removeClass(notificationElement, 'hidden');
}
export function hideReleaseNotification(): void {
addClass(document.getElementById('release-notification'), 'dismissed');
}
export function showReleaseNotes(): void {
createModalContainer().innerHTML = components.modal(components.releaseNotes(getReleases()));
}
export function render(plan: Plan): void {
if (plan.warnings) {
const warningList = document.getElementById('warnings');
warningList.innerHTML = plan.warnings.map(components.warning).join('');
}
if (plan.actions) {
const actionList = document.getElementById('actions');
actionList.innerHTML = plan.actions.map(components.action).join('');
}
}
const components = {
badge: (label: string): string => `
${label}
`,
id: (id: ResourceId): string => `
${id.prefixes.map(prefix =>
`${prefix} `
).join('')}
${id.type}
${id.name}
`,
warning: (warning: Warning): string => `
${components.badge('warning')}
${components.id(warning.id)}
${warning.detail}
`,
changeCount: (count: number): string => `
${count + ' change' + (count > 1 ? 's' : '')}
`,
change: (change: Diff): string => `
${change.property}
${change.forcesNewResource ? `(forces new resource) ` : ''}
${change.old ? prettify(change.old) : ''}
${prettify(change.new)}
`,
action: (action: Action): string => `
${components.badge(action.type)}
${components.id(action.id)}
${action.changes ? components.changeCount(action.changes.length) : ''}
${action.changes.map(components.change).join('')}
`,
modal: (content: string): string => `
`,
releaseNotes: (releases: Release[]): string => `
Release Notes
${releases.map(components.release).join('')}
`,
release: (release: Release): string => `
${release.version}
${release.notes.map((note) => `${note} `).join('')}
`,
releaseNotification: (version: string): string => `
Welcome to ${version}!
View release notes?
(or ignore )
`
};
function prettify(value: string): string {
if (value === '')
{
return `<computed> `;
}
else if (value.startsWith('${') && value.endsWith('}'))
{
return `${value} `;
}
else if (value.indexOf('\\n') >= 0 || value.indexOf('\\"') >= 0) {
var sanitisedValue = value.replace(new RegExp('\\\\n', 'g'), '\n')
.replace(new RegExp('\\\\"', 'g'), '"');
return `${prettifyJson(sanitisedValue)} `;
}
else {
return value;
}
}
function prettifyJson(maybeJson: string): string {
try {
return JSON.stringify(JSON.parse(maybeJson), null, 2);
}
catch (e) {
return maybeJson;
}
}
================================================
FILE: src/ts/ui.ts
================================================
export function accordion(element: Element): void {
const changes = element.parentElement.getElementsByClassName('changes');
for (var i = 0; i < changes.length; i++) {
toggleClass(changes[i], 'collapsed');
}
}
export function toggleClass(element: Element, className: string): void {
if (!element.className.match(className)) {
element.className += ' ' + className;
}
else {
element.className = element.className.replace(className, '');
}
}
export function addClass(element: Element, className: string): void {
if (!element.className.match(className)) element.className += ' ' + className;
}
export function removeClass(element: Element, className: string): void {
element.className = element.className.replace(className, '');
}
export function expandAll(): void {
const sections = document.querySelectorAll('.changes.collapsed');
for (var i = 0; i < sections.length; i++) {
toggleClass(sections[i], 'collapsed');
}
toggleClass(document.querySelector('.expand-all'), 'hidden');
toggleClass(document.querySelector('.collapse-all'), 'hidden');
}
export function collapseAll(): void {
const sections = document.querySelectorAll('.changes:not(.collapsed)');
for (var i = 0; i < sections.length; i++) {
toggleClass(sections[i], 'collapsed');
}
toggleClass(document.querySelector('.expand-all'), 'hidden');
toggleClass(document.querySelector('.collapse-all'), 'hidden');
}
export function removeChildren(element: Element): void {
while (element.lastChild) {
element.removeChild(element.lastChild);
}
}
export function createModalContainer(): HTMLElement {
const modalElement = document.createElement('div');
modalElement.id = 'modal-container';
document.body.appendChild(modalElement);
return modalElement;
}
export function closeModal(): void {
const modalElement = document.getElementById('modal-container');
document.body.removeChild(modalElement);
}
================================================
FILE: tests/extractChangeSummary.test.ts
================================================
import { extractChangeSummary } from '../src/ts/parse'
test('extract change summary - single line', function() {
const extractedSummary = extractChangeSummary('Terraform will perform the following actions:');
expect(extractedSummary).toBe('');
});
test('extract change summary - multi line', function() {
const extractedSummary = extractChangeSummary(`
Text preceding the terraform plan
Terraform will perform the following actions:
`
);
expect(extractedSummary).toBe('\n\n ');
});
test('extract change summary - without any Terraform summary prefix', function() {
const extractedSummary = extractChangeSummary('');
expect(extractedSummary).toBe('');
});
================================================
FILE: tests/extractInvididualChanges.test.ts
================================================
import { extractIndividualChanges } from '../src/ts/parse';
test('extract individual changes - with plan summary at end', function() {
const changes = extractIndividualChanges(`
+ module.alb.aws_alb_listener.default_https
ssl_policy: "old" => "new"
~ module.api.aws_alb_listener_rule.default
condition.2636223071.field: "path-pattern" => ""
condition.2636223071.field: "path-pattern" => ""
condition.2636223071.field: "path-pattern" => ""
- module.api.aws_alb_target_group.default
health_check.0.path: "/healthcheck/old" => "/healthcheck/new"
-/+ module.service_a.aws_ecs_service.default
task_definition: "service-a:185" => "service-a:179"
<= module.service_b.aws_ecs_service.default
task_definition: "service-b:171" => "service-b:165"
Plan: 2 to add, 1 to change, 2 to destroy.
`);
expect(changes).toHaveLength(5);
});
test('extract individual changes - without plan summary at end', function() {
const changes = extractIndividualChanges(`
+ module.alb.aws_alb_listener.default_https
ssl_policy: "old" => "new"
~ module.api.aws_alb_listener_rule.default
condition.2636223071.field: "path-pattern" => ""
`);
expect(changes).toHaveLength(2);
});
test('extract individual changes - with extra text at the start', function() {
const changes = extractIndividualChanges(`
this text here should not be detected part of the change
neither should this
-------------------------------------------
+ module.alb.aws_alb_listener.default_https
ssl_policy: "old" => "new"
~ module.api.aws_alb_listener_rule.default
condition.2636223071.field: "path-pattern" => ""
`);
expect(changes).toHaveLength(2);
});
================================================
FILE: tests/parseChangeSymbol.test.ts
================================================
import { parseChangeSymbol } from '../src/ts/parse';
test('parse change symbol', function() {
expect(parseChangeSymbol('+')).toBe('create');
expect(parseChangeSymbol('-')).toBe('destroy');
expect(parseChangeSymbol('~')).toBe('update');
expect(parseChangeSymbol('<=')).toBe('read');
expect(parseChangeSymbol('-/+')).toBe('recreate');
expect(parseChangeSymbol('gibberish')).toBe('unknown');
});
================================================
FILE: tests/parseId.test.ts
================================================
import { parseId } from '../src/ts/parse'
test('parse id - no prefixes', function() {
const id = parseId('aws_route53_record.domain_name');
expect(id.name).toBe('domain_name');
expect(id.type).toBe('aws_route53_record');
expect(id.prefixes).toEqual([]);
});
test('parse id - with prefixes', function() {
const id = parseId('module.api.aws_ecs_service.api_service');
expect(id.name).toBe('api_service');
expect(id.type).toBe('aws_ecs_service');
expect(id.prefixes).toEqual(['module', 'api']);
});
test('parse id - name only', function() {
const id = parseId('api_service');
expect(id.name).toBe('api_service');
expect(id.type).toBeNull();
expect(id.prefixes).toEqual([]);
});
================================================
FILE: tests/parseNewAndOldValueDiffs.test.ts
================================================
import { parseNewAndOldValueDiffs } from '../src/ts/parse';
test('new and old value diffs - quote formatting', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "old_value" => "new_value"');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].old).toBe('old_value');
expect(diffs[0].new).toBe('new_value');
});
test('new and old value diffs - empty quotes', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "" => "new_value"');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].old).toBe('');
expect(diffs[0].new).toBe('new_value');
});
test('new and old value diffs - computed values', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "old_value" => ');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].old).toBe('old_value');
expect(diffs[0].new).toBe('');
});
test('new and old value diffs - whitespace handling', function() {
const diffs = parseNewAndOldValueDiffs(' property_name : " old_value " => "new_value "');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].old).toBe(' old_value ');
expect(diffs[0].new).toBe('new_value ');
});
test('new and old value diffs - multi line', function() {
const diffs = parseNewAndOldValueDiffs('property1: "old1" => "new1"\n property2: "old2" => "new2"');
expect(diffs).toHaveLength(2);
expect(diffs[0].property).toBe('property1');
expect(diffs[0].old).toBe('old1');
expect(diffs[0].new).toBe('new1');
expect(diffs[1].property).toBe('property2');
expect(diffs[1].old).toBe('old2');
expect(diffs[1].new).toBe('new2');
});
test('new and old value diffs - IAM policy document', function() {
const diffs = parseNewAndOldValueDiffs(`
policy: "{\\r\\n \\"Version\\": \\"2012-10-17\\",\\r\\n \\"Statement\\": [\\r\\n {\\r\\n \\"Action\\": [\\r\\n \\"sqs:*\\",\\r\\n \\"sns:*\\"\\r\\n\\r\\n ],\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\",\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n \\"logs:CreateLogGroup\\",\\r\\n \\"logs:CreateLogStream\\",\\r\\n \\"logs:PutLogEvents\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n
\\"s3:PutObject\\",\\r\\n \\"s3:GetObject\\",\\r\\n \\"s3:GetObjectVersion\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Action\\": [\\r\\n \\"cloudformation:*\\"\\r\\n ],\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n"
=> "{\\r\\n \\"Version\\": \\"2012-10-17\\",\\r\\n \\"Statement\\": [\\r\\n {\\r\\n \\"Action\\": [\\r\\n \\"sqs:*\\",\\r\\n \\"sns:*\\"\\r\\n\\r\\n ],\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\",\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n \\"logs:CreateLogGroup\\",\\r\\n \\"logs:CreateLogStream\\",\\r\\n \\"logs:PutLogEvents\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n
\\"s3:PutObject\\",\\r\\n \\"s3:GetObject\\",\\r\\n \\"s3:GetObjectVersion\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Action\\": [\\r\\n \\"cloudformation:*\\"\\r\\n ],\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n"
`);
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('policy');
});
test('force new resource - false for normal diffs', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "old_value" => "new_value"');
expect(diffs).toHaveLength(1);
expect(diffs[0].forcesNewResource).toBe(false);
});
test('force new resource - true when included', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "old_value" => "new_value" (forces new resource)');
expect(diffs).toHaveLength(1);
expect(diffs[0].forcesNewResource).toBe(true);
});
test('force new resource - works for values', function() {
const diffs = parseNewAndOldValueDiffs('property_name: "old_value" => (forces new resource)');
expect(diffs).toHaveLength(1);
expect(diffs[0].forcesNewResource).toBe(true);
});
================================================
FILE: tests/parseSingleValueDiffs.test.ts
================================================
import { parseSingleValueDiffs } from '../src/ts/parse';
test('single value diffs - quote formatting', function() {
const diffs = parseSingleValueDiffs('property_name: "new_value"');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].new).toBe('new_value');
});
test('single value diffs - empty quotes', function() {
const diffs = parseSingleValueDiffs('property_name: ""');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].new).toBe('');
});
test('single value diffs - computed values', function() {
const diffs = parseSingleValueDiffs('property_name: ');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].new).toBe('');
});
test('single value diffs - whitespace handling', function() {
const diffs = parseSingleValueDiffs(' property_name : " value " ');
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('property_name');
expect(diffs[0].new).toBe(' value ');
});
test('single value diffs - multi line', function() {
const diffs = parseSingleValueDiffs('property1: "value1"\n property2: "value2"');
expect(diffs).toHaveLength(2);
expect(diffs[0].property).toBe('property1');
expect(diffs[0].new).toBe('value1');
expect(diffs[1].property).toBe('property2');
expect(diffs[1].new).toBe('value2');
});
test('single value diffs - IAM policy document', function() {
const diffs = parseSingleValueDiffs(`
policy: "{\\r\\n \\"Version\\": \\"2012-10-17\\",\\r\\n \\"Statement\\": [\\r\\n {\\r\\n \\"Action\\": [\\r\\n \\"sqs:*\\",\\r\\n \\"sns:*\\"\\r\\n\\r\\n ],\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\",\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n \\"logs:CreateLogGroup\\",\\r\\n \\"logs:CreateLogStream\\",\\r\\n \\"logs:PutLogEvents\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Resource\\": [\\r\\n \\"*\\"\\r\\n ],\\r\\n \\"Action\\": [\\r\\n
\\"s3:PutObject\\",\\r\\n \\"s3:GetObject\\",\\r\\n \\"s3:GetObjectVersion\\"\\r\\n ]\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n \\"Action\\": [\\r\\n \\"cloudformation:*\\"\\r\\n ],\\r\\n \\"Resource\\": \\"*\\"\\r\\n },\\r\\n {\\r\\n \\"Effect\\": \\"Allow\\",\\r\\n"
`);
expect(diffs).toHaveLength(1);
expect(diffs[0].property).toBe('policy');
});
================================================
FILE: tests/parseWarnings.test.ts
================================================
import { parseWarnings } from '../src/ts/parse';
test('parse warnings - single warning', function() {
const warnings = parseWarnings('Warning: resource_name: ');
expect(warnings).toHaveLength(1);
expect(warnings[0].id.name).toBe('resource_name:');
expect(warnings[0].detail).toBe(' ');
});
test('parse warnings - multiple warning', function() {
const warnings = parseWarnings('Warning: r1: w1\nWarning: r2: w2\nWarning: r3: w3');
expect(warnings).toHaveLength(3);
expect(warnings[0].id.name).toBe('r1:');
expect(warnings[0].detail).toBe(' w1');
expect(warnings[1].id.name).toBe('r2:');
expect(warnings[1].detail).toBe(' w2');
expect(warnings[2].id.name).toBe('r3:');
expect(warnings[2].detail).toBe(' w3');
});
test('parse warnings - no warnings', function() {
const warnings = parseWarnings('Here are some things that are NOT warnings');
expect(warnings).toHaveLength(0);
});
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"outDir": "./dist/",
"module": "es6",
"target": "es6"
}
}
================================================
FILE: webpack.config.js
================================================
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/ts/prettyplan.ts',
devServer: {
contentBase: './dist'
},
plugins: [
new HtmlWebpackPlugin({ template: 'src/index.html' }),
new CopyWebpackPlugin(['src/style.css'])
],
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist')
},
resolve: { extensions: ['.ts', '.js'] },
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
}
};