@lightdash/cli 0.3387.0 → 0.3389.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analytics/analytics.d.ts +16 -1
- package/dist/analytics/analytics.d.ts.map +1 -1
- package/dist/handlers/connectSnowflake.d.ts +43 -0
- package/dist/handlers/connectSnowflake.d.ts.map +1 -0
- package/dist/handlers/connectSnowflake.js +310 -0
- package/dist/handlers/connectSnowflake.test.d.ts +2 -0
- package/dist/handlers/connectSnowflake.test.d.ts.map +1 -0
- package/dist/handlers/connectSnowflake.test.js +347 -0
- package/dist/handlers/organizationContent/customRoles.d.ts.map +1 -1
- package/dist/handlers/organizationContent/customRoles.js +6 -30
- package/dist/handlers/organizationContent/fileNames.d.ts +5 -0
- package/dist/handlers/organizationContent/fileNames.d.ts.map +1 -0
- package/dist/handlers/organizationContent/fileNames.js +33 -0
- package/dist/handlers/organizationContent/fileNames.test.d.ts +2 -0
- package/dist/handlers/organizationContent/fileNames.test.d.ts.map +1 -0
- package/dist/handlers/organizationContent/fileNames.test.js +36 -0
- package/dist/handlers/organizationContent/groups.d.ts +18 -0
- package/dist/handlers/organizationContent/groups.d.ts.map +1 -0
- package/dist/handlers/organizationContent/groups.js +171 -0
- package/dist/handlers/organizationContent/groups.test.d.ts +2 -0
- package/dist/handlers/organizationContent/groups.test.d.ts.map +1 -0
- package/dist/handlers/organizationContent/groups.test.js +134 -0
- package/dist/handlers/organizationContent/index.d.ts.map +1 -1
- package/dist/handlers/organizationContent/index.js +41 -0
- package/dist/handlers/organizationContent/index.test.d.ts +2 -0
- package/dist/handlers/organizationContent/index.test.d.ts.map +1 -0
- package/dist/handlers/organizationContent/index.test.js +155 -0
- package/dist/handlers/organizationContent/users.d.ts.map +1 -1
- package/dist/handlers/organizationContent/users.js +6 -29
- package/dist/index.js +13 -0
- package/package.json +3 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.downloadGroups = exports.uploadGroups = exports.countDependencySkippedGroups = exports.readGroupFiles = exports.formatGroupUploadSummary = exports.getGroupsFolder = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/* eslint-disable no-await-in-loop */
|
|
6
|
+
const common_1 = require("@lightdash/common");
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const yaml = tslib_1.__importStar(require("js-yaml"));
|
|
9
|
+
const groupBy_1 = tslib_1.__importDefault(require("lodash/groupBy"));
|
|
10
|
+
const path = tslib_1.__importStar(require("path"));
|
|
11
|
+
const apiClient_1 = require("../dbt/apiClient");
|
|
12
|
+
const fileNames_1 = require("./fileNames");
|
|
13
|
+
const getGroupsFolder = (organizationContentPath) => path.join(organizationContentPath, 'groups');
|
|
14
|
+
exports.getGroupsFolder = getGroupsFolder;
|
|
15
|
+
const formatGroupUploadSummary = (summary) => `${summary.created} created, ${summary.updated} updated, ${summary.unchanged} unchanged, ${summary.failed} failed, ${summary.dependencySkipped} dependency-skipped`;
|
|
16
|
+
exports.formatGroupUploadSummary = formatGroupUploadSummary;
|
|
17
|
+
const getGroupName = (group) => {
|
|
18
|
+
if (typeof group !== 'object' || group === null || Array.isArray(group)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const { name } = group;
|
|
22
|
+
return typeof name === 'string' ? name : undefined;
|
|
23
|
+
};
|
|
24
|
+
const readGroupFileResults = async (organizationContentPath) => {
|
|
25
|
+
const folder = (0, exports.getGroupsFolder)(organizationContentPath);
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = await fs_1.promises.readdir(folder, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
return { groupFiles: [], failures: [] };
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const files = entries
|
|
37
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.yml'))
|
|
38
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
39
|
+
const results = await Promise.all(files.map(async (file) => {
|
|
40
|
+
const filePath = path.join(folder, file.name);
|
|
41
|
+
try {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'group',
|
|
44
|
+
groupFile: {
|
|
45
|
+
filePath,
|
|
46
|
+
group: yaml.load(await fs_1.promises.readFile(filePath, 'utf8')),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'failure',
|
|
53
|
+
failure: {
|
|
54
|
+
message: `Unable to parse group file "${filePath}": ${(0, common_1.getErrorMessage)(error)}`,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
const parsedGroupFiles = results.flatMap((result) => result.kind === 'group' ? [result.groupFile] : []);
|
|
60
|
+
const failures = results.flatMap((result) => result.kind === 'failure' ? [result.failure] : []);
|
|
61
|
+
const namedGroupFiles = parsedGroupFiles.flatMap((groupFile) => {
|
|
62
|
+
const name = getGroupName(groupFile.group);
|
|
63
|
+
return name === undefined ? [] : [{ name, groupFile }];
|
|
64
|
+
});
|
|
65
|
+
const filesByName = (0, groupBy_1.default)(namedGroupFiles, ({ name }) => name);
|
|
66
|
+
const duplicateNames = new Set(Object.entries(filesByName)
|
|
67
|
+
.filter(([, matchingFiles]) => matchingFiles.length > 1)
|
|
68
|
+
.map(([name]) => name));
|
|
69
|
+
for (const duplicateName of [...duplicateNames].sort()) {
|
|
70
|
+
for (const { groupFile } of filesByName[duplicateName]) {
|
|
71
|
+
failures.push({
|
|
72
|
+
message: `Duplicate group name "${duplicateName}" in "${groupFile.filePath}"`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
groupFiles: parsedGroupFiles.filter(({ group }) => !duplicateNames.has(getGroupName(group) ?? '')),
|
|
78
|
+
failures,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const readGroupFiles = async (organizationContentPath) => {
|
|
82
|
+
const { groupFiles, failures } = await readGroupFileResults(organizationContentPath);
|
|
83
|
+
if (failures.length > 0) {
|
|
84
|
+
throw new common_1.ParameterError(failures.map(({ message }) => message).join('\n'));
|
|
85
|
+
}
|
|
86
|
+
return groupFiles.map(({ group }) => group);
|
|
87
|
+
};
|
|
88
|
+
exports.readGroupFiles = readGroupFiles;
|
|
89
|
+
const countDependencySkippedGroups = async (organizationContentPath) => {
|
|
90
|
+
const folder = (0, exports.getGroupsFolder)(organizationContentPath);
|
|
91
|
+
try {
|
|
92
|
+
const entries = await fs_1.promises.readdir(folder, { withFileTypes: true });
|
|
93
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.yml')).length;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === 'ENOENT') {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
exports.countDependencySkippedGroups = countDependencySkippedGroups;
|
|
103
|
+
const uploadGroups = async (organizationUuid, organizationContentPath) => {
|
|
104
|
+
const { groupFiles, failures } = await readGroupFileResults(organizationContentPath);
|
|
105
|
+
const summary = {
|
|
106
|
+
created: 0,
|
|
107
|
+
updated: 0,
|
|
108
|
+
unchanged: 0,
|
|
109
|
+
failed: 0,
|
|
110
|
+
dependencySkipped: 0,
|
|
111
|
+
failures,
|
|
112
|
+
};
|
|
113
|
+
for (const { filePath, group } of groupFiles) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await (0, apiClient_1.lightdashApi)({
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: `/api/v2/orgs/${organizationUuid}/groups/code`,
|
|
118
|
+
body: JSON.stringify(group),
|
|
119
|
+
});
|
|
120
|
+
switch (result.action) {
|
|
121
|
+
case common_1.PromotionAction.CREATE:
|
|
122
|
+
summary.created += 1;
|
|
123
|
+
break;
|
|
124
|
+
case common_1.PromotionAction.UPDATE:
|
|
125
|
+
summary.updated += 1;
|
|
126
|
+
break;
|
|
127
|
+
case common_1.PromotionAction.NO_CHANGES:
|
|
128
|
+
summary.unchanged += 1;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
throw new common_1.ParameterError(`Unsupported group promotion action: ${result.action}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
summary.failures.push({
|
|
136
|
+
message: `Invalid group file "${filePath}": ${(0, common_1.getErrorMessage)(error)}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
summary.failed = summary.failures.length;
|
|
141
|
+
return summary;
|
|
142
|
+
};
|
|
143
|
+
exports.uploadGroups = uploadGroups;
|
|
144
|
+
const downloadGroups = async (organizationUuid, organizationContentPath) => {
|
|
145
|
+
const { groups } = await (0, apiClient_1.lightdashApi)({
|
|
146
|
+
method: 'GET',
|
|
147
|
+
url: `/api/v2/orgs/${organizationUuid}/groups/code`,
|
|
148
|
+
body: undefined,
|
|
149
|
+
});
|
|
150
|
+
const folder = (0, exports.getGroupsFolder)(organizationContentPath);
|
|
151
|
+
await fs_1.promises.mkdir(folder, { recursive: true });
|
|
152
|
+
const existingFiles = await fs_1.promises.readdir(folder, { withFileTypes: true });
|
|
153
|
+
await Promise.all(existingFiles
|
|
154
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.yml'))
|
|
155
|
+
.map((entry) => fs_1.promises.unlink(path.join(folder, entry.name))));
|
|
156
|
+
const sortedGroups = [...groups]
|
|
157
|
+
.map((group) => ({
|
|
158
|
+
...group,
|
|
159
|
+
members: [...group.members].sort(),
|
|
160
|
+
}))
|
|
161
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
162
|
+
const filenames = (0, fileNames_1.getOrganizationContentFileNames)({
|
|
163
|
+
values: sortedGroups.map(({ name }) => name),
|
|
164
|
+
fallbackPrefix: 'group',
|
|
165
|
+
});
|
|
166
|
+
await Promise.all(sortedGroups.map(async (group, index) => {
|
|
167
|
+
await fs_1.promises.writeFile(path.join(folder, filenames[index]), yaml.dump(group, { quotingType: '"', sortKeys: true }));
|
|
168
|
+
}));
|
|
169
|
+
return sortedGroups.length;
|
|
170
|
+
};
|
|
171
|
+
exports.downloadGroups = downloadGroups;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"groups.test.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/groups.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const common_1 = require("@lightdash/common");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const yaml = tslib_1.__importStar(require("js-yaml"));
|
|
8
|
+
const os = tslib_1.__importStar(require("os"));
|
|
9
|
+
const path = tslib_1.__importStar(require("path"));
|
|
10
|
+
const vitest_1 = require("vitest");
|
|
11
|
+
const apiClient_1 = require("../dbt/apiClient");
|
|
12
|
+
const groups_1 = require("./groups");
|
|
13
|
+
vitest_1.vi.mock('../dbt/apiClient', async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal()),
|
|
15
|
+
lightdashApi: vitest_1.vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
describe('groups as code', () => {
|
|
18
|
+
let tmpDir;
|
|
19
|
+
const group = (name, members = []) => ({
|
|
20
|
+
version: 1,
|
|
21
|
+
name,
|
|
22
|
+
members,
|
|
23
|
+
});
|
|
24
|
+
const writeGroup = async (filename, value) => {
|
|
25
|
+
const folder = (0, groups_1.getGroupsFolder)(tmpDir);
|
|
26
|
+
await fs_1.promises.mkdir(folder, { recursive: true });
|
|
27
|
+
await fs_1.promises.writeFile(path.join(folder, filename), typeof value === 'string'
|
|
28
|
+
? value
|
|
29
|
+
: yaml.dump(value, { sortKeys: true }));
|
|
30
|
+
};
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
|
|
33
|
+
tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'groups-test-'));
|
|
34
|
+
});
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
vitest_1.vi.restoreAllMocks();
|
|
37
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
it('downloads deterministic groups, empty groups, and collision-safe filenames', async () => {
|
|
40
|
+
const folder = (0, groups_1.getGroupsFolder)(tmpDir);
|
|
41
|
+
await fs_1.promises.mkdir(folder, { recursive: true });
|
|
42
|
+
await fs_1.promises.writeFile(path.join(folder, 'stale.yml'), 'stale: true');
|
|
43
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
|
|
44
|
+
groups: [
|
|
45
|
+
group('Finance?', ['z@example.com', 'a@example.com']),
|
|
46
|
+
group('Empty'),
|
|
47
|
+
group('Finance!'),
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
await expect((0, groups_1.downloadGroups)('organization-uuid', tmpDir)).resolves.toBe(3);
|
|
51
|
+
const financeBangHash = (0, crypto_1.createHash)('sha256')
|
|
52
|
+
.update('Finance!')
|
|
53
|
+
.digest('hex')
|
|
54
|
+
.slice(0, 8);
|
|
55
|
+
const financeQuestionHash = (0, crypto_1.createHash)('sha256')
|
|
56
|
+
.update('Finance?')
|
|
57
|
+
.digest('hex')
|
|
58
|
+
.slice(0, 8);
|
|
59
|
+
expect((await fs_1.promises.readdir(folder)).sort()).toStrictEqual([
|
|
60
|
+
'empty.yml',
|
|
61
|
+
`finance-${financeBangHash}.yml`,
|
|
62
|
+
`finance-${financeQuestionHash}.yml`,
|
|
63
|
+
].sort());
|
|
64
|
+
expect(yaml.load(await fs_1.promises.readFile(path.join(folder, `finance-${financeQuestionHash}.yml`), 'utf8'))).toStrictEqual({
|
|
65
|
+
version: 1,
|
|
66
|
+
name: 'Finance?',
|
|
67
|
+
members: ['a@example.com', 'z@example.com'],
|
|
68
|
+
});
|
|
69
|
+
expect(yaml.load(await fs_1.promises.readFile(path.join(folder, 'empty.yml'), 'utf8'))).toStrictEqual({ version: 1, name: 'Empty', members: [] });
|
|
70
|
+
});
|
|
71
|
+
it('rejects duplicate exact names but preserves case-sensitive identity', async () => {
|
|
72
|
+
await writeGroup('one.yml', group('Finance'));
|
|
73
|
+
await writeGroup('two.yml', group('Finance'));
|
|
74
|
+
await expect((0, groups_1.readGroupFiles)(tmpDir)).rejects.toThrow('Duplicate group name "Finance"');
|
|
75
|
+
await fs_1.promises.rm((0, groups_1.getGroupsFolder)(tmpDir), { recursive: true });
|
|
76
|
+
await writeGroup('upper.yml', group('Finance'));
|
|
77
|
+
await writeGroup('lower.yml', group('finance'));
|
|
78
|
+
await expect((0, groups_1.readGroupFiles)(tmpDir)).resolves.toHaveLength(2);
|
|
79
|
+
});
|
|
80
|
+
it('is a backward-compatible no-op when the groups folder is missing', async () => {
|
|
81
|
+
await expect((0, groups_1.uploadGroups)('organization-uuid', tmpDir)).resolves.toMatchObject({
|
|
82
|
+
created: 0,
|
|
83
|
+
updated: 0,
|
|
84
|
+
unchanged: 0,
|
|
85
|
+
failed: 0,
|
|
86
|
+
dependencySkipped: 0,
|
|
87
|
+
});
|
|
88
|
+
expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it('continues after malformed and API-failing group files', async () => {
|
|
91
|
+
await writeGroup('bad-yaml.yml', 'name: [unterminated');
|
|
92
|
+
await writeGroup('api-error.yml', group('API error'));
|
|
93
|
+
await writeGroup('valid.yml', group('Valid'));
|
|
94
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi)
|
|
95
|
+
.mockRejectedValueOnce(new Error('unknown member'))
|
|
96
|
+
.mockResolvedValueOnce({ action: common_1.PromotionAction.CREATE });
|
|
97
|
+
await expect((0, groups_1.uploadGroups)('organization-uuid', tmpDir)).resolves.toMatchObject({
|
|
98
|
+
created: 1,
|
|
99
|
+
updated: 0,
|
|
100
|
+
unchanged: 0,
|
|
101
|
+
failed: 2,
|
|
102
|
+
});
|
|
103
|
+
expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
it('reports all promotion actions and sends the portable body', async () => {
|
|
106
|
+
await writeGroup('create.yml', group('Create'));
|
|
107
|
+
await writeGroup('same.yml', group('Same'));
|
|
108
|
+
await writeGroup('update.yml', group('Update', ['member@example.com']));
|
|
109
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi)
|
|
110
|
+
.mockResolvedValueOnce({ action: common_1.PromotionAction.CREATE })
|
|
111
|
+
.mockResolvedValueOnce({
|
|
112
|
+
action: common_1.PromotionAction.NO_CHANGES,
|
|
113
|
+
})
|
|
114
|
+
.mockResolvedValueOnce({ action: common_1.PromotionAction.UPDATE });
|
|
115
|
+
await expect((0, groups_1.uploadGroups)('organization-uuid', tmpDir)).resolves.toMatchObject({
|
|
116
|
+
created: 1,
|
|
117
|
+
updated: 1,
|
|
118
|
+
unchanged: 1,
|
|
119
|
+
failed: 0,
|
|
120
|
+
});
|
|
121
|
+
expect(apiClient_1.lightdashApi).toHaveBeenNthCalledWith(3, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
url: '/api/v2/orgs/organization-uuid/groups/code',
|
|
124
|
+
body: expect.any(String),
|
|
125
|
+
});
|
|
126
|
+
expect(JSON.parse(vitest_1.vi.mocked(apiClient_1.lightdashApi).mock.calls[2][0].body)).toStrictEqual(group('Update', ['member@example.com']));
|
|
127
|
+
});
|
|
128
|
+
it('counts files skipped because a dependency phase failed', async () => {
|
|
129
|
+
await writeGroup('one.yml', group('One'));
|
|
130
|
+
await writeGroup('two.yml', 'invalid: [yaml');
|
|
131
|
+
await fs_1.promises.writeFile(path.join((0, groups_1.getGroupsFolder)(tmpDir), 'ignored.yaml'), 'name: ignored');
|
|
132
|
+
await expect((0, groups_1.countDependencySkippedGroups)(tmpDir)).resolves.toBe(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAiB3C,KAAK,0BAA0B,GAAG;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAmCF,eAAO,MAAM,4BAA4B,GAAI,aAAa,MAAM,KAAG,MAClC,CAAC;AAElC,eAAO,MAAM,2BAA2B,GAAU,yBAG/C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAqF3C,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAU,sCAI7C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAiI3C,CAAC"}
|
|
@@ -8,8 +8,10 @@ const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
|
|
|
8
8
|
const styles = tslib_1.__importStar(require("../../styles"));
|
|
9
9
|
const contentAsCodePaths_1 = require("../contentAsCodePaths");
|
|
10
10
|
const customRoles_1 = require("./customRoles");
|
|
11
|
+
const groups_1 = require("./groups");
|
|
11
12
|
const users_1 = require("./users");
|
|
12
13
|
const customRolePartialUploadErrors = new WeakSet();
|
|
14
|
+
const groupPartialUploadErrors = new WeakSet();
|
|
13
15
|
const userPartialUploadErrors = new WeakSet();
|
|
14
16
|
const createCustomRolePartialUploadError = (message) => {
|
|
15
17
|
const error = new common_1.ParameterError(message);
|
|
@@ -21,9 +23,18 @@ const createUserPartialUploadError = (message) => {
|
|
|
21
23
|
userPartialUploadErrors.add(error);
|
|
22
24
|
return error;
|
|
23
25
|
};
|
|
26
|
+
const createGroupPartialUploadError = (message) => {
|
|
27
|
+
const error = new common_1.ParameterError(message);
|
|
28
|
+
groupPartialUploadErrors.add(error);
|
|
29
|
+
return error;
|
|
30
|
+
};
|
|
24
31
|
const isPartialUploadError = (error) => error instanceof Error &&
|
|
25
32
|
(customRolePartialUploadErrors.has(error) ||
|
|
33
|
+
groupPartialUploadErrors.has(error) ||
|
|
26
34
|
userPartialUploadErrors.has(error));
|
|
35
|
+
const isGroupServiceDisabledError = (error) => error instanceof common_1.LightdashError &&
|
|
36
|
+
error.statusCode === 403 &&
|
|
37
|
+
error.message === 'Group service is not enabled';
|
|
27
38
|
const getOrganizationContentFolder = (customPath) => (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
|
|
28
39
|
exports.getOrganizationContentFolder = getOrganizationContentFolder;
|
|
29
40
|
const downloadOrganizationContent = async ({ customPath, config, }) => {
|
|
@@ -48,6 +59,19 @@ const downloadOrganizationContent = async ({ customPath, config, }) => {
|
|
|
48
59
|
spinner.start(`Downloading users`);
|
|
49
60
|
const usersTotal = await (0, users_1.downloadUsers)(organizationUuid, organizationContentPath);
|
|
50
61
|
spinner.succeed(`Downloaded ${usersTotal} users`);
|
|
62
|
+
spinner.start(`Downloading groups`);
|
|
63
|
+
let groupsTotal = 0;
|
|
64
|
+
try {
|
|
65
|
+
groupsTotal = await (0, groups_1.downloadGroups)(organizationUuid, organizationContentPath);
|
|
66
|
+
spinner.succeed(`Downloaded ${groupsTotal} groups`);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!isGroupServiceDisabledError(error)) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
spinner.stop();
|
|
73
|
+
globalState_1.default.debug('> Warning: groups were not downloaded because the group service is not enabled');
|
|
74
|
+
}
|
|
51
75
|
globalState_1.default.log(styles.success(`Downloaded organization content saved to ${organizationContentPath}`));
|
|
52
76
|
await analytics_1.LightdashAnalytics.track({
|
|
53
77
|
event: 'download.completed',
|
|
@@ -57,6 +81,7 @@ const downloadOrganizationContent = async ({ customPath, config, }) => {
|
|
|
57
81
|
scope: 'organization',
|
|
58
82
|
customRolesNum: customRolesTotal,
|
|
59
83
|
usersNum: usersTotal,
|
|
84
|
+
groupsNum: groupsTotal,
|
|
60
85
|
timeToCompleted: (Date.now() - start) / 1000,
|
|
61
86
|
},
|
|
62
87
|
});
|
|
@@ -98,6 +123,8 @@ const uploadOrganizationContent = async ({ customPath, config, sendInvites = fal
|
|
|
98
123
|
if (summary.failed > 0) {
|
|
99
124
|
summary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
|
|
100
125
|
spinner.stop();
|
|
126
|
+
const skippedGroups = await (0, groups_1.countDependencySkippedGroups)(organizationContentPath);
|
|
127
|
+
globalState_1.default.log(styles.warning(`Skipped users and ${skippedGroups} groups because custom roles failed`));
|
|
101
128
|
throw createCustomRolePartialUploadError(`Processed custom roles: ${summaryMessage}`);
|
|
102
129
|
}
|
|
103
130
|
spinner.succeed(`Uploaded custom roles: ${summaryMessage}`);
|
|
@@ -107,9 +134,20 @@ const uploadOrganizationContent = async ({ customPath, config, sendInvites = fal
|
|
|
107
134
|
if (userSummary.failed > 0) {
|
|
108
135
|
userSummary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
|
|
109
136
|
spinner.stop();
|
|
137
|
+
const skippedGroups = await (0, groups_1.countDependencySkippedGroups)(organizationContentPath);
|
|
138
|
+
globalState_1.default.log(styles.warning(`Skipped ${skippedGroups} groups because users failed`));
|
|
110
139
|
throw createUserPartialUploadError(`Processed users: ${userSummaryMessage}`);
|
|
111
140
|
}
|
|
112
141
|
spinner.succeed(`Uploaded users: ${userSummaryMessage}`);
|
|
142
|
+
spinner.start(`Uploading groups`);
|
|
143
|
+
const groupSummary = await (0, groups_1.uploadGroups)(organizationUuid, organizationContentPath);
|
|
144
|
+
const groupSummaryMessage = (0, groups_1.formatGroupUploadSummary)(groupSummary);
|
|
145
|
+
if (groupSummary.failed > 0) {
|
|
146
|
+
groupSummary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
|
|
147
|
+
spinner.stop();
|
|
148
|
+
throw createGroupPartialUploadError(`Processed groups: ${groupSummaryMessage}`);
|
|
149
|
+
}
|
|
150
|
+
spinner.succeed(`Uploaded groups: ${groupSummaryMessage}`);
|
|
113
151
|
globalState_1.default.log(styles.success(`Uploaded organization content from ${organizationContentPath}`));
|
|
114
152
|
await analytics_1.LightdashAnalytics.track({
|
|
115
153
|
event: 'upload.completed',
|
|
@@ -125,6 +163,9 @@ const uploadOrganizationContent = async ({ customPath, config, sendInvites = fal
|
|
|
125
163
|
usersUnchanged: userSummary.unchanged,
|
|
126
164
|
usersAwaitingAuthentication: userSummary.awaitingAuthentication,
|
|
127
165
|
usersInvited: userSummary.invited,
|
|
166
|
+
groupsCreated: groupSummary.created,
|
|
167
|
+
groupsUpdated: groupSummary.updated,
|
|
168
|
+
groupsUnchanged: groupSummary.unchanged,
|
|
128
169
|
timeToCompleted: (Date.now() - start) / 1000,
|
|
129
170
|
},
|
|
130
171
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const common_1 = require("@lightdash/common");
|
|
5
|
+
const analytics_1 = require("../../analytics/analytics");
|
|
6
|
+
const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
|
|
7
|
+
const customRoles_1 = require("./customRoles");
|
|
8
|
+
const groups_1 = require("./groups");
|
|
9
|
+
const index_1 = require("./index");
|
|
10
|
+
const users_1 = require("./users");
|
|
11
|
+
vi.mock('./customRoles', async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal()),
|
|
13
|
+
downloadCustomRoles: vi.fn(),
|
|
14
|
+
uploadCustomRoles: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('./users', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal()),
|
|
18
|
+
downloadUsers: vi.fn(),
|
|
19
|
+
uploadUsers: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('./groups', async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal()),
|
|
23
|
+
downloadGroups: vi.fn(),
|
|
24
|
+
uploadGroups: vi.fn(),
|
|
25
|
+
countDependencySkippedGroups: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
describe('organization content download', () => {
|
|
28
|
+
const spinner = {
|
|
29
|
+
start: vi.fn(),
|
|
30
|
+
succeed: vi.fn(),
|
|
31
|
+
stop: vi.fn(),
|
|
32
|
+
fail: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
const config = {
|
|
35
|
+
user: {
|
|
36
|
+
userUuid: 'user-uuid',
|
|
37
|
+
organizationUuid: 'organization-uuid',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
vi.spyOn(globalState_1.default, 'startSpinner').mockReturnValue(spinner);
|
|
43
|
+
vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
|
|
44
|
+
vi.spyOn(globalState_1.default, 'debug').mockImplementation(() => undefined);
|
|
45
|
+
vi.spyOn(analytics_1.LightdashAnalytics, 'track').mockResolvedValue(undefined);
|
|
46
|
+
vi.mocked(customRoles_1.downloadCustomRoles).mockResolvedValue(2);
|
|
47
|
+
vi.mocked(users_1.downloadUsers).mockResolvedValue(3);
|
|
48
|
+
vi.mocked(groups_1.downloadGroups).mockResolvedValue(4);
|
|
49
|
+
});
|
|
50
|
+
it('skips groups when the group service is disabled', async () => {
|
|
51
|
+
vi.mocked(groups_1.downloadGroups).mockRejectedValue(new common_1.LightdashError({
|
|
52
|
+
message: 'Group service is not enabled',
|
|
53
|
+
name: 'ForbiddenError',
|
|
54
|
+
statusCode: 403,
|
|
55
|
+
data: {},
|
|
56
|
+
}));
|
|
57
|
+
await expect((0, index_1.downloadOrganizationContent)({ config })).resolves.toBeUndefined();
|
|
58
|
+
expect(spinner.stop).toHaveBeenCalledOnce();
|
|
59
|
+
expect(spinner.fail).not.toHaveBeenCalled();
|
|
60
|
+
expect(globalState_1.default.debug).toHaveBeenCalledWith('> Warning: groups were not downloaded because the group service is not enabled');
|
|
61
|
+
});
|
|
62
|
+
it('still fails for other group download errors', async () => {
|
|
63
|
+
vi.mocked(groups_1.downloadGroups).mockRejectedValue(new Error('Network error'));
|
|
64
|
+
await expect((0, index_1.downloadOrganizationContent)({ config })).rejects.toThrow('Network error');
|
|
65
|
+
expect(spinner.fail).toHaveBeenCalledOnce();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('organization content upload sequencing', () => {
|
|
69
|
+
const spinner = {
|
|
70
|
+
start: vi.fn(),
|
|
71
|
+
succeed: vi.fn(),
|
|
72
|
+
stop: vi.fn(),
|
|
73
|
+
fail: vi.fn(),
|
|
74
|
+
};
|
|
75
|
+
const config = {
|
|
76
|
+
user: {
|
|
77
|
+
userUuid: 'user-uuid',
|
|
78
|
+
organizationUuid: 'organization-uuid',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const customRoleSummary = (failed = 0) => ({
|
|
82
|
+
created: 0,
|
|
83
|
+
updated: 0,
|
|
84
|
+
unchanged: 0,
|
|
85
|
+
failed,
|
|
86
|
+
failures: failed > 0 ? [{ message: 'role failure' }] : [],
|
|
87
|
+
});
|
|
88
|
+
const userSummary = (failed = 0) => ({
|
|
89
|
+
created: 0,
|
|
90
|
+
updated: 0,
|
|
91
|
+
unchanged: 0,
|
|
92
|
+
awaitingAuthentication: 0,
|
|
93
|
+
invited: 0,
|
|
94
|
+
skippedAuthenticated: 0,
|
|
95
|
+
skippedDisabled: 0,
|
|
96
|
+
skippedValidInvite: 0,
|
|
97
|
+
failed,
|
|
98
|
+
failures: failed > 0 ? [{ message: 'user failure' }] : [],
|
|
99
|
+
});
|
|
100
|
+
const groupSummary = (failed = 0) => ({
|
|
101
|
+
created: 0,
|
|
102
|
+
updated: 0,
|
|
103
|
+
unchanged: 0,
|
|
104
|
+
failed,
|
|
105
|
+
dependencySkipped: 0,
|
|
106
|
+
failures: failed > 0 ? [{ message: 'group failure' }] : [],
|
|
107
|
+
});
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
vi.clearAllMocks();
|
|
110
|
+
vi.spyOn(globalState_1.default, 'startSpinner').mockReturnValue(spinner);
|
|
111
|
+
vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
|
|
112
|
+
vi.spyOn(analytics_1.LightdashAnalytics, 'track').mockResolvedValue(undefined);
|
|
113
|
+
vi.mocked(customRoles_1.uploadCustomRoles).mockResolvedValue(customRoleSummary());
|
|
114
|
+
vi.mocked(users_1.uploadUsers).mockResolvedValue(userSummary());
|
|
115
|
+
vi.mocked(groups_1.uploadGroups).mockResolvedValue(groupSummary());
|
|
116
|
+
vi.mocked(groups_1.countDependencySkippedGroups).mockResolvedValue(2);
|
|
117
|
+
});
|
|
118
|
+
it('awaits custom roles, then users, then groups', async () => {
|
|
119
|
+
const phaseOrder = [];
|
|
120
|
+
vi.mocked(customRoles_1.uploadCustomRoles).mockImplementation(async () => {
|
|
121
|
+
phaseOrder.push('roles:start', 'roles:end');
|
|
122
|
+
return customRoleSummary();
|
|
123
|
+
});
|
|
124
|
+
vi.mocked(users_1.uploadUsers).mockImplementation(async () => {
|
|
125
|
+
phaseOrder.push('users:start', 'users:end');
|
|
126
|
+
return userSummary();
|
|
127
|
+
});
|
|
128
|
+
vi.mocked(groups_1.uploadGroups).mockImplementation(async () => {
|
|
129
|
+
phaseOrder.push('groups:start', 'groups:end');
|
|
130
|
+
return groupSummary();
|
|
131
|
+
});
|
|
132
|
+
await (0, index_1.uploadOrganizationContent)({ config });
|
|
133
|
+
expect(phaseOrder).toStrictEqual([
|
|
134
|
+
'roles:start',
|
|
135
|
+
'roles:end',
|
|
136
|
+
'users:start',
|
|
137
|
+
'users:end',
|
|
138
|
+
'groups:start',
|
|
139
|
+
'groups:end',
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
it('does not start users or groups when custom roles fail', async () => {
|
|
143
|
+
vi.mocked(customRoles_1.uploadCustomRoles).mockResolvedValue(customRoleSummary(1));
|
|
144
|
+
await expect((0, index_1.uploadOrganizationContent)({ config })).rejects.toThrow('Processed custom roles');
|
|
145
|
+
expect(users_1.uploadUsers).not.toHaveBeenCalled();
|
|
146
|
+
expect(groups_1.uploadGroups).not.toHaveBeenCalled();
|
|
147
|
+
expect(groups_1.countDependencySkippedGroups).toHaveBeenCalledOnce();
|
|
148
|
+
});
|
|
149
|
+
it('does not start groups when users fail', async () => {
|
|
150
|
+
vi.mocked(users_1.uploadUsers).mockResolvedValue(userSummary(1));
|
|
151
|
+
await expect((0, index_1.uploadOrganizationContent)({ config })).rejects.toThrow('Processed users');
|
|
152
|
+
expect(groups_1.uploadGroups).not.toHaveBeenCalled();
|
|
153
|
+
expect(groups_1.countDependencySkippedGroups).toHaveBeenCalledOnce();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/users.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/users.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,iBAAiB,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CACjC,CAAC;AAgBF,eAAO,MAAM,cAAc,GAAI,yBAAyB,MAAM,KAAG,MAClB,CAAC;AAEhD,eAAO,MAAM,uBAAuB,GAAI,SAAS,iBAAiB,KAAG,MAUpE,CAAC;AAyGF,eAAO,MAAM,aAAa,GACtB,yBAAyB,MAAM,KAChC,OAAO,CAAC,OAAO,EAAE,CAUnB,CAAC;AAsCF,eAAO,MAAM,WAAW,GACpB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,EAC/B,cAAa,OAAe,KAC7B,OAAO,CAAC,iBAAiB,CAkG3B,CAAC;AAEF,eAAO,MAAM,aAAa,GACtB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,KAChC,OAAO,CAAC,MAAM,CAkChB,CAAC"}
|
|
@@ -4,13 +4,12 @@ exports.downloadUsers = exports.uploadUsers = exports.readUserFiles = exports.fo
|
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
/* eslint-disable no-await-in-loop */
|
|
6
6
|
const common_1 = require("@lightdash/common");
|
|
7
|
-
const crypto_1 = require("crypto");
|
|
8
7
|
const fs_1 = require("fs");
|
|
9
8
|
const yaml = tslib_1.__importStar(require("js-yaml"));
|
|
10
9
|
const groupBy_1 = tslib_1.__importDefault(require("lodash/groupBy"));
|
|
11
10
|
const path = tslib_1.__importStar(require("path"));
|
|
12
11
|
const apiClient_1 = require("../dbt/apiClient");
|
|
13
|
-
const
|
|
12
|
+
const fileNames_1 = require("./fileNames");
|
|
14
13
|
const getUsersFolder = (organizationContentPath) => path.join(organizationContentPath, 'users');
|
|
15
14
|
exports.getUsersFolder = getUsersFolder;
|
|
16
15
|
const formatUserUploadSummary = (summary) => {
|
|
@@ -49,19 +48,6 @@ const asUserAsCode = (user) => {
|
|
|
49
48
|
}
|
|
50
49
|
return user;
|
|
51
50
|
};
|
|
52
|
-
const getUserFilenameBase = (email) => {
|
|
53
|
-
const normalizedEmail = email.toLowerCase();
|
|
54
|
-
const slug = normalizedEmail
|
|
55
|
-
.normalize('NFKD')
|
|
56
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
57
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
58
|
-
.replace(/^-+|-+$/g, '');
|
|
59
|
-
const emailHash = (0, crypto_1.createHash)('sha256')
|
|
60
|
-
.update(normalizedEmail)
|
|
61
|
-
.digest('hex')
|
|
62
|
-
.slice(0, 8);
|
|
63
|
-
return (slug || `user-${emailHash}`).slice(0, USER_FILENAME_MAX_LENGTH);
|
|
64
|
-
};
|
|
65
51
|
const readUserFileResults = async (organizationContentPath) => {
|
|
66
52
|
const folder = (0, exports.getUsersFolder)(organizationContentPath);
|
|
67
53
|
let entries;
|
|
@@ -245,21 +231,12 @@ const downloadUsers = async (organizationUuid, organizationContentPath) => {
|
|
|
245
231
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.yml'))
|
|
246
232
|
.map((entry) => fs_1.promises.unlink(path.join(folder, entry.name))));
|
|
247
233
|
const sortedUsers = [...users].sort((left, right) => left.email.localeCompare(right.email));
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}), {});
|
|
234
|
+
const filenames = (0, fileNames_1.getOrganizationContentFileNames)({
|
|
235
|
+
values: sortedUsers.map(({ email }) => email.toLowerCase()),
|
|
236
|
+
fallbackPrefix: 'user',
|
|
237
|
+
});
|
|
253
238
|
await Promise.all(sortedUsers.map(async (user, index) => {
|
|
254
|
-
|
|
255
|
-
const collisionSuffix = `-${(0, crypto_1.createHash)('sha256')
|
|
256
|
-
.update(user.email.toLowerCase())
|
|
257
|
-
.digest('hex')
|
|
258
|
-
.slice(0, 8)}`;
|
|
259
|
-
const uniqueFilenameBase = baseCounts[filenameBase] > 1
|
|
260
|
-
? `${filenameBase.slice(0, USER_FILENAME_MAX_LENGTH - collisionSuffix.length)}${collisionSuffix}`
|
|
261
|
-
: filenameBase;
|
|
262
|
-
await fs_1.promises.writeFile(path.join(folder, `${uniqueFilenameBase}.yml`), yaml.dump(user, { quotingType: '"', sortKeys: true }));
|
|
239
|
+
await fs_1.promises.writeFile(path.join(folder, filenames[index]), yaml.dump(user, { quotingType: '"', sortKeys: true }));
|
|
263
240
|
}));
|
|
264
241
|
return sortedUsers.length;
|
|
265
242
|
};
|