Skip to content

Commit 0923f88

Browse files
committed
feat: Adding ability to add repos.
1 parent 74b76f3 commit 0923f88

14 files changed

+765
-80
lines changed

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"@types/request-promise-native": "1.0.15",
2727
"@types/sinon": "7.0.3",
2828
"@types/sinon-chai": "3.2.2",
29-
"@xapp/serverless-plugin-type-definitions": "0.1.20",
29+
"@xapp/serverless-plugin-type-definitions": "0.1.22",
3030
"chai": "4.2.0",
3131
"mocha": "5.2.0",
3232
"nyc": "13.1.0",

src/AwsUtils.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { CloudFormation } from "aws-sdk";
2+
import FeatureNotSupportedError from "./FeatureNotSupportedError";
3+
import ResourceNotFoundError from "./ResourceNotFoundError";
24

5+
/**
6+
* Returns the value of the CloudFormation exported item.
7+
* @param cf The CloudFormation object to use.
8+
* @param exportName The name of the exported item.
9+
*/
310
export function findCloudformationExport(cf: CloudFormation, exportName: string): Promise<string> {
411
const find = (NextToken?: string): Promise<string> => {
512
return cf.listExports({ NextToken }).promise()
@@ -10,3 +17,94 @@ export function findCloudformationExport(cf: CloudFormation, exportName: string)
1017
};
1118
return find();
1219
}
20+
21+
export interface ConfigObject {
22+
[key: string]: any | CloudFormationObject;
23+
}
24+
25+
/**
26+
* Will recursively filter through a configuration object and look for CloudFormation items to parse
27+
* like "Ref:" and "Fn::*" functions.
28+
* @param cf
29+
* @param stackName
30+
* @param configObject
31+
*/
32+
export async function parseConfigObject(cf: CloudFormation, stackName: string, configObject: any | ConfigObject) {
33+
if (isNotDefined(configObject)) {
34+
return configObject;
35+
}
36+
if (typeof configObject !== "object") {
37+
return configObject;
38+
}
39+
const keys = Object.keys(configObject);
40+
const newObject: ConfigObject = Array.isArray(configObject) ? configObject.slice() : { ...configObject };
41+
for (const key of keys) {
42+
const item = configObject[key];
43+
if (!isNotDefined(item)) {
44+
if (Array.isArray(item)) {
45+
newObject[key] = await parseConfigObject(cf, stackName, item);
46+
} else if (typeof item === "object") {
47+
const internalObjectKeys = Object.keys(item);
48+
if (internalObjectKeys.length === 1 && cloudFormationObjectKeys.indexOf(internalObjectKeys[0] as keyof CloudFormationObject) >= 0) {
49+
// Possibly a Cloudformation object like Ref or Fn::*
50+
newObject[key] = await retrieveCloudFormationValue(cf, stackName, item);
51+
} else {
52+
newObject[key] = await parseConfigObject(cf, stackName, item);
53+
}
54+
} // Else it's already what it should be.
55+
}
56+
}
57+
return newObject;
58+
}
59+
60+
const cloudFormationObjectKeys: (keyof CloudFormationObject)[] = Object.keys({
61+
Ref: ""
62+
} as Required<CloudFormationObject>) as (keyof CloudFormationObject)[];
63+
64+
export interface CloudFormationObject {
65+
Ref?: string;
66+
}
67+
68+
export function retrieveCloudFormationValue(cf: CloudFormation, stackName: string, value: CloudFormationObject): Promise<boolean | number | string> {
69+
if (isNotDefined(value)) {
70+
return Promise.resolve(value as string); // It's undefined but Typescript doesn't know or care so we're just going to lie.
71+
}
72+
if (typeof value !== "object" || Object.keys(value).length !== 1) {
73+
return Promise.reject(new Error("Value is not a CloudFormation parsable object."));
74+
}
75+
if (!isNotDefined(value.Ref)) {
76+
return findPhysicalID(cf, stackName, value.Ref);
77+
}
78+
return Promise.reject(new FeatureNotSupportedError(`CloudFormation value ${Object.keys(value)[0]} not currently supported.`));
79+
}
80+
81+
/**
82+
* Returns the *physical ID* of a resource in a stack from the given ref.
83+
*
84+
* Throws an error if the ID is not found at the stack.
85+
* @param cf
86+
* @param stackName
87+
* @param ref
88+
*/
89+
export function findPhysicalID(cf: CloudFormation, stackName: string, ref: string): Promise<string> {
90+
const find = (NextToken?: string): Promise<string> => {
91+
return cf.listStackResources({ StackName: stackName, NextToken }).promise()
92+
.then((results): string | Promise<string> => {
93+
const item = results.StackResourceSummaries.find(resource => resource.LogicalResourceId === ref);
94+
return (item) ? item.PhysicalResourceId : (results.NextToken) ? find(results.NextToken) : undefined;
95+
});
96+
};
97+
return find()
98+
.then((physicalId) => {
99+
if (isNotDefined(physicalId)) {
100+
throw new ResourceNotFoundError(`Physical ID not found for ref "${ref}" in stack "${stackName}".`);
101+
}
102+
return physicalId;
103+
});
104+
}
105+
106+
function isNotDefined(item: any) {
107+
// tslint:disable:no-null-keyword Checking for null with double equals checks both undefined and null
108+
return item == null;
109+
// tslint:enable:no-null-keyword
110+
}

src/Config.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,27 @@ export interface Template {
1111
export type S3RepositoryType = "s3";
1212

1313
export interface S3RepositorySettings {
14+
/**
15+
* The s3 bucket to send backups to.
16+
*/
1417
bucket: string;
18+
/**
19+
* The region that the s3 bucket is in.
20+
*/
1521
region: string;
16-
role_arn: string;
22+
/**
23+
* The ARN that Elasticsearch will assume to send items.
24+
* Either this or `role_name` must be set.
25+
*/
26+
role_arn?: string;
27+
/**
28+
* The ARN that Elasticsearch will assume to send items.
29+
* Either this or `role_arn` must be set.
30+
*/
31+
role_name?: string;
32+
/**
33+
* Whether or not the repository is encrypted on server.
34+
*/
1735
server_side_encryption?: boolean;
1836
}
1937

@@ -55,11 +73,6 @@ export interface PluginConfig {
5573
* The repositories to be set for Elasticsearch
5674
*/
5775
"repositories"?: Repository[];
58-
59-
/**
60-
* The AWS Profile credentials that is to be used to send information to the Elasticsearch server.
61-
*/
62-
"aws-profile"?: string;
6376
}
6477

6578
export default PluginConfig;

src/FeatureNotSupportedError.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export class FeatureNotSupported extends Error {
2+
constructor(msg: string) {
3+
super(msg);
4+
}
5+
}
6+
7+
export default FeatureNotSupported;

src/Plugin.ts

Lines changed: 57 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { CLI, Hooks, Serverless, ServerlessPlugin} from "@xapp/serverless-plugin-type-definitions";
2-
import { CloudFormation, SharedIniFileCredentials } from "aws-sdk";
1+
import { CLI, Hooks, Serverless, ServerlessPlugin, ServerlessProvider } from "@xapp/serverless-plugin-type-definitions";
2+
import { CloudFormation, config as AWSConfig, SharedIniFileCredentials, STS } from "aws-sdk";
33
import * as Path from "path";
44
import { AWSOptions } from "request";
55
import * as Request from "request-promise-native";
6-
import * as AwsUtils from "./AwsUtils";
7-
import Config, { Index, Repository, Template } from "./Config";
8-
import * as ServerlessUtils from "./ServerlessObjUtils";
6+
import { findCloudformationExport, parseConfigObject } from "./AwsUtils";
7+
import Config, { Index, Template } from "./Config";
8+
import { getProfile, getProviderName, getRegion, getStackName } from "./ServerlessObjUtils";
9+
import { setupRepo } from "./SetupRepo";
910

1011
interface Custom {
1112
elasticsearch?: Config;
@@ -15,27 +16,26 @@ class Plugin implements ServerlessPlugin {
1516

1617
private serverless: Serverless<Custom>;
1718
private cli: CLI;
18-
private config: Config;
1919
hooks: Hooks;
2020

2121
constructor(serverless: Serverless<Custom>, context: any) {
2222
this.serverless = serverless;
2323
this.cli = serverless.cli;
2424

2525
this.hooks = {
26-
"before:aws:deploy:deploy:updateStack": this.create.bind(this),
26+
"before:aws:deploy:deploy:updateStack": this.validate.bind(this),
2727
"after:aws:deploy:deploy:updateStack": this.setupElasticCache.bind(this)
2828
};
2929
}
3030

3131
/**
3232
* Creates the plugin with the fully parsed Serverless object.
3333
*/
34-
private async create() {
34+
private async validate() {
3535
const custom = this.serverless.service.custom || {};
36-
this.config = custom.elasticsearch || {};
36+
const config = custom.elasticsearch || {};
3737

38-
if (!this.config.endpoint && !this.config["cf-endpoint"]) {
38+
if (!config.endpoint && !config["cf-endpoint"]) {
3939
throw new Error("Elasticsearch endpoint not specified.");
4040
}
4141
}
@@ -44,42 +44,67 @@ class Plugin implements ServerlessPlugin {
4444
* Sends the mapping information to elasticsearch.
4545
*/
4646
private async setupElasticCache() {
47-
let domain = this.config.endpoint;
48-
if (this.config["cf-endpoint"]) {
49-
const cloudFormation = new CloudFormation({
50-
region: ServerlessUtils.getRegion(this.serverless),
51-
credentials: new SharedIniFileCredentials({
52-
profile: this.config["aws-profile"] || ServerlessUtils.getProfile(this.serverless)
53-
})
54-
});
55-
domain = await AwsUtils.findCloudformationExport(cloudFormation, this.config["cf-endpoint"]);
56-
if (!domain) {
57-
throw new Error("Endpoint not found at cloudformation export.");
58-
}
59-
}
60-
61-
const endpoint = domain.startsWith("http") ? domain : `https://${domain}`;
47+
const serviceName = await getProviderName(this.serverless);
48+
const profile = getProfile(this.serverless);
49+
const region = getRegion(this.serverless);
6250

6351
const requestOptions: Partial<Request.Options> = {};
64-
if (this.config["aws-profile"]) {
65-
const sharedIni = new SharedIniFileCredentials({ profile: this.config["aws-profile"] });
52+
if (serviceName === "aws") {
53+
AWSConfig.credentials = new SharedIniFileCredentials({ profile });
54+
AWSConfig.region = region;
6655
requestOptions.aws = {
67-
key: sharedIni.accessKeyId,
68-
secret: sharedIni.secretAccessKey,
56+
key: AWSConfig.credentials.accessKeyId,
57+
secret: AWSConfig.credentials.secretAccessKey,
6958
sign_version: 4
7059
} as AWSOptions; // The typings are wrong. It need to include "key" and "sign_version"
7160
}
7261

62+
const config = await parseConfig(this.serverless);
63+
const endpoint = config.endpoint.startsWith("http") ? config.endpoint : `https://${config.endpoint}`;
64+
7365
this.cli.log("Setting up templates...");
74-
await setupTemplates(endpoint, this.config.templates, requestOptions);
66+
await setupTemplates(endpoint, config.templates, requestOptions);
7567
this.cli.log("Setting up indices...");
76-
await setupIndices(endpoint, this.config.indices, requestOptions);
68+
await setupIndices(endpoint, config.indices, requestOptions);
7769
this.cli.log("Setting up repositories...");
78-
await setupRepo(endpoint, this.config.repositories, requestOptions);
70+
await setupRepo({
71+
baseUrl: endpoint,
72+
sts: new STS(),
73+
repos: config.repositories,
74+
requestOptions
75+
});
7976
this.cli.log("Elasticsearch setup complete.");
8077
}
8178
}
8279

80+
/**
81+
* Parses the config object so all attributes are usable values.
82+
*
83+
* If the user has defined "cf-Endpoint" then the correct value will be moved to "endpoint".
84+
*
85+
* @param serverless
86+
*/
87+
async function parseConfig(serverless: Serverless<Custom>): Promise<Config> {
88+
const provider = serverless.service.provider || {} as Partial<ServerlessProvider>;
89+
const custom = serverless.service.custom || {};
90+
let config = custom.elasticsearch || {} as Config;
91+
92+
if (provider.name === "aws" || config["cf-endpoint"]) {
93+
const cloudFormation = new CloudFormation();
94+
95+
config = await parseConfigObject(cloudFormation, getStackName(serverless), config);
96+
97+
if (config["cf-endpoint"]) {
98+
config.endpoint = await findCloudformationExport(cloudFormation, config["cf-endpoint"]);
99+
if (!config.endpoint) {
100+
throw new Error("Endpoint not found at cloudformation export.");
101+
}
102+
}
103+
}
104+
105+
return config;
106+
}
107+
83108
/**
84109
* Sets up all the indices in the given object.
85110
* @param baseUrl The elasticsearch URL
@@ -132,31 +157,6 @@ function validateTemplate(template: Template) {
132157
}
133158
}
134159

135-
/**
136-
* Sets up all the repos.
137-
* @param baseUrl
138-
* @param repo
139-
*/
140-
function setupRepo(baseUrl: string, repos: Repository[] = [], requestOptions: Partial<Request.Options>) {
141-
const setupPromises: PromiseLike<Request.FullResponse>[] = repos.map((repo) => {
142-
validateRepo(repo);
143-
const { name, type, settings } = repo;
144-
const url = `${baseUrl}/_snapshot/${name}`;
145-
return esPut(url, { type, settings }, requestOptions);
146-
});
147-
return Promise.all(setupPromises);
148-
}
149-
150-
function validateRepo(repo: Repository) {
151-
if (!repo.name) {
152-
throw new Error("Repo does not have a name.");
153-
}
154-
if (!repo.type) {
155-
throw new Error("Repo does not have a type.");
156-
}
157-
// The settings will be validated by Elasticsearch.
158-
}
159-
160160
function esPut(url: string, settings: object, requestOpts?: Partial<Request.Options>) {
161161
const headers = {
162162
"Content-Type": "application/json",

src/ResourceNotFoundError.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export class ResourceNotFound extends Error {
2+
constructor(msg: string) {
3+
super(msg);
4+
}
5+
}
6+
7+
export default ResourceNotFound;

0 commit comments

Comments
 (0)