@serve.zone/gitops 3.0.0 → 3.1.0
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/changelog.md +17 -0
- package/deno.json +1 -1
- package/dist_serve/bundle.js +726 -694
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes/connectionmanager.d.ts +5 -1
- package/dist_ts/classes/connectionmanager.js +62 -7
- package/dist_ts/classes/syncmanager.d.ts +40 -0
- package/dist_ts/classes/syncmanager.js +804 -111
- package/dist_ts/classes/syncpath.d.ts +13 -0
- package/dist_ts/classes/syncpath.js +53 -0
- package/dist_ts/opsserver/handlers/connections.handler.js +8 -2
- package/dist_ts/opsserver/handlers/sync.handler.js +5 -1
- package/dist_ts/providers/classes.baseprovider.d.ts +22 -0
- package/dist_ts/providers/classes.baseprovider.js +93 -1
- package/dist_ts/providers/classes.giteaprovider.d.ts +18 -1
- package/dist_ts/providers/classes.giteaprovider.js +236 -1
- package/dist_ts/providers/classes.gitlabprovider.d.ts +16 -1
- package/dist_ts/providers/classes.gitlabprovider.js +235 -1
- package/dist_ts_interfaces/data/artifact.d.ts +39 -0
- package/dist_ts_interfaces/data/artifact.js +2 -0
- package/dist_ts_interfaces/data/connection.d.ts +3 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/sync.d.ts +21 -0
- package/dist_ts_interfaces/requests/connections.d.ts +6 -0
- package/dist_ts_interfaces/requests/sync.d.ts +4 -0
- package/package.json +3 -3
- package/readme.md +8 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes/connectionmanager.ts +61 -6
- package/ts/classes/syncmanager.ts +1095 -119
- package/ts/classes/syncpath.ts +71 -0
- package/ts/opsserver/handlers/connections.handler.ts +9 -0
- package/ts/opsserver/handlers/sync.handler.ts +4 -0
- package/ts/providers/classes.baseprovider.ts +137 -0
- package/ts/providers/classes.giteaprovider.ts +293 -1
- package/ts/providers/classes.gitlabprovider.ts +292 -1
- package/ts_interfaces/data/artifact.ts +43 -0
- package/ts_interfaces/data/connection.ts +3 -0
- package/ts_interfaces/data/index.ts +1 -0
- package/ts_interfaces/data/sync.ts +22 -0
- package/ts_interfaces/requests/connections.ts +6 -0
- package/ts_interfaces/requests/sync.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +10 -0
- package/ts_web/elements/views/connections/index.ts +26 -0
- package/ts_web/elements/views/sync/index.ts +26 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
3
|
-
import { BaseProvider, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
|
|
3
|
+
import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOptions, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* GitLab API v4 provider implementation
|
|
@@ -188,8 +188,299 @@ export class GitLabProvider extends BaseProvider {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// --- Releases / Release Assets ---
|
|
192
|
+
|
|
193
|
+
async getReleases(projectFullPath: string): Promise<interfaces.data.IRelease[]> {
|
|
194
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
195
|
+
try {
|
|
196
|
+
const releases = await this.requestPaginated<any>(
|
|
197
|
+
`/api/v4/projects/${encodedProject}/releases?order_by=released_at&sort=desc`,
|
|
198
|
+
);
|
|
199
|
+
return releases.map((release) => this.mapRelease(release));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (this.isNotFoundError(err)) return [];
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async upsertRelease(projectFullPath: string, release: interfaces.data.IRelease): Promise<interfaces.data.IRelease> {
|
|
207
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
208
|
+
const encodedTag = encodeURIComponent(release.tagName);
|
|
209
|
+
const body: Record<string, unknown> = {
|
|
210
|
+
name: release.name || release.tagName,
|
|
211
|
+
tag_name: release.tagName,
|
|
212
|
+
description: release.description || '',
|
|
213
|
+
};
|
|
214
|
+
if (release.releasedAt) body.released_at = release.releasedAt;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await this.requestJson<any>('GET', `/api/v4/projects/${encodedProject}/releases/${encodedTag}`);
|
|
218
|
+
const updated = await this.requestJson<any>(
|
|
219
|
+
'PUT',
|
|
220
|
+
`/api/v4/projects/${encodedProject}/releases/${encodedTag}`,
|
|
221
|
+
body,
|
|
222
|
+
);
|
|
223
|
+
return this.mapRelease(updated);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
226
|
+
const created = await this.requestJson<any>(
|
|
227
|
+
'POST',
|
|
228
|
+
`/api/v4/projects/${encodedProject}/releases`,
|
|
229
|
+
body,
|
|
230
|
+
);
|
|
231
|
+
return this.mapRelease(created);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async deleteRelease(projectFullPath: string, tagName: string): Promise<void> {
|
|
236
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
237
|
+
const encodedTag = encodeURIComponent(tagName);
|
|
238
|
+
try {
|
|
239
|
+
await this.requestVoid('DELETE', `/api/v4/projects/${encodedProject}/releases/${encodedTag}`);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async deleteReleaseAsset(
|
|
246
|
+
projectFullPath: string,
|
|
247
|
+
release: interfaces.data.IRelease,
|
|
248
|
+
asset: interfaces.data.IReleaseAsset,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
if (asset.kind !== 'link' || !asset.id) return;
|
|
251
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
252
|
+
const encodedTag = encodeURIComponent(release.tagName);
|
|
253
|
+
await this.requestVoid(
|
|
254
|
+
'DELETE',
|
|
255
|
+
`/api/v4/projects/${encodedProject}/releases/${encodedTag}/assets/links/${encodeURIComponent(asset.id)}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async downloadReleaseAsset(
|
|
260
|
+
projectFullPath: string,
|
|
261
|
+
release: interfaces.data.IRelease,
|
|
262
|
+
asset: interfaces.data.IReleaseAsset,
|
|
263
|
+
options?: IReleaseAssetTransferOptions,
|
|
264
|
+
): Promise<IReleaseAssetPayload | null> {
|
|
265
|
+
const baseOrigin = new URL(this.baseUrl).origin;
|
|
266
|
+
if (asset.url && new URL(asset.url, this.baseUrl).origin !== baseOrigin) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let url: string | undefined;
|
|
271
|
+
if (asset.directAssetPath) {
|
|
272
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
273
|
+
const encodedTag = encodeURIComponent(release.tagName);
|
|
274
|
+
const encodedDirectPath = asset.directAssetPath
|
|
275
|
+
.replace(/^\/+/, '')
|
|
276
|
+
.split('/')
|
|
277
|
+
.map((segment) => encodeURIComponent(segment))
|
|
278
|
+
.join('/');
|
|
279
|
+
url = `${this.baseUrl}/api/v4/projects/${encodedProject}/releases/${encodedTag}/downloads/${encodedDirectPath}`;
|
|
280
|
+
} else if (asset.downloadUrl || asset.url) {
|
|
281
|
+
const resolvedUrl = new URL(asset.downloadUrl || asset.url || '', this.baseUrl);
|
|
282
|
+
if (resolvedUrl.origin !== baseOrigin) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
url = resolvedUrl.toString();
|
|
286
|
+
}
|
|
287
|
+
if (!url) return null;
|
|
288
|
+
|
|
289
|
+
const response = await fetch(url, {
|
|
290
|
+
headers: { 'PRIVATE-TOKEN': this.token },
|
|
291
|
+
signal: options?.signal,
|
|
292
|
+
});
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
throw new Error(`GET release asset ${asset.name}: ${response.status} - ${text}`);
|
|
296
|
+
}
|
|
297
|
+
return await this.readReleaseAssetPayload(response, asset.name, asset.contentType, options);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async uploadReleaseAsset(
|
|
301
|
+
projectFullPath: string,
|
|
302
|
+
release: interfaces.data.IRelease,
|
|
303
|
+
asset: interfaces.data.IReleaseAsset,
|
|
304
|
+
payload: IReleaseAssetPayload,
|
|
305
|
+
options?: IReleaseAssetTransferOptions,
|
|
306
|
+
): Promise<interfaces.data.IReleaseAsset> {
|
|
307
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
308
|
+
const formData = new FormData();
|
|
309
|
+
const blob = new Blob([this.getPayloadArrayBuffer(payload)], {
|
|
310
|
+
type: payload.contentType || asset.contentType || 'application/octet-stream',
|
|
311
|
+
});
|
|
312
|
+
formData.append('file', blob, asset.name);
|
|
313
|
+
const uploadResponse = await fetch(`${this.baseUrl}/api/v4/projects/${encodedProject}/uploads`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: { 'PRIVATE-TOKEN': this.token },
|
|
316
|
+
body: formData,
|
|
317
|
+
signal: options?.signal,
|
|
318
|
+
});
|
|
319
|
+
if (!uploadResponse.ok) {
|
|
320
|
+
const text = await uploadResponse.text();
|
|
321
|
+
throw new Error(`POST project upload ${asset.name}: ${uploadResponse.status} - ${text}`);
|
|
322
|
+
}
|
|
323
|
+
const upload = await uploadResponse.json() as { full_path?: string; url?: string };
|
|
324
|
+
const uploadPath = upload.full_path || upload.url;
|
|
325
|
+
if (!uploadPath) {
|
|
326
|
+
throw new Error(`GitLab upload response did not include a path for ${asset.name}`);
|
|
327
|
+
}
|
|
328
|
+
const uploadUrl = uploadPath.startsWith('http') ? uploadPath : `${this.baseUrl}${uploadPath}`;
|
|
329
|
+
return await this.createReleaseAssetLink(projectFullPath, release, {
|
|
330
|
+
...asset,
|
|
331
|
+
kind: 'link',
|
|
332
|
+
url: uploadUrl,
|
|
333
|
+
directAssetPath: asset.directAssetPath || `/gitops-mirror-assets/${release.tagName}/${asset.name}`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async createReleaseAssetLink(
|
|
338
|
+
projectFullPath: string,
|
|
339
|
+
release: interfaces.data.IRelease,
|
|
340
|
+
asset: interfaces.data.IReleaseAsset,
|
|
341
|
+
): Promise<interfaces.data.IReleaseAsset> {
|
|
342
|
+
if (!asset.url) {
|
|
343
|
+
throw new Error(`Release asset "${asset.name}" has no URL to link`);
|
|
344
|
+
}
|
|
345
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
346
|
+
const encodedTag = encodeURIComponent(release.tagName);
|
|
347
|
+
const body: Record<string, unknown> = {
|
|
348
|
+
name: asset.name,
|
|
349
|
+
url: asset.url,
|
|
350
|
+
link_type: asset.linkType || 'other',
|
|
351
|
+
};
|
|
352
|
+
if (asset.directAssetPath) body.direct_asset_path = asset.directAssetPath;
|
|
353
|
+
const created = await this.requestJson<any>(
|
|
354
|
+
'POST',
|
|
355
|
+
`/api/v4/projects/${encodedProject}/releases/${encodedTag}/assets/links`,
|
|
356
|
+
body,
|
|
357
|
+
);
|
|
358
|
+
return this.mapReleaseLink(created);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Container Images ---
|
|
362
|
+
|
|
363
|
+
async getContainerImageRepositories(projectFullPath: string): Promise<interfaces.data.IContainerImageRepository[]> {
|
|
364
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
365
|
+
try {
|
|
366
|
+
const repositories = await this.requestPaginated<any>(
|
|
367
|
+
`/api/v4/projects/${encodedProject}/registry/repositories?tags_count=true`,
|
|
368
|
+
);
|
|
369
|
+
const result: interfaces.data.IContainerImageRepository[] = [];
|
|
370
|
+
for (const repo of repositories) {
|
|
371
|
+
const tags = await this.requestPaginated<any>(
|
|
372
|
+
`/api/v4/projects/${encodedProject}/registry/repositories/${repo.id}/tags`,
|
|
373
|
+
);
|
|
374
|
+
result.push({
|
|
375
|
+
id: String(repo.id),
|
|
376
|
+
name: repo.name || '',
|
|
377
|
+
path: repo.path,
|
|
378
|
+
location: repo.location,
|
|
379
|
+
projectFullPath,
|
|
380
|
+
tags: tags.map((tag) => ({
|
|
381
|
+
name: tag.name,
|
|
382
|
+
location: tag.location || `${repo.location}:${tag.name}`,
|
|
383
|
+
})),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return result;
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (this.isNotFoundError(err)) return [];
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async deleteContainerImageTag(
|
|
394
|
+
projectFullPath: string,
|
|
395
|
+
repositoryId: string,
|
|
396
|
+
tagName: string,
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
const encodedProject = encodeURIComponent(projectFullPath);
|
|
399
|
+
try {
|
|
400
|
+
await this.requestVoid(
|
|
401
|
+
'DELETE',
|
|
402
|
+
`/api/v4/projects/${encodedProject}/registry/repositories/${encodeURIComponent(repositoryId)}/tags/${encodeURIComponent(tagName)}`,
|
|
403
|
+
);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
191
409
|
// --- Mappers ---
|
|
192
410
|
|
|
411
|
+
private async requestPaginated<T>(path: string): Promise<T[]> {
|
|
412
|
+
const items: T[] = [];
|
|
413
|
+
const perPage = 100;
|
|
414
|
+
const maxPages = 1000;
|
|
415
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
416
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
417
|
+
const requestPath = `${path}${separator}page=${page}&per_page=${perPage}`;
|
|
418
|
+
const pageItems = await this.requestJson<T[]>('GET', requestPath);
|
|
419
|
+
if (!Array.isArray(pageItems) || pageItems.length === 0) break;
|
|
420
|
+
items.push(...pageItems);
|
|
421
|
+
if (pageItems.length < perPage) break;
|
|
422
|
+
}
|
|
423
|
+
if (items.length >= perPage * maxPages) {
|
|
424
|
+
throw new Error(`Pagination exceeded ${maxPages} pages for ${path}`);
|
|
425
|
+
}
|
|
426
|
+
return items;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async requestJson<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
|
|
430
|
+
const headers: Record<string, string> = { 'PRIVATE-TOKEN': this.token };
|
|
431
|
+
if (body) headers['Content-Type'] = 'application/json';
|
|
432
|
+
return await this.fetchJsonWithTimeout<T>(
|
|
433
|
+
`${this.baseUrl.replace(/\/+$/, '')}${path}`,
|
|
434
|
+
{
|
|
435
|
+
method,
|
|
436
|
+
headers,
|
|
437
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
438
|
+
},
|
|
439
|
+
`GitLab ${method} ${path}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private async requestVoid(method: string, path: string): Promise<void> {
|
|
444
|
+
await this.fetchVoidWithTimeout(
|
|
445
|
+
`${this.baseUrl.replace(/\/+$/, '')}${path}`,
|
|
446
|
+
{
|
|
447
|
+
method,
|
|
448
|
+
headers: { 'PRIVATE-TOKEN': this.token },
|
|
449
|
+
},
|
|
450
|
+
`GitLab ${method} ${path}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private isNotFoundError(err: unknown): boolean {
|
|
455
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
456
|
+
return errMsg.includes(': 404 ') || errMsg.includes(': 404 -') || errMsg.includes(' 404 Not Found');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private mapRelease(raw: any): interfaces.data.IRelease {
|
|
460
|
+
const links = Array.isArray(raw.assets?.links) ? raw.assets.links : [];
|
|
461
|
+
return {
|
|
462
|
+
tagName: raw.tag_name,
|
|
463
|
+
name: raw.name || raw.tag_name,
|
|
464
|
+
description: raw.description || '',
|
|
465
|
+
releasedAt: raw.released_at,
|
|
466
|
+
createdAt: raw.created_at,
|
|
467
|
+
targetCommitish: raw.commit?.id,
|
|
468
|
+
assets: links.map((link: any) => this.mapReleaseLink(link)),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private mapReleaseLink(link: any): interfaces.data.IReleaseAsset {
|
|
473
|
+
return {
|
|
474
|
+
id: link.id ? String(link.id) : undefined,
|
|
475
|
+
name: link.name || '',
|
|
476
|
+
kind: 'link',
|
|
477
|
+
url: link.url,
|
|
478
|
+
downloadUrl: link.direct_asset_url,
|
|
479
|
+
directAssetPath: link.direct_asset_path,
|
|
480
|
+
linkType: link.link_type || 'other',
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
193
484
|
private mapProject(p: plugins.gitlabClient.GitLabProject): interfaces.data.IProject {
|
|
194
485
|
return {
|
|
195
486
|
id: String(p.id),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type TReleaseAssetKind = 'link' | 'attachment' | 'source';
|
|
2
|
+
|
|
3
|
+
export interface IReleaseAsset {
|
|
4
|
+
id?: string;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: TReleaseAssetKind;
|
|
7
|
+
url?: string;
|
|
8
|
+
downloadUrl?: string;
|
|
9
|
+
directAssetPath?: string;
|
|
10
|
+
linkType?: 'other' | 'runbook' | 'image' | 'package';
|
|
11
|
+
contentType?: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IRelease {
|
|
16
|
+
id?: string;
|
|
17
|
+
tagName: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
releasedAt?: string;
|
|
21
|
+
createdAt?: string;
|
|
22
|
+
draft?: boolean;
|
|
23
|
+
preRelease?: boolean;
|
|
24
|
+
targetCommitish?: string;
|
|
25
|
+
assets: IReleaseAsset[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IContainerImageTag {
|
|
29
|
+
name: string;
|
|
30
|
+
digest?: string;
|
|
31
|
+
location: string;
|
|
32
|
+
createdAt?: string;
|
|
33
|
+
size?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IContainerImageRepository {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
path: string;
|
|
40
|
+
location: string;
|
|
41
|
+
projectFullPath: string;
|
|
42
|
+
tags: IContainerImageTag[];
|
|
43
|
+
}
|
|
@@ -10,4 +10,7 @@ export interface IProviderConnection {
|
|
|
10
10
|
status: 'connected' | 'disconnected' | 'error' | 'paused';
|
|
11
11
|
groupFilter?: string; // Restricts which repos this connection can see (e.g. "foss.global")
|
|
12
12
|
groupFilterId?: string; // Resolved filter group ID (numeric for GitLab, org name for Gitea)
|
|
13
|
+
registryUrl?: string; // Optional OCI registry host/base URL when it differs from provider baseUrl
|
|
14
|
+
registryUsername?: string; // Optional OCI registry username; provider token is used first when omitted
|
|
15
|
+
registryToken?: string; // Optional OCI registry token/password; stored in keychain when configured
|
|
13
16
|
}
|
|
@@ -12,8 +12,12 @@ export interface ISyncConfig {
|
|
|
12
12
|
lastSyncError?: string;
|
|
13
13
|
lastSyncDurationMs?: number;
|
|
14
14
|
reposSynced: number;
|
|
15
|
+
releasesSynced?: number;
|
|
16
|
+
imagesSynced?: number;
|
|
15
17
|
enforceDelete: boolean; // When true, stale target repos are moved to obsolete
|
|
16
18
|
enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
|
|
19
|
+
syncReleases?: boolean; // When true, release metadata and assets are mirrored exactly
|
|
20
|
+
syncContainerImages?: boolean; // When true, OCI image tags/manifests are mirrored exactly
|
|
17
21
|
addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
|
|
18
22
|
useGroupAvatarsForProjects?: boolean; // When true, projects without avatars inherit the group avatar
|
|
19
23
|
createdAt: number;
|
|
@@ -26,9 +30,27 @@ export interface ISyncRepoStatus {
|
|
|
26
30
|
targetFullPath: string; // e.g. "foss.global/push.rocks/smartstate"
|
|
27
31
|
lastSyncAt: number;
|
|
28
32
|
lastSyncError?: string;
|
|
33
|
+
sourceProjectId?: string;
|
|
34
|
+
targetProjectId?: string;
|
|
35
|
+
releasesSynced?: number;
|
|
36
|
+
imagesSynced?: number;
|
|
37
|
+
releaseSyncError?: string;
|
|
38
|
+
imageSyncError?: string;
|
|
29
39
|
status: 'synced' | 'error' | 'pending';
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
export interface ISyncProjectMapping {
|
|
43
|
+
id: string;
|
|
44
|
+
syncConfigId: string;
|
|
45
|
+
sourceConnectionId: string;
|
|
46
|
+
sourceProjectId: string;
|
|
47
|
+
sourceFullPath: string;
|
|
48
|
+
targetConnectionId: string;
|
|
49
|
+
targetFullPath: string;
|
|
50
|
+
targetProjectId?: string;
|
|
51
|
+
updatedAt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export interface ISyncLogEntry {
|
|
33
55
|
timestamp: number;
|
|
34
56
|
level: 'info' | 'warn' | 'error' | 'success' | 'debug';
|
|
@@ -26,6 +26,9 @@ export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.im
|
|
|
26
26
|
baseUrl: string;
|
|
27
27
|
token: string;
|
|
28
28
|
groupFilter?: string;
|
|
29
|
+
registryUrl?: string;
|
|
30
|
+
registryUsername?: string;
|
|
31
|
+
registryToken?: string;
|
|
29
32
|
};
|
|
30
33
|
response: {
|
|
31
34
|
connection: data.IProviderConnection;
|
|
@@ -44,6 +47,9 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
|
|
|
44
47
|
baseUrl?: string;
|
|
45
48
|
token?: string;
|
|
46
49
|
groupFilter?: string;
|
|
50
|
+
registryUrl?: string;
|
|
51
|
+
registryUsername?: string;
|
|
52
|
+
registryToken?: string;
|
|
47
53
|
};
|
|
48
54
|
response: {
|
|
49
55
|
connection: data.IProviderConnection;
|
|
@@ -28,6 +28,8 @@ export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.im
|
|
|
28
28
|
intervalMinutes?: number;
|
|
29
29
|
enforceDelete?: boolean;
|
|
30
30
|
enforceGroupDelete?: boolean;
|
|
31
|
+
syncReleases?: boolean;
|
|
32
|
+
syncContainerImages?: boolean;
|
|
31
33
|
addMirrorHint?: boolean;
|
|
32
34
|
useGroupAvatarsForProjects?: boolean;
|
|
33
35
|
};
|
|
@@ -49,6 +51,8 @@ export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.im
|
|
|
49
51
|
intervalMinutes?: number;
|
|
50
52
|
enforceDelete?: boolean;
|
|
51
53
|
enforceGroupDelete?: boolean;
|
|
54
|
+
syncReleases?: boolean;
|
|
55
|
+
syncContainerImages?: boolean;
|
|
52
56
|
addMirrorHint?: boolean;
|
|
53
57
|
useGroupAvatarsForProjects?: boolean;
|
|
54
58
|
};
|
package/ts_web/appstate.ts
CHANGED
|
@@ -180,6 +180,9 @@ export const createConnectionAction = connectionsStatePart.createAction<{
|
|
|
180
180
|
baseUrl: string;
|
|
181
181
|
token: string;
|
|
182
182
|
groupFilter?: string;
|
|
183
|
+
registryUrl?: string;
|
|
184
|
+
registryUsername?: string;
|
|
185
|
+
registryToken?: string;
|
|
183
186
|
}>(async (statePartArg, dataArg) => {
|
|
184
187
|
const context = getActionContext();
|
|
185
188
|
try {
|
|
@@ -281,6 +284,9 @@ export const updateConnectionAction = connectionsStatePart.createAction<{
|
|
|
281
284
|
baseUrl?: string;
|
|
282
285
|
token?: string;
|
|
283
286
|
groupFilter?: string;
|
|
287
|
+
registryUrl?: string;
|
|
288
|
+
registryUsername?: string;
|
|
289
|
+
registryToken?: string;
|
|
284
290
|
}>(async (statePartArg, dataArg) => {
|
|
285
291
|
const context = getActionContext();
|
|
286
292
|
try {
|
|
@@ -887,6 +893,8 @@ export const createSyncConfigAction = syncStatePart.createAction<{
|
|
|
887
893
|
intervalMinutes?: number;
|
|
888
894
|
enforceDelete?: boolean;
|
|
889
895
|
enforceGroupDelete?: boolean;
|
|
896
|
+
syncReleases?: boolean;
|
|
897
|
+
syncContainerImages?: boolean;
|
|
890
898
|
addMirrorHint?: boolean;
|
|
891
899
|
useGroupAvatarsForProjects?: boolean;
|
|
892
900
|
}>(async (statePartArg, dataArg) => {
|
|
@@ -915,6 +923,8 @@ export const updateSyncConfigAction = syncStatePart.createAction<{
|
|
|
915
923
|
intervalMinutes?: number;
|
|
916
924
|
enforceDelete?: boolean;
|
|
917
925
|
enforceGroupDelete?: boolean;
|
|
926
|
+
syncReleases?: boolean;
|
|
927
|
+
syncContainerImages?: boolean;
|
|
918
928
|
addMirrorHint?: boolean;
|
|
919
929
|
useGroupAvatarsForProjects?: boolean;
|
|
920
930
|
}>(async (statePartArg, dataArg) => {
|
|
@@ -63,6 +63,8 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
63
63
|
Type: item.providerType,
|
|
64
64
|
URL: item.baseUrl,
|
|
65
65
|
'Group Filter': item.groupFilter || '-',
|
|
66
|
+
Registry: item.registryUrl || '-',
|
|
67
|
+
'Registry User': item.registryUsername || '-',
|
|
66
68
|
Status: item.status,
|
|
67
69
|
Created: new Date(item.createdAt).toLocaleDateString(),
|
|
68
70
|
})}
|
|
@@ -168,6 +170,15 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
168
170
|
<div class="form-row">
|
|
169
171
|
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .value=${item.groupFilter || ''} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
|
170
172
|
</div>
|
|
173
|
+
<div class="form-row">
|
|
174
|
+
<dees-input-text .label=${'Registry URL (optional)'} .key=${'registryUrl'} .value=${item.registryUrl || ''} .description=${'OCI registry host/base URL when it differs from the provider base URL, for example registry.example.com:5050.'}></dees-input-text>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="form-row">
|
|
177
|
+
<dees-input-text .label=${'Registry Username (optional)'} .key=${'registryUsername'} .value=${item.registryUsername || ''} .description=${'Optional OCI registry username. GitLab defaults to oauth2 with the API token when omitted.'}></dees-input-text>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="form-row">
|
|
180
|
+
<dees-input-text .label=${'Registry Token (leave empty to keep current)'} .key=${'registryToken'} type="password" .description=${'Optional OCI registry token/password. When omitted, the provider API token is tried first.'}></dees-input-text>
|
|
181
|
+
</div>
|
|
171
182
|
`,
|
|
172
183
|
menuOptions: [
|
|
173
184
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
|
@@ -186,7 +197,10 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
186
197
|
name: data.name,
|
|
187
198
|
baseUrl: data.baseUrl,
|
|
188
199
|
groupFilter: data.groupFilter,
|
|
200
|
+
registryUrl: data.registryUrl,
|
|
201
|
+
registryUsername: data.registryUsername,
|
|
189
202
|
...(data.token ? { token: data.token } : {}),
|
|
203
|
+
...(data.registryToken ? { registryToken: data.registryToken } : {}),
|
|
190
204
|
},
|
|
191
205
|
);
|
|
192
206
|
modal.destroy();
|
|
@@ -226,6 +240,15 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
226
240
|
<div class="form-row">
|
|
227
241
|
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
|
228
242
|
</div>
|
|
243
|
+
<div class="form-row">
|
|
244
|
+
<dees-input-text .label=${'Registry URL (optional)'} .key=${'registryUrl'} .description=${'OCI registry host/base URL when it differs from the provider base URL, for example registry.example.com:5050.'}></dees-input-text>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="form-row">
|
|
247
|
+
<dees-input-text .label=${'Registry Username (optional)'} .key=${'registryUsername'} .description=${'Optional OCI registry username. GitLab defaults to oauth2 with the API token when omitted.'}></dees-input-text>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="form-row">
|
|
250
|
+
<dees-input-text .label=${'Registry Token (optional)'} .key=${'registryToken'} type="password" .description=${'Optional OCI registry token/password. When omitted, the provider API token is tried first.'}></dees-input-text>
|
|
251
|
+
</div>
|
|
229
252
|
`,
|
|
230
253
|
menuOptions: [
|
|
231
254
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
|
@@ -249,6 +272,9 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
249
272
|
baseUrl: data.baseUrl,
|
|
250
273
|
token: data.token,
|
|
251
274
|
groupFilter: data.groupFilter || undefined,
|
|
275
|
+
registryUrl: data.registryUrl || undefined,
|
|
276
|
+
registryUsername: data.registryUsername || undefined,
|
|
277
|
+
registryToken: data.registryToken || undefined,
|
|
252
278
|
},
|
|
253
279
|
);
|
|
254
280
|
modal.destroy();
|
|
@@ -103,10 +103,14 @@ export class GitopsViewSync extends DeesElement {
|
|
|
103
103
|
Status: item.status,
|
|
104
104
|
'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
|
|
105
105
|
'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
|
|
106
|
+
'Sync Releases': item.syncReleases ? 'Yes' : 'No',
|
|
107
|
+
'Sync Images': item.syncContainerImages ? 'Yes' : 'No',
|
|
106
108
|
'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
|
|
107
109
|
'Group Avatars': item.useGroupAvatarsForProjects ? 'Yes' : 'No',
|
|
108
110
|
'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
|
|
109
111
|
Repos: String(item.reposSynced),
|
|
112
|
+
Releases: String(item.releasesSynced || 0),
|
|
113
|
+
Images: String(item.imagesSynced || 0),
|
|
110
114
|
};
|
|
111
115
|
}}
|
|
112
116
|
.dataActions=${[
|
|
@@ -286,6 +290,12 @@ export class GitopsViewSync extends DeesElement {
|
|
|
286
290
|
<div class="form-row">
|
|
287
291
|
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${false} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
|
288
292
|
</div>
|
|
293
|
+
<div class="form-row">
|
|
294
|
+
<dees-input-checkbox .label=${'Sync Releases'} .key=${'syncReleases'} .value=${false} .description=${'When enabled, releases and release assets are mirrored to the target.'}></dees-input-checkbox>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="form-row">
|
|
297
|
+
<dees-input-checkbox .label=${'Sync Container Images'} .key=${'syncContainerImages'} .value=${false} .description=${'When enabled, OCI container image tags are mirrored to the target registry.'}></dees-input-checkbox>
|
|
298
|
+
</div>
|
|
289
299
|
<div class="form-row">
|
|
290
300
|
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${false} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
|
291
301
|
</div>
|
|
@@ -303,7 +313,7 @@ export class GitopsViewSync extends DeesElement {
|
|
|
303
313
|
for (const input of inputs) {
|
|
304
314
|
if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
|
|
305
315
|
data[input.key] = input.selectedOption?.key || '';
|
|
306
|
-
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
|
316
|
+
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'syncReleases' || input.key === 'syncContainerImages' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
|
307
317
|
data[input.key] = input.getValue();
|
|
308
318
|
} else {
|
|
309
319
|
data[input.key] = input.value || '';
|
|
@@ -313,10 +323,12 @@ export class GitopsViewSync extends DeesElement {
|
|
|
313
323
|
name: data.name,
|
|
314
324
|
sourceConnectionId: data.sourceConnectionId,
|
|
315
325
|
targetConnectionId: data.targetConnectionId,
|
|
316
|
-
targetGroupOffset: data.targetGroupOffset
|
|
326
|
+
targetGroupOffset: data.targetGroupOffset,
|
|
317
327
|
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
|
318
328
|
enforceDelete: !!data.enforceDelete,
|
|
319
329
|
enforceGroupDelete: !!data.enforceGroupDelete,
|
|
330
|
+
syncReleases: !!data.syncReleases,
|
|
331
|
+
syncContainerImages: !!data.syncContainerImages,
|
|
320
332
|
addMirrorHint: !!data.addMirrorHint,
|
|
321
333
|
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
|
322
334
|
});
|
|
@@ -347,6 +359,12 @@ export class GitopsViewSync extends DeesElement {
|
|
|
347
359
|
<div class="form-row">
|
|
348
360
|
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${!!item.enforceGroupDelete} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
|
349
361
|
</div>
|
|
362
|
+
<div class="form-row">
|
|
363
|
+
<dees-input-checkbox .label=${'Sync Releases'} .key=${'syncReleases'} .value=${!!item.syncReleases} .description=${'When enabled, releases and release assets are mirrored to the target.'}></dees-input-checkbox>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="form-row">
|
|
366
|
+
<dees-input-checkbox .label=${'Sync Container Images'} .key=${'syncContainerImages'} .value=${!!item.syncContainerImages} .description=${'When enabled, OCI container image tags are mirrored to the target registry.'}></dees-input-checkbox>
|
|
367
|
+
</div>
|
|
350
368
|
<div class="form-row">
|
|
351
369
|
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${!!item.addMirrorHint} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
|
352
370
|
</div>
|
|
@@ -362,7 +380,7 @@ export class GitopsViewSync extends DeesElement {
|
|
|
362
380
|
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
|
|
363
381
|
const data: any = {};
|
|
364
382
|
for (const input of inputs) {
|
|
365
|
-
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
|
383
|
+
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'syncReleases' || input.key === 'syncContainerImages' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
|
366
384
|
data[input.key] = input.getValue();
|
|
367
385
|
} else {
|
|
368
386
|
data[input.key] = input.value || '';
|
|
@@ -371,10 +389,12 @@ export class GitopsViewSync extends DeesElement {
|
|
|
371
389
|
await appstate.syncStatePart.dispatchAction(appstate.updateSyncConfigAction, {
|
|
372
390
|
syncConfigId: item.id,
|
|
373
391
|
name: data.name,
|
|
374
|
-
targetGroupOffset: data.targetGroupOffset
|
|
392
|
+
targetGroupOffset: data.targetGroupOffset,
|
|
375
393
|
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
|
376
394
|
enforceDelete: !!data.enforceDelete,
|
|
377
395
|
enforceGroupDelete: !!data.enforceGroupDelete,
|
|
396
|
+
syncReleases: !!data.syncReleases,
|
|
397
|
+
syncContainerImages: !!data.syncContainerImages,
|
|
378
398
|
addMirrorHint: !!data.addMirrorHint,
|
|
379
399
|
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
|
380
400
|
});
|
|
@@ -494,6 +514,8 @@ export class GitopsViewSync extends DeesElement {
|
|
|
494
514
|
<div>
|
|
495
515
|
<div class="repo-path">${s.sourceFullPath}</div>
|
|
496
516
|
${s.lastSyncError ? html`<div class="repo-error">${s.lastSyncError}</div>` : ''}
|
|
517
|
+
${s.releaseSyncError ? html`<div class="repo-error">Release sync: ${s.releaseSyncError}</div>` : ''}
|
|
518
|
+
${s.imageSyncError ? html`<div class="repo-error">Image sync: ${s.imageSyncError}</div>` : ''}
|
|
497
519
|
</div>
|
|
498
520
|
<div>
|
|
499
521
|
<span class="repo-status ${s.status}">${s.status}</span>
|