@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.
Files changed (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analytics/analytics.d.ts +16 -1
  3. package/dist/analytics/analytics.d.ts.map +1 -1
  4. package/dist/handlers/connectSnowflake.d.ts +43 -0
  5. package/dist/handlers/connectSnowflake.d.ts.map +1 -0
  6. package/dist/handlers/connectSnowflake.js +310 -0
  7. package/dist/handlers/connectSnowflake.test.d.ts +2 -0
  8. package/dist/handlers/connectSnowflake.test.d.ts.map +1 -0
  9. package/dist/handlers/connectSnowflake.test.js +347 -0
  10. package/dist/handlers/organizationContent/customRoles.d.ts.map +1 -1
  11. package/dist/handlers/organizationContent/customRoles.js +6 -30
  12. package/dist/handlers/organizationContent/fileNames.d.ts +5 -0
  13. package/dist/handlers/organizationContent/fileNames.d.ts.map +1 -0
  14. package/dist/handlers/organizationContent/fileNames.js +33 -0
  15. package/dist/handlers/organizationContent/fileNames.test.d.ts +2 -0
  16. package/dist/handlers/organizationContent/fileNames.test.d.ts.map +1 -0
  17. package/dist/handlers/organizationContent/fileNames.test.js +36 -0
  18. package/dist/handlers/organizationContent/groups.d.ts +18 -0
  19. package/dist/handlers/organizationContent/groups.d.ts.map +1 -0
  20. package/dist/handlers/organizationContent/groups.js +171 -0
  21. package/dist/handlers/organizationContent/groups.test.d.ts +2 -0
  22. package/dist/handlers/organizationContent/groups.test.d.ts.map +1 -0
  23. package/dist/handlers/organizationContent/groups.test.js +134 -0
  24. package/dist/handlers/organizationContent/index.d.ts.map +1 -1
  25. package/dist/handlers/organizationContent/index.js +41 -0
  26. package/dist/handlers/organizationContent/index.test.d.ts +2 -0
  27. package/dist/handlers/organizationContent/index.test.d.ts.map +1 -0
  28. package/dist/handlers/organizationContent/index.test.js +155 -0
  29. package/dist/handlers/organizationContent/users.d.ts.map +1 -1
  30. package/dist/handlers/organizationContent/users.js +6 -29
  31. package/dist/index.js +13 -0
  32. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=groups.test.d.ts.map
@@ -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":"AAMA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAW3C,KAAK,0BAA0B,GAAG;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAsBF,eAAO,MAAM,4BAA4B,GAAI,aAAa,MAAM,KAAG,MAClC,CAAC;AAElC,eAAO,MAAM,2BAA2B,GAAU,yBAG/C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAkE3C,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAU,sCAI7C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CA6F3C,CAAC"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -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":"AAsBA,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;AAwHF,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,CAqDhB,CAAC"}
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 USER_FILENAME_MAX_LENGTH = 200;
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 filenameBases = sortedUsers.map(({ email }) => getUserFilenameBase(email));
249
- const baseCounts = filenameBases.reduce((counts, filenameBase) => ({
250
- ...counts,
251
- [filenameBase]: (counts[filenameBase] ?? 0) + 1,
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
- const filenameBase = filenameBases[index];
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
  };