1 | 'use strict';
|
2 |
|
3 | const util = require('./import/utils');
|
4 | const bytes = require('bytes');
|
5 | const funignore = require('./package/ignore');
|
6 | const definition = require('./definition');
|
7 | const promiseRetry = require('./retry');
|
8 | const getProfile = require('./profile').getProfile;
|
9 | const securityGroup = require('./security-group');
|
10 |
|
11 | const fs = require('fs-extra');
|
12 | const path = require('path');
|
13 | const debug = require('debug')('fun:fc');
|
14 | const yaml = require('js-yaml');
|
15 | const zip = require('./package/zip');
|
16 | const vpc = require('./vpc');
|
17 | const nas = require('./nas');
|
18 | const nasCp = require('./nas/cp');
|
19 |
|
20 | const { sleep } = require('./time');
|
21 | const { getTpl, getBaseDir } = require('./tpl');
|
22 | const { green, red, yellow } = require('colors');
|
23 | const { getFcClient, getEcsPopClient, getNasPopClient } = require('./client');
|
24 | const { addEnv, resolveLibPathsFromLdConf, generateDefaultLibPath } = require('./install/env');
|
25 | const { readFileFromNasYml, mergeNasMappingsInNasYml } = require('./nas/support');
|
26 |
|
27 | const _ = require('lodash');
|
28 |
|
29 | const {
|
30 | promptForConfirmContinue,
|
31 | promptForMountTargets,
|
32 | promptForMountPoints,
|
33 | promptForFileSystems,
|
34 | promptForSecurityGroup
|
35 | } = require('./init/prompt');
|
36 |
|
37 | const FUN_GENERATED_SERVICE = 'fun-generated-default-service';
|
38 |
|
39 | const SYSTEM_DEPENDENCY_PATH = path.join('.fun', 'root');
|
40 |
|
41 | const SUPPORT_RUNTIMES = ['nodejs6', 'nodejs8', 'nodejs10', 'python2.7', 'python3', 'custom'];
|
42 |
|
43 | const defaultVpcConfig = {
|
44 | securityGroupId: '',
|
45 | vSwitchIds: [],
|
46 | vpcId: ''
|
47 | };
|
48 |
|
49 | const defaultNasConfig = {
|
50 | UserId: -1,
|
51 | GroupId: -1,
|
52 | MountPoints: []
|
53 | };
|
54 |
|
55 | async function generateFunIngore(baseDir, codeUri) {
|
56 | const absCodeUri = path.resolve(baseDir, codeUri);
|
57 | const absBaseDir = path.resolve(baseDir);
|
58 |
|
59 | const relative = path.relative(absBaseDir, absCodeUri);
|
60 |
|
61 | if (codeUri.startsWith('..') || relative.startsWith('..')) {
|
62 | console.warn(red(`\t\twarning: funignore is not supported for your CodeUri: ${codeUri}`));
|
63 | return null;
|
64 | }
|
65 |
|
66 | return await funignore(baseDir);
|
67 | }
|
68 |
|
69 | const runtimeTypeMapping = {
|
70 | 'nodejs6': 'node_modules',
|
71 | 'nodejs8': 'node_modules',
|
72 | 'nodejs10': 'node_modules',
|
73 | 'python2.7': ['.egg-info', '.dist-info', '.fun'],
|
74 | 'python3': ['.egg-info', '.dist-info', '.fun'],
|
75 | 'php7.2': ['extension', 'vendor']
|
76 | };
|
77 |
|
78 | async function detectLibraryFolders(dirName, libraryFolders, childDir, wrap, functionName) {
|
79 | if (Array.isArray(libraryFolders)) {
|
80 | for (const iterator of libraryFolders) {
|
81 | for (const name of childDir) {
|
82 | if (_.endsWith(name, iterator)) {
|
83 | console.warn(red(`${wrap}Fun detected that the library directory '${name}' is not included in function '${functionName}' CodeUri.\n\t\tPlease make sure if it is the right configuration. if yes, ignore please.`));
|
84 | return;
|
85 | }
|
86 | }
|
87 | }
|
88 | } else {
|
89 | if (childDir.includes(libraryFolders)) {
|
90 | console.warn(red(`${wrap}Fun detected that the library directory '${libraryFolders}' is not included in function '${functionName}' CodeUri.\n\t\tPlease make sure if it is the right configuration. if yes, ignore please.`));
|
91 | } else {
|
92 |
|
93 | const funDir = childDir.filter(p => p === '.fun');
|
94 | if (Array.isArray(funDir) && funDir.length > 0) {
|
95 |
|
96 | const childFun = await fs.readdir(path.join(dirName, '.fun'));
|
97 |
|
98 | if (childFun.includes('root')) {
|
99 |
|
100 | console.warn(red(`${wrap}Fun detected that the library directory '.fun/root' is not included in function '${functionName}' CodeUri.\n\t\tPlease make sure if it is the right configuration. if yes, ignore please.`));
|
101 |
|
102 | }
|
103 | }
|
104 | }
|
105 | }
|
106 | }
|
107 |
|
108 | async function detectLibrary(codeUri, runtime, baseDir, functionName, wrap = '') {
|
109 | const absoluteCodePath = path.resolve(baseDir, codeUri);
|
110 |
|
111 | const stats = await fs.lstat(absoluteCodePath);
|
112 | if (stats.isFile()) {
|
113 | let libraryFolders = runtimeTypeMapping[runtime];
|
114 |
|
115 | const dirName = path.dirname(absoluteCodePath);
|
116 | const childDir = await fs.readdir(dirName);
|
117 |
|
118 | await detectLibraryFolders(dirName, libraryFolders, childDir, wrap, functionName);
|
119 | }
|
120 | }
|
121 |
|
122 | function extractOssCodeUri(ossUri) {
|
123 | const prefixLength = 'oss://'.length;
|
124 |
|
125 | const index = ossUri.indexOf('/', prefixLength);
|
126 |
|
127 | return {
|
128 | ossBucketName: ossUri.substring(prefixLength, index),
|
129 | ossObjectName: ossUri.substring(index + 1)
|
130 | };
|
131 | }
|
132 |
|
133 | async function zipCode(baseDir, codeUri, runtime, functionName) {
|
134 | let codeAbsPath;
|
135 |
|
136 | if (codeUri) {
|
137 | codeAbsPath = path.resolve(baseDir, codeUri);
|
138 |
|
139 | if (codeUri.endsWith('.zip') || codeUri.endsWith('.jar') || codeUri.endsWith('.war')) {
|
140 | return { base64: Buffer.from(await fs.readFile(codeAbsPath)).toString('base64') };
|
141 | }
|
142 | } else {
|
143 | codeAbsPath = path.resolve(baseDir, './');
|
144 | }
|
145 |
|
146 | const ignore = await generateFunIngore(baseDir, codeAbsPath);
|
147 |
|
148 | await detectLibrary(codeAbsPath, runtime, baseDir, functionName, '\t\t');
|
149 |
|
150 | return await zip.pack(codeAbsPath, ignore);
|
151 | }
|
152 |
|
153 | const NODE_RUNTIME_MAPPING = {
|
154 | 'localDir': 'node_modules',
|
155 | 'remoteDir': 'node_modules',
|
156 | 'env': 'NODE_PATH'
|
157 | };
|
158 |
|
159 | const PYTHON_RUNTIME_MAPPING = {
|
160 | 'localDir': '.fun/python',
|
161 | 'remoteDir': 'python',
|
162 | 'env': 'PYTHONUSERBASE'
|
163 | };
|
164 |
|
165 | const runtimeDependencyMappings = {
|
166 | 'nodejs6': [ NODE_RUNTIME_MAPPING ],
|
167 | 'nodejs8': [ NODE_RUNTIME_MAPPING ],
|
168 | 'nodejs10': [ NODE_RUNTIME_MAPPING ],
|
169 | 'python2.7': [ PYTHON_RUNTIME_MAPPING ],
|
170 | 'python3': [ PYTHON_RUNTIME_MAPPING ],
|
171 | 'custom': [ NODE_RUNTIME_MAPPING, PYTHON_RUNTIME_MAPPING ]
|
172 | };
|
173 |
|
174 | async function saveNasMappings(baseDir, nasMappings) {
|
175 |
|
176 | if (_.isEmpty(nasMappings)) { return {}; }
|
177 |
|
178 | const nasYmlPath = path.resolve(baseDir, '.nas.yml');
|
179 |
|
180 | const contentObj = await readFileFromNasYml(baseDir);
|
181 |
|
182 | const mergedNasMappings = await mergeNasMappingsInNasYml(baseDir, nasMappings);
|
183 |
|
184 | contentObj.nasMappings = mergedNasMappings;
|
185 |
|
186 | await fs.writeFile(nasYmlPath, yaml.dump(contentObj));
|
187 |
|
188 | return mergedNasMappings;
|
189 | }
|
190 |
|
191 | async function updateEnvironmentsInTpl({ tplPath, tpl, envs,
|
192 | serviceName,
|
193 | functionName
|
194 | }) {
|
195 | const updatedTplContent = _.cloneDeep(tpl);
|
196 |
|
197 | const { functionRes } = definition.findFunctionByServiceAndFunctionName(updatedTplContent.Resources, serviceName, functionName);
|
198 |
|
199 | const customizer = (objValue, srcValue) => {
|
200 | if (objValue) {
|
201 | const spliceEnvs = objValue + ':' + srcValue;
|
202 | const uniqEnvs = _.uniq(spliceEnvs.split(':'));
|
203 | return _.join(uniqEnvs, ':');
|
204 | }
|
205 | return srcValue;
|
206 | };
|
207 |
|
208 | const functionProp = (functionRes.Properties || {});
|
209 | const formerEnvs = (functionProp.EnvironmentVariables) || {};
|
210 | const mergedEnvs = _.mergeWith(formerEnvs, envs, customizer);
|
211 |
|
212 | if (_.isEmpty(functionRes['Properties'])) {
|
213 | functionRes.Properties = {
|
214 | 'EnvironmentVariables': mergedEnvs
|
215 | };
|
216 | } else {
|
217 | functionRes.Properties.EnvironmentVariables = mergedEnvs;
|
218 | }
|
219 |
|
220 | util.outputTemplateFile(tplPath, updatedTplContent);
|
221 |
|
222 | console.log(green(`Fun add environment variables to your template.yml`));
|
223 |
|
224 | return updatedTplContent;
|
225 | }
|
226 |
|
227 | function generateBackupTplPath(tplPath) {
|
228 | const tplDir = path.dirname(tplPath);
|
229 | const tplName = path.basename(tplPath);
|
230 | const newTplName = `.${tplName}.backup`;
|
231 | return path.join(tplDir, newTplName);
|
232 | }
|
233 |
|
234 | function updateNasAutoConfigureInTpl(tplPath, tpl, nasServiceName) {
|
235 | const updatedTplContent = _.cloneDeep(tpl);
|
236 |
|
237 | const { serviceRes } = definition.findServiceByServiceName(updatedTplContent.Resources, nasServiceName);
|
238 |
|
239 | if (_.isEmpty(serviceRes['Properties'])) {
|
240 | serviceRes.Properties = {
|
241 | 'NasConfig': 'Auto'
|
242 | };
|
243 | } else {
|
244 | serviceRes.Properties.NasConfig = 'Auto';
|
245 | }
|
246 |
|
247 | util.outputTemplateFile(tplPath, updatedTplContent);
|
248 |
|
249 | console.log(green(`Fun add 'NasConfig: Auto' configuration to your template.yml.`));
|
250 |
|
251 | return updatedTplContent;
|
252 | }
|
253 |
|
254 | function updateNasAndVpcInTpl(tplPath, tpl, nasServiceName, nasAndVpcConfig) {
|
255 | const updatedTplContent = _.cloneDeep(tpl);
|
256 |
|
257 | const { serviceRes } = definition.findServiceByServiceName(updatedTplContent.Resources, nasServiceName);
|
258 |
|
259 | if (_.isEmpty(serviceRes['Properties'])) {
|
260 | serviceRes.Properties = nasAndVpcConfig;
|
261 | } else {
|
262 | serviceRes.Properties.VpcConfig = nasAndVpcConfig.VpcConfig;
|
263 | serviceRes.Properties.NasConfig = nasAndVpcConfig.NasConfig;
|
264 | }
|
265 |
|
266 | console.log(green(`Fun add 'NasConfig' and 'VpcConfig' configuration to your template.yml.`));
|
267 |
|
268 | util.outputTemplateFile(tplPath, updatedTplContent);
|
269 | return updatedTplContent;
|
270 | }
|
271 |
|
272 | async function generateNasMappingsAndEnvs({
|
273 | baseDir,
|
274 | serviceName,
|
275 | runtime,
|
276 | codeUri,
|
277 | nasConfig
|
278 | }) {
|
279 | const envs = {};
|
280 |
|
281 | const nasMappings = {};
|
282 | const nasMapping = [];
|
283 |
|
284 | const prefix = parseMountDirPrefix(nasConfig);
|
285 |
|
286 | const nasMappingPath = path.resolve(baseDir, '.nas.yml');
|
287 | const localSystemDependency = path.resolve(codeUri, SYSTEM_DEPENDENCY_PATH);
|
288 |
|
289 | if (await fs.pathExists(localSystemDependency)) {
|
290 | const remoteNasDir = `${prefix}root`;
|
291 |
|
292 | nasMapping.push({
|
293 | localNasDir: localSystemDependency,
|
294 | remoteNasDir
|
295 | });
|
296 |
|
297 | nasMappings[serviceName] = nasMapping;
|
298 |
|
299 | Object.assign(envs, generateSystemNasEnvs(remoteNasDir));
|
300 |
|
301 | outputNasMappingLog(baseDir, nasMappingPath, localSystemDependency);
|
302 | }
|
303 |
|
304 | const dependencyMappings = runtimeDependencyMappings[runtime];
|
305 |
|
306 | for (const mapping of dependencyMappings) {
|
307 | const localDir = path.join(codeUri, mapping.localDir);
|
308 |
|
309 | if (await fs.pathExists(localDir)) {
|
310 | const remoteDir = `${prefix}${mapping.remoteDir}`;
|
311 |
|
312 | nasMapping.push({
|
313 | localNasDir: localDir,
|
314 | remoteNasDir: remoteDir
|
315 | });
|
316 |
|
317 | Object.assign(envs, {
|
318 | [mapping.env]: remoteDir
|
319 | });
|
320 |
|
321 | outputNasMappingLog(baseDir, nasMappingPath, localDir);
|
322 | }
|
323 | }
|
324 |
|
325 | nasMappings[serviceName] = nasMapping;
|
326 |
|
327 | return {
|
328 | envs,
|
329 | nasMappings
|
330 | };
|
331 | }
|
332 |
|
333 | function parseMountDirPrefix(nasConfig) {
|
334 | if (definition.isNasAutoConfig(nasConfig)) {
|
335 | return '/mnt/auto/';
|
336 | }
|
337 | const mountPoints = nasConfig.MountPoints;
|
338 | ensureOnlyOneMountPoinExists(mountPoints);
|
339 |
|
340 | const mountPoint = _.head(mountPoints).MountDir;
|
341 | if (_.endsWith(mountPoint, '/')) {
|
342 | return mountPoint;
|
343 | }
|
344 | return mountPoint + '/';
|
345 | }
|
346 |
|
347 |
|
348 | function outputNasMappingLog(baseDir, nasMappingPath, localNasDir) {
|
349 | console.log(green(`${path.relative(baseDir, localNasDir)} has been correctly added to ${nasMappingPath}`));
|
350 | }
|
351 |
|
352 | function generateSystemNasEnvs(rootEnvPrefix) {
|
353 | return {
|
354 | 'LD_LIBRARY_PATH': `${generateDefaultLibPath(rootEnvPrefix)}`
|
355 | };
|
356 | }
|
357 |
|
358 | async function nasCpFromlocalNasDirToRemoteNasDir(tpl, tplPath, baseDir, nasServiceName, nasMappings) {
|
359 | const localNasTmpDir = path.join(baseDir, '.fun', 'tmp', 'nas', 'cp');
|
360 |
|
361 | const errors = [];
|
362 |
|
363 | for (const { localNasDir, remoteNasDir } of nasMappings) {
|
364 | const srcPath = localNasDir;
|
365 | const dstPath = `nas://${nasServiceName}${remoteNasDir}/`;
|
366 |
|
367 | console.log(yellow(`\nstarting upload ${srcPath} to ${dstPath}`));
|
368 | try {
|
369 | await nasCp(srcPath, dstPath, true, false, localNasTmpDir, tpl, tplPath, baseDir, false, true);
|
370 | } catch (error) {
|
371 | errors.push(`Upload ${srcPath} To ${dstPath} ${error}`);
|
372 | }
|
373 | }
|
374 |
|
375 | if (errors.length) {
|
376 | console.log();
|
377 | _.forEach(errors, (error) => {
|
378 | console.log(red(`${error}\n`));
|
379 | });
|
380 | }
|
381 | }
|
382 |
|
383 | async function processNasAutomationConfiguration({ tpl, tplPath, baseDir, runtime, codeUri, convertedNasConfig,
|
384 | nasServiceName,
|
385 | nasFunctionName
|
386 | }) {
|
387 |
|
388 | const { serviceRes } = definition.findFunctionByServiceAndFunctionName(tpl.Resources, nasServiceName, nasFunctionName);
|
389 |
|
390 | const nasConfig = (serviceRes.Properties || {}).NasConfig;
|
391 |
|
392 | const { envs, nasMappings } = await generateNasMappingsAndEnvs({
|
393 | baseDir,
|
394 | serviceName: nasServiceName,
|
395 | runtime,
|
396 | codeUri,
|
397 | nasConfig: convertedNasConfig || nasConfig
|
398 | });
|
399 |
|
400 | const localDirs = _.map(runtimeDependencyMappings[runtime], mapping => path.join(codeUri, mapping.localDir));
|
401 |
|
402 | if (_.isEmpty(nasMappings)) {
|
403 | throw new Error(red(`\nFun detects that your dependencies are not included in path ${localDirs} or ${path.resolve(codeUri, SYSTEM_DEPENDENCY_PATH)}`));
|
404 | }
|
405 |
|
406 | const nasMappingsObj = await saveNasMappings(baseDir, nasMappings);
|
407 |
|
408 | const updatedTplContent = await updateEnvironmentsInTpl({
|
409 | tplPath, tpl, envs,
|
410 | serviceName: nasServiceName,
|
411 | functionName: nasFunctionName
|
412 | });
|
413 |
|
414 |
|
415 | await nasCpFromlocalNasDirToRemoteNasDir(tpl, tplPath, baseDir, nasServiceName, nasMappingsObj[nasServiceName]);
|
416 |
|
417 | console.log(yellow(`\nFun has automatically uploaded your code dependency to NAS, then fun will use 'fun deploy ${nasServiceName}/${nasFunctionName}' to redeploy.`));
|
418 |
|
419 | console.log(`Waiting for service ${nasServiceName} to be deployed...`);
|
420 |
|
421 | const partialDeploy = await require('./deploy/deploy-by-tpl').partialDeployment(`${nasServiceName}/${nasFunctionName}`, updatedTplContent);
|
422 |
|
423 | if (partialDeploy.serviceName) {
|
424 | await require('./deploy/deploy-by-tpl').deployService(baseDir, partialDeploy.serviceName, partialDeploy.serviceRes, false, tplPath, true);
|
425 | }
|
426 | }
|
427 |
|
428 | async function backupTemplateFile(tplPath) {
|
429 | const newPath = generateBackupTplPath(tplPath);
|
430 | await fs.copy(tplPath, newPath);
|
431 | console.log(green(`\nFun automatically backups the original ${path.basename(tplPath)} file to ${newPath}`));
|
432 | }
|
433 |
|
434 | function ensureMountTargetsExist(mountTargets) {
|
435 | if (_.isEmpty(mountTargets)) {
|
436 | throw new Error(red('Nas has not configured the mountTarget yet, please go to the console https://nas.console.aliyun.com/ to manually create the mountTarget.'));
|
437 | }
|
438 | }
|
439 |
|
440 | function ensureSecurityGroupsExist(securityGroups) {
|
441 | if (_.isEmpty(securityGroups)) {
|
442 | throw new Error(red(`\nThere is no SecurityGroup available. You need to login to the vpc console https://ecs.console.aliyun.com/ to create one and then use 'fun deploy' to deploy your resources again.`));
|
443 | }
|
444 | }
|
445 |
|
446 | function ensureNasFileSystemsExist(nasFileSystems) {
|
447 | if (_.isEmpty(nasFileSystems)) {
|
448 | throw new Error(red(`\nThere is no NAS file system available. You need to login to the nas console http://nas.console.aliyun.com to create one and then use 'fun deploy' to deploy your resources again.`));
|
449 | }
|
450 | }
|
451 |
|
452 | function ensureOnlyOneMountPoinExists(mountPoints) {
|
453 | if (mountPoints.length > 1) {
|
454 | throw new Error(red(`More than one 'NasConfig' configuration in template.yml.`));
|
455 | }
|
456 | }
|
457 |
|
458 | async function getSecurityGroups(vpcId) {
|
459 | const ecsClient = await getEcsPopClient();
|
460 | const profile = await getProfile();
|
461 | return await securityGroup.describeSecurityGroups(ecsClient, profile.defaultRegion, vpcId, undefined);
|
462 | }
|
463 |
|
464 | async function processNasSelection() {
|
465 | const nasClient = await getNasPopClient();
|
466 | const nasFileSystems = await nas.getAvailableNasFileSystems(nasClient);
|
467 | ensureNasFileSystemsExist(nasFileSystems);
|
468 |
|
469 | const nasAnswer = await promptForFileSystems(nasFileSystems);
|
470 | const nasSelected = nasFileSystems.filter(f => f.fileSystemId === nasAnswer.fileSystemId);
|
471 | const mountTargets = _.head(nasSelected).mountTargets;
|
472 | ensureMountTargetsExist(mountTargets);
|
473 |
|
474 | const mountTargetAnswer = await promptForMountTargets(mountTargets);
|
475 | const mountTargetSelected = mountTargets.filter(f => f.MountTargetDomain === mountTargetAnswer.mountTargetDomain);
|
476 | const mountTarget = _.head(mountTargetSelected);
|
477 |
|
478 | const securityGroups = await getSecurityGroups(mountTarget.VpcId);
|
479 | ensureSecurityGroupsExist(securityGroups);
|
480 |
|
481 | const securityGroupAnswer = await promptForSecurityGroup(securityGroups);
|
482 | const securityGroupId = securityGroupAnswer.securityGroupId;
|
483 |
|
484 | return {
|
485 | mountTarget,
|
486 | securityGroupId
|
487 | };
|
488 | }
|
489 |
|
490 | function replaceNasConfig(nasConfig, mountDir) {
|
491 | const cloneNasConfig = _.cloneDeep(nasConfig);
|
492 | cloneNasConfig.MountPoints = cloneNasConfig.MountPoints.filter(f => f.MountDir === mountDir);
|
493 | return cloneNasConfig;
|
494 | }
|
495 |
|
496 | async function nasAutomationConfigurationIfNecessary({ tplPath, runtime, baseDir, codeUri, nasConfig, vpcConfig,
|
497 | compressedSize,
|
498 | nasFunctionName,
|
499 | nasServiceName
|
500 | }) {
|
501 |
|
502 | let stop = false;
|
503 |
|
504 | if (compressedSize > 52428800 && _.includes(SUPPORT_RUNTIMES, runtime)) {
|
505 | console.log(red(`\nFun detected that your function ${nasServiceName}/${nasFunctionName} sizes exceed 50M. It is recommended that using the nas service to manage your function dependencies.`));
|
506 |
|
507 | if (await promptForConfirmContinue(`Do you want to let fun to help you automate the configuration?`)) {
|
508 | const tpl = await getTpl(tplPath);
|
509 | const onlyOneNas = definition.onlyOneNASExists(nasConfig);
|
510 |
|
511 | if (definition.isNasAutoConfig(nasConfig)) {
|
512 | const yes = await promptForConfirmContinue(`You have already configured 'NasConfig: Auto’. We want to use this configuration to store your function dependencies.`);
|
513 | if (yes) {
|
514 | await backupTemplateFile(tplPath);
|
515 | await processNasAutomationConfiguration({
|
516 | tpl, tplPath, baseDir, runtime, codeUri,
|
517 | nasServiceName,
|
518 | nasFunctionName
|
519 | });
|
520 |
|
521 | stop = true;
|
522 | } else {
|
523 | throw new Error(red(`\nIf 'NasConfig: Auto' is configured, only the configuration store function dependency is currently supported.`));
|
524 | }
|
525 | } else if (!_.isEmpty(vpcConfig) && _.isEmpty(nasConfig)) {
|
526 |
|
527 | throw new Error(red(`\nFun has detected that you only have VPC configuration. This scenario is not supported at this time. You also need to manually configure the NAS service. You can refer to: https://github.com/alibaba/funcraft/blob/master/docs/specs/2018-04-03-zh-cn.md#nas-%E9%85%8D%E7%BD%AE%E5%AF%B9%E8%B1%A1 and https://nas.console.aliyun.com/`));
|
528 | } else if (!_.isEmpty(vpcConfig) && !_.isEmpty(nasConfig)) {
|
529 | if (onlyOneNas) {
|
530 | const yes = await promptForConfirmContinue(`We have detected that you already have a NAS configuration. Do you directly use this NAS storage function dependencies.`);
|
531 | if (yes) {
|
532 | await backupTemplateFile(tplPath);
|
533 |
|
534 | await processNasAutomationConfiguration({
|
535 | tpl, tplPath, baseDir, runtime, codeUri,
|
536 | nasServiceName,
|
537 | nasFunctionName
|
538 | });
|
539 | } else {
|
540 | throw new Error(red(`If your yml has been already configured with 'NasConfig', fun only supports to use this 'NasConfig' to process your function dependencies. Otherwise you need to handle the dependencies by yourself.\n\nRefer to https://yq.aliyun.com/articles/712700 for more help.`));
|
541 | }
|
542 | } else {
|
543 | const answer = await promptForMountPoints(nasConfig.MountPoints);
|
544 | const convertedNasConfig = replaceNasConfig(nasConfig, answer.mountDir);
|
545 | await backupTemplateFile(tplPath);
|
546 | await processNasAutomationConfiguration({
|
547 | tpl, tplPath, baseDir, runtime, codeUri, convertedNasConfig,
|
548 | nasServiceName,
|
549 | nasFunctionName
|
550 | });
|
551 | }
|
552 | stop = true;
|
553 | } else if (_.isEmpty(vpcConfig) && _.isEmpty(nasConfig)) {
|
554 | const yes = await promptForConfirmContinue(`We recommend using the 'NasConfig: Auto' configuration to manage your function dependencies.`);
|
555 | if (yes) {
|
556 |
|
557 | await backupTemplateFile(tplPath);
|
558 |
|
559 | const updatedTpl = updateNasAutoConfigureInTpl(tplPath, tpl, nasServiceName);
|
560 |
|
561 | await processNasAutomationConfiguration({
|
562 | tpl: updatedTpl, tplPath, baseDir, runtime, codeUri,
|
563 | nasServiceName,
|
564 | nasFunctionName
|
565 | });
|
566 | } else {
|
567 |
|
568 | const { mountTarget, securityGroupId } = await processNasSelection();
|
569 |
|
570 | await backupTemplateFile(tplPath);
|
571 |
|
572 | const nasAndVpcConfig = generateNasAndVpcConfig(mountTarget, securityGroupId, nasServiceName);
|
573 | const updatedTpl = updateNasAndVpcInTpl(tplPath, tpl, nasServiceName, nasAndVpcConfig);
|
574 |
|
575 | await processNasAutomationConfiguration({
|
576 | tpl: updatedTpl, tplPath, baseDir, runtime, codeUri,
|
577 | nasServiceName,
|
578 | nasFunctionName
|
579 | });
|
580 | }
|
581 | stop = true;
|
582 | }
|
583 | }
|
584 | }
|
585 | return stop;
|
586 | }
|
587 |
|
588 | function generateNasAndVpcConfig(mountTarget, securityGroupId, serviceName) {
|
589 | const nasConfig = {
|
590 | 'UserId': 10003,
|
591 | 'GroupId': 10003,
|
592 | 'MountPoints': [
|
593 | {
|
594 | 'ServerAddr': `${mountTarget.MountTargetDomain}:/${serviceName}`,
|
595 | 'MountDir': '/mnt/nas'
|
596 | }
|
597 | ]
|
598 | };
|
599 |
|
600 | const vpcConfig = {
|
601 | 'VpcId': mountTarget.VpcId,
|
602 | 'VSwitchIds': [mountTarget.VswId],
|
603 | 'SecurityGroupId': securityGroupId
|
604 | };
|
605 |
|
606 | return {
|
607 | 'VpcConfig': vpcConfig,
|
608 | 'NasConfig': nasConfig
|
609 | };
|
610 | }
|
611 |
|
612 | async function makeFunction(baseDir, {
|
613 | serviceName,
|
614 | functionName,
|
615 | description = '',
|
616 | handler,
|
617 | initializer = '',
|
618 | timeout = 3,
|
619 | initializationTimeout = 3,
|
620 | memorySize = 128,
|
621 | runtime = 'nodejs6',
|
622 | codeUri,
|
623 | environmentVariables = {},
|
624 | instanceConcurrency,
|
625 | nasConfig,
|
626 | vpcConfig
|
627 | }, onlyConfig, tplPath) {
|
628 | const fc = await getFcClient();
|
629 |
|
630 | var fn;
|
631 | try {
|
632 | fn = await fc.getFunction(serviceName, functionName);
|
633 | } catch (ex) {
|
634 | if (ex.code !== 'FunctionNotFound') {
|
635 | throw ex;
|
636 | }
|
637 | }
|
638 |
|
639 | if (!fn && onlyConfig) {
|
640 | throw new Error(`\nFunction '` + `${serviceName}` + '/' + `${functionName}` + `' was detected as the first deployment, and the code package had to be uploaded when creating the function. You can ` + yellow(`either`) + ` re-execute the command to remove the -u(--update-config)` + ` option ` + yellow(`or`) + ` execute 'fun deploy ${serviceName}/${functionName}' before doing so.`);
|
641 | }
|
642 |
|
643 | let code;
|
644 |
|
645 | if (!onlyConfig) {
|
646 |
|
647 | if (codeUri && codeUri.startsWith('oss://')) {
|
648 | code = extractOssCodeUri(codeUri);
|
649 | } else {
|
650 | console.log(`\t\tWaiting for packaging function ${functionName} code...`);
|
651 | const { base64, count, compressedSize } = await zipCode(baseDir, codeUri, runtime, functionName);
|
652 |
|
653 | const stop = await nasAutomationConfigurationIfNecessary({
|
654 | compressedSize, tplPath, baseDir: getBaseDir(tplPath), runtime, nasConfig, vpcConfig,
|
655 | nasFunctionName: functionName,
|
656 | nasServiceName: serviceName,
|
657 | codeUri: path.resolve(baseDir, codeUri)
|
658 | });
|
659 |
|
660 | if (stop) { return; }
|
661 |
|
662 | const convertedSize = bytes(compressedSize, {
|
663 | unitSeparator: ' '
|
664 | });
|
665 |
|
666 | if (!count || !compressedSize) {
|
667 | console.log(green(`\t\tThe function ${functionName} has been packaged.`));
|
668 | } else {
|
669 | console.log(green(`\t\tThe function ${functionName} has been packaged. A total of ` + yellow(`${count}`) + `${count === 1 ? ' file' : ' files'}` + ` files were compressed and the final size was` + yellow(` ${convertedSize}`)));
|
670 | }
|
671 |
|
672 | code = {
|
673 | zipFile: base64
|
674 | };
|
675 | }
|
676 | }
|
677 |
|
678 | const confEnv = await resolveLibPathsFromLdConf(baseDir, codeUri);
|
679 |
|
680 | Object.assign(environmentVariables, confEnv);
|
681 |
|
682 | const params = {
|
683 | description,
|
684 | handler,
|
685 | initializer,
|
686 | timeout,
|
687 | initializationTimeout,
|
688 | memorySize,
|
689 | runtime,
|
690 | code,
|
691 | environmentVariables: addEnv(environmentVariables, nasConfig),
|
692 | instanceConcurrency
|
693 | };
|
694 |
|
695 | for (let i in params.environmentVariables) {
|
696 | if (!isNaN(params.environmentVariables[i])) {
|
697 | debug(`the value in environmentVariables:${params.environmentVariables[i]} cast String Done`);
|
698 | params.environmentVariables[i] = params.environmentVariables[i] + '';
|
699 | }
|
700 | }
|
701 |
|
702 | try {
|
703 | if (!fn) {
|
704 |
|
705 | params['functionName'] = functionName;
|
706 | fn = await fc.createFunction(serviceName, params);
|
707 | } else {
|
708 |
|
709 | fn = await fc.updateFunction(serviceName, functionName, params);
|
710 | }
|
711 | } catch (ex) {
|
712 |
|
713 | if (ex.message.indexOf('timeout') !== -1) {
|
714 | throw new Error(`\nError message: ${ex.message}.\n\n` + red(`This error may be caused by network latency. You can set the client timeout to a larger value through 'fun config' and try again.`));
|
715 | }
|
716 | throw ex;
|
717 | }
|
718 | return fn;
|
719 | }
|
720 |
|
721 | async function makeService({
|
722 | serviceName,
|
723 | role,
|
724 | description,
|
725 | internetAccess = true,
|
726 | logConfig = {},
|
727 | vpcConfig,
|
728 | nasConfig
|
729 | }) {
|
730 | const fc = await getFcClient();
|
731 |
|
732 | var service;
|
733 | await promiseRetry(async (retry, times) => {
|
734 | try {
|
735 | service = await fc.getService(serviceName);
|
736 | } catch (ex) {
|
737 |
|
738 | if (ex.code === 'AccessDenied' || !ex.code || ex.code === 'ENOTFOUND') {
|
739 |
|
740 | if (ex.message.indexOf('the caller is not authorized to perform') !== -1) {
|
741 |
|
742 | console.error(red(`\nMaybe you need grant AliyunRAMFullAccess policy to the subuser or use the primary account. You can refer to Chinese doc https://github.com/aliyun/fun/blob/master/docs/usage/faq-zh.md#nopermissionerror-you-are-not-authorized-to-do-this-action-resource-acsramxxxxxxxxxxrole-action-ramgetrole or English doc https://github.com/aliyun/fun/blob/master/docs/usage/faq.md#nopermissionerror-you-are-not-authorized-to-do-this-action-resource-acsramxxxxxxxxxxrole-action-ramgetrole for help.\n\nIf you don’t want use the AliyunRAMFullAccess policy or primary account, you can also specify the Role property for Service. You can refer to Chinese doc https://github.com/aliyun/fun/blob/master/docs/specs/2018-04-03-zh-cn.md#aliyunserverlessservice or English doc https://github.com/aliyun/fun/blob/master/docs/specs/2018-04-03.md#aliyunserverlessservice for help.\n`));
|
743 |
|
744 | } else if (ex.message.indexOf('FC service is not enabled for current user') !== -1) {
|
745 |
|
746 | console.error(red(`\nFC service is not enabled for current user. Please enable FC service before using fun.\nYou can enable FC service on this page https://www.aliyun.com/product/fc .\n`));
|
747 |
|
748 | } else {
|
749 | console.error(red(`\nThe accountId you entered is incorrect. You can only use the primary account id, whether or not you use a sub-account or a primary account ak. You can get primary account ID on this page https://account.console.aliyun.com/#/secure .\n`));
|
750 | }
|
751 |
|
752 | throw ex;
|
753 | } else if (ex.code !== 'ServiceNotFound') {
|
754 | debug('error when getService, serviceName is %s, error is: \n%O', serviceName, ex);
|
755 |
|
756 | console.log(red(`\tretry ${times} times`));
|
757 | retry(ex);
|
758 | }
|
759 | }
|
760 | });
|
761 |
|
762 | const options = {
|
763 | description,
|
764 | role,
|
765 | logConfig: {
|
766 | project: logConfig.Project || '',
|
767 | logstore: logConfig.Logstore || ''
|
768 | }
|
769 | };
|
770 |
|
771 | if (internetAccess !== null) {
|
772 |
|
773 | Object.assign(options, {
|
774 | internetAccess
|
775 | });
|
776 | }
|
777 |
|
778 | const isNasAuto = definition.isNasAutoConfig(nasConfig);
|
779 | const isVpcAuto = definition.isVpcAutoConfig(vpcConfig);
|
780 |
|
781 | if (!_.isEmpty(vpcConfig) || isNasAuto) {
|
782 |
|
783 | if (isVpcAuto || (_.isEmpty(vpcConfig) && isNasAuto)) {
|
784 | console.log('\tusing \'VpcConfig: Auto\', Fun will try to generate related vpc resources automatically');
|
785 | vpcConfig = await vpc.createDefaultVpcIfNotExist();
|
786 | console.log(green('\tgenerated auto VpcConfig done: ', JSON.stringify(vpcConfig)));
|
787 |
|
788 | debug('generated vpcConfig: %j', vpcConfig);
|
789 | }
|
790 | }
|
791 |
|
792 | Object.assign(options, {
|
793 | vpcConfig: vpcConfig || defaultVpcConfig
|
794 | });
|
795 |
|
796 | if (isNasAuto) {
|
797 |
|
798 | const vpcId = vpcConfig.vpcId;
|
799 | const vswitchId = _.head(vpcConfig.vswitchIds);
|
800 |
|
801 | console.log('\tusing \'NasConfig: Auto\', Fun will try to generate related nas file system automatically');
|
802 | nasConfig = await nas.generateAutoNasConfig(serviceName, vpcId, vswitchId, nasConfig.UserId, nasConfig.GroupId);
|
803 | console.log(green('\tgenerated auto NasConfig done: ', JSON.stringify(nasConfig)));
|
804 | }
|
805 |
|
806 | Object.assign(options, {
|
807 | nasConfig: nasConfig || defaultNasConfig
|
808 | });
|
809 |
|
810 | await promiseRetry(async (retry, times) => {
|
811 | try {
|
812 | if (!service) {
|
813 | debug('create service %s, options is %j', serviceName, options);
|
814 | service = await fc.createService(serviceName, options);
|
815 | } else {
|
816 | debug('update service %s, options is %j', serviceName, options);
|
817 | service = await fc.updateService(serviceName, options);
|
818 | }
|
819 | } catch (ex) {
|
820 | debug('error when createService or updateService, serviceName is %s, options is %j, error is: \n%O', serviceName, options, ex);
|
821 |
|
822 | console.log(red(`\tretry ${times} times`));
|
823 | retry(ex);
|
824 | }
|
825 | });
|
826 |
|
827 |
|
828 | if (serviceName !== FUN_GENERATED_SERVICE
|
829 | && !_.isEmpty(nasConfig)
|
830 | && !_.isEmpty(nasConfig.MountPoints)) {
|
831 |
|
832 | await ensureNasDirExist({
|
833 | role, vpcConfig, nasConfig
|
834 | });
|
835 | }
|
836 |
|
837 | return service;
|
838 | }
|
839 |
|
840 | function mapMountPointDir(mountPoints, func) {
|
841 | let resolvedMountPoints = _.map(mountPoints, (mountPoint) => {
|
842 | const serverAddr = mountPoint.ServerAddr;
|
843 |
|
844 | const index = _.lastIndexOf(serverAddr, ':');
|
845 | if (index >= 0) {
|
846 | const mountPointDomain = serverAddr.substring(0, index);
|
847 | const remoteDir = serverAddr.substring(index + 1);
|
848 | const mountDir = mountPoint.MountDir;
|
849 |
|
850 | debug('remoteDir is: %s', remoteDir);
|
851 |
|
852 | return func(mountPointDomain, remoteDir, mountDir);
|
853 | }
|
854 | });
|
855 |
|
856 | resolvedMountPoints = _.compact(resolvedMountPoints);
|
857 |
|
858 | return resolvedMountPoints;
|
859 | }
|
860 |
|
861 | const EXTREME_PATH_PREFIX = '/share';
|
862 |
|
863 | function checkMountPointDomainIsExtremeNas(mountPointDomain, remoteDir) {
|
864 | const isExtremeNAS = mountPointDomain.indexOf('.extreme.nas.aliyuncs.com') !== -1;
|
865 |
|
866 | if (isExtremeNAS && (remoteDir !== EXTREME_PATH_PREFIX && !remoteDir.startsWith(EXTREME_PATH_PREFIX + '/'))) {
|
867 | throw new Error('Extreme nas mount point must start with /share. Please refer to https://nas.console.aliyun.com/#/extreme for more help.');
|
868 | }
|
869 |
|
870 | return isExtremeNAS;
|
871 | }
|
872 |
|
873 | async function ensureNasDirExist({
|
874 | role,
|
875 | vpcConfig,
|
876 | nasConfig
|
877 | }) {
|
878 | const mountPoints = nasConfig.MountPoints;
|
879 | const modifiedNasConfig = _.cloneDeep(nasConfig);
|
880 |
|
881 | modifiedNasConfig.MountPoints = mapMountPointDir(mountPoints, (mountPointDomain, remoteDir, mountDir) => {
|
882 |
|
883 | if (checkMountPointDomainIsExtremeNas(mountPointDomain, remoteDir)) {
|
884 |
|
885 | return {
|
886 | ServerAddr: `${mountPointDomain}:${EXTREME_PATH_PREFIX}`,
|
887 | MountDir: `${mountDir}`
|
888 | };
|
889 | } else if (remoteDir !== '/') {
|
890 | return {
|
891 | ServerAddr: `${mountPointDomain}:/`,
|
892 | MountDir: `${mountDir}`
|
893 | };
|
894 | } return null;
|
895 | });
|
896 |
|
897 | const nasMountDirs = mapMountPointDir(mountPoints, (mountPointDomain, remoteDir, mountDir) => {
|
898 | if (checkMountPointDomainIsExtremeNas(mountPointDomain, remoteDir)) {
|
899 | if (remoteDir !== EXTREME_PATH_PREFIX) {
|
900 | return { mountDir, remoteDir, isExtreme: true };
|
901 | }
|
902 | } else if (remoteDir !== '/') {
|
903 | return { mountDir, remoteDir, isExtreme: false };
|
904 | }
|
905 | return null;
|
906 | });
|
907 |
|
908 | debug('dirs need to check: %s', nasMountDirs);
|
909 |
|
910 | if (!_.isEmpty(nasMountDirs)) {
|
911 | let nasRemoteDirs = [];
|
912 | let nasDirsNeedToCheck = [];
|
913 | for (let nasMountDir of nasMountDirs) {
|
914 | nasRemoteDirs.push(nasMountDir.remoteDir);
|
915 | if (nasMountDir.isExtreme) {
|
916 |
|
917 | nasDirsNeedToCheck.push(path.posix.join(nasMountDir.mountDir, nasMountDir.remoteDir.substring(EXTREME_PATH_PREFIX.length)));
|
918 | } else {
|
919 | nasDirsNeedToCheck.push(path.posix.join(nasMountDir.mountDir, nasMountDir.remoteDir));
|
920 | }
|
921 | }
|
922 |
|
923 | console.log(`\tChecking if nas directories ${nasRemoteDirs} exists, if not, it will be created automatically`);
|
924 |
|
925 | const utilFunctionName = await makeFcUtilsFunctionNasDirChecker(role, vpcConfig, modifiedNasConfig);
|
926 | await sleep(1000);
|
927 | await invokeFcUtilsFunction({
|
928 | functionName: utilFunctionName,
|
929 | event: JSON.stringify(nasDirsNeedToCheck)
|
930 | });
|
931 |
|
932 | console.log(green('\tChecking nas directories done', JSON.stringify(nasRemoteDirs)));
|
933 | }
|
934 | }
|
935 |
|
936 | async function makeFcUtilsService(role, vpcConfig, nasConfig) {
|
937 | return await makeService({
|
938 | serviceName: FUN_GENERATED_SERVICE,
|
939 | role,
|
940 | description: 'generated by Funcraft',
|
941 | vpcConfig,
|
942 | nasConfig
|
943 | });
|
944 | }
|
945 |
|
946 | async function makeFcUtilsFunction({
|
947 | serviceName,
|
948 | functionName,
|
949 | codes,
|
950 | description = '',
|
951 | handler,
|
952 | timeout = 60,
|
953 | memorySize = 128,
|
954 | runtime = 'nodejs8'
|
955 | }) {
|
956 | const fc = await getFcClient();
|
957 |
|
958 | var fn;
|
959 | try {
|
960 | fn = await fc.getFunction(serviceName, functionName);
|
961 | } catch (ex) {
|
962 | if (ex.code !== 'FunctionNotFound') {
|
963 | throw ex;
|
964 | }
|
965 | }
|
966 |
|
967 | const base64 = await zip.packFromJson(codes);
|
968 |
|
969 | let code = {
|
970 | zipFile: base64
|
971 | };
|
972 |
|
973 | const params = {
|
974 | description,
|
975 | handler,
|
976 | initializer: '',
|
977 | timeout,
|
978 | memorySize,
|
979 | runtime,
|
980 | code
|
981 | };
|
982 |
|
983 | if (!fn) {
|
984 |
|
985 | params['functionName'] = functionName;
|
986 | fn = await fc.createFunction(serviceName, params);
|
987 | } else {
|
988 |
|
989 | fn = await fc.updateFunction(serviceName, functionName, params);
|
990 | }
|
991 |
|
992 | return fn;
|
993 | }
|
994 |
|
995 | async function invokeFcUtilsFunction({
|
996 | functionName,
|
997 | event
|
998 | }) {
|
999 | const fc = await getFcClient();
|
1000 | const rs = await fc.invokeFunction(FUN_GENERATED_SERVICE, functionName, event, {
|
1001 | 'X-Fc-Log-Type': 'Tail'
|
1002 | });
|
1003 |
|
1004 | if (rs.data !== 'OK') {
|
1005 | const log = rs.headers['x-fc-log-result'];
|
1006 |
|
1007 | if (log) {
|
1008 | const decodedLog = Buffer.from(log, 'base64');
|
1009 | if ((decodedLog.toString().toLowerCase()).includes('permission denied')) {
|
1010 | throw new Error(`fc utils function ${functionName} invoke error, error message is: ${decodedLog}\n${red('May be UserId and GroupId in NasConfig don\'t have enough \
|
1011 | permission, more information please refer to https://github.com/alibaba/funcraft/blob/master/docs/usage/faq-zh.md')}`);
|
1012 | }
|
1013 | throw new Error(`fc utils function ${functionName} invoke error, error message is: ${decodedLog}`);
|
1014 | }
|
1015 | }
|
1016 | }
|
1017 |
|
1018 | async function getFcUtilsFunctionCode(filename) {
|
1019 | return await fs.readFile(path.join(__dirname, 'utils', filename));
|
1020 | }
|
1021 |
|
1022 | async function makeFcUtilsFunctionNasDirChecker(role, vpcConfig, nasConfig) {
|
1023 | await makeFcUtilsService(role, vpcConfig, nasConfig);
|
1024 |
|
1025 | const functionName = 'nas_dir_checker';
|
1026 |
|
1027 | const functionCode = await getFcUtilsFunctionCode('nas-dir-check.js');
|
1028 |
|
1029 | const codes = {
|
1030 | 'index.js': functionCode
|
1031 | };
|
1032 |
|
1033 | await makeFcUtilsFunction({
|
1034 | serviceName: FUN_GENERATED_SERVICE,
|
1035 | functionName: 'nas_dir_checker',
|
1036 | codes,
|
1037 | description: 'used for fun to ensure nas remote dir exist',
|
1038 | handler: 'index.handler'
|
1039 | });
|
1040 |
|
1041 | return functionName;
|
1042 | }
|
1043 |
|
1044 |
|
1045 | async function invokeFunction({
|
1046 | serviceName,
|
1047 | functionName,
|
1048 | event,
|
1049 | invocationType
|
1050 | }) {
|
1051 |
|
1052 | var rs;
|
1053 | const fc = await getFcClient();
|
1054 |
|
1055 | if (invocationType === 'Sync') {
|
1056 |
|
1057 | rs = await fc.invokeFunction(serviceName, functionName, event, {
|
1058 | 'X-Fc-Log-Type': 'Tail',
|
1059 | 'X-Fc-Invocation-Type': invocationType
|
1060 | });
|
1061 |
|
1062 | const log = rs.headers['x-fc-log-result'];
|
1063 |
|
1064 | if (log) {
|
1065 |
|
1066 | console.log(yellow('========= FC invoke Logs begin ========='));
|
1067 | const decodedLog = Buffer.from(log, 'base64');
|
1068 | console.log(decodedLog.toString());
|
1069 | console.log(yellow('========= FC invoke Logs end ========='));
|
1070 |
|
1071 | console.log(green('\nFC Invoke Result:'));
|
1072 | console.log(rs.data);
|
1073 | }
|
1074 | } else {
|
1075 |
|
1076 | rs = await fc.invokeFunction(serviceName, functionName, event, {
|
1077 | 'X-Fc-Invocation-Type': invocationType
|
1078 | });
|
1079 |
|
1080 | console.log(green('✔ ') + `${serviceName}/${functionName} async invoke success.`);
|
1081 | }
|
1082 |
|
1083 | return rs;
|
1084 | }
|
1085 |
|
1086 | module.exports = {
|
1087 | invokeFcUtilsFunction,
|
1088 | makeFcUtilsFunctionNasDirChecker,
|
1089 | FUN_GENERATED_SERVICE,
|
1090 | makeService,
|
1091 | makeFunction,
|
1092 | zipCode,
|
1093 | detectLibrary,
|
1094 | getFcUtilsFunctionCode,
|
1095 | invokeFunction,
|
1096 | generateFunIngore
|
1097 | }; |
\ | No newline at end of file |