@platform-mesh/portal-server-lib 0.0.0 → 0.5.2

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 (99) hide show
  1. package/.github/workflows/pipeline.yaml +20 -0
  2. package/.prettierrc.mjs +6 -0
  3. package/CODEOWNERS +4 -0
  4. package/CODE_OF_CONDUCT.md +86 -0
  5. package/CONTRIBUTING.md +40 -0
  6. package/LICENSE +201 -0
  7. package/LICENSES/Apache-2.0.txt +73 -0
  8. package/README.md +41 -0
  9. package/base.jest.config.js +15 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/portal-options/account-entity-context-provider.service.d.ts +4 -0
  14. package/dist/portal-options/account-entity-context-provider.service.js +35 -0
  15. package/dist/portal-options/account-entity-context-provider.service.js.map +1 -0
  16. package/dist/portal-options/auth-callback-provider.d.ts +10 -0
  17. package/dist/portal-options/auth-callback-provider.js +36 -0
  18. package/dist/portal-options/auth-callback-provider.js.map +1 -0
  19. package/dist/portal-options/auth-config-provider.d.ts +9 -0
  20. package/dist/portal-options/auth-config-provider.js +72 -0
  21. package/dist/portal-options/auth-config-provider.js.map +1 -0
  22. package/dist/portal-options/index.d.ts +10 -0
  23. package/dist/portal-options/index.js +11 -0
  24. package/dist/portal-options/index.js.map +1 -0
  25. package/dist/portal-options/logout-callback.service.d.ts +12 -0
  26. package/dist/portal-options/logout-callback.service.js +63 -0
  27. package/dist/portal-options/logout-callback.service.js.map +1 -0
  28. package/dist/portal-options/models/luigi-context.d.ts +4 -0
  29. package/dist/portal-options/models/luigi-context.js +2 -0
  30. package/dist/portal-options/models/luigi-context.js.map +1 -0
  31. package/dist/portal-options/pm-portal-context.service.d.ts +11 -0
  32. package/dist/portal-options/pm-portal-context.service.js +50 -0
  33. package/dist/portal-options/pm-portal-context.service.js.map +1 -0
  34. package/dist/portal-options/pm-request-context-provider.d.ts +13 -0
  35. package/dist/portal-options/pm-request-context-provider.js +34 -0
  36. package/dist/portal-options/pm-request-context-provider.js.map +1 -0
  37. package/dist/portal-options/service-providers/content-configuration-service-providers.service.d.ts +5 -0
  38. package/dist/portal-options/service-providers/content-configuration-service-providers.service.js +83 -0
  39. package/dist/portal-options/service-providers/content-configuration-service-providers.service.js.map +1 -0
  40. package/dist/portal-options/service-providers/contentconfigurations-query.d.ts +1 -0
  41. package/dist/portal-options/service-providers/contentconfigurations-query.js +22 -0
  42. package/dist/portal-options/service-providers/contentconfigurations-query.js.map +1 -0
  43. package/dist/portal-options/service-providers/kubernetes-service-providers.service.d.ts +8 -0
  44. package/dist/portal-options/service-providers/kubernetes-service-providers.service.js +98 -0
  45. package/dist/portal-options/service-providers/kubernetes-service-providers.service.js.map +1 -0
  46. package/dist/portal-options/service-providers/models/contentconfigurations.d.ts +20 -0
  47. package/dist/portal-options/service-providers/models/contentconfigurations.js +2 -0
  48. package/dist/portal-options/service-providers/models/contentconfigurations.js.map +1 -0
  49. package/dist/portal-options/service-providers/models/welcome-node-config.d.ts +2 -0
  50. package/dist/portal-options/service-providers/models/welcome-node-config.js +35 -0
  51. package/dist/portal-options/service-providers/models/welcome-node-config.js.map +1 -0
  52. package/dist/portal-options/services/iam-graphql.service.d.ts +7 -0
  53. package/dist/portal-options/services/iam-graphql.service.js +40 -0
  54. package/dist/portal-options/services/iam-graphql.service.js.map +1 -0
  55. package/dist/portal-options/services/kcp-k8s.service.d.ts +11 -0
  56. package/dist/portal-options/services/kcp-k8s.service.js +60 -0
  57. package/dist/portal-options/services/kcp-k8s.service.js.map +1 -0
  58. package/dist/portal-options/services/queries.d.ts +1 -0
  59. package/dist/portal-options/services/queries.js +7 -0
  60. package/dist/portal-options/services/queries.js.map +1 -0
  61. package/dist/portal-options/utils/domain.d.ts +3 -0
  62. package/dist/portal-options/utils/domain.js +11 -0
  63. package/dist/portal-options/utils/domain.js.map +1 -0
  64. package/eslint.config.mjs +27 -0
  65. package/jest.config.ts +42 -0
  66. package/nest-cli.json +6 -0
  67. package/package.json +85 -2
  68. package/renovate.json +6 -0
  69. package/src/index.ts +1 -0
  70. package/src/portal-options/account-entity-context-provider.service.ts +30 -0
  71. package/src/portal-options/auth-callback-provider.spec.ts +85 -0
  72. package/src/portal-options/auth-callback-provider.ts +27 -0
  73. package/src/portal-options/auth-config-provider.spec.ts +101 -0
  74. package/src/portal-options/auth-config-provider.ts +82 -0
  75. package/src/portal-options/index.ts +11 -0
  76. package/src/portal-options/logout-callback.service.spec.ts +113 -0
  77. package/src/portal-options/logout-callback.service.ts +60 -0
  78. package/src/portal-options/models/luigi-context.ts +4 -0
  79. package/src/portal-options/pm-portal-context.service.spec.ts +155 -0
  80. package/src/portal-options/pm-portal-context.service.ts +63 -0
  81. package/src/portal-options/pm-request-context-provider.spec.ts +69 -0
  82. package/src/portal-options/pm-request-context-provider.ts +33 -0
  83. package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts +157 -0
  84. package/src/portal-options/service-providers/content-configuration-service-providers.service.ts +130 -0
  85. package/src/portal-options/service-providers/contentconfigurations-query.ts +22 -0
  86. package/src/portal-options/service-providers/kubernetes-service-providers.service.spec.ts +197 -0
  87. package/src/portal-options/service-providers/kubernetes-service-providers.service.ts +115 -0
  88. package/src/portal-options/service-providers/models/contentconfigurations.ts +13 -0
  89. package/src/portal-options/service-providers/models/welcome-node-config.ts +36 -0
  90. package/src/portal-options/services/iam-graphql.service.spec.ts +77 -0
  91. package/src/portal-options/services/iam-graphql.service.ts +33 -0
  92. package/src/portal-options/services/kcp-k8s.service.spec.ts +78 -0
  93. package/src/portal-options/services/kcp-k8s.service.ts +56 -0
  94. package/src/portal-options/services/queries.ts +7 -0
  95. package/src/portal-options/utils/domain.spec.ts +114 -0
  96. package/src/portal-options/utils/domain.ts +13 -0
  97. package/tsconfig.build.json +10 -0
  98. package/tsconfig.json +18 -0
  99. package/tsconfig.test.json +3 -0
@@ -0,0 +1,157 @@
1
+ import { RequestContext } from '../pm-request-context-provider.js';
2
+ import { ContentConfigurationServiceProvidersService } from './content-configuration-service-providers.service.js';
3
+ import { welcomeNodeConfig } from './models/welcome-node-config.js';
4
+ import { GraphQLClient } from 'graphql-request';
5
+
6
+ jest.mock('graphql-request', () => {
7
+ return {
8
+ GraphQLClient: jest.fn().mockImplementation(() => ({
9
+ request: jest.fn(),
10
+ })),
11
+ gql(query: TemplateStringsArray) {
12
+ return query[0];
13
+ },
14
+ };
15
+ });
16
+
17
+ describe('ContentConfigurationServiceProvidersService', () => {
18
+ let service: ContentConfigurationServiceProvidersService;
19
+ let mockClient: jest.Mocked<GraphQLClient>;
20
+ let context: RequestContext;
21
+
22
+ beforeEach(() => {
23
+ service = new ContentConfigurationServiceProvidersService();
24
+ mockClient = new GraphQLClient('') as any;
25
+ (GraphQLClient as jest.Mock).mockReturnValue(mockClient);
26
+ context = {
27
+ isSubDomain: true,
28
+ organization: 'org1',
29
+ crdGatewayApiUrl:
30
+ 'http://example.com/kubernetes-graphql-gateway/root/graphql',
31
+ account: 'acc1',
32
+ } as RequestContext;
33
+ });
34
+
35
+ it('throws if token is missing', async () => {
36
+ await expect(
37
+ service.getServiceProviders('', ['entity'], context),
38
+ ).rejects.toThrow('Token is required');
39
+ });
40
+
41
+ it('throws if context organization is missing', async () => {
42
+ const badContext = { ...context, organization: undefined } as any;
43
+ await expect(
44
+ service.getServiceProviders('token', ['entity'], badContext),
45
+ ).rejects.toThrow('Context with organization is required');
46
+ });
47
+
48
+ it('returns welcome node config when on the base domain', async () => {
49
+ context.isSubDomain = false;
50
+ const result = await service.getServiceProviders(
51
+ 'token',
52
+ ['entity'],
53
+ context,
54
+ );
55
+
56
+ expect(result).toEqual(welcomeNodeConfig);
57
+ });
58
+
59
+ it('throws if context organization is missing', async () => {
60
+ context.isSubDomain = false;
61
+ const result = await service.getServiceProviders(
62
+ 'token',
63
+ ['entity'],
64
+ context,
65
+ );
66
+
67
+ expect(result).toEqual(welcomeNodeConfig);
68
+ });
69
+
70
+ it('returns parsed content configurations', async () => {
71
+ mockClient.request.mockResolvedValue({
72
+ ui_platform_mesh_io: {
73
+ ContentConfigurations: [
74
+ {
75
+ metadata: {
76
+ name: 'conf1',
77
+ labels: { 'ui.platform-mesh.io/entity': 'entity' },
78
+ },
79
+ spec: { remoteConfiguration: { url: 'http://remote' } },
80
+ status: {
81
+ configurationResult: JSON.stringify({ url: 'http://parsed' }),
82
+ },
83
+ },
84
+ ],
85
+ },
86
+ });
87
+ const result = await service.getServiceProviders(
88
+ 'token',
89
+ ['entity'],
90
+ context,
91
+ );
92
+ expect(result.rawServiceProviders[0].contentConfiguration[0].url).toBe(
93
+ 'http://parsed',
94
+ );
95
+ });
96
+
97
+ it('falls back to spec.remoteConfiguration.url if missing in parsed config', async () => {
98
+ mockClient.request.mockResolvedValue({
99
+ ui_platform_mesh_io: {
100
+ ContentConfigurations: [
101
+ {
102
+ metadata: {
103
+ name: 'conf1',
104
+ labels: { 'ui.platform-mesh.io/entity': 'entity' },
105
+ },
106
+ spec: { remoteConfiguration: { url: 'http://remote' } },
107
+ status: { configurationResult: JSON.stringify({}) },
108
+ },
109
+ ],
110
+ },
111
+ });
112
+ const result = await service.getServiceProviders(
113
+ 'token',
114
+ ['entity'],
115
+ context,
116
+ );
117
+ expect(result.rawServiceProviders[0].contentConfiguration[0].url).toBe(
118
+ 'http://remote',
119
+ );
120
+ });
121
+
122
+ it('throws on missing configurationResult', async () => {
123
+ mockClient.request.mockResolvedValue({
124
+ ui_platform_mesh_io: {
125
+ ContentConfigurations: [
126
+ {
127
+ metadata: {
128
+ name: 'conf1',
129
+ labels: { 'ui.platform-mesh.io/entity': 'entity' },
130
+ },
131
+ spec: { remoteConfiguration: { url: 'http://remote' } },
132
+ status: {},
133
+ },
134
+ ],
135
+ },
136
+ });
137
+ await expect(
138
+ service.getServiceProviders('token', ['entity'], context),
139
+ ).rejects.toThrow('Missing configurationResult');
140
+ });
141
+
142
+ it('throws if response structure is invalid', async () => {
143
+ mockClient.request.mockResolvedValue({});
144
+ await expect(
145
+ service.getServiceProviders('token', ['entity'], context),
146
+ ).rejects.toThrow(
147
+ 'Invalid response structure: missing ContentConfigurations',
148
+ );
149
+ });
150
+
151
+ it('wraps unexpected errors', async () => {
152
+ mockClient.request.mockRejectedValue(new Error('network error'));
153
+ await expect(
154
+ service.getServiceProviders('token', ['entity'], context),
155
+ ).rejects.toThrow('Failed to fetch content configurations: network error');
156
+ });
157
+ });
@@ -0,0 +1,130 @@
1
+ import { RequestContext } from '../pm-request-context-provider.js';
2
+ import { contentConfigurationsQuery } from './contentconfigurations-query.js';
3
+ import { ContentConfigurationQueryResponse } from './models/contentconfigurations.js';
4
+ import { welcomeNodeConfig } from './models/welcome-node-config.js';
5
+ import { Injectable } from '@nestjs/common';
6
+ import {
7
+ ContentConfiguration,
8
+ ServiceProviderResponse,
9
+ ServiceProviderService,
10
+ } from '@openmfp/portal-server-lib';
11
+ import { GraphQLClient } from 'graphql-request';
12
+
13
+ @Injectable()
14
+ export class ContentConfigurationServiceProvidersService
15
+ implements ServiceProviderService
16
+ {
17
+ async getServiceProviders(
18
+ token: string,
19
+ entities: string[],
20
+ context: RequestContext,
21
+ ): Promise<ServiceProviderResponse> {
22
+ // Validate required parameters
23
+ if (!token) {
24
+ throw new Error('Token is required');
25
+ }
26
+
27
+ if (!context.isSubDomain) {
28
+ return welcomeNodeConfig;
29
+ }
30
+
31
+ if (!context?.organization) {
32
+ throw new Error('Context with organization is required');
33
+ }
34
+
35
+ let url = context.crdGatewayApiUrl.replace(
36
+ 'kubernetes-graphql-gateway/root',
37
+ 'kubernetes-graphql-gateway/virtual-workspace/contentconfigurations/root',
38
+ );
39
+
40
+ const platformMeshAccountId = context?.['core_platform-mesh_io_account'];
41
+ if (platformMeshAccountId) {
42
+ url = url.replace('/graphql', `:${platformMeshAccountId}/graphql`);
43
+ }
44
+
45
+ console.log(`Calculated crd gateway api url: ${url}`);
46
+ const client = new GraphQLClient(url, {
47
+ headers: {
48
+ Authorization: `Bearer ${token}`,
49
+ },
50
+ });
51
+
52
+ try {
53
+ const response = await client.request<ContentConfigurationQueryResponse>(
54
+ contentConfigurationsQuery,
55
+ {},
56
+ );
57
+
58
+ // Validate response structure
59
+ if (!response?.ui_platform_mesh_io?.ContentConfigurations) {
60
+ throw new Error(
61
+ 'Invalid response structure: missing ContentConfigurations',
62
+ );
63
+ }
64
+
65
+ const entity = !entities || !entities.length ? 'main' : entities[0];
66
+ const contentConfigurations =
67
+ response.ui_platform_mesh_io.ContentConfigurations.filter(
68
+ (item) =>
69
+ item.metadata.labels?.['ui.platform-mesh.io/entity'] === entity,
70
+ ).map((item) => {
71
+ try {
72
+ // Validate required fields
73
+ if (!item.status?.configurationResult) {
74
+ throw new Error(
75
+ `Missing configurationResult for item: ${item.metadata?.name || 'unknown'}`,
76
+ );
77
+ }
78
+
79
+ const contentConfiguration = JSON.parse(
80
+ item.status.configurationResult,
81
+ ) as ContentConfiguration;
82
+
83
+ if (!contentConfiguration.url) {
84
+ contentConfiguration.url = item.spec.remoteConfiguration?.url;
85
+ }
86
+ return contentConfiguration;
87
+ } catch (parseError) {
88
+ // Log the error but don't fail the entire operation
89
+ console.error(
90
+ `Failed to parse configuration for item ${item.metadata?.name || 'unknown'}:`,
91
+ parseError,
92
+ );
93
+
94
+ // Re-throw specific errors as-is, others as JSON parse errors
95
+ if (
96
+ parseError instanceof Error &&
97
+ parseError.message.includes('Missing configurationResult')
98
+ ) {
99
+ throw parseError;
100
+ }
101
+ throw new Error(
102
+ `Invalid JSON in configurationResult for item: ${item.metadata?.name || 'unknown'}`,
103
+ );
104
+ }
105
+ });
106
+
107
+ return {
108
+ rawServiceProviders: [
109
+ {
110
+ name: 'platform-mesh-system',
111
+ displayName: '',
112
+ creationTimestamp: '',
113
+ contentConfiguration: contentConfigurations,
114
+ },
115
+ ],
116
+ };
117
+ } catch (error) {
118
+ // Re-throw with more context if it's not already our custom error
119
+ if (
120
+ error instanceof Error &&
121
+ error.message.includes('configurationResult')
122
+ ) {
123
+ throw error;
124
+ }
125
+ throw new Error(
126
+ `Failed to fetch content configurations: ${error instanceof Error ? error.message : 'Unknown error'}`,
127
+ );
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,22 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const contentConfigurationsQuery = gql`
4
+ query {
5
+ ui_platform_mesh_io {
6
+ ContentConfigurations {
7
+ metadata {
8
+ name
9
+ labels
10
+ }
11
+ spec {
12
+ remoteConfiguration {
13
+ url
14
+ }
15
+ }
16
+ status {
17
+ configurationResult
18
+ }
19
+ }
20
+ }
21
+ }
22
+ `;
@@ -0,0 +1,197 @@
1
+ import { KcpKubernetesService } from '../services/kcp-k8s.service.js';
2
+ import { KubernetesServiceProvidersService } from './kubernetes-service-providers.service.js';
3
+ import { welcomeNodeConfig } from './models/welcome-node-config.js';
4
+ import { mock } from 'jest-mock-extended';
5
+
6
+ const listClusterCustomObject = jest.fn();
7
+
8
+ jest.mock('@kubernetes/client-node', () => {
9
+ class KubeConfig {
10
+ loadFromDefault = jest.fn();
11
+ loadFromFile = jest.fn();
12
+ getCurrentCluster = jest.fn().mockReturnValue({
13
+ server: 'https://k8s.example.com/base',
14
+ name: 'test-cluster',
15
+ });
16
+ makeApiClient = jest.fn().mockImplementation(() => ({
17
+ listClusterCustomObject,
18
+ }));
19
+ addUser = jest.fn();
20
+ addContext = jest.fn();
21
+ setCurrentContext = jest.fn();
22
+ }
23
+ class CustomObjectsApi {}
24
+ return { KubeConfig, CustomObjectsApi };
25
+ });
26
+
27
+ jest.mock('@kubernetes/client-node/dist/gen/middleware.js', () => ({
28
+ PromiseMiddlewareWrapper: class {
29
+ pre?: (ctx: any) => Promise<any> | any;
30
+ post?: (ctx: any) => Promise<any> | any;
31
+ constructor(opts: any) {
32
+ this.pre = opts.pre;
33
+ this.post = opts.post;
34
+ }
35
+ },
36
+ }));
37
+
38
+ describe('KubernetesServiceProvidersService', () => {
39
+ let kcpKubernetesServiceMock: jest.Mocked<KcpKubernetesService>;
40
+
41
+ beforeEach(() => {
42
+ jest.resetAllMocks();
43
+ kcpKubernetesServiceMock = mock();
44
+ kcpKubernetesServiceMock.getKcpWorkspaceUrl.mockReturnValue(
45
+ new URL('https://k8s.example.com/clusters/root:orgs:test-org'),
46
+ );
47
+ kcpKubernetesServiceMock.getKcpK8sApiClient.mockReturnValue({
48
+ listClusterCustomObject,
49
+ } as any);
50
+ });
51
+
52
+ it('throws if token is missing', async () => {
53
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
54
+ await expect(
55
+ svc.getServiceProviders('', ['entity'], {
56
+ token: undefined,
57
+ }),
58
+ ).rejects.toThrow('Token is required');
59
+ });
60
+
61
+ it('throws if context organization is missing', async () => {
62
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
63
+ await expect(
64
+ svc.getServiceProviders('token', ['entity'], {
65
+ token: 'token',
66
+ organization: undefined,
67
+ isSubDomain: true,
68
+ }),
69
+ ).rejects.toThrow('Context with organization is required');
70
+ });
71
+
72
+ it('returns welcome node config when on the base domain', async () => {
73
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
74
+ const result = await svc.getServiceProviders('token', ['entity'], {
75
+ organization: undefined,
76
+ isSubDomain: false,
77
+ });
78
+
79
+ expect(result).toEqual(welcomeNodeConfig);
80
+ });
81
+
82
+ it('should return empty list when API returns no items', async () => {
83
+ listClusterCustomObject.mockImplementation(
84
+ async (_gvr: any, _opts: any) => {
85
+ return {};
86
+ },
87
+ );
88
+
89
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
90
+ const res = await svc.getServiceProviders('token', [], {
91
+ organization: 'org',
92
+ isSubDomain: true,
93
+ });
94
+ expect(res.rawServiceProviders).toEqual([]);
95
+ });
96
+
97
+ it('should map items to contentConfiguration and fill url from spec when missing', async () => {
98
+ let capturedUrl = '';
99
+ const ctx = {
100
+ _url: 'https://k8s.example.com/base',
101
+ getUrl() {
102
+ return this._url;
103
+ },
104
+ setUrl(u: string) {
105
+ this._url = u;
106
+ },
107
+ setHeaderParam: jest.fn(),
108
+ };
109
+ listClusterCustomObject.mockImplementation(async (_gvr: any, opts: any) => {
110
+ const mw = opts?.middleware?.[0];
111
+ if (mw?.pre) await mw.pre(ctx);
112
+ capturedUrl = ctx._url;
113
+ return {
114
+ items: [
115
+ {
116
+ status: { configurationResult: JSON.stringify({}) },
117
+ spec: {
118
+ remoteConfiguration: { url: 'http://fallback.example/app' },
119
+ },
120
+ },
121
+ ],
122
+ };
123
+ });
124
+ kcpKubernetesServiceMock.getKcpVirtualWorkspaceUrl.mockReturnValue(
125
+ new URL(
126
+ 'https://k8s.example.com/services/contentconfigurations/clusters/root:orgs:acme:a1',
127
+ ),
128
+ );
129
+
130
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
131
+ const res = await svc.getServiceProviders('token', ['main'], {
132
+ organization: 'acme',
133
+ isSubDomain: true,
134
+ 'core_platform-mesh_io_account': 'a1',
135
+ });
136
+
137
+ expect(res.rawServiceProviders[0].contentConfiguration).toHaveLength(1);
138
+ expect(res.rawServiceProviders[0].contentConfiguration[0].url).toBe(
139
+ 'http://fallback.example/app',
140
+ );
141
+
142
+ expect(capturedUrl).toEqual(
143
+ 'https://k8s.example.com/services/contentconfigurations/clusters/root:orgs:acme:a1/apis/ui.platform-mesh.io/v1alpha1/contentconfigurations',
144
+ );
145
+ expect(ctx.setHeaderParam).toHaveBeenCalledWith(
146
+ 'Authorization',
147
+ `Bearer token`,
148
+ );
149
+ });
150
+
151
+ it('should retry once on HTTP 429 and log retry message', async () => {
152
+ jest.useFakeTimers();
153
+ const sequence: any[] = [
154
+ Object.assign(new Error('Too Many Requests'), { code: 429 }),
155
+ { items: [] },
156
+ ];
157
+ listClusterCustomObject.mockImplementation(async () => {
158
+ const next = sequence.shift();
159
+ if (next instanceof Error || next?.code === 429) {
160
+ throw next;
161
+ }
162
+ return next;
163
+ });
164
+
165
+ const logSpy = jest
166
+ .spyOn(console, 'log')
167
+ .mockImplementation(() => undefined as unknown as never);
168
+ const errSpy = jest
169
+ .spyOn(console, 'error')
170
+ .mockImplementation(() => undefined as unknown as never);
171
+
172
+ const svc = new KubernetesServiceProvidersService(kcpKubernetesServiceMock);
173
+ const promise = svc.getServiceProviders('token', [], {
174
+ organization: 'org',
175
+ isSubDomain: true,
176
+ });
177
+
178
+ await jest.advanceTimersByTimeAsync(1000);
179
+
180
+ const res = await promise;
181
+ expect(res.rawServiceProviders).toEqual([
182
+ {
183
+ name: 'platform-mesh-system',
184
+ displayName: '',
185
+ creationTimestamp: '',
186
+ contentConfiguration: [],
187
+ },
188
+ ]);
189
+ expect(logSpy).toHaveBeenCalledWith(
190
+ 'Retry after 1 second reading kubernetes resources.',
191
+ );
192
+
193
+ logSpy.mockRestore();
194
+ errSpy.mockRestore();
195
+ jest.useRealTimers();
196
+ });
197
+ });
@@ -0,0 +1,115 @@
1
+ import { KcpKubernetesService } from '../services/kcp-k8s.service.js';
2
+ import { welcomeNodeConfig } from './models/welcome-node-config.js';
3
+ import { PromiseMiddlewareWrapper } from '@kubernetes/client-node/dist/gen/middleware.js';
4
+ import { Injectable } from '@nestjs/common';
5
+ import {
6
+ ContentConfiguration,
7
+ ServiceProviderResponse,
8
+ ServiceProviderService,
9
+ } from '@openmfp/portal-server-lib';
10
+
11
+ @Injectable()
12
+ export class KubernetesServiceProvidersService
13
+ implements ServiceProviderService
14
+ {
15
+ constructor(private kcpKubernetesService: KcpKubernetesService) {}
16
+
17
+ async getServiceProviders(
18
+ token: string,
19
+ entities: string[],
20
+ context: Record<string, any>,
21
+ ): Promise<ServiceProviderResponse> {
22
+ // Validate required parameters
23
+ if (!token) {
24
+ throw new Error('Token is required');
25
+ }
26
+
27
+ if (!context.isSubDomain) {
28
+ return welcomeNodeConfig;
29
+ }
30
+
31
+ if (!context?.organization) {
32
+ throw new Error('Context with organization is required');
33
+ }
34
+
35
+ const entity = !entities || !entities.length ? 'main' : entities[0];
36
+
37
+ let response;
38
+ try {
39
+ response = await this.getKubernetesResources(entity, context, token);
40
+ } catch (error) {
41
+ console.error(error);
42
+
43
+ if (error.code == 429 || error.statusCode == 429) {
44
+ await new Promise((resolve) => setTimeout(resolve, 1000));
45
+ console.log('Retry after 1 second reading kubernetes resources.');
46
+ response = await this.getKubernetesResources(entity, context, token);
47
+ }
48
+ }
49
+
50
+ if (!response.items) {
51
+ return {
52
+ rawServiceProviders: [],
53
+ };
54
+ }
55
+
56
+ const responseItems = response.items as any[];
57
+
58
+ const contentConfigurations = responseItems
59
+ .filter((item) => !!item.status.configurationResult)
60
+ .map((item) => {
61
+ const contentConfiguration = JSON.parse(
62
+ item.status.configurationResult,
63
+ ) as ContentConfiguration;
64
+ if (!contentConfiguration.url) {
65
+ contentConfiguration.url = item.spec.remoteConfiguration?.url;
66
+ }
67
+ return contentConfiguration;
68
+ });
69
+
70
+ return {
71
+ rawServiceProviders: [
72
+ {
73
+ name: 'platform-mesh-system',
74
+ displayName: '',
75
+ creationTimestamp: '',
76
+ contentConfiguration: contentConfigurations,
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ private async getKubernetesResources(
83
+ entity: string,
84
+ requestContext: Record<string, any>,
85
+ token: string,
86
+ ) {
87
+ const gvr = {
88
+ group: 'ui.platform-mesh.io',
89
+ version: 'v1alpha1',
90
+ plural: 'contentconfigurations',
91
+ labelSelector: `ui.platform-mesh.io/entity=${entity}`,
92
+ };
93
+
94
+ const k8sApi = this.kcpKubernetesService.getKcpK8sApiClient();
95
+ return await k8sApi.listClusterCustomObject(gvr, {
96
+ middleware: [
97
+ new PromiseMiddlewareWrapper({
98
+ pre: async (context) => {
99
+ const kcpUrl = this.kcpKubernetesService.getKcpVirtualWorkspaceUrl(
100
+ requestContext.organization,
101
+ requestContext?.['core_platform-mesh_io_account'],
102
+ );
103
+ const path = `${kcpUrl}/apis/${gvr.group}/${gvr.version}/${gvr.plural}`;
104
+ console.log('kcp url: ', path);
105
+
106
+ context.setUrl(path);
107
+ context.setHeaderParam('Authorization', `Bearer ${token}`);
108
+ return context;
109
+ },
110
+ post: async (context) => context,
111
+ }),
112
+ ],
113
+ });
114
+ }
115
+ }
@@ -0,0 +1,13 @@
1
+ export interface ContentConfigurationQueryResponse {
2
+ ui_platform_mesh_io: ContentConfigurationsResponse;
3
+ }
4
+
5
+ export interface ContentConfigurationsResponse {
6
+ ContentConfigurations: ContentConfigurationResponse[];
7
+ }
8
+
9
+ export interface ContentConfigurationResponse {
10
+ metadata: { name: string; labels?: Record<string, string>; };
11
+ spec: { remoteConfiguration?: { url?: string; }; };
12
+ status: { configurationResult?: string; };
13
+ }
@@ -0,0 +1,36 @@
1
+ import { ServiceProviderResponse } from '@openmfp/portal-server-lib';
2
+
3
+ export const welcomeNodeConfig: ServiceProviderResponse = {
4
+ rawServiceProviders: [
5
+ {
6
+ name: 'platform-mesh-system',
7
+ displayName: '',
8
+ creationTimestamp: '',
9
+ contentConfiguration: [
10
+ {
11
+ name: 'platform-mesh-system',
12
+ creationTimestamp: '',
13
+ luigiConfigFragment: {
14
+ data: {
15
+ nodes: [
16
+ {
17
+ entityType: 'global',
18
+ pathSegment: 'welcome',
19
+ hideFromNav: true,
20
+ hideSideNav: true,
21
+ showBreadcrumbs: false,
22
+ order: 1,
23
+ url: '/assets/platform-mesh-portal-ui-wc.js#welcome-view',
24
+ webcomponent: {
25
+ selfRegistered: true,
26
+ },
27
+ context: { kcpPath: 'root:orgs' },
28
+ },
29
+ ],
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ };