Skip to content

Commit 1f6770f

Browse files
authored
chore(x2a): project name and short id used in target repo (#2764)
* project name and short id used in target repo * Creating Project ts class for project directory creation
1 parent 55bb20e commit 1f6770f

7 files changed

Lines changed: 232 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch
3+
---
4+
5+
Move project directory naming logic from bash to TypeScript Project value object

workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ describe('JobResourceBuilder', () => {
680680
{ name: 'PHASE', value: 'init' },
681681
{ name: 'PROJECT_ID', value: 'proj-123' },
682682
{ name: 'PROJECT_NAME', value: 'Test Project' },
683+
{ name: 'PROJECT_DIR', value: 'test-project-proj-1' },
683684
{ name: 'JOB_ID', value: 'job-123' },
684685
{ name: 'USER', value: 'user:default/test' },
685686
{

workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { V1Job, V1OwnerReference, V1Secret } from '@kubernetes/client-node';
1919
import crypto from 'node:crypto';
2020
import fs from 'node:fs';
2121
import { X2AConfig } from '../../config';
22+
import { Project } from './Project';
2223
import { JobCreateParams, AAPCredentials, GitRepo } from './types';
2324

2425
/**
@@ -202,6 +203,7 @@ export class JobResourceBuilder {
202203
* @returns V1Job resource ready to be created in Kubernetes
203204
*/
204205
static buildJobSpec(params: JobCreateParams, config: X2AConfig): V1Job {
206+
const project = new Project(params.projectId, params.projectName);
205207
const shortId = crypto.randomBytes(4).toString('hex');
206208
const jobName = `job-x2a-${params.phase}-${shortId}`;
207209
const projectSecretName = `x2a-project-secret-${params.projectId}`;
@@ -303,6 +305,10 @@ export class JobResourceBuilder {
303305
name: 'PROJECT_NAME',
304306
value: params.projectName,
305307
},
308+
{
309+
name: 'PROJECT_DIR',
310+
value: project.dirName,
311+
},
306312
{
307313
name: 'JOB_ID',
308314
value: params.jobId,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Project } from './Project';
18+
19+
describe('Project', () => {
20+
const uuid = '0d52e6f4-1a2b-3c4d-5e6f-7a8b9c0d1e2f';
21+
22+
describe('projectId', () => {
23+
it('returns the raw project ID', () => {
24+
const project = new Project(uuid, 'My Project');
25+
expect(project.projectId).toBe(uuid);
26+
});
27+
});
28+
29+
describe('shortId', () => {
30+
it('returns the first 6 characters of the UUID', () => {
31+
const project = new Project(uuid, 'My Project');
32+
expect(project.shortId).toBe('0d52e6');
33+
});
34+
});
35+
36+
describe('baseName', () => {
37+
it('lowercases and sanitizes a normal name', () => {
38+
const project = new Project(uuid, 'My Chef Migration');
39+
expect(project.baseName).toBe('my-chef-migration');
40+
});
41+
42+
it('replaces special characters with dashes', () => {
43+
const project = new Project(uuid, 'project@name#with$special!chars');
44+
expect(project.baseName).toBe('project-name-with-special-chars');
45+
});
46+
47+
it('collapses consecutive dashes', () => {
48+
const project = new Project(uuid, 'my---project---name');
49+
expect(project.baseName).toBe('my-project-name');
50+
});
51+
52+
it('removes leading and trailing dashes', () => {
53+
const project = new Project(uuid, '---my-project---');
54+
expect(project.baseName).toBe('my-project');
55+
});
56+
57+
it('truncates to 64 characters', () => {
58+
const longName = 'a'.repeat(100);
59+
const project = new Project(uuid, longName);
60+
expect(project.baseName).toBe('a'.repeat(64));
61+
});
62+
63+
it('removes trailing dash created by truncation', () => {
64+
// 63 chars of 'a' + '-' + more chars = truncation at 64 leaves trailing dash
65+
const name = `${'a'.repeat(63)}-bbbbb`;
66+
const project = new Project(uuid, name);
67+
expect(project.baseName).toBe('a'.repeat(63));
68+
expect(project.baseName.endsWith('-')).toBe(false);
69+
});
70+
71+
it('falls back to "project" when name sanitizes to empty', () => {
72+
const project = new Project(uuid, '!!!@@@###');
73+
expect(project.baseName).toBe('project');
74+
});
75+
76+
it('falls back to "project" for empty string', () => {
77+
const project = new Project(uuid, '');
78+
expect(project.baseName).toBe('project');
79+
});
80+
81+
it('handles unicode characters', () => {
82+
const project = new Project(uuid, 'проект-миграции');
83+
// All non-ascii chars become dashes, collapse to empty after trimming
84+
expect(project.baseName).toBe('project');
85+
});
86+
87+
it('preserves numbers', () => {
88+
const project = new Project(uuid, 'migration-2024-v3');
89+
expect(project.baseName).toBe('migration-2024-v3');
90+
});
91+
});
92+
93+
describe('dirName', () => {
94+
it('combines baseName and shortId', () => {
95+
const project = new Project(uuid, 'My Chef Migration');
96+
expect(project.dirName).toBe('my-chef-migration-0d52e6');
97+
});
98+
99+
it('uses fallback name for empty sanitized name', () => {
100+
const project = new Project(uuid, '!!!');
101+
expect(project.dirName).toBe('project-0d52e6');
102+
});
103+
104+
it('handles very long names with truncation', () => {
105+
const longName = 'a'.repeat(100);
106+
const project = new Project(uuid, longName);
107+
expect(project.dirName).toBe(`${'a'.repeat(64)}-0d52e6`);
108+
// total length: 64 + 1 + 6 = 71
109+
expect(project.dirName.length).toBe(71);
110+
});
111+
});
112+
113+
describe('ReDoS protection', () => {
114+
it('handles adversarial input in under 100ms', () => {
115+
// Craft a string that could cause catastrophic backtracking with naive regex
116+
const adversarial =
117+
'-'.repeat(1000) + 'a'.repeat(1000) + '-'.repeat(1000);
118+
const start = Date.now();
119+
const project = new Project(uuid, adversarial);
120+
expect(project.baseName).toBeDefined();
121+
const elapsed = Date.now() - start;
122+
expect(elapsed).toBeLessThan(100);
123+
});
124+
});
125+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
const MAX_BASE_NAME_LENGTH = 64;
18+
const SHORT_ID_LENGTH = 6;
19+
const DEFAULT_BASE_NAME = 'project';
20+
21+
/**
22+
* Value Object that encapsulates project naming and directory conventions.
23+
*
24+
* All sanitization and truncation logic lives here so that consumers
25+
* (K8s job specs, bash scripts, etc.) receive a safe, pre-computed name.
26+
*/
27+
export class Project {
28+
constructor(
29+
private readonly id: string,
30+
private readonly projectName: string,
31+
) {}
32+
33+
/** Raw project ID (UUID) */
34+
get projectId(): string {
35+
return this.id;
36+
}
37+
38+
/** First 6 characters of the project UUID */
39+
get shortId(): string {
40+
return this.id.substring(0, SHORT_ID_LENGTH);
41+
}
42+
43+
/**
44+
* Sanitized project name suitable for use as a directory component.
45+
*
46+
* Rules:
47+
* - Lowercased
48+
* - Non-alphanumeric characters (except dash) replaced with dash
49+
* - Consecutive dashes collapsed
50+
* - Leading/trailing dashes removed
51+
* - Truncated to 64 characters
52+
* - Falls back to "project" if empty after sanitization
53+
*
54+
* Uses manual iteration for trimming to prevent ReDoS (O(n) guaranteed).
55+
*/
56+
get baseName(): string {
57+
let sanitized = this.projectName
58+
.toLowerCase()
59+
.replace(/[^a-z0-9-]/g, '-')
60+
.replace(/-{2,}/g, '-');
61+
62+
// Remove leading dashes (manual iteration — O(n), no ReDoS)
63+
let start = 0;
64+
while (start < sanitized.length && sanitized[start] === '-') {
65+
start++;
66+
}
67+
sanitized = sanitized.substring(start);
68+
69+
// Remove trailing dashes (manual iteration — O(n), no ReDoS)
70+
let end = sanitized.length;
71+
while (end > 0 && sanitized[end - 1] === '-') {
72+
end--;
73+
}
74+
sanitized = sanitized.substring(0, end);
75+
76+
// Truncate to max length
77+
sanitized = sanitized.substring(0, MAX_BASE_NAME_LENGTH);
78+
79+
// Remove any trailing dash created by truncation
80+
while (sanitized.length > 0 && sanitized[sanitized.length - 1] === '-') {
81+
sanitized = sanitized.substring(0, sanitized.length - 1);
82+
}
83+
84+
return sanitized || DEFAULT_BASE_NAME;
85+
}
86+
87+
/** Directory name for the target repo: `<baseName>-<shortId>` */
88+
get dirName(): string {
89+
return `${this.baseName}-${this.shortId}`;
90+
}
91+
}

workspaces/x2a/plugins/x2a-backend/src/services/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export interface JobCreateParams {
4747
projectId: string;
4848
projectName: string;
4949
/**
50-
* Project abbreviation - used for directory naming in target repository
50+
* Project abbreviation
5151
*/
5252
projectAbbrev: string;
5353
phase: MigrationPhase;

workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ cleanup() {
122122
# Sanitize secrets from output files before committing
123123
sanitize_secrets "${PROJECT_PATH:-/workspace/target}"
124124

125-
git add "${PROJECT_DIR:-${PROJECT_ID}.${PROJECT_ABBREV}}" 2>/dev/null || git add -A || true
125+
git add "${PROJECT_DIR}" 2>/dev/null || git add -A || true
126126
git commit -m "x2a: ${PHASE} phase for ${MODULE_NAME:-project}
127127
128128
Phase: ${PHASE}
@@ -203,7 +203,7 @@ trap 'TERMINATED=true' SIGTERM SIGINT
203203
# │ ├── [source code] # Original Chef/Puppet/etc code
204204
# │ └── [x2a outputs] # x2a tool writes here (migration-plan.md, etc)
205205
# └── target/ # Cloned target repo (output, committed to git)
206-
# └── [PROJECT_ID].[PROJECT_ABBREV]/
206+
# └── [SANITIZED_NAME]-[SHORT_UUID]/ # e.g. my-chef-migration-0d52e6
207207
# ├── migration-plan.md
208208
# └── modules/[MODULE_NAME]/
209209
# ├── migration-plan-{module_name}.md
@@ -235,7 +235,7 @@ git_clone_repos
235235
# Define paths
236236
TARGET_BASE="/workspace/target"
237237
SOURCE_BASE="/workspace/source"
238-
PROJECT_DIR="${PROJECT_ID}.${PROJECT_ABBREV}"
238+
# PROJECT_DIR is pre-computed by the backend (sanitized name + short UUID)
239239
PROJECT_PATH="${TARGET_BASE}/${PROJECT_DIR}"
240240

241241
# Create project directory in target

0 commit comments

Comments
 (0)