@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.
- package/.github/workflows/pipeline.yaml +20 -0
- package/.prettierrc.mjs +6 -0
- package/CODEOWNERS +4 -0
- package/CODE_OF_CONDUCT.md +86 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +201 -0
- package/LICENSES/Apache-2.0.txt +73 -0
- package/README.md +41 -0
- package/base.jest.config.js +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/portal-options/account-entity-context-provider.service.d.ts +4 -0
- package/dist/portal-options/account-entity-context-provider.service.js +35 -0
- package/dist/portal-options/account-entity-context-provider.service.js.map +1 -0
- package/dist/portal-options/auth-callback-provider.d.ts +10 -0
- package/dist/portal-options/auth-callback-provider.js +36 -0
- package/dist/portal-options/auth-callback-provider.js.map +1 -0
- package/dist/portal-options/auth-config-provider.d.ts +9 -0
- package/dist/portal-options/auth-config-provider.js +72 -0
- package/dist/portal-options/auth-config-provider.js.map +1 -0
- package/dist/portal-options/index.d.ts +10 -0
- package/dist/portal-options/index.js +11 -0
- package/dist/portal-options/index.js.map +1 -0
- package/dist/portal-options/logout-callback.service.d.ts +12 -0
- package/dist/portal-options/logout-callback.service.js +63 -0
- package/dist/portal-options/logout-callback.service.js.map +1 -0
- package/dist/portal-options/models/luigi-context.d.ts +4 -0
- package/dist/portal-options/models/luigi-context.js +2 -0
- package/dist/portal-options/models/luigi-context.js.map +1 -0
- package/dist/portal-options/pm-portal-context.service.d.ts +11 -0
- package/dist/portal-options/pm-portal-context.service.js +50 -0
- package/dist/portal-options/pm-portal-context.service.js.map +1 -0
- package/dist/portal-options/pm-request-context-provider.d.ts +13 -0
- package/dist/portal-options/pm-request-context-provider.js +34 -0
- package/dist/portal-options/pm-request-context-provider.js.map +1 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.d.ts +5 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js +83 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js.map +1 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.d.ts +1 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.js +22 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.js.map +1 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.d.ts +8 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js +98 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js.map +1 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.d.ts +20 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.js +2 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.js.map +1 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.d.ts +2 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.js +35 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.js.map +1 -0
- package/dist/portal-options/services/iam-graphql.service.d.ts +7 -0
- package/dist/portal-options/services/iam-graphql.service.js +40 -0
- package/dist/portal-options/services/iam-graphql.service.js.map +1 -0
- package/dist/portal-options/services/kcp-k8s.service.d.ts +11 -0
- package/dist/portal-options/services/kcp-k8s.service.js +60 -0
- package/dist/portal-options/services/kcp-k8s.service.js.map +1 -0
- package/dist/portal-options/services/queries.d.ts +1 -0
- package/dist/portal-options/services/queries.js +7 -0
- package/dist/portal-options/services/queries.js.map +1 -0
- package/dist/portal-options/utils/domain.d.ts +3 -0
- package/dist/portal-options/utils/domain.js +11 -0
- package/dist/portal-options/utils/domain.js.map +1 -0
- package/eslint.config.mjs +27 -0
- package/jest.config.ts +42 -0
- package/nest-cli.json +6 -0
- package/package.json +85 -2
- package/renovate.json +6 -0
- package/src/index.ts +1 -0
- package/src/portal-options/account-entity-context-provider.service.ts +30 -0
- package/src/portal-options/auth-callback-provider.spec.ts +85 -0
- package/src/portal-options/auth-callback-provider.ts +27 -0
- package/src/portal-options/auth-config-provider.spec.ts +101 -0
- package/src/portal-options/auth-config-provider.ts +82 -0
- package/src/portal-options/index.ts +11 -0
- package/src/portal-options/logout-callback.service.spec.ts +113 -0
- package/src/portal-options/logout-callback.service.ts +60 -0
- package/src/portal-options/models/luigi-context.ts +4 -0
- package/src/portal-options/pm-portal-context.service.spec.ts +155 -0
- package/src/portal-options/pm-portal-context.service.ts +63 -0
- package/src/portal-options/pm-request-context-provider.spec.ts +69 -0
- package/src/portal-options/pm-request-context-provider.ts +33 -0
- package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts +157 -0
- package/src/portal-options/service-providers/content-configuration-service-providers.service.ts +130 -0
- package/src/portal-options/service-providers/contentconfigurations-query.ts +22 -0
- package/src/portal-options/service-providers/kubernetes-service-providers.service.spec.ts +197 -0
- package/src/portal-options/service-providers/kubernetes-service-providers.service.ts +115 -0
- package/src/portal-options/service-providers/models/contentconfigurations.ts +13 -0
- package/src/portal-options/service-providers/models/welcome-node-config.ts +36 -0
- package/src/portal-options/services/iam-graphql.service.spec.ts +77 -0
- package/src/portal-options/services/iam-graphql.service.ts +33 -0
- package/src/portal-options/services/kcp-k8s.service.spec.ts +78 -0
- package/src/portal-options/services/kcp-k8s.service.ts +56 -0
- package/src/portal-options/services/queries.ts +7 -0
- package/src/portal-options/utils/domain.spec.ts +114 -0
- package/src/portal-options/utils/domain.ts +13 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsconfig.test.json +3 -0
package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts
ADDED
|
@@ -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
|
+
});
|
package/src/portal-options/service-providers/content-configuration-service-providers.service.ts
ADDED
|
@@ -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
|
+
};
|