Skip to content

Commit 4a1e7af

Browse files
committed
feat(plugins): add q dev blueprint
1 parent cfe519c commit 4a1e7af

File tree

8 files changed

+353
-0
lines changed

8 files changed

+353
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/errors"
22+
coreModels "github.com/apache/incubator-devlake/core/models"
23+
"github.com/apache/incubator-devlake/core/plugin"
24+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
25+
"github.com/apache/incubator-devlake/helpers/srvhelper"
26+
"github.com/apache/incubator-devlake/plugins/q_dev/models"
27+
"github.com/apache/incubator-devlake/plugins/q_dev/tasks"
28+
)
29+
30+
func MakeDataSourcePipelinePlanV200(
31+
subtaskMetas []plugin.SubTaskMeta,
32+
connectionId uint64,
33+
bpScopes []*coreModels.BlueprintScope,
34+
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
35+
// load connection and scope from the db
36+
connection, err := dsHelper.ConnSrv.FindByPk(connectionId)
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes)
41+
if err != nil {
42+
return nil, nil, err
43+
}
44+
45+
plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails, connection)
46+
if err != nil {
47+
return nil, nil, err
48+
}
49+
scopes, err := makeScopesV200(scopeDetails, connection)
50+
if err != nil {
51+
return nil, nil, err
52+
}
53+
54+
return plan, scopes, nil
55+
}
56+
57+
func makeDataSourcePipelinePlanV200(
58+
subtaskMetas []plugin.SubTaskMeta,
59+
scopeDetails []*srvhelper.ScopeDetail[models.QDevS3Slice, srvhelper.NoScopeConfig],
60+
connection *models.QDevConnection,
61+
) (coreModels.PipelinePlan, errors.Error) {
62+
plan := make(coreModels.PipelinePlan, len(scopeDetails))
63+
for i, scopeDetail := range scopeDetails {
64+
s3Slice := scopeDetail.Scope
65+
stage := plan[i]
66+
if stage == nil {
67+
stage = coreModels.PipelineStage{}
68+
}
69+
70+
// construct task options for q_dev
71+
op := &tasks.QDevOptions{
72+
ConnectionId: s3Slice.ConnectionId,
73+
S3Prefix: s3Slice.Prefix,
74+
}
75+
76+
// Pass empty entities array to enable all subtasks
77+
task, err := helper.MakePipelinePlanTask("q_dev", subtaskMetas, []string{}, op)
78+
if err != nil {
79+
return nil, err
80+
}
81+
stage = append(stage, task)
82+
plan[i] = stage
83+
}
84+
return plan, nil
85+
}
86+
87+
func makeScopesV200(
88+
scopeDetails []*srvhelper.ScopeDetail[models.QDevS3Slice, srvhelper.NoScopeConfig],
89+
connection *models.QDevConnection,
90+
) ([]plugin.Scope, errors.Error) {
91+
scopes := make([]plugin.Scope, 0)
92+
// For Q Developer metrics, we don't need to create domain layer scopes
93+
// The data is collected and stored directly in the tool layer
94+
return scopes, nil
95+
}

backend/plugins/q_dev/impl/impl.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/apache/incubator-devlake/core/context"
2424
"github.com/apache/incubator-devlake/core/dal"
2525
"github.com/apache/incubator-devlake/core/errors"
26+
coreModels "github.com/apache/incubator-devlake/core/models"
2627
"github.com/apache/incubator-devlake/core/plugin"
2728
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
2829
"github.com/apache/incubator-devlake/plugins/q_dev/api"
@@ -39,6 +40,7 @@ var _ interface {
3940
plugin.PluginModel
4041
plugin.PluginSource
4142
plugin.PluginMigration
43+
plugin.DataSourcePluginBlueprintV200
4244
plugin.CloseablePluginTask
4345
} = (*QDev)(nil)
4446

@@ -170,3 +172,10 @@ func (p QDev) Close(taskCtx plugin.TaskContext) errors.Error {
170172
data.S3Client.Close()
171173
return nil
172174
}
175+
176+
func (p QDev) MakeDataSourcePipelinePlanV200(
177+
connectionId uint64,
178+
scopes []*coreModels.BlueprintScope,
179+
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
180+
return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes)
181+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package migrationscripts
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/errors"
23+
)
24+
25+
type addScopeConfigIdToS3Slice struct{}
26+
27+
func (*addScopeConfigIdToS3Slice) Up(basicRes context.BasicRes) errors.Error {
28+
db := basicRes.GetDal()
29+
30+
// Add scope_config_id column to _tool_q_dev_s3_slices table
31+
err := db.Exec(`
32+
ALTER TABLE _tool_q_dev_s3_slices
33+
ADD COLUMN scope_config_id BIGINT UNSIGNED DEFAULT 0
34+
`)
35+
if err != nil {
36+
return errors.Convert(err)
37+
}
38+
39+
return nil
40+
}
41+
42+
func (*addScopeConfigIdToS3Slice) Version() uint64 {
43+
return 20251123000001
44+
}
45+
46+
func (*addScopeConfigIdToS3Slice) Name() string {
47+
return "Add scope_config_id column to S3 slice table"
48+
}

backend/plugins/q_dev/models/migrationscripts/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ func All() []plugin.MigrationScript {
2929
new(addDisplayNameFields),
3030
new(addMissingMetrics),
3131
new(addS3SliceTable),
32+
new(addScopeConfigIdToS3Slice),
3233
}
3334
}

backend/plugins/q_dev/tasks/identity_client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ func NewQDevIdentityClient(connection *models.QDevConnection) (*QDevIdentityClie
7070
// ResolveUserDisplayName resolves a user ID to a human-readable display name
7171
// Returns the display name if found, otherwise returns the original userId as fallback
7272
func (client *QDevIdentityClient) ResolveUserDisplayName(userId string) (string, error) {
73+
// Check if client or IdentityStore is nil
74+
if client == nil || client.IdentityStore == nil {
75+
return userId, nil
76+
}
77+
7378
input := &identitystore.DescribeUserInput{
7479
IdentityStoreId: aws.String(client.StoreId),
7580
UserId: aws.String(userId),

backend/plugins/q_dev/tasks/s3_data_extractor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,4 +318,5 @@ var ExtractQDevS3DataMeta = plugin.SubTaskMeta{
318318
EnabledByDefault: true,
319319
Description: "Extract data from S3 CSV files and save to database",
320320
DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
321+
Dependencies: []*plugin.SubTaskMeta{&CollectQDevS3FilesMeta},
321322
}

backend/plugins/q_dev/tasks/s3_file_collector.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,5 @@ var CollectQDevS3FilesMeta = plugin.SubTaskMeta{
114114
EntryPoint: CollectQDevS3Files,
115115
EnabledByDefault: true,
116116
Description: "Collect S3 file metadata from AWS S3 bucket",
117+
DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
117118
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
import { useState } from 'react';
20+
import { Button, Alert, Space } from 'antd';
21+
import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons';
22+
23+
import API from '@/api';
24+
import { operator } from '@/utils';
25+
26+
interface Props {
27+
plugin: string;
28+
connectionId?: ID;
29+
values: any;
30+
initialValues: any;
31+
disabled?: boolean;
32+
}
33+
34+
interface TestResult {
35+
success: boolean;
36+
message: string;
37+
details?: {
38+
s3Access?: boolean;
39+
identityCenterAccess?: boolean;
40+
};
41+
}
42+
43+
export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues, disabled }: Props) => {
44+
const [testing, setTesting] = useState(false);
45+
const [testResult, setTestResult] = useState<TestResult | null>(null);
46+
47+
const handleTest = async () => {
48+
setTesting(true);
49+
setTestResult(null);
50+
51+
try {
52+
const [success, result] = await operator(
53+
() => {
54+
if (connectionId) {
55+
// Test existing connection with only changed values
56+
return API.connection.test(plugin, connectionId, {
57+
authType: values.authType !== initialValues.authType ? values.authType : undefined,
58+
accessKeyId: values.accessKeyId !== initialValues.accessKeyId ? values.accessKeyId : undefined,
59+
secretAccessKey: values.secretAccessKey !== initialValues.secretAccessKey ? values.secretAccessKey : undefined,
60+
region: values.region !== initialValues.region ? values.region : undefined,
61+
bucket: values.bucket !== initialValues.bucket ? values.bucket : undefined,
62+
identityStoreId: values.identityStoreId !== initialValues.identityStoreId ? values.identityStoreId : undefined,
63+
identityStoreRegion: values.identityStoreRegion !== initialValues.identityStoreRegion ? values.identityStoreRegion : undefined,
64+
rateLimitPerHour: values.rateLimitPerHour !== initialValues.rateLimitPerHour ? values.rateLimitPerHour : undefined,
65+
proxy: values.proxy !== initialValues.proxy ? values.proxy : undefined,
66+
});
67+
} else {
68+
// Test new connection with all values
69+
return API.connection.testOld(plugin, {
70+
name: values.name || '',
71+
authType: values.authType || 'access_key',
72+
accessKeyId: values.accessKeyId || '',
73+
secretAccessKey: values.secretAccessKey || '',
74+
region: values.region || '',
75+
bucket: values.bucket || '',
76+
identityStoreId: values.identityStoreId || '',
77+
identityStoreRegion: values.identityStoreRegion || '',
78+
rateLimitPerHour: values.rateLimitPerHour || 20000,
79+
proxy: values.proxy || '',
80+
endpoint: '', // Not used by Q Developer
81+
authMethod: '', // Not used by Q Developer
82+
username: '', // Not used by Q Developer
83+
password: '', // Not used by Q Developer
84+
token: '', // Not used by Q Developer
85+
appId: '', // Not used by Q Developer
86+
secretKey: '', // Not used by Q Developer
87+
dbUrl: '', // Not used by Q Developer
88+
organization: '', // Not used by Q Developer
89+
});
90+
}
91+
},
92+
{
93+
setOperating: () => {}, // We handle loading state ourselves
94+
hideToast: true, // We show our own success/error messages
95+
},
96+
);
97+
98+
if (success && result) {
99+
setTestResult({
100+
success: true,
101+
message: 'Connection test successful! AWS credentials and S3 access verified.',
102+
details: {
103+
s3Access: true,
104+
identityCenterAccess: values.identityStoreId ? true : undefined,
105+
},
106+
});
107+
} else {
108+
setTestResult({
109+
success: false,
110+
message: 'Connection test failed. Please check your configuration.',
111+
});
112+
}
113+
} catch (error: any) {
114+
let errorMessage = 'Connection test failed. Please check your configuration.';
115+
116+
if (error?.response?.data?.message) {
117+
errorMessage = error.response.data.message;
118+
} else if (error?.message) {
119+
errorMessage = error.message;
120+
}
121+
122+
// Provide more specific error messages based on common issues
123+
if (errorMessage.includes('InvalidAccessKeyId') || errorMessage.includes('SignatureDoesNotMatch')) {
124+
errorMessage = 'Invalid AWS credentials. Please check your Access Key ID and Secret Access Key.';
125+
} else if (errorMessage.includes('NoSuchBucket')) {
126+
errorMessage = 'S3 bucket not found. Please check the bucket name and region.';
127+
} else if (errorMessage.includes('AccessDenied')) {
128+
errorMessage = 'Access denied. Please check your AWS permissions for S3 and IAM Identity Center.';
129+
} else if (errorMessage.includes('InvalidBucketName')) {
130+
errorMessage = 'Invalid S3 bucket name. Please check the bucket name format.';
131+
} else if (errorMessage.includes('NoCredentialsError')) {
132+
errorMessage = 'AWS credentials not found. Please provide valid Access Key ID and Secret Access Key, or ensure IAM role is properly configured.';
133+
}
134+
135+
setTestResult({
136+
success: false,
137+
message: errorMessage,
138+
});
139+
} finally {
140+
setTesting(false);
141+
}
142+
};
143+
144+
const getAlertType = () => {
145+
if (!testResult) return undefined;
146+
return testResult.success ? 'success' : 'error';
147+
};
148+
149+
const getAlertIcon = () => {
150+
if (testing) return <LoadingOutlined />;
151+
if (!testResult) return undefined;
152+
return testResult.success ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />;
153+
};
154+
155+
return (
156+
<Space direction="vertical" style={{ width: '100%' }}>
157+
<Button
158+
type="default"
159+
loading={testing}
160+
disabled={disabled || testing}
161+
onClick={handleTest}
162+
style={{ marginTop: 16 }}
163+
>
164+
{testing ? 'Testing Connection...' : 'Test Connection'}
165+
</Button>
166+
167+
{(testResult || testing) && (
168+
<Alert
169+
type={getAlertType()}
170+
icon={getAlertIcon()}
171+
message={testing ? 'Testing connection to AWS S3 and IAM Identity Center...' : testResult?.message}
172+
description={
173+
testResult?.success && testResult.details ? (
174+
<div>
175+
<div>✓ S3 Access: Verified</div>
176+
{testResult.details.identityCenterAccess && (
177+
<div>✓ IAM Identity Center: Configured</div>
178+
)}
179+
{!values.identityStoreId && (
180+
<div style={{ marginTop: 8, color: '#faad14' }}>
181+
⚠️ IAM Identity Center not configured - user display names will show as user IDs
182+
</div>
183+
)}
184+
</div>
185+
) : undefined
186+
}
187+
showIcon
188+
style={{ marginTop: 8 }}
189+
/>
190+
)}
191+
</Space>
192+
);
193+
};

0 commit comments

Comments
 (0)