Skip to content

Commit c27c272

Browse files
committed
feat(deploy): add the terraform deployment
1 parent 4424218 commit c27c272

File tree

5 files changed

+403
-0
lines changed

5 files changed

+403
-0
lines changed

packages/whook-example/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,57 @@ Debug `knifecycle` internals (dependency injection issues):
102102
DEBUG=knifecycle npm run dev
103103
```
104104

105+
## Deploying with Google Cloud Functions
106+
107+
Create a project and save its credentials to `.credentials.json`.
108+
109+
Then install Terraform:
110+
```sh
111+
wget https://releases.hashicorp.com/terraform/0.12.24/terraform_0.12.24_linux_amd64.zip
112+
mkdir .bin
113+
unzip -d .bin terraform_0.12.24_linux_amd64.zip
114+
rm terraform_0.12.24_linux_amd64.zip
115+
```
116+
117+
Then initialize the Terraform configuration:
118+
```sh
119+
.bin/terraform init ./terraform;
120+
```
121+
122+
Create a new workspace:
123+
```sh
124+
.bin/terraform workspace new staging
125+
```
126+
127+
Build the functions:
128+
```sh
129+
NODE_ENV=staging npm run build
130+
```
131+
132+
Build the Whook commands Terraform depends on:
133+
```sh
134+
npm run compile
135+
```
136+
137+
Plan the deployment:
138+
```sh
139+
.bin/terraform plan -var="project_id=my-project-1664" \
140+
-out=terraform.plan terraform
141+
```
142+
143+
Apply changes:
144+
```sh
145+
# parallelism may be necessary to avoid hitting
146+
# timeouts with a slow connection
147+
.bin/terraform apply -parallelism=1 terraform.plan
148+
```
149+
150+
Finally retrieve the API URL and enjoy!
151+
```sh
152+
.bin/terraform -var="project_id=my-project-1664" \
153+
output api_url
154+
```
155+
105156
## Testing the GCP Functions
106157

107158
```sh
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { extra, autoService } from 'knifecycle';
2+
import { readArgs } from '@whook/whook';
3+
import { getOpenAPIOperations } from '@whook/http-router';
4+
import { YError } from 'yerror';
5+
import { exec } from 'child_process';
6+
import crypto from 'crypto';
7+
import yaml from 'js-yaml';
8+
import type { ExecException } from 'child_process';
9+
import type { LogService } from 'common-services';
10+
import type {
11+
WhookCommandArgs,
12+
WhookCommandDefinition,
13+
WhookAPIHandlerDefinition,
14+
} from '@whook/whook';
15+
import type { OpenAPIV3_1 } from 'openapi-types';
16+
17+
export const definition: WhookCommandDefinition = {
18+
description: 'A command printing functions informations for Terraform',
19+
example: `whook terraformValues --type paths`,
20+
arguments: {
21+
type: 'object',
22+
additionalProperties: false,
23+
required: ['type'],
24+
properties: {
25+
type: {
26+
description: 'Type of values to return',
27+
type: 'string',
28+
enum: ['globals', 'paths', 'functions', 'function'],
29+
},
30+
pretty: {
31+
description: 'Pretty print JSON values',
32+
type: 'boolean',
33+
},
34+
functionName: {
35+
description: 'Name of the function',
36+
type: 'string',
37+
},
38+
pathsIndex: {
39+
description: 'Index of the paths to retrieve',
40+
type: 'number',
41+
},
42+
functionType: {
43+
description: 'Types of the functions to return',
44+
type: 'string',
45+
},
46+
},
47+
},
48+
};
49+
50+
export default extra(definition, autoService(initTerraformValuesCommand));
51+
52+
async function initTerraformValuesCommand({
53+
API,
54+
BASE_PATH,
55+
log,
56+
args,
57+
execAsync = _execAsync,
58+
}: {
59+
API: OpenAPIV3_1.Document;
60+
BASE_PATH: string;
61+
log: LogService;
62+
args: WhookCommandArgs;
63+
execAsync: typeof _execAsync;
64+
}) {
65+
return async () => {
66+
const {
67+
namedArguments: { type, pretty, functionName, functionType },
68+
} = readArgs<{
69+
type: string;
70+
pretty: boolean;
71+
functionName: string;
72+
functionType: string;
73+
}>(definition.arguments, args);
74+
const operations =
75+
getOpenAPIOperations<WhookAPIHandlerDefinition['operation']['x-whook']>(
76+
API,
77+
);
78+
const configurations = operations.map((operation) => {
79+
const whookConfiguration = (operation['x-whook'] || {
80+
type: 'http',
81+
}) as WhookAPIHandlerDefinition['operation']['x-whook'];
82+
const configuration = {
83+
type: 'http',
84+
timeout: '10',
85+
memory: '128',
86+
description: operation.summary || '',
87+
enabled: 'true',
88+
sourceOperationId: operation.operationId,
89+
suffix: '',
90+
...Object.keys(whookConfiguration || {}).reduce(
91+
(accConfigurations, key) => ({
92+
...accConfigurations,
93+
[key]: (
94+
(
95+
whookConfiguration as NonNullable<
96+
WhookAPIHandlerDefinition['operation']['x-whook']
97+
>
98+
)[key] as string
99+
).toString(),
100+
}),
101+
{},
102+
),
103+
};
104+
const qualifiedOperationId =
105+
(configuration.sourceOperationId || operation.operationId) +
106+
(configuration.suffix || '');
107+
108+
return {
109+
qualifiedOperationId,
110+
method: operation.method.toUpperCase(),
111+
path: operation.path,
112+
...configuration,
113+
};
114+
});
115+
116+
if (type === 'globals') {
117+
const commitHash = await execAsync(`git rev-parse HEAD`);
118+
const commitMessage = (
119+
await execAsync(`git rev-list --format=%B --max-count=1 HEAD`)
120+
).split('\n')[1];
121+
const openapi2 = yaml.safeDump({
122+
swagger: '2.0',
123+
info: {
124+
title: API.info.title,
125+
description: API.info.description,
126+
version: API.info.version,
127+
},
128+
host: '${infos_host}',
129+
basePath: BASE_PATH,
130+
schemes: ['https'],
131+
produces: ['application/json'],
132+
paths: configurations.reduce((accPaths, configuration) => {
133+
const operation = operations.find(
134+
({ operationId }) =>
135+
operationId === configuration.sourceOperationId,
136+
);
137+
138+
return {
139+
...accPaths,
140+
[configuration.path]: {
141+
...(accPaths[configuration.path] || {}),
142+
[configuration.method.toLowerCase()]: {
143+
summary: configuration.description || '',
144+
operationId: configuration.qualifiedOperationId,
145+
...((operation?.parameters || []).length
146+
? {
147+
parameters: (
148+
operation?.parameters as OpenAPIV3_1.ParameterObject[]
149+
).map(({ in: theIn, name, required }) => ({
150+
in: theIn,
151+
name,
152+
type: 'string',
153+
required: required || false,
154+
})),
155+
}
156+
: undefined),
157+
'x-google-backend': {
158+
address: `\${function_${configuration.qualifiedOperationId}}`,
159+
},
160+
responses: {
161+
'200': { description: 'x', schema: { type: 'string' } },
162+
},
163+
},
164+
},
165+
};
166+
}, {}),
167+
});
168+
const openapiHash = crypto
169+
.createHash('md5')
170+
.update(JSON.stringify(API))
171+
.digest('hex');
172+
const infos = {
173+
commitHash,
174+
commitMessage,
175+
openapi2,
176+
openapiHash,
177+
};
178+
log('info', JSON.stringify(infos));
179+
return;
180+
}
181+
182+
if (type === 'functions') {
183+
const functions = configurations
184+
.filter((configuration) =>
185+
functionType ? configuration.type === functionType : true,
186+
)
187+
.reduce(
188+
(accLambdas, configuration) => ({
189+
...accLambdas,
190+
[configuration.qualifiedOperationId]:
191+
configuration.qualifiedOperationId,
192+
}),
193+
{},
194+
);
195+
196+
log('info', `${JSON.stringify(functions, null, pretty ? 2 : 0)}`);
197+
return;
198+
}
199+
200+
if (!functionName) {
201+
throw new YError('E_FUNCTION_NAME_REQUIRED');
202+
}
203+
204+
const functionConfiguration = configurations.find(
205+
({ qualifiedOperationId }) => qualifiedOperationId === functionName,
206+
);
207+
208+
log(
209+
'info',
210+
`${JSON.stringify(functionConfiguration, null, pretty ? 2 : 0)}`,
211+
);
212+
};
213+
}
214+
215+
async function _execAsync(command: string): Promise<string> {
216+
return await new Promise((resolve, reject) => {
217+
exec(
218+
command,
219+
(err: ExecException | null, stdout: string, stderr: string) => {
220+
if (err) {
221+
reject(YError.wrap(err, 'E_EXEC_FAILURE', stderr));
222+
return;
223+
}
224+
resolve(stdout.trim());
225+
},
226+
);
227+
});
228+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
data "external" "functionConfiguration" {
2+
for_each = data.external.functions.result
3+
4+
program = ["env", "APP_ENV=${terraform.workspace}", "NODE_ENV=${var.node_env}", "npx", "whook", "terraformValues", "--type='function'", "--functionName='${each.key}'"]
5+
}
6+
7+
resource "google_storage_bucket" "storage_bucket" {
8+
name = "whook_functions"
9+
}
10+
11+
data "archive_file" "functions" {
12+
for_each = data.external.functions.result
13+
14+
type = "zip"
15+
source_dir = "./builds/${terraform.workspace}/${each.key}"
16+
output_path = "./builds/${terraform.workspace}/${each.key}.zip"
17+
}
18+
19+
resource "google_storage_bucket_object" "storage_bucket_object" {
20+
for_each = data.external.functions.result
21+
22+
name = "${terraform.workspace}_${each.key}"
23+
source = "./builds/${terraform.workspace}/${each.key}.zip"
24+
bucket = google_storage_bucket.storage_bucket.name
25+
depends_on = [data.archive_file.functions]
26+
}
27+
28+
resource "google_cloudfunctions_function" "cloudfunctions_function" {
29+
for_each = data.external.functions.result
30+
31+
name = "${terraform.workspace}_${each.key}"
32+
description = data.external.functionConfiguration[each.key].result["description"]
33+
runtime = "nodejs10"
34+
35+
available_memory_mb = data.external.functionConfiguration[each.key].result["memory"]
36+
timeout = data.external.functionConfiguration[each.key].result["timeout"]
37+
source_archive_bucket = google_storage_bucket.storage_bucket.name
38+
source_archive_object = google_storage_bucket_object.storage_bucket_object[each.key].name
39+
trigger_http = true
40+
entry_point = "default"
41+
}
42+
43+
# Seems to not work (no idea why)
44+
# resource "google_cloudfunctions_function_iam_member" "invoker" {
45+
# for_each = data.external.functions.result
46+
47+
# project = google_cloudfunctions_function.cloudfunctions_function[each.key].project
48+
# region = google_cloudfunctions_function.cloudfunctions_function[each.key].region
49+
# cloud_function = google_cloudfunctions_function.cloudfunctions_function[each.key].name
50+
51+
# role = "roles/cloudfunctions.invoker"
52+
# member = "allUsers"
53+
# }
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
provider "google" {
2+
version = "~> 3.14"
3+
project = var.project_id
4+
region = var.region
5+
zone = var.zone
6+
credentials = file(".credentials.json")
7+
}
8+
9+
provider "archive" {
10+
version = "~> 1.3"
11+
}
12+
13+
provider "template" {
14+
version = "~> 2.1.2"
15+
}
16+
17+
output "api_url" {
18+
value = google_endpoints_service.endpoints_service.dns_address
19+
}
20+
21+
data "google_project" "project" {
22+
project_id = var.project_id
23+
}
24+
25+
# imports the functions list
26+
data "external" "functions" {
27+
program = ["env", "NODE_ENV=${terraform.workspace}", "npx", "whook", "terraformValues", "--type='functions'", "--functionType='http'"]
28+
}
29+
data "external" "globals" {
30+
program = ["env", "NODE_ENV=${terraform.workspace}", "npx", "whook", "terraformValues", "--type='globals'"]
31+
}
32+
33+
data "template_file" "template_file" {
34+
template = data.external.globals.result["openapi2"]
35+
36+
vars = merge({
37+
"infos_host" : "${var.api_name}.endpoints.${data.google_project.project.project_id}.cloud.goog"
38+
}, zipmap(
39+
[for key in keys(data.external.functions.result) : "function_${key}"],
40+
[for key in keys(data.external.functions.result) : google_cloudfunctions_function.cloudfunctions_function[key].https_trigger_url]
41+
))
42+
}
43+
44+
resource "google_endpoints_service" "endpoints_service" {
45+
service_name = "${var.api_name}.endpoints.${data.google_project.project.project_id}.cloud.goog"
46+
project = data.google_project.project.project_id
47+
openapi_config = data.template_file.template_file.rendered
48+
}

0 commit comments

Comments
 (0)