@causa/workspace-terraform 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2023 Causa
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # `@causa/workspace-terraform` module
@@ -0,0 +1 @@
1
+ export { TerraformConfiguration } from './terraform.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The schema for the Terraform configuration.
3
+ */
4
+ export type TerraformConfiguration = {
5
+ /**
6
+ * Configuration for Terraform.
7
+ */
8
+ terraform?: {
9
+ /**
10
+ * The Terraform workspace that should be selected prior to performing `plan` and `apply` operations.
11
+ */
12
+ workspace?: string;
13
+ };
14
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { ModuleRegistrationContext } from '@causa/workspace';
2
+ export declare function registerFunctions(context: ModuleRegistrationContext): void;
@@ -0,0 +1,5 @@
1
+ import { InfrastructureDeployForTerraform } from './infrastructure-deploy-terraform.js';
2
+ import { InfrastructurePrepareForTerraform } from './infrastructure-prepare-terraform.js';
3
+ export function registerFunctions(context) {
4
+ context.registerFunctionImplementations(InfrastructureDeployForTerraform, InfrastructurePrepareForTerraform);
5
+ }
@@ -0,0 +1,10 @@
1
+ import { WorkspaceContext } from '@causa/workspace';
2
+ import { InfrastructureDeploy } from '@causa/workspace-core';
3
+ /**
4
+ * Implements the {@link InfrastructureDeploy} function for Infrastructure as Code defined using Terraform.
5
+ * This calls the `terraform apply` command for the given project.
6
+ */
7
+ export declare class InfrastructureDeployForTerraform extends InfrastructureDeploy {
8
+ _call(context: WorkspaceContext): Promise<void>;
9
+ _supports(context: WorkspaceContext): boolean;
10
+ }
@@ -0,0 +1,22 @@
1
+ import { InfrastructureDeploy } from '@causa/workspace-core';
2
+ import { resolve } from 'path';
3
+ import { TerraformService } from '../services/index.js';
4
+ /**
5
+ * Implements the {@link InfrastructureDeploy} function for Infrastructure as Code defined using Terraform.
6
+ * This calls the `terraform apply` command for the given project.
7
+ */
8
+ export class InfrastructureDeployForTerraform extends InfrastructureDeploy {
9
+ async _call(context) {
10
+ context.getProjectPathOrThrow();
11
+ const projectName = context.getOrThrow('project.name');
12
+ const terraformService = context.service(TerraformService);
13
+ context.logger.info(`🧱 Applying Terraform plan for project '${projectName}'.`);
14
+ const plan = resolve(this.deployment);
15
+ await terraformService.wrapWorkspaceOperation(() => terraformService.apply(plan, { logging: 'info' }));
16
+ context.logger.info('🧱 Successfully applied Terraform plan.');
17
+ }
18
+ _supports(context) {
19
+ return (context.get('project.language') === 'terraform' &&
20
+ context.get('project.type') === 'infrastructure');
21
+ }
22
+ }
@@ -0,0 +1,13 @@
1
+ import { WorkspaceContext } from '@causa/workspace';
2
+ import { InfrastructurePrepare } from '@causa/workspace-core';
3
+ /**
4
+ * Implements the {@link InfrastructurePrepare} function for Infrastructure as Code defined using Terraform.
5
+ * This calls the `terraform plan` command for the given project.
6
+ */
7
+ export declare class InfrastructurePrepareForTerraform extends InfrastructurePrepare {
8
+ _call(context: WorkspaceContext): Promise<{
9
+ output: string;
10
+ isDeploymentNeeded: boolean;
11
+ }>;
12
+ _supports(context: WorkspaceContext): boolean;
13
+ }
@@ -0,0 +1,38 @@
1
+ import { InfrastructurePrepare, } from '@causa/workspace-core';
2
+ import { resolve } from 'path';
3
+ import { TerraformService } from '../services/index.js';
4
+ /**
5
+ * The default name of the output Terraform plan file.
6
+ */
7
+ const DEFAULT_PLAN_FILE = 'plan.out';
8
+ /**
9
+ * Implements the {@link InfrastructurePrepare} function for Infrastructure as Code defined using Terraform.
10
+ * This calls the `terraform plan` command for the given project.
11
+ */
12
+ export class InfrastructurePrepareForTerraform extends InfrastructurePrepare {
13
+ async _call(context) {
14
+ context.getProjectPathOrThrow();
15
+ const infrastructureConf = context.asConfiguration();
16
+ const projectName = infrastructureConf.getOrThrow('project.name');
17
+ const terraformService = context.service(TerraformService);
18
+ const variables = (await infrastructureConf.getAndRender('infrastructure.variables')) ?? {};
19
+ const output = resolve(this.output ?? DEFAULT_PLAN_FILE);
20
+ context.logger.info(`🧱 Planning Terraform deployment for project '${projectName}'.`);
21
+ const isDeploymentNeeded = await terraformService.wrapWorkspaceOperation({ createWorkspaceIfNeeded: true }, () => terraformService.plan(output, { variables }));
22
+ if (isDeploymentNeeded) {
23
+ context.logger.info('🧱 Terraform plan has changes that can be deployed.');
24
+ if (this.print) {
25
+ const show = await terraformService.show(output);
26
+ console.log(show);
27
+ }
28
+ }
29
+ else {
30
+ context.logger.info('🧱 Terraform plan has no change.');
31
+ }
32
+ return { output, isDeploymentNeeded };
33
+ }
34
+ _supports(context) {
35
+ return (context.get('project.language') === 'terraform' &&
36
+ context.get('project.type') === 'infrastructure');
37
+ }
38
+ }
@@ -0,0 +1,5 @@
1
+ import { ModuleRegistrationFunction } from '@causa/workspace';
2
+ export * from './configurations/index.js';
3
+ export * from './services/index.js';
4
+ declare const registerModule: ModuleRegistrationFunction;
5
+ export default registerModule;
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { registerFunctions } from './functions/index.js';
2
+ export * from './configurations/index.js';
3
+ export * from './services/index.js';
4
+ const registerModule = async (context) => {
5
+ registerFunctions(context);
6
+ };
7
+ export default registerModule;
@@ -0,0 +1 @@
1
+ export { TerraformService } from './terraform.js';
@@ -0,0 +1 @@
1
+ export { TerraformService } from './terraform.js';
@@ -0,0 +1,125 @@
1
+ import { WorkspaceContext } from '@causa/workspace';
2
+ import { SpawnOptions, SpawnedProcessResult } from '@causa/workspace-core';
3
+ /**
4
+ * Options for the {@link TerraformService.wrapWorkspaceOperation} method.
5
+ */
6
+ type WrapWorkspaceOperationOptions = {
7
+ /**
8
+ * The Terraform workspace to select, instead of {@link TerraformService.defaultTerraformWorkspace}.
9
+ */
10
+ workspace?: string;
11
+ /**
12
+ * Whether the Terraform workspace should be created if it does not already exists.
13
+ */
14
+ createWorkspaceIfNeeded?: boolean;
15
+ /**
16
+ * Whether the `terraform init` step should be skipped.
17
+ */
18
+ skipInit?: boolean;
19
+ } & SpawnOptions;
20
+ /**
21
+ * A service exposing the Terraform CLI.
22
+ */
23
+ export declare class TerraformService {
24
+ /**
25
+ * The underlying {@link ProcessService} spawning the Terraform CLI.
26
+ */
27
+ private readonly processService;
28
+ /**
29
+ * Default options when spawning a `terraform` process.
30
+ * The {@link SpawnOptions.workingDirectory} will be set to the project's root (if it is defined).
31
+ */
32
+ private readonly defaultSpawnOptions;
33
+ /**
34
+ * The logger to use.
35
+ */
36
+ private readonly logger;
37
+ /**
38
+ * The default Terraform workspace, obtained from the configuration at `terraform.workspace`.
39
+ * This is used by {@link TerraformService.wrapWorkspaceOperation} if no workspace is specified.
40
+ */
41
+ private readonly defaultTerraformWorkspace;
42
+ constructor(context: WorkspaceContext);
43
+ /**
44
+ * Runs `terraform init`.
45
+ *
46
+ * @param options Options when initializing Terraform.
47
+ */
48
+ init(options?: SpawnOptions): Promise<void>;
49
+ /**
50
+ * Runs `terraform workspace show` and returns the currently selected workspace.
51
+ *
52
+ * @param options Options when running the command.
53
+ * @returns The name of the currently selected workspace.
54
+ */
55
+ workspaceShow(options?: SpawnOptions): Promise<string>;
56
+ /**
57
+ * Runs `terraform workspace select` to change the active workspace.
58
+ *
59
+ * @param workspace The workspace to select.
60
+ * @param options Options when running the command.
61
+ */
62
+ workspaceSelect(workspace: string, options?: {
63
+ orCreate?: boolean;
64
+ } & SpawnOptions): Promise<void>;
65
+ /**
66
+ * Runs `terraform plan` and writes the corresponding plan to a file.
67
+ *
68
+ * @param out The path to the output file plan.
69
+ * @param options Options for the `plan` command.
70
+ * @returns `true` if the plan contains changes, `false` otherwise.
71
+ */
72
+ plan(out: string, options?: {
73
+ /**
74
+ * Whether destruction of the workspace should be planned, instead of comparing it with the infrastructure
75
+ * definition.
76
+ */
77
+ destroy?: boolean;
78
+ /**
79
+ * A map of Terraform variables to forward to `terraform plan`.
80
+ */
81
+ variables?: Record<string, string>;
82
+ } & SpawnOptions): Promise<boolean>;
83
+ /**
84
+ * Runs `terraform apply` to perform the changes described in the given plan.
85
+ *
86
+ * @param plan The path to the plan file to apply.
87
+ * @param options Options when running the command.
88
+ */
89
+ apply(plan: string, options?: SpawnOptions): Promise<void>;
90
+ /**
91
+ * Runs `terraform show` on the given plan and returns the output of the command.
92
+ *
93
+ * @param path The path to the plan to describe.
94
+ * @param options Options when running the command. `capture` is enabled automatically.
95
+ * @returns The standard output of the command, describing the plan.
96
+ */
97
+ show(path: string, options?: Omit<SpawnOptions, 'capture'>): Promise<string>;
98
+ /**
99
+ * Wrap the `fn` function and ensures Terraform is initialized and that the correct Terraform workspace is selected.
100
+ * After `fn` has run, the workspace is reverted back to the previous workspace if necessary.
101
+ *
102
+ * @param fn The function to run after Terraform has been initialized and the workspace selected.
103
+ * @returns The result of `fn`.
104
+ */
105
+ wrapWorkspaceOperation<T>(fn: () => Promise<T>): Promise<T>;
106
+ /**
107
+ * Wrap the `fn` function and ensures Terraform is initialized and that the correct Terraform workspace is selected.
108
+ * After `fn` has run, the workspace is reverted back to the previous workspace if necessary.
109
+ *
110
+ * @param options Options when performing the operation.
111
+ * @param fn The function to run after Terraform has been initialized and the workspace selected.
112
+ * @returns The result of `fn`.
113
+ */
114
+ wrapWorkspaceOperation<T>(options: WrapWorkspaceOperationOptions, fn: () => Promise<T>): Promise<T>;
115
+ /**
116
+ * Runs an arbitrary Terraform command.
117
+ *
118
+ * @param command The Terraform command to run.
119
+ * @param args Arguments to place after the command name.
120
+ * @param options {@link SpawnOptions} for the process.
121
+ * @returns The result of the spawned process.
122
+ */
123
+ terraform(command: string, args: string[], options?: SpawnOptions): Promise<SpawnedProcessResult>;
124
+ }
125
+ export {};
@@ -0,0 +1,164 @@
1
+ import { ProcessService, ProcessServiceExitCodeError, } from '@causa/workspace-core';
2
+ /**
3
+ * A service exposing the Terraform CLI.
4
+ */
5
+ export class TerraformService {
6
+ /**
7
+ * The underlying {@link ProcessService} spawning the Terraform CLI.
8
+ */
9
+ processService;
10
+ /**
11
+ * Default options when spawning a `terraform` process.
12
+ * The {@link SpawnOptions.workingDirectory} will be set to the project's root (if it is defined).
13
+ */
14
+ defaultSpawnOptions;
15
+ /**
16
+ * The logger to use.
17
+ */
18
+ logger;
19
+ /**
20
+ * The default Terraform workspace, obtained from the configuration at `terraform.workspace`.
21
+ * This is used by {@link TerraformService.wrapWorkspaceOperation} if no workspace is specified.
22
+ */
23
+ defaultTerraformWorkspace;
24
+ constructor(context) {
25
+ this.processService = context.service(ProcessService);
26
+ this.defaultSpawnOptions = {
27
+ workingDirectory: context.projectPath ?? undefined,
28
+ };
29
+ this.logger = context.logger;
30
+ this.defaultTerraformWorkspace = context
31
+ .asConfiguration()
32
+ .getOrThrow('terraform.workspace');
33
+ }
34
+ /**
35
+ * Runs `terraform init`.
36
+ *
37
+ * @param options Options when initializing Terraform.
38
+ */
39
+ async init(options = {}) {
40
+ await this.terraform('init', ['-input=false'], options);
41
+ }
42
+ /**
43
+ * Runs `terraform workspace show` and returns the currently selected workspace.
44
+ *
45
+ * @param options Options when running the command.
46
+ * @returns The name of the currently selected workspace.
47
+ */
48
+ async workspaceShow(options = {}) {
49
+ const result = await this.terraform('workspace', ['show'], {
50
+ logging: { stdout: null, stderr: 'debug' },
51
+ ...options,
52
+ capture: { ...options.capture, stdout: true },
53
+ });
54
+ return result.stdout?.trim() ?? '';
55
+ }
56
+ /**
57
+ * Runs `terraform workspace select` to change the active workspace.
58
+ *
59
+ * @param workspace The workspace to select.
60
+ * @param options Options when running the command.
61
+ */
62
+ async workspaceSelect(workspace, options = {}) {
63
+ const { orCreate, ...spawnOptions } = options;
64
+ await this.terraform('workspace', ['select', ...(orCreate ? ['-or-create=true'] : []), workspace], spawnOptions);
65
+ }
66
+ /**
67
+ * Runs `terraform plan` and writes the corresponding plan to a file.
68
+ *
69
+ * @param out The path to the output file plan.
70
+ * @param options Options for the `plan` command.
71
+ * @returns `true` if the plan contains changes, `false` otherwise.
72
+ */
73
+ async plan(out, options = {}) {
74
+ const { destroy, variables, ...spawnOptions } = options;
75
+ const args = ['-input=false', `-out=${out}`, '-detailed-exitcode'];
76
+ if (destroy) {
77
+ args.push('-destroy');
78
+ }
79
+ Object.entries(variables ?? {}).forEach(([varName, varValue]) => args.push('-var', `${varName}=${varValue}`));
80
+ try {
81
+ await this.terraform('plan', args, spawnOptions);
82
+ // When specifying `-detailed-exitcode`, a return code of `0` means that the plan does not contain any change.
83
+ return false;
84
+ }
85
+ catch (error) {
86
+ // When specifying `-detailed-exitcode`, a return code of `2` means that the plan contains changes.
87
+ if (error instanceof ProcessServiceExitCodeError &&
88
+ error.result.code === 2) {
89
+ return true;
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+ /**
95
+ * Runs `terraform apply` to perform the changes described in the given plan.
96
+ *
97
+ * @param plan The path to the plan file to apply.
98
+ * @param options Options when running the command.
99
+ */
100
+ async apply(plan, options = {}) {
101
+ await this.terraform('apply', ['-input=false', plan], options);
102
+ }
103
+ /**
104
+ * Runs `terraform show` on the given plan and returns the output of the command.
105
+ *
106
+ * @param path The path to the plan to describe.
107
+ * @param options Options when running the command. `capture` is enabled automatically.
108
+ * @returns The standard output of the command, describing the plan.
109
+ */
110
+ async show(path, options = {}) {
111
+ const result = await this.terraform('show', [path], {
112
+ ...options,
113
+ capture: { stdout: true, stderr: true },
114
+ logging: options.logging ?? null,
115
+ });
116
+ return result.stdout ?? '';
117
+ }
118
+ async wrapWorkspaceOperation(optionsOrFn, fn) {
119
+ const options = fn ? optionsOrFn : {};
120
+ const func = fn ?? optionsOrFn;
121
+ const { skipInit, createWorkspaceIfNeeded, workspace, ...spawnOptions } = options;
122
+ if (!skipInit) {
123
+ await this.init(spawnOptions);
124
+ }
125
+ const workspaceToSelect = workspace ?? this.defaultTerraformWorkspace;
126
+ let workspaceToRestore = null;
127
+ try {
128
+ const currentWorkspace = await this.workspaceShow(spawnOptions);
129
+ if (workspaceToSelect !== currentWorkspace) {
130
+ this.logger.debug(`🔧 Changing Terraform workspace from '${currentWorkspace}' to '${workspaceToSelect}'.`);
131
+ await this.workspaceSelect(workspaceToSelect, {
132
+ orCreate: createWorkspaceIfNeeded,
133
+ ...spawnOptions,
134
+ });
135
+ workspaceToRestore = currentWorkspace;
136
+ }
137
+ else {
138
+ this.logger.debug(`🔧 Terraform workspace '${workspaceToSelect}' is already configured.`);
139
+ }
140
+ return await func();
141
+ }
142
+ finally {
143
+ if (workspaceToRestore) {
144
+ this.logger.debug(`🔧 Switching back to Terraform workspace '${workspaceToRestore}'.`);
145
+ await this.workspaceSelect(workspaceToRestore, spawnOptions);
146
+ }
147
+ }
148
+ }
149
+ /**
150
+ * Runs an arbitrary Terraform command.
151
+ *
152
+ * @param command The Terraform command to run.
153
+ * @param args Arguments to place after the command name.
154
+ * @param options {@link SpawnOptions} for the process.
155
+ * @returns The result of the spawned process.
156
+ */
157
+ async terraform(command, args, options = {}) {
158
+ const p = this.processService.spawn('terraform', [command, ...args], {
159
+ ...this.defaultSpawnOptions,
160
+ ...options,
161
+ });
162
+ return await p.result;
163
+ }
164
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@causa/workspace-terraform",
3
+ "version": "0.1.0",
4
+ "description": "The Causa workspace module providing functionalities for infrastructure projects coded in Terraform.",
5
+ "repository": "github:causa-io/workspace-module-terraform",
6
+ "license": "ISC",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=16"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": "./dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist/",
18
+ "LICENSE.md",
19
+ "package.json",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "prebuild": "rimraf ./dist",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "format": "prettier --write \"src/**/*.ts\"",
26
+ "lint": "eslint \"src/**/*.ts\"",
27
+ "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings=ExperimentalWarning\" jest",
28
+ "test:cov": "npm run test -- --coverage"
29
+ },
30
+ "dependencies": {
31
+ "@causa/cli": ">= 0.2.1 < 1.0.0",
32
+ "@causa/workspace": ">= 0.4.0 < 1.0.0",
33
+ "@causa/workspace-core": ">= 0.1.0 < 1.0.0",
34
+ "pino": "^8.14.1"
35
+ },
36
+ "devDependencies": {
37
+ "@tsconfig/node18": "^2.0.1",
38
+ "@types/jest": "^29.5.1",
39
+ "eslint": "^8.40.0",
40
+ "eslint-config-prettier": "^8.8.0",
41
+ "eslint-plugin-prettier": "^4.2.1",
42
+ "jest": "^29.5.0",
43
+ "jest-extended": "^3.2.4",
44
+ "rimraf": "^5.0.0",
45
+ "ts-jest": "^29.1.0",
46
+ "ts-node": "^10.9.1",
47
+ "typescript": "^5.0.4"
48
+ }
49
+ }