Skip to content

Commit 5ce8b94

Browse files
committed
Merge remote-tracking branch 'origin/main' into dependabot/npm_and_yarn/eslint-10.2.1
2 parents eaf7eb8 + ff420f7 commit 5ce8b94

16 files changed

Lines changed: 781 additions & 20 deletions

.github/workflows/postman.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ jobs:
6060
- name: Sync values schema from apl-core
6161
run: cp apl-core/values-schema.yaml src/values-schema.yaml
6262
- name: Start api
63+
env:
64+
APL_CORE_PATH: apl-core
6365
run: |
6466
npm install
6567
cp .env.sample .env

src/api-v2.authz.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,15 @@ describe('API V2 authz tests', () => {
156156
'deleteCloudtty',
157157
// Other
158158
'createTeam',
159+
// Git migration
160+
'migrateGitSettings',
161+
// API status
162+
'getApiStatus',
159163
]
160164

165+
// Reset locked state so a prior git migration test does not bleed into subsequent tests
166+
otomiStack.locked = false
167+
161168
// Mock all methods with default return values
162169
v2Methods.forEach((method) => {
163170
if (typeof (otomiStack as any)[method] === 'function') {
@@ -1109,6 +1116,66 @@ describe('API V2 authz tests', () => {
11091116
})
11101117
})
11111118

1119+
describe('V2 Git Migration Endpoint', () => {
1120+
const gitBody = {
1121+
repoUrl: 'https://new.example.com/repo.git',
1122+
username: 'user',
1123+
password: 'pass',
1124+
email: 'admin@example.com',
1125+
branch: 'main',
1126+
}
1127+
1128+
describe('Platform Admin', () => {
1129+
test('platform admin can migrate git', async () => {
1130+
await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200)
1131+
})
1132+
})
1133+
1134+
describe('Team Admin', () => {
1135+
test('team admin cannot migrate git', async () => {
1136+
await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${teamAdminToken}`).expect(403)
1137+
})
1138+
})
1139+
1140+
describe('Team Member', () => {
1141+
test('team member cannot migrate git', async () => {
1142+
await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${teamMemberToken}`).expect(403)
1143+
})
1144+
})
1145+
1146+
describe('Unauthenticated', () => {
1147+
test('anonymous user cannot migrate git', async () => {
1148+
await agent.put('/v2/git').send(gitBody).expect(401)
1149+
})
1150+
})
1151+
})
1152+
1153+
describe('V2 API Status Endpoint', () => {
1154+
describe('Platform Admin', () => {
1155+
test('platform admin can get api status', async () => {
1156+
await agent.get('/v2/status').set('Authorization', `Bearer ${platformAdminToken}`).expect(200)
1157+
})
1158+
})
1159+
1160+
describe('Team Admin', () => {
1161+
test('team admin can get api status', async () => {
1162+
await agent.get('/v2/status').set('Authorization', `Bearer ${teamAdminToken}`).expect(200)
1163+
})
1164+
})
1165+
1166+
describe('Team Member', () => {
1167+
test('team member can get api status', async () => {
1168+
await agent.get('/v2/status').set('Authorization', `Bearer ${teamMemberToken}`).expect(200)
1169+
})
1170+
})
1171+
1172+
describe('Unauthenticated', () => {
1173+
test('anonymous user cannot get api status', async () => {
1174+
await agent.get('/v2/status').expect(401)
1175+
})
1176+
})
1177+
})
1178+
11121179
test('team member cannot create its own services when disabled', async () => {
11131180
jest.spyOn(otomiStack, 'createService').mockResolvedValue({} as any)
11141181
await agent

src/api/v2/git.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Debug from 'debug'
2+
import { Response } from 'express'
3+
import { lockApi } from 'src/middleware'
4+
import { OpenApiRequestExt } from 'src/otomi-models'
5+
6+
const debug = Debug('otomi:api:v2:git')
7+
8+
/**
9+
* PUT /v2/git
10+
* Migrate the values repository to a new git remote.
11+
* Flow:
12+
* 1. Validate connectivity to new remote (400 on failure)
13+
* 2. Write + commit git config locally, push to new remote first (so new remote has correct config),
14+
* then push to current remote (so operator picks up the switch)
15+
* 3. Lock API
16+
*/
17+
export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
18+
debug('migrateGit')
19+
const { repoUrl, username, password, email, branch } = req.body as {
20+
repoUrl: string
21+
username?: string
22+
password: string
23+
email: string
24+
branch: string
25+
}
26+
27+
// Validate new remote connectivity; returns true if remote already has content
28+
let remoteHasContent: boolean
29+
try {
30+
remoteHasContent = await req.otomi.git.testRemoteConnection(repoUrl, password, branch, username)
31+
} catch (e: any) {
32+
if (e.message.includes('not found')) {
33+
const error = { message: `Cannot connect to new git remote`, statusCode: 404 }
34+
res.json(error)
35+
return
36+
} else {
37+
const error = { message: `Error connecting to new git remote`, statusCode: 400 }
38+
res.json(error)
39+
return
40+
}
41+
}
42+
43+
// Write config + commit locally → push to new remote (if empty) → push to current remote
44+
await req.otomi.migrateGitSettings({ repoUrl, username, password, email, branch, remoteHasContent })
45+
46+
await lockApi()
47+
48+
res.json({})
49+
}

src/api/v2/status.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Debug from 'debug'
2+
import { Response } from 'express'
3+
import { OpenApiRequestExt } from 'src/otomi-models'
4+
5+
const debug = Debug('otomi:api:v2:status')
6+
7+
/**
8+
* GET /v2/status
9+
* Returns the current API status including the locked state.
10+
*/
11+
export const getApiStatus = (req: OpenApiRequestExt, res: Response): void => {
12+
debug('getApiStatus')
13+
res.json(req.otomi.getApiStatus())
14+
}

src/error.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ export class BadRequestError extends OtomiError {
7070
this.code = 400
7171
}
7272
}
73+
export class ApiLockedError extends OtomiError {
74+
public constructor(err?: string) {
75+
super('Api is locked. No further changes are accepted.', err)
76+
this.code = 503
77+
}
78+
}
79+
7380
export class HttpError extends OtomiError {
7481
protected static messages: Record<number, string> = {
7582
400: 'Bad Request',

src/git.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import simpleGit from 'simple-git'
2+
import { Git } from './git'
3+
4+
jest.mock('simple-git')
5+
6+
const mockRaw = jest.fn()
7+
const mockPush = jest.fn()
8+
const mockRemote = jest.fn()
9+
const mockFetch = jest.fn()
10+
const mockGitInstance = {
11+
env: jest.fn().mockReturnThis(),
12+
raw: mockRaw,
13+
push: mockPush,
14+
remote: mockRemote,
15+
fetch: mockFetch,
16+
}
17+
;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance)
18+
19+
function makeRepo(): Git {
20+
return new Git('/tmp/test', 'https://origin.example.com/repo.git', 'user', 'user@example.com', undefined, 'main')
21+
}
22+
23+
describe('Git.testRemoteConnection', () => {
24+
beforeEach(() => jest.clearAllMocks())
25+
26+
it('returns false when remote is empty (no refs)', async () => {
27+
mockRaw.mockResolvedValue('')
28+
const repo = makeRepo()
29+
const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'main', 'myuser')
30+
expect(result).toBe(false)
31+
expect(mockRaw).toHaveBeenCalledWith(['ls-remote', expect.stringContaining('myuser'), 'refs/heads/main'])
32+
})
33+
34+
it('returns true when remote has existing refs', async () => {
35+
mockRaw.mockResolvedValue('abc123\trefs/heads/main\n')
36+
const repo = makeRepo()
37+
const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'main', 'myuser')
38+
expect(result).toBe(true)
39+
})
40+
41+
it('calls ls-remote with PAT only (no username) in url', async () => {
42+
mockRaw.mockResolvedValue('abc123\trefs/heads/main\n')
43+
const repo = makeRepo()
44+
const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mytoken', 'main')
45+
expect(result).toBe(true)
46+
expect(mockRaw).toHaveBeenCalledWith(['ls-remote', 'https://mytoken@example.com/repo.git', 'refs/heads/main'])
47+
})
48+
49+
it('returns false when branch does not exist but remote has other refs', async () => {
50+
mockRaw.mockResolvedValue('')
51+
const repo = makeRepo()
52+
const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'feature-branch', 'myuser')
53+
expect(result).toBe(false)
54+
expect(mockRaw).toHaveBeenCalledWith(['ls-remote', expect.stringContaining('myuser'), 'refs/heads/feature-branch'])
55+
})
56+
57+
it('throws when ls-remote fails (unreachable remote)', async () => {
58+
mockRaw.mockRejectedValue(new Error('exit code 128'))
59+
const repo = makeRepo()
60+
await expect(repo.testRemoteConnection('https://bad.example.com/repo.git', 'p', 'main', 'u')).rejects.toThrow()
61+
})
62+
})
63+
64+
describe('Git.pushToNewRemote', () => {
65+
beforeEach(() => jest.clearAllMocks())
66+
67+
it('unshallows, adds migration-remote, pushes, then removes in finally', async () => {
68+
mockFetch.mockResolvedValue({})
69+
mockRemote.mockResolvedValue('')
70+
mockPush.mockResolvedValue({})
71+
const repo = makeRepo()
72+
await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u')
73+
74+
expect(mockFetch).toHaveBeenCalledWith(['origin', '--unshallow'])
75+
expect(mockRemote).toHaveBeenCalledWith(
76+
expect.arrayContaining(['add', 'migration-remote', expect.stringContaining('u')]),
77+
)
78+
expect(mockPush).toHaveBeenCalledWith('migration-remote', 'HEAD:refs/heads/main')
79+
expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote'])
80+
})
81+
82+
it('continues when unshallow fails (repo already complete)', async () => {
83+
mockFetch.mockRejectedValue(new Error('--unshallow on a complete repository does not make sense'))
84+
mockRemote.mockResolvedValue('')
85+
mockPush.mockResolvedValue({})
86+
const repo = makeRepo()
87+
await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u')
88+
89+
expect(mockPush).toHaveBeenCalledWith('migration-remote', 'HEAD:refs/heads/main')
90+
})
91+
92+
it('uses PAT only (no username) in migration-remote url', async () => {
93+
mockFetch.mockResolvedValue({})
94+
mockRemote.mockResolvedValue('')
95+
mockPush.mockResolvedValue({})
96+
const repo = makeRepo()
97+
await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'mytoken')
98+
99+
expect(mockRemote).toHaveBeenCalledWith(
100+
expect.arrayContaining(['add', 'migration-remote', 'https://mytoken@example.com/repo.git']),
101+
)
102+
})
103+
104+
it('pushes local branch to target branch using refspec when branch names differ', async () => {
105+
mockFetch.mockResolvedValue({})
106+
mockRemote.mockResolvedValue('')
107+
mockPush.mockResolvedValue({})
108+
const repo = makeRepo() // local branch is 'main'
109+
await repo.pushToNewRemote('https://example.com/repo.git', 'feature-branch', 'p', 'u')
110+
111+
expect(mockPush).toHaveBeenCalledWith('migration-remote', 'HEAD:refs/heads/feature-branch')
112+
})
113+
114+
it('removes migration-remote in finally even when push fails', async () => {
115+
mockFetch.mockResolvedValue({})
116+
mockRemote.mockResolvedValue('')
117+
mockPush.mockRejectedValue(new Error('push failed'))
118+
const repo = makeRepo()
119+
await expect(repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u')).rejects.toThrow('push failed')
120+
expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote'])
121+
})
122+
})

src/git.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ const getProtocol = (url): string => (url && url.includes('://') ? url.split(':/
4343

4444
const getUrl = (url): string => (!url || url.includes('://') ? url : `${getProtocol(url)}://${url}`)
4545

46-
function getUrlAuth(url, user, password): string | undefined {
46+
function getUrlAuth(url, user: string | undefined, password): string | undefined {
4747
if (!url) return
4848
const protocol = getProtocol(url)
4949
const [_, bareUrl] = url.split('://')
50-
const encodedUser = encodeURIComponent(user as string)
51-
const encodedPassword = encodeURIComponent(password as string)
52-
return protocol === 'file' ? `${protocol}://${bareUrl}` : `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}`
50+
const credentials = user
51+
? `${encodeURIComponent(user)}:${encodeURIComponent(password)}`
52+
: encodeURIComponent(password)
53+
return protocol === 'file' ? `${protocol}://${bareUrl}` : `${protocol}://${credentials}@${bareUrl}`
5354
}
5455

5556
export class Git {
@@ -321,6 +322,36 @@ export class Git {
321322
return
322323
}
323324

325+
async testRemoteConnection(url: string, password: string, branch: string, user?: string): Promise<boolean> {
326+
const authUrl = password ? getUrlAuth(url, user, password) : url
327+
// returns true only if the configured branch exists on the remote
328+
const result = await this.git.raw(['ls-remote', authUrl!, `refs/heads/${branch}`])
329+
return result.trim().length > 0
330+
}
331+
332+
async pushToNewRemote(url: string, branch: string, password: string, user?: string): Promise<void> {
333+
const authUrl = password ? getUrlAuth(url, user, password) : url
334+
// Pulls use --depth which can leave the clone shallow. A shallow clone cannot be pushed to a
335+
// fresh empty remote because referenced parent objects are missing. Unshallow first to restore
336+
// full history; ignore failures when the repo is already complete.
337+
try {
338+
await this.git.fetch([this.remote, '--unshallow'])
339+
} catch {
340+
debug('Unshallow fetch skipped (repo is not shallow or fetch failed)')
341+
}
342+
try {
343+
await this.git.remote(['add', 'migration-remote', authUrl!])
344+
// Push HEAD so the worktree's session branch commit is included, not the stale local main
345+
await this.git.push('migration-remote', `HEAD:refs/heads/${branch}`)
346+
} finally {
347+
try {
348+
await this.git.remote(['remove', 'migration-remote'])
349+
} catch (e) {
350+
debug(`Could not remove migration-remote: ${getSanitizedErrorMessage(e)}`)
351+
}
352+
}
353+
}
354+
324355
async createWorktree(worktreePath: string, branch: string = this.branch): Promise<void> {
325356
debug(`Creating worktree at: ${worktreePath} from branch: ${branch}`)
326357
await ensureDir(dirname(worktreePath), { mode: 0o744 })
@@ -360,13 +391,8 @@ export class Git {
360391
return this.git.revparse('HEAD')
361392
}
362393

363-
async save(editor: string): Promise<void> {
364-
await this.commit(editor)
394+
async pushWithRetry(): Promise<void> {
365395
try {
366-
// we are in a unique developer branch, so we can pull, push, and merge
367-
// with the remote root, which might have been modified by another developer
368-
// since this is a child branch, we don't need to re-init
369-
// retry up to 10 times to pull and push if there are conflicts
370396
const retries = env.GIT_PUSH_RETRIES
371397
for (let attempt = 1; attempt <= retries; attempt++) {
372398
try {
@@ -392,6 +418,13 @@ export class Git {
392418
throw new GitPullError()
393419
}
394420
}
421+
422+
async save(editor: string): Promise<void> {
423+
// we are in a unique developer branch, so we can pull, push, and merge
424+
// with the remote root, which might have been modified by another developer
425+
await this.commit(editor)
426+
await this.pushWithRetry()
427+
}
395428
}
396429

397430
export async function getWorktreeRepo(

0 commit comments

Comments
 (0)