-
Notifications
You must be signed in to change notification settings - Fork 6
/
index.js
268 lines (225 loc) · 8.02 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
const DEFAULT_HOST = 'https://app.launchdarkly.com';
const jsonPatch = require('fast-json-patch');
const request = require('request');
const program = require('commander');
// Use to calculate changing flags
let flagsWithChanges = 0;
let flagsWithoutChanges = 0;
function patchFlag(patch, key, config, cb) {
const { baseUrl, projectKey, apiToken } = config;
const requestOptions = {
url: `${baseUrl}/flags/${projectKey}/${key}`,
body: patch,
headers: {
Authorization: apiToken,
'Content-Type': 'application/json',
},
};
return new Promise(function (resolve) {
request.patch(requestOptions, function (error, response, body) {
cb(error, response, body);
resolve(true);
});
});
}
const fetchFlags = function (config, cb) {
const { baseUrl, projectKey, sourceEnvironment, destinationEnvironment, apiToken, tags, flag } = config;
let isSingle = flag && !tags;
let url = `${baseUrl}/flags/${projectKey}`;
if (isSingle) {
url += `/${flag}`;
}
url += `?summary=0&env=${sourceEnvironment}&env=${destinationEnvironment}`;
if (tags) {
url += '&filter=tags:' + tags.join('+');
}
const requestOptions = {
url,
headers: {
Authorization: apiToken,
'Content-Type': 'application/json',
},
};
function callback(error, response, body) {
if (error) {
return cb(error);
}
if (response.statusCode === 200) {
const parsed = JSON.parse(body);
return cb(null, isSingle ? [parsed] : parsed.items);
}
if (response.statusCode === 404) {
return cb({ message: `Unknown flag key: ${flag}` });
}
try {
const parsed = JSON.parse(body);
return cb(parsed);
} catch (err) {
cb({ message: 'Unknown error', response: response.toJSON() });
}
}
request(requestOptions, callback);
};
const copyValues = function (flag, config) {
const { destinationEnvironment, sourceEnvironment } = config;
const attributes = ['on', 'archived', 'targets', 'rules', 'prerequisites', 'fallthrough', 'offVariation'];
attributes.forEach(function (attr) {
flag.environments[destinationEnvironment][attr] = flag.environments[sourceEnvironment][attr];
});
};
const stripRuleAndClauseIds = function (flag) {
for (let env in flag.environments) {
if (!flag.environments.env) continue;
for (let rule of flag.environments[env].rules) {
delete rule._id;
for (let clause of rule.clauses) {
delete clause._id;
}
}
}
};
const stripSegments = function (flag) {
for (let env in flag.environments) {
if (!flag.environments.env) continue;
for (let i = 0; i < flag.environments[env].rules.length; i++) {
const rule = flag.environments[env].rules[i];
// remove any clauses that reference segments
for (let j = 0; j < rule.clauses.length; j++) {
const clause = rule.clauses[j];
if (clause.op === 'segmentMatch') {
delete flag.environments[env].rules[i].clauses[j];
}
}
// filter out any empty items in the clause array (clauses we deleted above)
flag.environments[env].rules[i].clauses = flag.environments[env].rules[i].clauses.filter((c) => !!c);
// remove any rules that don't have any clauses (because we removed the only clause(s) above)
if (!flag.environments[env].rules[i].clauses.length) {
delete flag.environments[env].rules[i];
}
}
// filter out any empty items in the rules array (rules we deleted above)
flag.environments[env].rules = flag.environments[env].rules.filter((r) => !!r);
}
};
async function syncFlag(flag, config = {}) {
const { omitSegments, dryRun, verbose } = config;
// Remove rule ids because _id is read-only and cannot be written except when reordering rules
stripRuleAndClauseIds(flag);
if (omitSegments) {
// Remove segments because segments are not guaranteed to exist across environments
stripSegments(flag);
}
const observer = jsonPatch.observe(flag);
if (verbose) console.log(`Checking ${flag.key}`);
copyValues(flag, config);
const diff = jsonPatch.generate(observer);
if (diff.length > 0) {
flagsWithChanges += 1;
if (dryRun) {
console.log(`Preview changes for ${flag.key}:\n`, diff);
return;
}
console.log(`Modifying ${flag.key} with:\n`, diff);
await patchFlag(JSON.stringify(diff), flag.key, config, function (error, response, body) {
if (error) {
throw new Error(error);
}
if (response.statusCode >= 400) {
console.error(`PATCH failed (${response.statusCode}) for flag ${flag.key}:\n`, body);
}
});
} else {
flagsWithoutChanges += 1;
if (verbose) console.log(`No changes in ${flag.key}`);
}
}
async function syncEnvironment(config = {}) {
fetchFlags(config, async function (err, flags) {
if (err) {
const message = err.message || '';
const matches = message.match(/^Unknown environment key: (?<envKey>.+)$/);
if (matches && matches.groups && matches.groups.envKey) {
const envKey = matches.groups.envKey;
console.error(
`Invalid ${
config.sourceEnv === envKey ? 'source' : 'destination'
} environment "${envKey}". Did you specify the right project?`,
);
} else {
console.error('Error fetching flags\n', err);
}
process.exit(1);
}
for (const flag of flags) {
await syncFlag(flag, config);
}
const modifiedMessage = config.dryRun ? 'To be modified' : 'Modified';
console.log(`${modifiedMessage}: ${flagsWithChanges}, No changes required: ${flagsWithoutChanges}`);
});
}
program
.name('./sync-ld-flags')
.description('Copy flag settings from one environment to another.')
.option('-p, --project-key <key>', 'Project key')
.option('-s, --source-env <key>', 'Source environment key')
.option('-d, --destination-env <key>', 'Destination environment key')
.option('-t, --api-token <token>', 'LaunchDarkly personal access token with write-level access.')
.option('-f, --flag <flag>', 'Sync only the specified flag')
.option('-T, --tag <tags...>', 'Sync flags with the given tag(s). Only flags with all tags will sync.')
.option('-o, --omit-segments', 'Omit segments when syncing', false)
.option('-H, --host <host>', 'Hostname override', DEFAULT_HOST)
.option('-v, --verbose', 'Enable verbose logging', false)
.option('-n, --dry-run', 'Preview changes', false)
.option('-D, --debug', 'Enable HTTP debugging', false)
.parse(process.argv);
if (require.main === module) {
const options = program.opts();
const hostUrl = options.host;
const config = {
projectKey: options.projectKey,
sourceEnvironment: options.sourceEnv,
destinationEnvironment: options.destinationEnv,
apiToken: options.apiToken,
baseUrl: hostUrl + '/api/v2',
omitSegments: options.omitSegments,
flag: options.flag,
tags: options.tag,
dryRun: options.dryRun,
verbose: options.verbose,
};
if (options.debug) {
// see https://github.com/request/request#debugging
require('request').debug = true;
}
if (!config.projectKey) {
console.error('Invalid usage: Please provide a value for --project-key');
program.outputHelp();
process.exit(1);
}
if (!config.sourceEnvironment) {
console.error('Invalid usage: Please provide a value for --source-env');
program.outputHelp();
process.exit(1);
}
if (!config.destinationEnvironment) {
console.error('Invalid usage: Please provide a value for --destination-env');
program.outputHelp();
process.exit(1);
}
if (config.sourceEnvironment === config.destinationEnvironment) {
console.error('Invalid usage: Source and destination environments should be different');
program.outputHelp();
process.exit(1);
}
if (!config.apiToken) {
console.error('Invalid usage: Please provide a value for --api-token');
program.outputHelp();
process.exit(1);
}
if (config.flag && config.tags) {
console.error('Invalid usage: Only --flag OR --tag may be specified');
program.outputHelp();
process.exit(1);
}
syncEnvironment(config);
}