@causa/workspace-terraform 0.1.0 → 0.2.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/README.md CHANGED
@@ -1 +1,35 @@
1
1
  # `@causa/workspace-terraform` module
2
+
3
+ This repository contains the source code for the `@causa/workspace-terraform` Causa module. It provides Causa features and implementations of `cs` commands for Terraform infrastructure projects. For more information about the Causa CLI `cs`, checkout [its repository](https://github.com/causa-io/cli).
4
+
5
+ ## ➕ Requirements
6
+
7
+ The Causa Terraform module requires [Terraform](https://www.terraform.io/) to be installed.
8
+
9
+ ## 🎉 Installation
10
+
11
+ Add `@causa/workspace-terraform` to your Causa configuration in `causa.modules`.
12
+
13
+ ## 🔧 Configuration
14
+
15
+ For all the configuration in your Causa files related to Terraform, look at [the schema for the `TerraformConfiguration`](./src/configurations/terraform.ts).
16
+
17
+ ## ✨ Supported project types and commands
18
+
19
+ This module supports Causa projects with `project.type` set to `infrastructure` and `project.language` set to `terraform`.
20
+
21
+ ### Infrastructure commands
22
+
23
+ - `cs infrastructure prepare`: Runs `terraform plan`.
24
+ - `cs infrastructure deploy`: Runs `terraform apply` using a previously computed plan.
25
+
26
+ The dictionary defined in the `infrastructure.variables` configuration can be used to pass input Terraform variables. This dictionary is rendered before being used, meaning it can contain formatting templates referencing other configuration values and secrets.
27
+
28
+ The `terraform.workspace` configuration determines the Terraform workspace set prior to running any operation. Combined with Causa environments, this can be a powerful feature to manage the infrastructure of several deployment environments.
29
+
30
+ ### Project commands
31
+
32
+ The Terraform module also supports some of the project-level commands, namely:
33
+
34
+ - `cs init`: Runs `terraform init`.
35
+ - `cs lint`: Runs `terraform validate` and `terraform fmt`. The format operation is run with the `-check` argument, such that it only lints the code without fixing it. This operation applies supports the `project.additionalDirectories` configuration.
@@ -10,5 +10,12 @@ export type TerraformConfiguration = {
10
10
  * The Terraform workspace that should be selected prior to performing `plan` and `apply` operations.
11
11
  */
12
12
  workspace?: string;
13
+ /**
14
+ * The version of Terraform to use.
15
+ * Can be a semver version, or `latest`.
16
+ * The installed version is considered compatible if it is greater than or equal to the configured version, within
17
+ * the same major version.
18
+ */
19
+ version?: string;
13
20
  };
14
21
  };
@@ -1,5 +1,7 @@
1
1
  import { InfrastructureDeployForTerraform } from './infrastructure-deploy-terraform.js';
2
2
  import { InfrastructurePrepareForTerraform } from './infrastructure-prepare-terraform.js';
3
+ import { ProjectInitForTerraform } from './project-init-terraform.js';
4
+ import { ProjectLintForTerraform } from './project-lint-terraform.js';
3
5
  export function registerFunctions(context) {
4
- context.registerFunctionImplementations(InfrastructureDeployForTerraform, InfrastructurePrepareForTerraform);
6
+ context.registerFunctionImplementations(InfrastructureDeployForTerraform, InfrastructurePrepareForTerraform, ProjectInitForTerraform, ProjectLintForTerraform);
5
7
  }
@@ -0,0 +1,9 @@
1
+ import { WorkspaceContext } from '@causa/workspace';
2
+ import { ProjectInit } from '@causa/workspace-core';
3
+ /**
4
+ * Implements the {@link ProjectInit} function for Terraform projects, by running `terraform init`.
5
+ */
6
+ export declare class ProjectInitForTerraform extends ProjectInit {
7
+ _call(context: WorkspaceContext): Promise<void>;
8
+ _supports(context: WorkspaceContext): boolean;
9
+ }
@@ -0,0 +1,28 @@
1
+ import { ProjectInit } from '@causa/workspace-core';
2
+ import { rm } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { TerraformService } from '../services/index.js';
5
+ /**
6
+ * The name of the folder automatically created by Terraform during initialization.
7
+ */
8
+ const TERRAFORM_DIR = '.terraform';
9
+ /**
10
+ * Implements the {@link ProjectInit} function for Terraform projects, by running `terraform init`.
11
+ */
12
+ export class ProjectInitForTerraform extends ProjectInit {
13
+ async _call(context) {
14
+ const projectName = context.get('project.name');
15
+ if (this.force) {
16
+ context.logger.info('🔥 Removing Terraform folder.');
17
+ const terraformDir = join(context.getProjectPathOrThrow(), TERRAFORM_DIR);
18
+ await rm(terraformDir, { recursive: true, force: true });
19
+ }
20
+ context.logger.info(`️🎉 Initializing Terraform for project '${projectName}'.`);
21
+ await context.service(TerraformService).init({ logging: 'debug' });
22
+ context.logger.info(`️✅ Successfully initialized Terraform.`);
23
+ }
24
+ _supports(context) {
25
+ return (context.get('project.language') === 'terraform' &&
26
+ context.get('project.type') === 'infrastructure');
27
+ }
28
+ }
@@ -0,0 +1,11 @@
1
+ import { WorkspaceContext } from '@causa/workspace';
2
+ import { ProjectLint } from '@causa/workspace-core';
3
+ /**
4
+ * Implements the {@link ProjectLint} function for Terraform projects, by running `terraform validate` and
5
+ * `terraform fmt`.
6
+ * The Terraform format check is also run on any additional directories listed in the project configuration.
7
+ */
8
+ export declare class ProjectLintForTerraform extends ProjectLint {
9
+ _call(context: WorkspaceContext): Promise<void>;
10
+ _supports(context: WorkspaceContext): boolean;
11
+ }
@@ -0,0 +1,41 @@
1
+ import { ProcessServiceExitCodeError, ProjectLint, } from '@causa/workspace-core';
2
+ import { TerraformService } from '../services/index.js';
3
+ /**
4
+ * Implements the {@link ProjectLint} function for Terraform projects, by running `terraform validate` and
5
+ * `terraform fmt`.
6
+ * The Terraform format check is also run on any additional directories listed in the project configuration.
7
+ */
8
+ export class ProjectLintForTerraform extends ProjectLint {
9
+ async _call(context) {
10
+ const projectPath = context.getProjectPathOrThrow();
11
+ const projectName = context.get('project.name');
12
+ const terraformService = context.service(TerraformService);
13
+ const targets = [
14
+ projectPath,
15
+ ...(await context.getProjectAdditionalDirectories()),
16
+ ];
17
+ try {
18
+ context.logger.info(`🚨 Validating Terraform code for project '${projectName}'.`);
19
+ await terraformService.validate({
20
+ logging: { stdout: null, stderr: 'info' },
21
+ });
22
+ context.logger.info(`🎨 Checking format of Terraform code for project '${projectName}'.`);
23
+ await terraformService.fmt({
24
+ check: true,
25
+ recursive: true,
26
+ targets,
27
+ logging: 'info',
28
+ });
29
+ }
30
+ catch (error) {
31
+ if (error instanceof ProcessServiceExitCodeError) {
32
+ throw new Error('Linting the Terraform project failed.');
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ _supports(context) {
38
+ return (context.get('project.language') === 'terraform' &&
39
+ context.get('project.type') === 'infrastructure');
40
+ }
41
+ }
@@ -1 +1,2 @@
1
+ export * from './terraform.errors.js';
1
2
  export { TerraformService } from './terraform.js';
@@ -1 +1,2 @@
1
+ export * from './terraform.errors.js';
1
2
  export { TerraformService } from './terraform.js';
@@ -39,6 +39,11 @@ export declare class TerraformService {
39
39
  * This is used by {@link TerraformService.wrapWorkspaceOperation} if no workspace is specified.
40
40
  */
41
41
  private readonly defaultTerraformWorkspace;
42
+ /**
43
+ * The required Terraform version set in the configuration.
44
+ * Defaults to `latest`.
45
+ */
46
+ readonly requiredVersion: string;
42
47
  constructor(context: WorkspaceContext);
43
48
  /**
44
49
  * Runs `terraform init`.
@@ -95,6 +100,32 @@ export declare class TerraformService {
95
100
  * @returns The standard output of the command, describing the plan.
96
101
  */
97
102
  show(path: string, options?: Omit<SpawnOptions, 'capture'>): Promise<string>;
103
+ /**
104
+ * Runs `terraform validate`.
105
+ *
106
+ * @param options Options when running the command.
107
+ */
108
+ validate(options?: SpawnOptions): Promise<void>;
109
+ /**
110
+ * Runs `terraform fmt`.
111
+ *
112
+ * @param options Options when running the command.
113
+ * @returns The result of the Terraform process.
114
+ */
115
+ fmt(options?: {
116
+ /**
117
+ * Whether to check if the files are formatted correctly, instead of formatting them.
118
+ */
119
+ check?: boolean;
120
+ /**
121
+ * Whether to recursively format all files in the current directory and its subdirectories.
122
+ */
123
+ recursive?: boolean;
124
+ /**
125
+ * The list of files or directories to format.
126
+ */
127
+ targets?: string[];
128
+ } & SpawnOptions): Promise<SpawnedProcessResult>;
98
129
  /**
99
130
  * Wrap the `fn` function and ensures Terraform is initialized and that the correct Terraform workspace is selected.
100
131
  * After `fn` has run, the workspace is reverted back to the previous workspace if necessary.
@@ -121,5 +152,23 @@ export declare class TerraformService {
121
152
  * @returns The result of the spawned process.
122
153
  */
123
154
  terraform(command: string, args: string[], options?: SpawnOptions): Promise<SpawnedProcessResult>;
155
+ /**
156
+ * Whether the installed Terraform version is compatible with the required version set in the configuration.
157
+ * It is `undefined` before the first call to {@link TerraformService.checkTerraformVersion}.
158
+ */
159
+ private hasCompatibleTerraformVersion;
160
+ /**
161
+ * A promise that resolves when the installed Terraform version has been checked.
162
+ * It is `undefined` before the first call to {@link TerraformService.checkTerraformVersion}, or if the actual check
163
+ * is not needed.
164
+ */
165
+ private terraformVersionCheck;
166
+ /**
167
+ * Checks whether the installed Terraform version is compatible with the required version set in the configuration.
168
+ * If the required version is `latest`, the check is skipped.
169
+ * If the installed version is not compatible, an {@link IncompatibleTerraformVersionError} is thrown.
170
+ * The result of the check is cached, and this returns synchronously on subsequent calls.
171
+ */
172
+ private checkTerraformVersion;
124
173
  }
125
174
  export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * An error thrown when the installed Terraform version is incompatible with the required version set in the
3
+ * configuration.
4
+ */
5
+ export declare class IncompatibleTerraformVersionError extends Error {
6
+ readonly installedVersion: string;
7
+ readonly requiredVersion: string;
8
+ constructor(installedVersion: string, requiredVersion: string);
9
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * An error thrown when the installed Terraform version is incompatible with the required version set in the
3
+ * configuration.
4
+ */
5
+ export class IncompatibleTerraformVersionError extends Error {
6
+ installedVersion;
7
+ requiredVersion;
8
+ constructor(installedVersion, requiredVersion) {
9
+ super(`Installed Terraform version ${installedVersion} is incompatible with required version ${requiredVersion}.`);
10
+ this.installedVersion = installedVersion;
11
+ this.requiredVersion = requiredVersion;
12
+ }
13
+ }
@@ -1,4 +1,6 @@
1
1
  import { ProcessService, ProcessServiceExitCodeError, } from '@causa/workspace-core';
2
+ import { satisfies } from 'semver';
3
+ import { IncompatibleTerraformVersionError } from './terraform.errors.js';
2
4
  /**
3
5
  * A service exposing the Terraform CLI.
4
6
  */
@@ -21,15 +23,20 @@ export class TerraformService {
21
23
  * This is used by {@link TerraformService.wrapWorkspaceOperation} if no workspace is specified.
22
24
  */
23
25
  defaultTerraformWorkspace;
26
+ /**
27
+ * The required Terraform version set in the configuration.
28
+ * Defaults to `latest`.
29
+ */
30
+ requiredVersion;
24
31
  constructor(context) {
25
32
  this.processService = context.service(ProcessService);
26
33
  this.defaultSpawnOptions = {
27
34
  workingDirectory: context.projectPath ?? undefined,
28
35
  };
29
36
  this.logger = context.logger;
30
- this.defaultTerraformWorkspace = context
31
- .asConfiguration()
32
- .getOrThrow('terraform.workspace');
37
+ const conf = context.asConfiguration();
38
+ this.defaultTerraformWorkspace = conf.get('terraform.workspace');
39
+ this.requiredVersion = conf.get('terraform.version') ?? 'latest';
33
40
  }
34
41
  /**
35
42
  * Runs `terraform init`.
@@ -115,14 +122,44 @@ export class TerraformService {
115
122
  });
116
123
  return result.stdout ?? '';
117
124
  }
125
+ /**
126
+ * Runs `terraform validate`.
127
+ *
128
+ * @param options Options when running the command.
129
+ */
130
+ async validate(options = {}) {
131
+ await this.terraform('validate', [], options);
132
+ }
133
+ /**
134
+ * Runs `terraform fmt`.
135
+ *
136
+ * @param options Options when running the command.
137
+ * @returns The result of the Terraform process.
138
+ */
139
+ async fmt(options = {}) {
140
+ const args = [];
141
+ if (options.check) {
142
+ args.push('-check');
143
+ }
144
+ if (options.recursive) {
145
+ args.push('-recursive');
146
+ }
147
+ if (options.targets) {
148
+ args.push(...options.targets);
149
+ }
150
+ return await this.terraform('fmt', args, options);
151
+ }
118
152
  async wrapWorkspaceOperation(optionsOrFn, fn) {
119
153
  const options = fn ? optionsOrFn : {};
120
154
  const func = fn ?? optionsOrFn;
121
155
  const { skipInit, createWorkspaceIfNeeded, workspace, ...spawnOptions } = options;
156
+ const workspaceToSelect = workspace ?? this.defaultTerraformWorkspace;
157
+ if (workspaceToSelect === undefined) {
158
+ throw new Error('The Terraform workspace for the operation is not configured.');
159
+ }
122
160
  if (!skipInit) {
123
161
  await this.init(spawnOptions);
124
162
  }
125
- const workspaceToSelect = workspace ?? this.defaultTerraformWorkspace;
126
163
  let workspaceToRestore = null;
127
164
  try {
128
165
  const currentWorkspace = await this.workspaceShow(spawnOptions);
@@ -155,10 +192,48 @@ export class TerraformService {
155
192
  * @returns The result of the spawned process.
156
193
  */
157
194
  async terraform(command, args, options = {}) {
195
+ await this.checkTerraformVersion();
158
196
  const p = this.processService.spawn('terraform', [command, ...args], {
159
197
  ...this.defaultSpawnOptions,
160
198
  ...options,
161
199
  });
162
200
  return await p.result;
163
201
  }
202
+ /**
203
+ * Whether the installed Terraform version is compatible with the required version set in the configuration.
204
+ * It is `undefined` before the first call to {@link TerraformService.checkTerraformVersion}.
205
+ */
206
+ hasCompatibleTerraformVersion;
207
+ /**
208
+ * A promise that resolves when the installed Terraform version has been checked.
209
+ * It is `undefined` before the first call to {@link TerraformService.checkTerraformVersion}, or if the actual check
210
+ * is not needed.
211
+ */
212
+ terraformVersionCheck;
213
+ /**
214
+ * Checks whether the installed Terraform version is compatible with the required version set in the configuration.
215
+ * If the required version is `latest`, the check is skipped.
216
+ * If the installed version is not compatible, an {@link IncompatibleTerraformVersionError} is thrown.
217
+ * The result of the check is cached, and this returns synchronously on subsequent calls.
218
+ */
219
+ async checkTerraformVersion() {
220
+ if (this.hasCompatibleTerraformVersion === true) {
221
+ return;
222
+ }
223
+ if (this.requiredVersion === 'latest') {
224
+ this.hasCompatibleTerraformVersion = true;
225
+ return;
226
+ }
227
+ if (!this.terraformVersionCheck) {
228
+ this.terraformVersionCheck = (async () => {
229
+ const result = await this.processService.spawn('terraform', ['-version', '-json'], { capture: { stdout: true } }).result;
230
+ const version = JSON.parse(result.stdout ?? '').terraform_version;
231
+ this.hasCompatibleTerraformVersion = satisfies(version, `^${this.requiredVersion}`);
232
+ if (!this.hasCompatibleTerraformVersion) {
233
+ throw new IncompatibleTerraformVersionError(version, this.requiredVersion);
234
+ }
235
+ })();
236
+ }
237
+ await this.terraformVersionCheck;
238
+ }
164
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@causa/workspace-terraform",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Causa workspace module providing functionalities for infrastructure projects coded in Terraform.",
5
5
  "repository": "github:causa-io/workspace-module-terraform",
6
6
  "license": "ISC",
@@ -28,22 +28,24 @@
28
28
  "test:cov": "npm run test -- --coverage"
29
29
  },
30
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"
31
+ "@causa/workspace": ">= 0.10.0 < 1.0.0",
32
+ "@causa/workspace-core": ">= 0.7.0 < 1.0.0",
33
+ "pino": "^8.14.1",
34
+ "semver": "^7.5.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@tsconfig/node18": "^2.0.1",
38
- "@types/jest": "^29.5.1",
39
- "eslint": "^8.40.0",
38
+ "@types/jest": "^29.5.2",
39
+ "@types/node": "^18.16.14",
40
+ "@typescript-eslint/eslint-plugin": "^5.59.9",
41
+ "eslint": "^8.42.0",
40
42
  "eslint-config-prettier": "^8.8.0",
41
43
  "eslint-plugin-prettier": "^4.2.1",
42
44
  "jest": "^29.5.0",
43
- "jest-extended": "^3.2.4",
44
- "rimraf": "^5.0.0",
45
+ "jest-extended": "^4.0.0",
46
+ "rimraf": "^5.0.1",
45
47
  "ts-jest": "^29.1.0",
46
48
  "ts-node": "^10.9.1",
47
- "typescript": "^5.0.4"
49
+ "typescript": "^5.1.3"
48
50
  }
49
51
  }