@foss.global/codefeed 2.1.0 → 3.0.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 +19 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +18 -6
- package/dist_ts/classes.giteasourceinventory.js +68 -45
- package/dist_ts/forgeinventory.client.d.ts +7 -2
- package/dist_ts/forgeinventory.client.js +9 -6
- package/dist_ts/forgeinventory.transport.d.ts +5 -4
- package/dist_ts/forgeinventory.transport.js +6 -6
- package/dist_ts/giteasourceinventory.git.d.ts +70 -0
- package/dist_ts/giteasourceinventory.git.js +294 -0
- package/dist_ts/plugins.d.ts +4 -1
- package/dist_ts/plugins.js +5 -2
- package/package.json +4 -4
- package/readme.md +7 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +82 -40
- package/ts/forgeinventory.client.ts +12 -5
- package/ts/forgeinventory.transport.ts +10 -9
- package/ts/giteasourceinventory.git.ts +316 -0
- package/ts/plugins.ts +4 -1
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
2
|
import * as transport from './forgeinventory.transport.js';
|
|
3
3
|
import * as validation from './forgeinventory.validation.js';
|
|
4
4
|
import { ForgeRequestClient } from './forgeinventory.client.js';
|
|
5
5
|
import type { IForgeRequestMetrics, IForgeRequestOptions } from './forgeinventory.client.js';
|
|
6
|
+
import { measureGiteaMirror } from './giteasourceinventory.git.js';
|
|
6
7
|
|
|
7
8
|
type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
|
|
8
9
|
type TUnit = plugins.fossInterfaces.data.TForgeSourceInventoryUnit;
|
|
@@ -24,8 +25,17 @@ export interface IGiteaSourceInventoryOptions {
|
|
|
24
25
|
* sent only to `baseUrl` and never appears in results, reasons or errors.
|
|
25
26
|
*/
|
|
26
27
|
token: string;
|
|
27
|
-
/** PEM certificates that replace the default trust store for this reader's connections. */
|
|
28
|
+
/** PEM certificates that replace the default trust store for this reader's connections, Git transfers included. */
|
|
28
29
|
caCertificates?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path of a private directory for temporary mirrors. Each
|
|
32
|
+
* repository is cloned into it with every ref, measured at the Git level and
|
|
33
|
+
* removed again, so it needs room for the largest repositories measured at
|
|
34
|
+
* the same time.
|
|
35
|
+
*/
|
|
36
|
+
gitWorkDir: string;
|
|
37
|
+
/** Wall-clock limit of one repository's mirror clone; defaults to 30 minutes, at most six hours. */
|
|
38
|
+
gitTimeoutMs?: number;
|
|
29
39
|
/** Items requested per page; Gitea caps it at its `MAX_RESPONSE_ITEMS`. */
|
|
30
40
|
pageSize?: number;
|
|
31
41
|
/** Pages read per listing before the listing counts as partial. */
|
|
@@ -66,8 +76,6 @@ export const giteaSourceInventoryLimitations: readonly string[] = [
|
|
|
66
76
|
'Gitea has no read that lists user-scoped Actions secrets; they are reissued.',
|
|
67
77
|
];
|
|
68
78
|
|
|
69
|
-
const lfsNotMeasured: TMeasure = { status: 'notMeasured', reason: 'Gitea exposes no API that lists LFS objects.' };
|
|
70
|
-
|
|
71
79
|
const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
|
|
72
80
|
const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
|
|
73
81
|
const notMeasured = (reasonArg: string): TMeasure => ({ status: 'notMeasured', reason: reasonArg });
|
|
@@ -143,19 +151,28 @@ const unitKey = (unitArg: TUnit): string => unitArg.kind === 'instance' ? 'insta
|
|
|
143
151
|
const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
|
|
144
152
|
|
|
145
153
|
/**
|
|
146
|
-
* Authenticated, read-only inventory of a Gitea instance
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
154
|
+
* Authenticated, read-only inventory of a Gitea instance, read with a site
|
|
155
|
+
* administrator's token: GET requests to its REST API, a mirror clone of every
|
|
156
|
+
* repository for refs, objects and LFS pointers, and LFS batch `download`
|
|
157
|
+
* requests that check which referenced objects Gitea holds. It counts secrets
|
|
158
|
+
* and variables without retaining their values, and reports every read that
|
|
159
|
+
* did not finish as a partial or unmeasured value instead of a count.
|
|
150
160
|
*/
|
|
151
161
|
export class GiteaSourceInventory {
|
|
152
|
-
readonly #options: {
|
|
162
|
+
readonly #options: {
|
|
163
|
+
instanceId: string; baseUrl: string; pageSize: number; maxPages: number; requestTimeoutMs: number; maxResponseBytes: number;
|
|
164
|
+
token: string; caCertificates?: string[]; gitWorkDir: string; gitTimeoutMs: number;
|
|
165
|
+
};
|
|
166
|
+
readonly #transport: transport.TForgeTransport;
|
|
153
167
|
readonly #client: ForgeRequestClient;
|
|
168
|
+
#lfsClient: ForgeRequestClient | null = null;
|
|
169
|
+
#readerLogin: Promise<string> | null = null;
|
|
154
170
|
|
|
155
171
|
constructor(optionsArg: IGiteaSourceInventoryOptions) {
|
|
156
172
|
const baseUrl = validation.httpUrl(optionsArg.baseUrl);
|
|
157
173
|
if (!baseUrl || !baseUrl.startsWith('https:') || typeof optionsArg.instanceId !== 'string' || !optionsArg.instanceId.trim() ||
|
|
158
|
-
typeof optionsArg.token !== 'string' || !optionsArg.token || /[\s\u0000-\u001f\u007f]/.test(optionsArg.token)
|
|
174
|
+
typeof optionsArg.token !== 'string' || !optionsArg.token || /[\s\u0000-\u001f\u007f]/.test(optionsArg.token) ||
|
|
175
|
+
typeof optionsArg.gitWorkDir !== 'string' || !plugins.path.isAbsolute(optionsArg.gitWorkDir)) {
|
|
159
176
|
throw new validation.ForgeInventoryError('invalid-options');
|
|
160
177
|
}
|
|
161
178
|
const limits = {
|
|
@@ -163,17 +180,22 @@ export class GiteaSourceInventory {
|
|
|
163
180
|
maxPages: optionsArg.maxPages ?? 10_000,
|
|
164
181
|
requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
|
|
165
182
|
maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
|
|
183
|
+
gitTimeoutMs: optionsArg.gitTimeoutMs ?? 30 * 60 * 1000,
|
|
166
184
|
};
|
|
167
185
|
for (const [name, maximum] of [
|
|
168
|
-
['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024],
|
|
186
|
+
['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024], ['gitTimeoutMs', 6 * 60 * 60 * 1000],
|
|
169
187
|
] as const) {
|
|
170
188
|
const value = limits[name];
|
|
171
189
|
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
|
|
172
190
|
}
|
|
173
|
-
|
|
191
|
+
const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
|
|
192
|
+
this.#options = {
|
|
193
|
+
instanceId: optionsArg.instanceId, baseUrl: baseUrl.replace(/\/+$/, ''), ...limits,
|
|
194
|
+
token: optionsArg.token, caCertificates, gitWorkDir: optionsArg.gitWorkDir,
|
|
195
|
+
};
|
|
196
|
+
this.#transport = caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates);
|
|
174
197
|
this.#client = new ForgeRequestClient({
|
|
175
|
-
transport:
|
|
176
|
-
: transport.createHttpsTransport(validation.caCertificates(optionsArg.caCertificates)),
|
|
198
|
+
transport: this.#transport,
|
|
177
199
|
headers: { authorization: `token ${optionsArg.token}` },
|
|
178
200
|
requestTimeoutMs: limits.requestTimeoutMs,
|
|
179
201
|
maxResponseBytes: limits.maxResponseBytes,
|
|
@@ -183,7 +205,16 @@ export class GiteaSourceInventory {
|
|
|
183
205
|
/** Validated, normalized instance root; contains no credentials or API suffix. */
|
|
184
206
|
public get baseUrl(): string { return this.#options.baseUrl; }
|
|
185
207
|
|
|
186
|
-
|
|
208
|
+
/** REST and LFS batch requests together. */
|
|
209
|
+
public get metrics(): IForgeRequestMetrics {
|
|
210
|
+
const rest = this.#client.metrics;
|
|
211
|
+
const lfs = this.#lfsClient?.metrics ?? { requests: 0, responseBytes: 0, rateLimitedRequests: 0 };
|
|
212
|
+
return {
|
|
213
|
+
requests: rest.requests + lfs.requests,
|
|
214
|
+
responseBytes: rest.responseBytes + lfs.responseBytes,
|
|
215
|
+
rateLimitedRequests: rest.rateLimitedRequests + lfs.rateLimitedRequests,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
187
218
|
|
|
188
219
|
/**
|
|
189
220
|
* Identifies the source and its reader and lists every owner and repository.
|
|
@@ -455,8 +486,7 @@ export class GiteaSourceInventory {
|
|
|
455
486
|
objectFormat: requiredString(item.object_format_name),
|
|
456
487
|
defaultBranch: requiredString(item.default_branch),
|
|
457
488
|
features,
|
|
458
|
-
|
|
459
|
-
lfs: { objects: lfsNotMeasured, bytes: lfsNotMeasured },
|
|
489
|
+
...await this.#git(ownerArg, nameArg, item, signalArg),
|
|
460
490
|
collaboration: await this.#collaboration(path, features, signalArg),
|
|
461
491
|
// Releases stay readable while their unit is disabled; a disabled wiki answers 404.
|
|
462
492
|
releases: await this.#releases(path, signalArg),
|
|
@@ -486,28 +516,39 @@ export class GiteaSourceInventory {
|
|
|
486
516
|
}
|
|
487
517
|
}
|
|
488
518
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return { reportedSizeKiB, branches: failed, tags: failed, pullRequestRefs: failed, otherRefs: failed };
|
|
519
|
+
/** Refs, objects and LFS objects, read from a mirror clone; only the reported size comes from the API. */
|
|
520
|
+
async #git(ownerArg: string, nameArg: string, itemArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<Pick<IRepository, 'git' | 'lfs'>> {
|
|
521
|
+
const sizeKiB = BigInt(validation.decimal(itemArg.size));
|
|
522
|
+
const username = await this.#login(signalArg);
|
|
523
|
+
const measured = await measureGiteaMirror({
|
|
524
|
+
baseUrl: this.#options.baseUrl, owner: ownerArg, name: nameArg, username, token: this.#options.token,
|
|
525
|
+
caCertificates: this.#options.caCertificates, workDir: this.#options.gitWorkDir, reportedSizeKiB: sizeKiB,
|
|
526
|
+
timeoutMs: this.#options.gitTimeoutMs,
|
|
527
|
+
lfsClient: this.#lfs(username), signal: signalArg,
|
|
528
|
+
});
|
|
529
|
+
return { git: { reportedSizeKiB: complete(sizeKiB), ...measured.git }, lfs: measured.lfs };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The reader's login, the user name of its Git and LFS credentials. */
|
|
533
|
+
#login(signalArg?: AbortSignal): Promise<string> {
|
|
534
|
+
if (this.#readerLogin === null) {
|
|
535
|
+
const login = this.#get('/user', { signal: signalArg }).then((responseArg) => segment(validation.record(responseArg.body).login));
|
|
536
|
+
// A failed read is retried by the next repository.
|
|
537
|
+
login.catch(() => { if (this.#readerLogin === login) this.#readerLogin = null; });
|
|
538
|
+
this.#readerLogin = login;
|
|
510
539
|
}
|
|
540
|
+
return this.#readerLogin;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** LFS routes accept the token as a Basic password, not as a `token` authorization. */
|
|
544
|
+
#lfs(usernameArg: string): ForgeRequestClient {
|
|
545
|
+
this.#lfsClient ??= new ForgeRequestClient({
|
|
546
|
+
transport: this.#transport,
|
|
547
|
+
headers: { authorization: `Basic ${Buffer.from(`${usernameArg}:${this.#options.token}`).toString('base64')}` },
|
|
548
|
+
requestTimeoutMs: this.#options.requestTimeoutMs,
|
|
549
|
+
maxResponseBytes: this.#options.maxResponseBytes,
|
|
550
|
+
});
|
|
551
|
+
return this.#lfsClient;
|
|
511
552
|
}
|
|
512
553
|
|
|
513
554
|
async #collaboration(pathArg: string, featuresArg: IRepository['features'], signalArg?: AbortSignal): Promise<IRepository['collaboration']> {
|
|
@@ -834,8 +875,9 @@ export const composeGiteaSourceInventory = (
|
|
|
834
875
|
empty: repositoryCount((repositoryArg) => repositoryArg.empty),
|
|
835
876
|
withWiki: sumMeasures([...repositories.map((repositoryArg) => indicator(repositoryArg.wikiPages, (valueArg) => valueArg > 0n)), ...missingRepositories]),
|
|
836
877
|
},
|
|
837
|
-
git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git),
|
|
838
|
-
|
|
878
|
+
git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git),
|
|
879
|
+
['reportedSizeKiB', 'branches', 'tags', 'pullRequestRefs', 'otherRefs', 'objects', 'objectBytes'], missingRepositories),
|
|
880
|
+
lfs: sumGroup(repositories.map((repositoryArg) => repositoryArg.lfs), ['objects', 'bytes', 'missingObjects'], missingRepositories),
|
|
839
881
|
collaboration: sumGroup(repositories.map((repositoryArg) => repositoryArg.collaboration),
|
|
840
882
|
['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
|
|
841
883
|
releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
|
|
@@ -21,6 +21,10 @@ export interface IForgeRequestOptions {
|
|
|
21
21
|
maxResponseBytes?: number;
|
|
22
22
|
/** Additional request headers, such as a provider's impersonation header. */
|
|
23
23
|
headers?: Record<string, string>;
|
|
24
|
+
/** JSON media type sent as `accept` and required of the response; defaults to `application/json`. */
|
|
25
|
+
mediaType?: string;
|
|
26
|
+
/** Sends this value as a JSON body of `mediaType` in a POST instead of a GET, for reads that take a request body. */
|
|
27
|
+
json?: unknown;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface IForgeJsonResponse {
|
|
@@ -29,8 +33,9 @@ export interface IForgeJsonResponse {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* One bounded, credential-scoped JSON
|
|
33
|
-
* response-size budget and a strict JSON
|
|
36
|
+
* One bounded, credential-scoped JSON read at a time, a GET or a POST with a
|
|
37
|
+
* JSON body: a per-request deadline, a response-size budget and a strict JSON
|
|
38
|
+
* content type. Failures surface as
|
|
34
39
|
* `ForgeInventoryError` codes that never contain bodies, URLs or credentials.
|
|
35
40
|
*/
|
|
36
41
|
export class ForgeRequestClient {
|
|
@@ -56,12 +61,14 @@ export class ForgeRequestClient {
|
|
|
56
61
|
let bodyChunks: AsyncIterator<Uint8Array> | undefined;
|
|
57
62
|
try {
|
|
58
63
|
this.#metrics.requests++;
|
|
59
|
-
const
|
|
60
|
-
const
|
|
64
|
+
const mediaType = requestArg.mediaType ?? 'application/json';
|
|
65
|
+
const requestBody = requestArg.json === undefined ? undefined : new TextEncoder().encode(JSON.stringify(requestArg.json));
|
|
66
|
+
const headers = { accept: mediaType, ...(requestBody ? { 'content-type': mediaType } : {}), ...requestArg.headers, ...this.#options.headers };
|
|
67
|
+
const response = await this.#options.transport(url, headers, controller.signal, requestBody);
|
|
61
68
|
bodyChunks = response.body?.[Symbol.asyncIterator]();
|
|
62
69
|
if (response.status === 429) this.#metrics.rateLimitedRequests++;
|
|
63
70
|
if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
|
|
64
|
-
if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
|
|
71
|
+
if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== mediaType || !bodyChunks) throw new validation.ForgeInventoryError('invalid-content-type');
|
|
65
72
|
const chunks: Uint8Array[] = [];
|
|
66
73
|
let bytes = 0;
|
|
67
74
|
while (true) {
|
|
@@ -9,11 +9,12 @@ export interface IForgeTransportResponse {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Sends one credential-scoped GET
|
|
13
|
-
* A redirect status rejects and is
|
|
14
|
-
* the connection, also while the
|
|
12
|
+
* Sends one credential-scoped GET, or a POST of `body` when one is given, and
|
|
13
|
+
* resolves once response headers arrive. A redirect status rejects and is
|
|
14
|
+
* never followed; aborting `signal` discards the connection, also while the
|
|
15
|
+
* body is still being read.
|
|
15
16
|
*/
|
|
16
|
-
export type TForgeTransport = (url: URL, headers: Record<string, string>, signal: AbortSignal) => Promise<IForgeTransportResponse>;
|
|
17
|
+
export type TForgeTransport = (url: URL, headers: Record<string, string>, signal: AbortSignal, body?: Uint8Array<ArrayBuffer>) => Promise<IForgeTransportResponse>;
|
|
17
18
|
|
|
18
19
|
/** The fetch statuses whose redirect mode `error` turns them into a network error. */
|
|
19
20
|
const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
@@ -26,8 +27,8 @@ const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
|
|
|
26
27
|
const idleSocketTimeoutMs = 4_000;
|
|
27
28
|
|
|
28
29
|
/** Node's global `fetch` with the process-wide default trust store. */
|
|
29
|
-
export const globalFetchTransport: TForgeTransport = async (url, headers, signal) => {
|
|
30
|
-
const response = await fetch(url, { method: 'GET', headers, redirect: 'error', credentials: 'omit', signal });
|
|
30
|
+
export const globalFetchTransport: TForgeTransport = async (url, headers, signal, body) => {
|
|
31
|
+
const response = await fetch(url, { method: body ? 'POST' : 'GET', headers, body, redirect: 'error', credentials: 'omit', signal });
|
|
31
32
|
return { status: response.status, headers: response.headers, body: response.body };
|
|
32
33
|
};
|
|
33
34
|
|
|
@@ -38,10 +39,10 @@ export const globalFetchTransport: TForgeTransport = async (url, headers, signal
|
|
|
38
39
|
*/
|
|
39
40
|
export const createHttpsTransport = (caCertificates: string[]): TForgeTransport => {
|
|
40
41
|
const agent = new plugins.https.Agent({ ca: caCertificates, keepAlive: true, timeout: idleSocketTimeoutMs });
|
|
41
|
-
return (url, headers, signal) => new Promise<IForgeTransportResponse>((resolve, reject) => {
|
|
42
|
+
return (url, headers, signal, body) => new Promise<IForgeTransportResponse>((resolve, reject) => {
|
|
42
43
|
if (signal.aborted) return reject(signal.reason);
|
|
43
44
|
// Bodies are read as sent, so ask for them without a content coding.
|
|
44
|
-
const request = plugins.https.request(url, { method: 'GET', headers: { ...headers, 'accept-encoding': 'identity' }, agent });
|
|
45
|
+
const request = plugins.https.request(url, { method: body ? 'POST' : 'GET', headers: { ...headers, 'accept-encoding': 'identity' }, agent });
|
|
45
46
|
// Stays attached for the request's lifetime, so a connection failure while
|
|
46
47
|
// the body is read never becomes an unhandled 'error' event.
|
|
47
48
|
request.on('error', reject);
|
|
@@ -68,6 +69,6 @@ export const createHttpsTransport = (caCertificates: string[]): TForgeTransport
|
|
|
68
69
|
request.destroy();
|
|
69
70
|
}
|
|
70
71
|
});
|
|
71
|
-
request.end();
|
|
72
|
+
request.end(body);
|
|
72
73
|
});
|
|
73
74
|
};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as validation from './forgeinventory.validation.js';
|
|
3
|
+
import type { ForgeRequestClient } from './forgeinventory.client.js';
|
|
4
|
+
|
|
5
|
+
type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
|
|
6
|
+
type IGitCounts = Omit<plugins.fossInterfaces.data.IForgeSourceGitCounts, 'reportedSizeKiB'>;
|
|
7
|
+
type ILfsCounts = plugins.fossInterfaces.data.IForgeSourceLfsCounts;
|
|
8
|
+
|
|
9
|
+
export interface IGiteaMirrorSource {
|
|
10
|
+
/** Instance root without a trailing slash. */
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
owner: string;
|
|
13
|
+
name: string;
|
|
14
|
+
/** The reader's login and token; Gitea accepts a token as the Basic password for Git and LFS. */
|
|
15
|
+
username: string;
|
|
16
|
+
token: string;
|
|
17
|
+
caCertificates?: string[];
|
|
18
|
+
/** Private directory that holds the temporary mirror. */
|
|
19
|
+
workDir: string;
|
|
20
|
+
/** The repository size Gitea reports, which sizes the mirror's scratch reservation. */
|
|
21
|
+
reportedSizeKiB: bigint;
|
|
22
|
+
/** Wall-clock limit of the mirror clone. */
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
/** Sends LFS batch reads with the reader's Basic credentials. */
|
|
25
|
+
lfsClient: ForgeRequestClient;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Objects per LFS batch request. */
|
|
30
|
+
const lfsBatchSize = 100;
|
|
31
|
+
const lfsMediaType = 'application/vnd.git-lfs+json';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scratch space reserved for one mirror: twice the size Gitea reports, because
|
|
35
|
+
* a clone holds received and indexed packs, plus fixed headroom for refs, the
|
|
36
|
+
* index and a size Gitea has not updated yet.
|
|
37
|
+
*/
|
|
38
|
+
export const mirrorScratchBytes = (reportedSizeKiBArg: bigint): bigint => reportedSizeKiBArg * 1024n * 2n + 64n * 1024n * 1024n;
|
|
39
|
+
|
|
40
|
+
/** Bytes an unprivileged process may still write to the file system holding `directoryArg`. */
|
|
41
|
+
const statfsFreeBytes = async (directoryArg: string): Promise<bigint> => {
|
|
42
|
+
const stats = await plugins.fs.statfs(directoryArg, { bigint: true });
|
|
43
|
+
return stats.bavail * stats.bsize;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Admits mirror clones on one file system in arrival order while the scratch
|
|
48
|
+
* they reserve fits its free space. A clone's reservation counts in full until
|
|
49
|
+
* it is released, although part of it is already written, so the check never
|
|
50
|
+
* admits more than fits. A clone that does not fit waits until the clones in
|
|
51
|
+
* flight release their reservations; one that does not fit even alone is
|
|
52
|
+
* refused.
|
|
53
|
+
*/
|
|
54
|
+
export class GitScratchBudget {
|
|
55
|
+
readonly #freeBytes: (directoryArg: string) => Promise<bigint>;
|
|
56
|
+
#reserved = 0n;
|
|
57
|
+
/** Settles when the clones ahead of the next arrival have been admitted or refused. */
|
|
58
|
+
#queue: Promise<void> = Promise.resolve();
|
|
59
|
+
readonly #releaseListeners = new Set<() => void>();
|
|
60
|
+
|
|
61
|
+
constructor(freeBytesArg: (directoryArg: string) => Promise<bigint> = statfsFreeBytes) {
|
|
62
|
+
this.#freeBytes = freeBytesArg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reserves `bytesArg` in `directoryArg` and resolves with the function that
|
|
67
|
+
* releases them, or with null when they exceed the free space with no other
|
|
68
|
+
* clone in flight. Rejects when the free space cannot be read or `signalArg`
|
|
69
|
+
* aborts.
|
|
70
|
+
*/
|
|
71
|
+
async reserve(directoryArg: string, bytesArg: bigint, signalArg?: AbortSignal): Promise<(() => void) | null> {
|
|
72
|
+
const ahead = this.#queue;
|
|
73
|
+
let admitted!: () => void;
|
|
74
|
+
const turn = new Promise<void>((resolve) => { admitted = resolve; });
|
|
75
|
+
// Followers wait for this turn and every turn ahead of it, even when this one ends early.
|
|
76
|
+
this.#queue = ahead.then(() => turn);
|
|
77
|
+
try {
|
|
78
|
+
await this.#until(ahead, signalArg);
|
|
79
|
+
for (;;) {
|
|
80
|
+
// Listens before reading the free space, so a release while it is read is not missed.
|
|
81
|
+
let listener!: () => void;
|
|
82
|
+
const released = new Promise<void>((resolve) => { listener = resolve; });
|
|
83
|
+
this.#releaseListeners.add(listener);
|
|
84
|
+
try {
|
|
85
|
+
const free = await this.#freeBytes(directoryArg);
|
|
86
|
+
if (this.#reserved + bytesArg <= free) {
|
|
87
|
+
this.#reserved += bytesArg;
|
|
88
|
+
return this.#releaser(bytesArg);
|
|
89
|
+
}
|
|
90
|
+
if (this.#reserved === 0n) return null;
|
|
91
|
+
await this.#until(released, signalArg);
|
|
92
|
+
} finally {
|
|
93
|
+
this.#releaseListeners.delete(listener);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
admitted();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#releaser(bytesArg: bigint): () => void {
|
|
102
|
+
let held = true;
|
|
103
|
+
return () => {
|
|
104
|
+
if (!held) return;
|
|
105
|
+
held = false;
|
|
106
|
+
this.#reserved -= bytesArg;
|
|
107
|
+
for (const listener of this.#releaseListeners) listener();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #until(promiseArg: Promise<void>, signalArg?: AbortSignal): Promise<void> {
|
|
112
|
+
signalArg?.throwIfAborted();
|
|
113
|
+
if (!signalArg) return promiseArg;
|
|
114
|
+
let abort!: () => void;
|
|
115
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
116
|
+
abort = () => reject(signalArg.reason);
|
|
117
|
+
signalArg.addEventListener('abort', abort, { once: true });
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
await Promise.race([promiseArg, aborted]);
|
|
121
|
+
} finally {
|
|
122
|
+
signalArg.removeEventListener('abort', abort);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One budget per file system, shared by every reader in the process. */
|
|
128
|
+
const scratchBudgets = new Map<bigint, GitScratchBudget>();
|
|
129
|
+
|
|
130
|
+
const scratchBudget = async (workDirArg: string): Promise<GitScratchBudget> => {
|
|
131
|
+
const device = (await plugins.fs.stat(workDirArg, { bigint: true })).dev;
|
|
132
|
+
let budget = scratchBudgets.get(device);
|
|
133
|
+
if (!budget) {
|
|
134
|
+
budget = new GitScratchBudget();
|
|
135
|
+
scratchBudgets.set(device, budget);
|
|
136
|
+
}
|
|
137
|
+
return budget;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
|
|
141
|
+
const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
|
|
142
|
+
|
|
143
|
+
const failedGit = (reasonArg: string): IGitCounts => {
|
|
144
|
+
const failed = partial(0, reasonArg);
|
|
145
|
+
return { branches: failed, tags: failed, pullRequestRefs: failed, otherRefs: failed, objects: failed, objectBytes: failed };
|
|
146
|
+
};
|
|
147
|
+
const failedLfs = (reasonArg: string): ILfsCounts => {
|
|
148
|
+
const failed = partial(0, reasonArg);
|
|
149
|
+
return { objects: failed, bytes: failed, missingObjects: failed };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fixed reason for a failed step. GitManager errors are never passed on: they
|
|
154
|
+
* are redacted, but still remote text.
|
|
155
|
+
*/
|
|
156
|
+
const stepFailure = (errorArg: unknown, stepArg: string, signalArg?: AbortSignal): string =>
|
|
157
|
+
signalArg?.aborted ? 'request-aborted' : errorArg instanceof validation.ForgeInventoryError ? errorArg.code : stepArg;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Measures one repository at the Git level: clones every ref Gitea advertises
|
|
161
|
+
* into a temporary mirror, counts its refs and objects, finds the LFS pointers
|
|
162
|
+
* of every reachable revision and checks through the LFS batch API which of
|
|
163
|
+
* their objects Gitea holds. The mirror is removed afterwards. The clone starts
|
|
164
|
+
* only once its scratch reservation fits the work directory's free space; a
|
|
165
|
+
* repository too large for it fails with `git-workdir-too-small`.
|
|
166
|
+
*/
|
|
167
|
+
export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
|
|
168
|
+
const { signal } = sourceArg;
|
|
169
|
+
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
170
|
+
let release: (() => void) | null;
|
|
171
|
+
try {
|
|
172
|
+
release = await (await scratchBudget(sourceArg.workDir)).reserve(sourceArg.workDir, mirrorScratchBytes(sourceArg.reportedSizeKiB), signal);
|
|
173
|
+
} catch {
|
|
174
|
+
const failure = signal?.aborted ? 'request-aborted' : 'git-workdir-failed';
|
|
175
|
+
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
|
176
|
+
}
|
|
177
|
+
if (release === null) return { git: failedGit('git-workdir-too-small'), lfs: failedLfs('git-workdir-too-small') };
|
|
178
|
+
try {
|
|
179
|
+
return await measureReservedMirror(sourceArg);
|
|
180
|
+
} finally {
|
|
181
|
+
release();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
|
|
186
|
+
const { signal } = sourceArg;
|
|
187
|
+
const { GitManager } = await plugins.getGitManager();
|
|
188
|
+
let directory: string;
|
|
189
|
+
try {
|
|
190
|
+
directory = await plugins.fs.mkdtemp(plugins.path.join(sourceArg.workDir, 'mirror-'));
|
|
191
|
+
} catch {
|
|
192
|
+
return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed') };
|
|
193
|
+
}
|
|
194
|
+
const manager = new GitManager({ dataDir: directory });
|
|
195
|
+
// Stopping the engine ends the operation in flight.
|
|
196
|
+
const abort = () => { void manager.stop().catch(() => undefined); };
|
|
197
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
198
|
+
const selector = { org: 'source', repo: 'mirror' };
|
|
199
|
+
try {
|
|
200
|
+
try {
|
|
201
|
+
// An abort while the engine module loaded or the directory was created reached no listener.
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
await manager.start();
|
|
204
|
+
await manager.importMirror({
|
|
205
|
+
...selector,
|
|
206
|
+
remoteUrl: `${sourceArg.baseUrl}/${encodeURIComponent(sourceArg.owner)}/${encodeURIComponent(sourceArg.name)}.git`,
|
|
207
|
+
credential: { kind: 'basic', username: sourceArg.username, password: sourceArg.token },
|
|
208
|
+
caCertificates: sourceArg.caCertificates,
|
|
209
|
+
timeoutMs: sourceArg.timeoutMs,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const failure = stepFailure(error, 'git-mirror-failed', signal);
|
|
213
|
+
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
|
214
|
+
}
|
|
215
|
+
const git = await measureRefsAndObjects(manager, selector, signal);
|
|
216
|
+
let pointers: Array<{ oid: string; sizeBytes: number }>;
|
|
217
|
+
try {
|
|
218
|
+
pointers = await manager.listRepositoryLfsPointers(selector);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)) };
|
|
221
|
+
}
|
|
222
|
+
return { git, lfs: await giteaLfsPresence(sourceArg, pointers) };
|
|
223
|
+
} finally {
|
|
224
|
+
signal?.removeEventListener('abort', abort);
|
|
225
|
+
await manager.stop().catch(() => undefined);
|
|
226
|
+
await plugins.fs.rm(directory, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const measureRefsAndObjects = async (
|
|
231
|
+
managerArg: plugins.TGitManager,
|
|
232
|
+
selectorArg: { org: string; repo: string },
|
|
233
|
+
signalArg?: AbortSignal,
|
|
234
|
+
): Promise<IGitCounts> => {
|
|
235
|
+
const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
|
|
236
|
+
let refsFailure: string | null = null;
|
|
237
|
+
try {
|
|
238
|
+
let cursor: string | undefined;
|
|
239
|
+
do {
|
|
240
|
+
const page = await managerArg.listRefs({ ...selectorArg, cursor });
|
|
241
|
+
// Nothing writes to the private mirror, so a changed inventory is a failure.
|
|
242
|
+
if (page.status === 'stale') throw new validation.ForgeInventoryError('git-refs-changed');
|
|
243
|
+
for (const ref of page.refs) {
|
|
244
|
+
if (ref.name.startsWith('refs/heads/')) counts.branches++;
|
|
245
|
+
else if (ref.name.startsWith('refs/tags/')) counts.tags++;
|
|
246
|
+
else if (ref.name.startsWith('refs/pull/')) counts.pullRequestRefs++;
|
|
247
|
+
else counts.otherRefs++;
|
|
248
|
+
}
|
|
249
|
+
cursor = page.pagination.status === 'more' ? page.pagination.cursor : undefined;
|
|
250
|
+
} while (cursor !== undefined);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
refsFailure = stepFailure(error, 'git-refs-failed', signalArg);
|
|
253
|
+
}
|
|
254
|
+
const refs = (valueArg: number) => refsFailure === null ? complete(valueArg) : partial(0, refsFailure);
|
|
255
|
+
let objects: TMeasure;
|
|
256
|
+
let objectBytes: TMeasure;
|
|
257
|
+
try {
|
|
258
|
+
const size = await managerArg.getRepositorySize(selectorArg);
|
|
259
|
+
objects = complete(size.objectCount);
|
|
260
|
+
objectBytes = complete(size.objectBytes);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
objects = objectBytes = partial(0, stepFailure(error, 'git-size-failed', signalArg));
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
branches: refs(counts.branches), tags: refs(counts.tags), pullRequestRefs: refs(counts.pullRequestRefs), otherRefs: refs(counts.otherRefs),
|
|
266
|
+
objects, objectBytes,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Asks Gitea's LFS batch API, as a `download`, which referenced objects it
|
|
272
|
+
* holds: a held object answers with a download action, a missing one with
|
|
273
|
+
* error 404. Actions, their URLs and headers are never retained.
|
|
274
|
+
*/
|
|
275
|
+
export const giteaLfsPresence = async (
|
|
276
|
+
sourceArg: Pick<IGiteaMirrorSource, 'baseUrl' | 'owner' | 'name' | 'lfsClient' | 'signal'>,
|
|
277
|
+
pointersArg: Array<{ oid: string; sizeBytes: number }>,
|
|
278
|
+
): Promise<ILfsCounts> => {
|
|
279
|
+
const url = new URL(`${sourceArg.baseUrl}/${encodeURIComponent(sourceArg.owner)}/${encodeURIComponent(sourceArg.name)}.git/info/lfs/objects/batch`);
|
|
280
|
+
let held = 0n;
|
|
281
|
+
let heldBytes = 0n;
|
|
282
|
+
let missing = 0n;
|
|
283
|
+
for (let index = 0; index < pointersArg.length; index += lfsBatchSize) {
|
|
284
|
+
const chunk = pointersArg.slice(index, index + lfsBatchSize);
|
|
285
|
+
try {
|
|
286
|
+
const response = await sourceArg.lfsClient.request(url, {
|
|
287
|
+
signal: sourceArg.signal,
|
|
288
|
+
mediaType: lfsMediaType,
|
|
289
|
+
json: { operation: 'download', transfers: ['basic'], objects: chunk.map((pointerArg) => ({ oid: pointerArg.oid, size: pointerArg.sizeBytes })) },
|
|
290
|
+
});
|
|
291
|
+
const objects = validation.record(response.body).objects;
|
|
292
|
+
if (!Array.isArray(objects) || objects.length !== chunk.length) throw new validation.ForgeInventoryError('invalid-lfs-batch');
|
|
293
|
+
const requested = new Map(chunk.map((pointerArg) => [pointerArg.oid, pointerArg.sizeBytes]));
|
|
294
|
+
for (const objectArg of objects) {
|
|
295
|
+
const object = validation.record(objectArg);
|
|
296
|
+
const size = typeof object.oid === 'string' ? requested.get(object.oid) : undefined;
|
|
297
|
+
if (size === undefined || object.size !== size) throw new validation.ForgeInventoryError('invalid-lfs-batch');
|
|
298
|
+
requested.delete(object.oid as string);
|
|
299
|
+
const download = object.actions === undefined || object.actions === null ? undefined : validation.record(object.actions).download;
|
|
300
|
+
if (download !== undefined && download !== null && object.error === undefined) {
|
|
301
|
+
held++;
|
|
302
|
+
heldBytes += BigInt(size);
|
|
303
|
+
} else if (object.error !== undefined && validation.record(object.error).code === 404) {
|
|
304
|
+
missing++;
|
|
305
|
+
} else {
|
|
306
|
+
// A size that disagrees with the stored object answers 422.
|
|
307
|
+
throw new validation.ForgeInventoryError('lfs-object-unavailable');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const failure = stepFailure(error, 'lfs-batch-failed', sourceArg.signal);
|
|
312
|
+
return { objects: partial(held, failure), bytes: partial(heldBytes, failure), missingObjects: partial(missing, failure) };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { objects: complete(held), bytes: complete(heldBytes), missingObjects: complete(missing) };
|
|
316
|
+
};
|
package/ts/plugins.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type * as fossInterfaces from '@foss.global/interfaces';
|
|
2
2
|
|
|
3
3
|
import * as crypto from 'node:crypto';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
4
5
|
import * as https from 'node:https';
|
|
5
|
-
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
export { crypto, fs, https, path };
|
|
6
8
|
|
|
7
9
|
// module
|
|
8
10
|
import * as interfaces from './interfaces/index.js';
|
|
@@ -25,6 +27,7 @@ export type TGitManagerTag = import('@foss.global/gitmanager').IGitManagerTag;
|
|
|
25
27
|
export type TGitManagerDiff = import('@foss.global/gitmanager').IGitManagerDiff;
|
|
26
28
|
|
|
27
29
|
export const getSmartnpm = async () => import('@push.rocks/smartnpm');
|
|
30
|
+
export const getGitManager = async () => import('@foss.global/gitmanager');
|
|
28
31
|
|
|
29
32
|
export {
|
|
30
33
|
smarttime,
|