1048 lines
32 KiB
JavaScript
1048 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
|
|
const API_BASE_URL = "https://api.split.io/internal/api/v2";
|
|
const MAX_FLAG_PAGE_SIZE = 50;
|
|
const MAX_SEGMENT_PAGE_SIZE = 50;
|
|
const MAX_SEGMENT_KEYS_PAGE_SIZE = 100;
|
|
|
|
/**
|
|
* Calculates the unsigned MurmurHash3 value Split uses for percentage bucket evaluation.
|
|
*/
|
|
function murmur3Hash32(value, seed = 0) {
|
|
let h1 = seed >>> 0;
|
|
const remainder = value.length & 3;
|
|
const bytes = value.length - remainder;
|
|
const c1 = 0xcc9e2d51;
|
|
const c2 = 0x1b873593;
|
|
let index = 0;
|
|
|
|
while (index < bytes) {
|
|
let k1 =
|
|
(value.charCodeAt(index) & 0xff) |
|
|
((value.charCodeAt(index + 1) & 0xff) << 8) |
|
|
((value.charCodeAt(index + 2) & 0xff) << 16) |
|
|
((value.charCodeAt(index + 3) & 0xff) << 24);
|
|
|
|
index += 4;
|
|
k1 = Math.imul(k1, c1);
|
|
k1 = (k1 << 15) | (k1 >>> 17);
|
|
k1 = Math.imul(k1, c2);
|
|
|
|
h1 ^= k1;
|
|
h1 = (h1 << 13) | (h1 >>> 19);
|
|
h1 = Math.imul(h1, 5) + 0xe6546b64;
|
|
}
|
|
|
|
let k1 = 0;
|
|
|
|
switch (remainder) {
|
|
case 3:
|
|
k1 ^= (value.charCodeAt(index + 2) & 0xff) << 16;
|
|
// falls through
|
|
case 2:
|
|
k1 ^= (value.charCodeAt(index + 1) & 0xff) << 8;
|
|
// falls through
|
|
case 1:
|
|
k1 ^= value.charCodeAt(index) & 0xff;
|
|
k1 = Math.imul(k1, c1);
|
|
k1 = (k1 << 15) | (k1 >>> 17);
|
|
k1 = Math.imul(k1, c2);
|
|
h1 ^= k1;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
h1 ^= value.length;
|
|
h1 ^= h1 >>> 16;
|
|
h1 = Math.imul(h1, 0x85ebca6b);
|
|
h1 ^= h1 >>> 13;
|
|
h1 = Math.imul(h1, 0xc2b2ae35);
|
|
h1 ^= h1 >>> 16;
|
|
|
|
return h1 >>> 0;
|
|
}
|
|
|
|
/**
|
|
* Converts a Split target key and seed into a one-based rollout bucket.
|
|
*/
|
|
function bucket(key, seed) {
|
|
return (murmur3Hash32(String(key), Number(seed) || 0) % 100) + 1;
|
|
}
|
|
|
|
/**
|
|
* Parses CLI arguments and environment variables into exporter options.
|
|
*/
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
apiKey: process.env.HARNESS_SPLIT_ADMIN_API_KEY || process.env.SPLIT_ADMIN_API_KEY,
|
|
sdkKey: process.env.HARNESS_SPLIT_SDK_KEY || process.env.HARNESS_SPLIT_SDK_API_KEY || process.env.SPLIT_SDK_API_KEY,
|
|
outputDir: "harness-feature-flags-export",
|
|
workspace: null,
|
|
environment: null,
|
|
includeSegments: true,
|
|
bodyshopMap: null
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = argv[index + 1];
|
|
|
|
switch (arg) {
|
|
case "--api-key":
|
|
args.apiKey = next;
|
|
index += 1;
|
|
break;
|
|
case "--sdk-key":
|
|
args.sdkKey = next;
|
|
index += 1;
|
|
break;
|
|
case "--output-dir":
|
|
args.outputDir = next;
|
|
index += 1;
|
|
break;
|
|
case "--workspace":
|
|
args.workspace = next;
|
|
index += 1;
|
|
break;
|
|
case "--environment":
|
|
args.environment = next;
|
|
index += 1;
|
|
break;
|
|
case "--bodyshop-map":
|
|
args.bodyshopMap = next;
|
|
index += 1;
|
|
break;
|
|
case "--skip-segments":
|
|
args.includeSegments = false;
|
|
break;
|
|
case "--help":
|
|
case "-h":
|
|
printHelp();
|
|
process.exit(0);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!args.apiKey && !args.sdkKey) {
|
|
throw new Error("Missing API key. Set HARNESS_SPLIT_ADMIN_API_KEY, HARNESS_SPLIT_SDK_KEY, --api-key, or --sdk-key.");
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Prints CLI usage for admin-key and SDK-key export modes.
|
|
*/
|
|
function printHelp() {
|
|
console.log(`
|
|
Export Harness/Split feature flags for migration into bodyshop.
|
|
|
|
Usage:
|
|
node scripts/export-harness-feature-flags.js --workspace Default --environment Production
|
|
|
|
Options:
|
|
--api-key <key> Split/Harness Admin API key. Defaults to HARNESS_SPLIT_ADMIN_API_KEY.
|
|
--sdk-key <key> Split/Harness SDK API key. Used when Admin API is not available.
|
|
--workspace <name|id> Workspace/project to export. Omit to export all workspaces.
|
|
--environment <name|id> Environment to export. Omit to export all environments.
|
|
--output-dir <path> Output directory. Defaults to harness-feature-flags-export.
|
|
--bodyshop-map <path> Optional JSON mapping of Harness target key to bodyshop UUID.
|
|
--skip-segments Do not expand segment-targeted flags.
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Fetches JSON from the Harness/Split Admin API and throws detailed HTTP errors.
|
|
*/
|
|
async function requestJson(url, apiKey) {
|
|
const response = await fetchWithFallbackAuth(url, apiKey);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Harness request failed ${response.status} ${response.statusText}: ${url}\n${body}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Fetches JSON from the SDK splitChanges endpoint.
|
|
*/
|
|
async function requestSdkJson(url, sdkKey) {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${sdkKey}`,
|
|
Accept: "application/json",
|
|
"Accept-Encoding": "identity"
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Harness SDK request failed ${response.status} ${response.statusText}: ${url}\n${body}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Tries Bearer auth first and falls back to the Split x-api-key header.
|
|
*/
|
|
async function fetchWithFallbackAuth(url, apiKey) {
|
|
const bearerResponse = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json"
|
|
}
|
|
});
|
|
|
|
if (bearerResponse.status !== 401) {
|
|
return bearerResponse;
|
|
}
|
|
|
|
return fetch(url, {
|
|
headers: {
|
|
"x-api-key": apiKey,
|
|
"Content-Type": "application/json"
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalizes common paginated API response collection shapes into an array.
|
|
*/
|
|
function getItems(response) {
|
|
if (Array.isArray(response)) {
|
|
return response;
|
|
}
|
|
|
|
if (Array.isArray(response.objects)) {
|
|
return response.objects;
|
|
}
|
|
|
|
if (Array.isArray(response.results)) {
|
|
return response.results;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Fetches all pages from a Split Admin API collection endpoint.
|
|
*/
|
|
async function fetchPaged(apiKey, url, limit) {
|
|
const results = [];
|
|
let offset = 0;
|
|
|
|
while (true) {
|
|
const separator = url.includes("?") ? "&" : "?";
|
|
const response = await requestJson(`${url}${separator}offset=${offset}&limit=${limit}`, apiKey);
|
|
const items = getItems(response);
|
|
|
|
results.push(...items);
|
|
|
|
const totalCount = response.totalCount ?? response.total;
|
|
if (typeof totalCount === "number" && results.length >= totalCount) {
|
|
break;
|
|
}
|
|
|
|
if (items.length < limit || Array.isArray(response)) {
|
|
break;
|
|
}
|
|
|
|
offset += limit;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Applies optional workspace or environment name/id filters.
|
|
*/
|
|
function matchesFilter(value, filter) {
|
|
if (!filter) {
|
|
return true;
|
|
}
|
|
|
|
return value.id === filter || value.name === filter;
|
|
}
|
|
|
|
/**
|
|
* Makes a workspace/environment name safe for export file names.
|
|
*/
|
|
function safeFileName(value) {
|
|
return String(value)
|
|
.replace(/[^a-z0-9._-]+/gi, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Writes pretty JSON and creates parent directories as needed.
|
|
*/
|
|
async function writeJson(filePath, value) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
/**
|
|
* Loads an optional Harness target key to bodyshop UUID map.
|
|
*/
|
|
async function loadBodyshopMap(filePath) {
|
|
if (!filePath) {
|
|
return new Map();
|
|
}
|
|
|
|
const raw = await fs.readFile(filePath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
|
|
if (Array.isArray(parsed)) {
|
|
return new Map(parsed.map((entry) => [entry.key ?? entry.harnessKey ?? entry.customerKey, entry.bodyshopid ?? entry.bodyshopId]));
|
|
}
|
|
|
|
return new Map(Object.entries(parsed));
|
|
}
|
|
|
|
/**
|
|
* Parses Split treatment configuration payloads into JSON when possible.
|
|
*/
|
|
function getTreatmentConfig(treatment) {
|
|
if (treatment.configurations === undefined || treatment.configurations === "") {
|
|
return null;
|
|
}
|
|
|
|
if (typeof treatment.configurations !== "string") {
|
|
return treatment.configurations;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(treatment.configurations);
|
|
} catch {
|
|
return treatment.configurations;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether a rule bucket assigns 100 percent of traffic to one treatment.
|
|
*/
|
|
function isFullTreatmentBucket(bucket) {
|
|
return bucket && bucket.size === 100 && typeof bucket.treatment === "string";
|
|
}
|
|
|
|
/**
|
|
* Returns a rule's single full-treatment bucket when it is simple enough to export.
|
|
*/
|
|
function getSingleFullTreatment(rule) {
|
|
if (!Array.isArray(rule?.buckets) || rule.buckets.length !== 1) {
|
|
return null;
|
|
}
|
|
|
|
return isFullTreatmentBucket(rule.buckets[0]) ? rule.buckets[0].treatment : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the one treatment that receives 100 percent of SDK partitions.
|
|
*/
|
|
function getFullPartitionTreatment(partitions) {
|
|
const fullPartitions = (partitions || []).filter((partition) => partition.size === 100);
|
|
|
|
return fullPartitions.length === 1 ? fullPartitions[0].treatment : null;
|
|
}
|
|
|
|
/**
|
|
* Evaluates an SDK percentage rollout for one known target key.
|
|
*/
|
|
function getPartitionTreatmentForKey({ key, seed, partitions }) {
|
|
const totalSize = (partitions || []).reduce((sum, partition) => sum + partition.size, 0);
|
|
if (totalSize !== 100 || !key || seed == null) {
|
|
return null;
|
|
}
|
|
|
|
const keyBucket = bucket(key, seed);
|
|
let upperBound = 0;
|
|
|
|
for (const partition of partitions) {
|
|
upperBound += partition.size;
|
|
if (keyBucket <= upperBound) {
|
|
return {
|
|
treatment: partition.treatment,
|
|
bucket: keyBucket,
|
|
source: "sdk.partition"
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extracts matchers from a Split Admin API rule.
|
|
*/
|
|
function getRuleMatchers(rule) {
|
|
const matchers = rule?.condition?.matchers;
|
|
return Array.isArray(matchers) ? matchers : [];
|
|
}
|
|
|
|
/**
|
|
* Adds or replaces an assignment, keeping the highest-priority source for a key/flag pair.
|
|
*/
|
|
function addAssignment(assignments, assignment) {
|
|
const key = `${assignment.workspaceId}:${assignment.environmentId}:${assignment.customerKey}:${assignment.name}`;
|
|
const existing = assignments.get(key);
|
|
|
|
if (!existing || assignment.priority < existing.priority) {
|
|
assignments.set(key, assignment);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts boolean-ish and custom treatment values into database-ready text.
|
|
*/
|
|
function normalizeTreatment(treatment) {
|
|
if (treatment === true) {
|
|
return "on";
|
|
}
|
|
|
|
if (treatment === false) {
|
|
return "off";
|
|
}
|
|
|
|
if (typeof treatment === "string") {
|
|
const trimmed = treatment.trim();
|
|
if (!trimmed) {
|
|
return "control";
|
|
}
|
|
|
|
const lowered = trimmed.toLowerCase();
|
|
if (lowered === "true") {
|
|
return "on";
|
|
}
|
|
if (lowered === "false") {
|
|
return "off";
|
|
}
|
|
if (["on", "off", "control"].includes(lowered)) {
|
|
return lowered;
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
if (treatment == null) {
|
|
return "control";
|
|
}
|
|
|
|
const fallback = String(treatment).trim();
|
|
return fallback || "control";
|
|
}
|
|
|
|
/**
|
|
* Collects explicit per-treatment target key assignments from an Admin API flag definition.
|
|
*/
|
|
function collectDirectTreatmentAssignments({ assignments, definition, workspace, environment }) {
|
|
for (const treatment of definition.treatments || []) {
|
|
const treatmentName = normalizeTreatment(treatment.name);
|
|
const config = getTreatmentConfig(treatment);
|
|
|
|
for (const customerKey of treatment.keys || []) {
|
|
addAssignment(assignments, {
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
customerKey,
|
|
imexshopid: customerKey,
|
|
name: definition.name,
|
|
treatment: treatmentName,
|
|
config,
|
|
source: "treatment.keys",
|
|
priority: 0
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expands per-treatment segment assignments when segment keys were exported.
|
|
*/
|
|
function collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) {
|
|
for (const treatment of definition.treatments || []) {
|
|
const treatmentName = normalizeTreatment(treatment.name);
|
|
const config = getTreatmentConfig(treatment);
|
|
|
|
for (const segmentName of treatment.segments || []) {
|
|
const segmentKeys = segmentKeysByName.get(segmentName);
|
|
|
|
if (!segmentKeys) {
|
|
unmappedRules.push({
|
|
workspace: workspace.name,
|
|
environment: environment.name,
|
|
flag: definition.name,
|
|
source: "treatment.segments",
|
|
segment: segmentName,
|
|
reason: "Segment keys were not exported."
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const customerKey of segmentKeys) {
|
|
addAssignment(assignments, {
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
customerKey,
|
|
imexshopid: customerKey,
|
|
name: definition.name,
|
|
treatment: treatmentName,
|
|
config,
|
|
source: `treatment.segment:${segmentName}`,
|
|
priority: 10
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts simple list/segment rules into fixed bodyshop assignments.
|
|
*/
|
|
function collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) {
|
|
for (const [ruleIndex, rule] of (definition.rules || []).entries()) {
|
|
const treatment = getSingleFullTreatment(rule);
|
|
const matchers = getRuleMatchers(rule);
|
|
|
|
if (!treatment) {
|
|
unmappedRules.push({
|
|
workspace: workspace.name,
|
|
environment: environment.name,
|
|
flag: definition.name,
|
|
source: `rules[${ruleIndex}]`,
|
|
reason: "Rule is not a single 100% treatment bucket.",
|
|
rule
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let expanded = false;
|
|
|
|
for (const matcher of matchers) {
|
|
if (matcher.type === "IN_SEGMENT" && matcher.string) {
|
|
const segmentKeys = segmentKeysByName.get(matcher.string);
|
|
|
|
if (!segmentKeys) {
|
|
unmappedRules.push({
|
|
workspace: workspace.name,
|
|
environment: environment.name,
|
|
flag: definition.name,
|
|
source: `rules[${ruleIndex}]`,
|
|
segment: matcher.string,
|
|
reason: "Segment keys were not exported.",
|
|
rule
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const customerKey of segmentKeys) {
|
|
addAssignment(assignments, {
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
customerKey,
|
|
imexshopid: customerKey,
|
|
name: definition.name,
|
|
treatment: normalizeTreatment(treatment),
|
|
config: null,
|
|
source: `rule.segment:${matcher.string}`,
|
|
priority: 20 + ruleIndex
|
|
});
|
|
}
|
|
|
|
expanded = true;
|
|
}
|
|
|
|
if (matcher.type === "IN_LIST_STRING" && Array.isArray(matcher.strings) && !matcher.attribute) {
|
|
for (const customerKey of matcher.strings) {
|
|
addAssignment(assignments, {
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
customerKey,
|
|
imexshopid: customerKey,
|
|
name: definition.name,
|
|
treatment: normalizeTreatment(treatment),
|
|
config: null,
|
|
source: "rule.key-list",
|
|
priority: 20 + ruleIndex
|
|
});
|
|
}
|
|
|
|
expanded = true;
|
|
}
|
|
}
|
|
|
|
if (!expanded) {
|
|
unmappedRules.push({
|
|
workspace: workspace.name,
|
|
environment: environment.name,
|
|
flag: definition.name,
|
|
source: `rules[${ruleIndex}]`,
|
|
reason: "Rule uses attributes, percentages, dependencies, or another matcher that cannot be converted to fixed customer assignments.",
|
|
rule
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records all-traffic default rules that are not bodyshop-specific assignments.
|
|
*/
|
|
function collectGlobalDefaults({ globalDefaults, definition, workspace, environment }) {
|
|
const defaultRule = definition.defaultRule || definition.default_rule || [];
|
|
|
|
if (!Array.isArray(defaultRule) || defaultRule.length !== 1 || !isFullTreatmentBucket(defaultRule[0])) {
|
|
return;
|
|
}
|
|
|
|
globalDefaults.push({
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
name: definition.name,
|
|
treatment: normalizeTreatment(defaultRule[0].treatment),
|
|
source: "defaultRule"
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Converts an Admin API flag definition into the export's canonical definition shape.
|
|
*/
|
|
function normalizeFlagDefinition(definition, workspace, environment) {
|
|
return {
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
name: definition.name,
|
|
description: definition.description || null,
|
|
defaultTreatment: normalizeTreatment(definition.defaultTreatment),
|
|
baselineTreatment: normalizeTreatment(definition.baselineTreatment),
|
|
killed: Boolean(definition.killed),
|
|
trafficAllocation: definition.trafficAllocation ?? null,
|
|
treatments: (definition.treatments || []).map((treatment) => ({
|
|
name: treatment.name,
|
|
description: treatment.description || null,
|
|
config: getTreatmentConfig(treatment)
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parses SDK treatment config for one treatment name.
|
|
*/
|
|
function parseSdkConfig(configurations, treatment) {
|
|
const rawConfig = configurations?.[treatment];
|
|
|
|
if (rawConfig === undefined || rawConfig === "") {
|
|
return null;
|
|
}
|
|
|
|
if (typeof rawConfig !== "string") {
|
|
return rawConfig;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawConfig);
|
|
} catch {
|
|
return rawConfig;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts an SDK splitChanges flag definition into the canonical export shape.
|
|
*/
|
|
function normalizeSdkFlagDefinition(definition) {
|
|
const treatments = new Set([definition.defaultTreatment, definition.baselineTreatment]);
|
|
|
|
for (const condition of definition.conditions || []) {
|
|
for (const partition of condition.partitions || []) {
|
|
if (partition.treatment) {
|
|
treatments.add(partition.treatment);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
workspaceId: null,
|
|
workspaceName: "sdk-export",
|
|
environmentId: null,
|
|
environmentName: "sdk-key-environment",
|
|
name: definition.name,
|
|
description: null,
|
|
defaultTreatment: normalizeTreatment(definition.defaultTreatment),
|
|
baselineTreatment: normalizeTreatment(definition.baselineTreatment),
|
|
killed: Boolean(definition.killed),
|
|
trafficAllocation: definition.trafficAllocation ?? null,
|
|
treatments: Array.from(treatments)
|
|
.filter(Boolean)
|
|
.map((treatment) => ({
|
|
name: treatment,
|
|
description: null,
|
|
config: parseSdkConfig(definition.configurations, treatment)
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converts SDK splitChanges conditions into fixed target-key assignments where possible.
|
|
*/
|
|
function collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults }) {
|
|
for (const [conditionIndex, condition] of (definition.conditions || []).entries()) {
|
|
const matchers = condition.matcherGroup?.matchers || [];
|
|
|
|
if (matchers.length !== 1) {
|
|
unmappedRules.push({
|
|
workspace: "sdk-export",
|
|
environment: "sdk-key-environment",
|
|
flag: definition.name,
|
|
source: `conditions[${conditionIndex}]`,
|
|
reason: "Condition is not a single matcher.",
|
|
condition
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const matcher = matchers[0];
|
|
|
|
if (matcher.matcherType === "ALL_KEYS") {
|
|
const treatment = getFullPartitionTreatment(condition.partitions);
|
|
|
|
if (!treatment) {
|
|
unmappedRules.push({
|
|
workspace: "sdk-export",
|
|
environment: "sdk-key-environment",
|
|
flag: definition.name,
|
|
source: `conditions[${conditionIndex}]`,
|
|
reason: "ALL_KEYS percentage rollout cannot be converted without a known target key list.",
|
|
condition
|
|
});
|
|
continue;
|
|
}
|
|
|
|
globalDefaults.push({
|
|
workspaceId: null,
|
|
workspaceName: "sdk-export",
|
|
environmentId: null,
|
|
environmentName: "sdk-key-environment",
|
|
name: definition.name,
|
|
treatment: normalizeTreatment(treatment),
|
|
source: "ALL_KEYS"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (matcher.matcherType === "WHITELIST" && !matcher.keySelector?.attribute) {
|
|
for (const customerKey of matcher.whitelistMatcherData?.whitelist || []) {
|
|
const resolved = getFullPartitionTreatment(condition.partitions)
|
|
? { treatment: getFullPartitionTreatment(condition.partitions), source: "sdk.whitelist" }
|
|
: getPartitionTreatmentForKey({ key: customerKey, seed: definition.seed, partitions: condition.partitions });
|
|
|
|
if (!resolved?.treatment) {
|
|
unmappedRules.push({
|
|
workspace: "sdk-export",
|
|
environment: "sdk-key-environment",
|
|
flag: definition.name,
|
|
source: `conditions[${conditionIndex}]`,
|
|
reason: "Whitelist percentage rollout could not be bucketed.",
|
|
customerKey,
|
|
condition
|
|
});
|
|
continue;
|
|
}
|
|
|
|
addAssignment(assignments, {
|
|
workspaceId: null,
|
|
workspaceName: "sdk-export",
|
|
environmentId: null,
|
|
environmentName: "sdk-key-environment",
|
|
customerKey,
|
|
imexshopid: customerKey,
|
|
bodyshopid: bodyshopMap.get(customerKey) || null,
|
|
name: definition.name,
|
|
treatment: normalizeTreatment(resolved.treatment),
|
|
config: parseSdkConfig(definition.configurations, resolved.treatment),
|
|
source: resolved.source,
|
|
bucket: resolved.bucket,
|
|
priority: conditionIndex
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
unmappedRules.push({
|
|
workspace: "sdk-export",
|
|
environment: "sdk-key-environment",
|
|
flag: definition.name,
|
|
source: `conditions[${conditionIndex}]`,
|
|
reason: `SDK matcher ${matcher.matcherType} cannot be converted to fixed customer assignments.`,
|
|
condition
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escapes a value for use as a single-quoted SQL literal.
|
|
*/
|
|
function sqlString(value) {
|
|
return `'${String(value).replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
/**
|
|
* Builds import SQL that maps exported target keys to bodyshops by imexshopid.
|
|
*/
|
|
function buildImportSql(assignments) {
|
|
const rows = assignments.filter((assignment) => assignment.bodyshopid || assignment.imexshopid || assignment.customerKey);
|
|
|
|
if (rows.length === 0) {
|
|
return "-- No bodyshop assignments were found.\n";
|
|
}
|
|
|
|
const values = rows
|
|
.map((assignment) => {
|
|
const imexshopid = assignment.imexshopid || assignment.customerKey;
|
|
const config = assignment.config === null ? "NULL::jsonb" : `${sqlString(JSON.stringify(assignment.config))}::jsonb`;
|
|
return `(${sqlString(imexshopid)}, ${sqlString(assignment.name)}, ${sqlString(assignment.treatment)}, ${config})`;
|
|
})
|
|
.join(",\n ");
|
|
|
|
const imexshopidValues = Array.from(new Set(rows.map((assignment) => assignment.imexshopid || assignment.customerKey)))
|
|
.map((imexshopid) => `(${sqlString(imexshopid)})`)
|
|
.join(",\n ");
|
|
|
|
const flagNameValues = Array.from(new Set(rows.map((assignment) => assignment.name)))
|
|
.map((name) => `(${sqlString(name)})`)
|
|
.join(",\n ");
|
|
|
|
return `WITH "exported_flags" ("imexshopid", "name", "treatment", "config") AS (\n VALUES\n ${values}\n),\n"matched_flags" AS (\n SELECT\n "bodyshops"."id" AS "bodyshopid",\n "exported_flags"."name",\n "exported_flags"."treatment",\n "exported_flags"."config"\n FROM "exported_flags"\n INNER JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid")\n INNER JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flags"."name"\n)\nINSERT INTO "public"."bodyshop_feature_flags" ("bodyshopid", "name", "treatment", "config")\nSELECT "bodyshopid", "name", "treatment", "config"\nFROM "matched_flags"\nON CONFLICT ("bodyshopid", "name") DO UPDATE\nSET\n "treatment" = EXCLUDED."treatment",\n "config" = EXCLUDED."config";\n\nWITH "exported_imexshopids" ("imexshopid") AS (\n VALUES\n ${imexshopidValues}\n)\nSELECT "exported_imexshopids"."imexshopid" AS "unmatched_imexshopid"\nFROM "exported_imexshopids"\nLEFT JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_imexshopids"."imexshopid")\nWHERE "bodyshops"."id" IS NULL\nORDER BY "exported_imexshopids"."imexshopid";\n\nWITH "exported_flag_names" ("name") AS (\n VALUES\n ${flagNameValues}\n)\nSELECT "exported_flag_names"."name" AS "unmatched_feature_flag"\nFROM "exported_flag_names"\nLEFT JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flag_names"."name"\nWHERE "feature_flags"."name" IS NULL\nORDER BY "exported_flag_names"."name";\n`;
|
|
}
|
|
|
|
/**
|
|
* Exports segment definitions and target keys for an Admin API environment.
|
|
*/
|
|
async function exportSegments({ apiKey, workspace, environment, outputDir }) {
|
|
const segmentKeysByName = new Map();
|
|
const segments = await fetchPaged(
|
|
apiKey,
|
|
`${API_BASE_URL}/segments/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`,
|
|
MAX_SEGMENT_PAGE_SIZE
|
|
);
|
|
|
|
await writeJson(
|
|
path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_segments.json`),
|
|
segments
|
|
);
|
|
|
|
for (const segment of segments) {
|
|
const keys = [];
|
|
let offset = 0;
|
|
|
|
while (true) {
|
|
const response = await requestJson(
|
|
`${API_BASE_URL}/segments/${encodeURIComponent(environment.id)}/${encodeURIComponent(segment.name)}/keys?offset=${offset}&limit=${MAX_SEGMENT_KEYS_PAGE_SIZE}`,
|
|
apiKey
|
|
);
|
|
const pageKeys = (response.keys || []).map((entry) => (typeof entry === "string" ? entry : entry.key)).filter(Boolean);
|
|
|
|
keys.push(...pageKeys);
|
|
|
|
if (pageKeys.length < MAX_SEGMENT_KEYS_PAGE_SIZE) {
|
|
break;
|
|
}
|
|
|
|
offset += MAX_SEGMENT_KEYS_PAGE_SIZE;
|
|
}
|
|
|
|
segmentKeysByName.set(segment.name, keys);
|
|
}
|
|
|
|
return {
|
|
segments,
|
|
segmentKeysByName,
|
|
segmentKeys: Object.fromEntries(segmentKeysByName)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs a full Admin API export across matching workspaces and environments.
|
|
*/
|
|
async function exportAdmin(args) {
|
|
const outputDir = path.resolve(args.outputDir);
|
|
const bodyshopMap = await loadBodyshopMap(args.bodyshopMap);
|
|
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
|
|
const workspaces = (await fetchPaged(args.apiKey, `${API_BASE_URL}/workspaces`, 200)).filter((workspace) =>
|
|
matchesFilter(workspace, args.workspace)
|
|
);
|
|
|
|
const normalizedFlags = [];
|
|
const allSegments = [];
|
|
const allSegmentKeys = {};
|
|
const assignments = new Map();
|
|
const unmappedRules = [];
|
|
const globalDefaults = [];
|
|
|
|
await writeJson(path.join(outputDir, "raw", "workspaces.json"), workspaces);
|
|
|
|
for (const workspace of workspaces) {
|
|
const environments = (await requestJson(`${API_BASE_URL}/environments/ws/${encodeURIComponent(workspace.id)}`, args.apiKey)).filter(
|
|
(environment) => matchesFilter(environment, args.environment)
|
|
);
|
|
|
|
await writeJson(path.join(outputDir, "raw", `${safeFileName(workspace.name)}_environments.json`), environments);
|
|
|
|
for (const environment of environments) {
|
|
const { segments, segmentKeysByName, segmentKeys } = args.includeSegments
|
|
? await exportSegments({ apiKey: args.apiKey, workspace, environment, outputDir })
|
|
: { segments: [], segmentKeysByName: new Map(), segmentKeys: {} };
|
|
|
|
allSegments.push(
|
|
...segments.map((segment) => ({
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
environmentId: environment.id,
|
|
environmentName: environment.name,
|
|
...segment
|
|
}))
|
|
);
|
|
allSegmentKeys[`${workspace.name}/${environment.name}`] = segmentKeys;
|
|
|
|
const definitions = await fetchPaged(
|
|
args.apiKey,
|
|
`${API_BASE_URL}/splits/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`,
|
|
MAX_FLAG_PAGE_SIZE
|
|
);
|
|
|
|
await writeJson(
|
|
path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_flag_definitions.json`),
|
|
definitions
|
|
);
|
|
|
|
for (const definition of definitions) {
|
|
normalizedFlags.push(normalizeFlagDefinition(definition, workspace, environment));
|
|
collectGlobalDefaults({ globalDefaults, definition, workspace, environment });
|
|
collectDirectTreatmentAssignments({ assignments, definition, workspace, environment });
|
|
collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules });
|
|
collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules });
|
|
}
|
|
}
|
|
}
|
|
|
|
const normalizedAssignments = Array.from(assignments.values())
|
|
.map((assignment) => ({
|
|
...assignment,
|
|
imexshopid: assignment.imexshopid || assignment.customerKey,
|
|
bodyshopid: bodyshopMap.get(assignment.customerKey) || null
|
|
}))
|
|
.sort((a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name));
|
|
|
|
await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags);
|
|
await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments);
|
|
await writeJson(path.join(outputDir, "segments.json"), allSegments);
|
|
await writeJson(path.join(outputDir, "segment_keys.json"), allSegmentKeys);
|
|
await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults);
|
|
await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules);
|
|
await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments));
|
|
|
|
console.log(`Export complete: ${outputDir}`);
|
|
console.log(`Flags: ${normalizedFlags.length}`);
|
|
console.log(`Customer assignments: ${normalizedAssignments.length}`);
|
|
console.log(`Global defaults: ${globalDefaults.length}`);
|
|
console.log(`Unmapped rules: ${unmappedRules.length}`);
|
|
}
|
|
|
|
/**
|
|
* Runs an SDK-key export using the splitChanges endpoint.
|
|
*/
|
|
async function exportSdk(args) {
|
|
const sdkKey = args.sdkKey || args.apiKey;
|
|
const outputDir = path.resolve(args.outputDir);
|
|
const bodyshopMap = await loadBodyshopMap(args.bodyshopMap);
|
|
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
|
|
const splitChanges = await requestSdkJson("https://sdk.split.io/api/splitChanges?since=-1", sdkKey);
|
|
const normalizedFlags = [];
|
|
const assignments = new Map();
|
|
const unmappedRules = [];
|
|
const globalDefaults = [];
|
|
|
|
await writeJson(path.join(outputDir, "raw", "sdk_split_changes.json"), splitChanges);
|
|
|
|
for (const definition of splitChanges.splits || []) {
|
|
normalizedFlags.push(normalizeSdkFlagDefinition(definition));
|
|
collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults });
|
|
}
|
|
|
|
const normalizedAssignments = Array.from(assignments.values()).sort(
|
|
(a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name)
|
|
);
|
|
|
|
await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags);
|
|
await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments);
|
|
await writeJson(path.join(outputDir, "segments.json"), []);
|
|
await writeJson(path.join(outputDir, "segment_keys.json"), {});
|
|
await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults);
|
|
await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules);
|
|
await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments));
|
|
|
|
console.log(`SDK export complete: ${outputDir}`);
|
|
console.log(`Flags: ${normalizedFlags.length}`);
|
|
console.log(`Customer assignments: ${normalizedAssignments.length}`);
|
|
console.log(`Global defaults: ${globalDefaults.length}`);
|
|
console.log(`Unmapped rules: ${unmappedRules.length}`);
|
|
}
|
|
|
|
/**
|
|
* Entrypoint that selects Admin API export or SDK fallback mode.
|
|
*/
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
if (!args.apiKey && args.sdkKey) {
|
|
await exportSdk(args);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await exportAdmin(args);
|
|
} catch (error) {
|
|
if (!/401 Unauthorized/.test(error.message) || !(args.sdkKey || args.apiKey)) {
|
|
throw error;
|
|
}
|
|
|
|
console.warn("Admin API key was unauthorized; falling back to SDK splitChanges export.");
|
|
await exportSdk(args);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error.message);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
buildImportSql,
|
|
normalizeTreatment,
|
|
sqlString
|
|
};
|