@foss.global/forgefixtures 0.2.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/.smartconfig.json +49 -0
- package/changelog.md +17 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.certificateauthority.d.ts +22 -0
- package/dist_ts/classes.certificateauthority.js +93 -0
- package/dist_ts/classes.containerlifecycle.d.ts +88 -0
- package/dist_ts/classes.containerlifecycle.js +383 -0
- package/dist_ts/classes.giteafixture.d.ts +41 -0
- package/dist_ts/classes.giteafixture.js +182 -0
- package/dist_ts/classes.giteaseed.d.ts +13 -0
- package/dist_ts/classes.giteaseed.js +432 -0
- package/dist_ts/classes.gitlabfixture.d.ts +49 -0
- package/dist_ts/classes.gitlabfixture.js +237 -0
- package/dist_ts/classes.gitlabseed.d.ts +13 -0
- package/dist_ts/classes.gitlabseed.js +466 -0
- package/dist_ts/classes.httpclient.d.ts +53 -0
- package/dist_ts/classes.httpclient.js +116 -0
- package/dist_ts/classes.reaper.d.ts +25 -0
- package/dist_ts/classes.reaper.js +102 -0
- package/dist_ts/classes.tlsterminator.d.ts +21 -0
- package/dist_ts/classes.tlsterminator.js +131 -0
- package/dist_ts/constants.d.ts +22 -0
- package/dist_ts/constants.js +30 -0
- package/dist_ts/giteaseed.default.d.ts +10 -0
- package/dist_ts/giteaseed.default.js +87 -0
- package/dist_ts/gitlabseed.default.d.ts +12 -0
- package/dist_ts/gitlabseed.default.js +84 -0
- package/dist_ts/index.d.ts +17 -0
- package/dist_ts/index.js +17 -0
- package/dist_ts/interfaces.d.ts +92 -0
- package/dist_ts/interfaces.giteaseed.d.ts +182 -0
- package/dist_ts/interfaces.giteaseed.js +2 -0
- package/dist_ts/interfaces.gitlabseed.d.ts +183 -0
- package/dist_ts/interfaces.gitlabseed.js +2 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/ownership.d.ts +29 -0
- package/dist_ts/ownership.js +140 -0
- package/dist_ts/plugins.d.ts +13 -0
- package/dist_ts/plugins.js +18 -0
- package/dist_ts/responses.d.ts +7 -0
- package/dist_ts/responses.js +30 -0
- package/license.md +21 -0
- package/package.json +65 -0
- package/readme.md +206 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.certificateauthority.ts +117 -0
- package/ts/classes.containerlifecycle.ts +432 -0
- package/ts/classes.giteafixture.ts +205 -0
- package/ts/classes.giteaseed.ts +486 -0
- package/ts/classes.gitlabfixture.ts +258 -0
- package/ts/classes.gitlabseed.ts +502 -0
- package/ts/classes.httpclient.ts +156 -0
- package/ts/classes.reaper.ts +126 -0
- package/ts/classes.tlsterminator.ts +136 -0
- package/ts/constants.ts +35 -0
- package/ts/giteaseed.default.ts +88 -0
- package/ts/gitlabseed.default.ts +86 -0
- package/ts/index.ts +17 -0
- package/ts/interfaces.giteaseed.ts +130 -0
- package/ts/interfaces.gitlabseed.ts +135 -0
- package/ts/interfaces.ts +94 -0
- package/ts/ownership.ts +160 -0
- package/ts/plugins.ts +23 -0
- package/ts/responses.ts +33 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { forgeFixtureLabels } from './constants.js';
|
|
3
|
+
import {
|
|
4
|
+
classifyForgeFixtureOwner,
|
|
5
|
+
parseForgeFixtureOwnerLabels,
|
|
6
|
+
readCurrentProcessIdentity,
|
|
7
|
+
} from './ownership.js';
|
|
8
|
+
import type { IForgeFixtureOwner, IForgeFixtureReapReport, TForgeFixtureReapReason } from './interfaces.js';
|
|
9
|
+
|
|
10
|
+
export interface IForgeFixtureReaperOptions {
|
|
11
|
+
/** Docker Engine socket; omit for `@apiclient.xyz/docker` resolution. */
|
|
12
|
+
dockerSocketPath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IForgeFixtureInventory {
|
|
16
|
+
containers: plugins.docker.DockerContainer[];
|
|
17
|
+
networks: plugins.docker.DockerNetwork[];
|
|
18
|
+
volumes: plugins.docker.DockerVolume[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createForgeFixtureDockerHost = (socketPathArg?: string): plugins.docker.DockerHost =>
|
|
22
|
+
new plugins.docker.DockerHost({
|
|
23
|
+
...(socketPathArg === undefined ? {} : { socketPath: socketPathArg }),
|
|
24
|
+
enableImageStore: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const managedFilter = (): Record<string, string[]> => ({ label: [`${forgeFixtureLabels.managed}=true`] });
|
|
28
|
+
|
|
29
|
+
/** Lists every Docker resource carrying this package's managed label on one daemon. */
|
|
30
|
+
export const listForgeFixtureResources = async (
|
|
31
|
+
dockerHostArg: plugins.docker.DockerHost,
|
|
32
|
+
): Promise<IForgeFixtureInventory> => {
|
|
33
|
+
const [containers, networks, volumes] = await Promise.all([
|
|
34
|
+
dockerHostArg.listContainers({ all: true, filters: managedFilter() }),
|
|
35
|
+
dockerHostArg.listNetworks({ filters: managedFilter() }),
|
|
36
|
+
dockerHostArg.listVolumes({ filters: managedFilter() }),
|
|
37
|
+
]);
|
|
38
|
+
return { containers, networks, volumes };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Removes the Docker resources of lifecycles whose owner process exited,
|
|
43
|
+
* whose host rebooted, or whose declared lifetime expired. Resources of live
|
|
44
|
+
* owners and resources with uninterpretable labels are left untouched.
|
|
45
|
+
*/
|
|
46
|
+
export class ForgeFixtureReaper {
|
|
47
|
+
readonly #options: IForgeFixtureReaperOptions;
|
|
48
|
+
|
|
49
|
+
constructor(optionsArg: IForgeFixtureReaperOptions = {}) {
|
|
50
|
+
this.#options = { ...optionsArg };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public async reap(nowArg: Date = new Date()): Promise<IForgeFixtureReapReport> {
|
|
54
|
+
const dockerHost = createForgeFixtureDockerHost(this.#options.dockerSocketPath);
|
|
55
|
+
try {
|
|
56
|
+
return await reapStaleForgeFixtures(dockerHost, nowArg);
|
|
57
|
+
} finally {
|
|
58
|
+
await dockerHost.stop();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const reapStaleForgeFixtures = async (
|
|
64
|
+
dockerHostArg: plugins.docker.DockerHost,
|
|
65
|
+
nowArg: Date = new Date(),
|
|
66
|
+
): Promise<IForgeFixtureReapReport> => {
|
|
67
|
+
const current = await readCurrentProcessIdentity();
|
|
68
|
+
const inventory = await listForgeFixtureResources(dockerHostArg);
|
|
69
|
+
const report: IForgeFixtureReapReport = {
|
|
70
|
+
removedContainers: [], removedNetworks: [], removedVolumes: [], reapedLifecycles: [], unrecognizedResources: [],
|
|
71
|
+
};
|
|
72
|
+
const verdicts = new Map<string, TForgeFixtureReapReason | 'alive'>();
|
|
73
|
+
const verdictFor = async (ownerArg: IForgeFixtureOwner): Promise<TForgeFixtureReapReason | 'alive'> => {
|
|
74
|
+
let verdict = verdicts.get(ownerArg.lifecycleId);
|
|
75
|
+
if (verdict === undefined) {
|
|
76
|
+
verdict = await classifyForgeFixtureOwner(ownerArg, current, nowArg);
|
|
77
|
+
verdicts.set(ownerArg.lifecycleId, verdict);
|
|
78
|
+
if (verdict !== 'alive') report.reapedLifecycles.push({ lifecycleId: ownerArg.lifecycleId, reason: verdict });
|
|
79
|
+
}
|
|
80
|
+
return verdict;
|
|
81
|
+
};
|
|
82
|
+
const stale = async (
|
|
83
|
+
typeArg: 'container' | 'network' | 'volume',
|
|
84
|
+
idArg: string,
|
|
85
|
+
labelsArg: Record<string, string> | undefined,
|
|
86
|
+
): Promise<boolean> => {
|
|
87
|
+
const owner = parseForgeFixtureOwnerLabels(labelsArg);
|
|
88
|
+
if (!owner) {
|
|
89
|
+
report.unrecognizedResources.push({ type: typeArg, id: idArg });
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (await verdictFor(owner)) !== 'alive';
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const failures: unknown[] = [];
|
|
96
|
+
// Dependency order: containers release their network endpoints and volumes first.
|
|
97
|
+
for (const container of inventory.containers) {
|
|
98
|
+
if (!await stale('container', container.Id, container.Labels)) continue;
|
|
99
|
+
try {
|
|
100
|
+
await container.remove({ force: true, removeAnonymousVolumes: true });
|
|
101
|
+
report.removedContainers.push(container.Id);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failures.push(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const network of inventory.networks) {
|
|
107
|
+
if (!await stale('network', network.Id, network.Labels)) continue;
|
|
108
|
+
try {
|
|
109
|
+
await network.remove();
|
|
110
|
+
report.removedNetworks.push(network.Id);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
failures.push(error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const volume of inventory.volumes) {
|
|
116
|
+
if (!await stale('volume', volume.Name, volume.Labels)) continue;
|
|
117
|
+
try {
|
|
118
|
+
await volume.remove({ force: true });
|
|
119
|
+
report.removedVolumes.push(volume.Name);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
failures.push(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (failures.length > 0) throw new AggregateError(failures, 'Forge fixture reaping left stale resources behind.');
|
|
125
|
+
return report;
|
|
126
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IForgeFixtureServerCertificate } from './classes.certificateauthority.js';
|
|
3
|
+
|
|
4
|
+
/** RFC 9110 hop-by-hop fields; they describe one connection and are never forwarded. */
|
|
5
|
+
const hopByHopHeaders = new Set([
|
|
6
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection',
|
|
7
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const forwardableHeaders = (headersArg: plugins.http.IncomingHttpHeaders): plugins.http.OutgoingHttpHeaders => {
|
|
11
|
+
const connectionTokens = new Set(
|
|
12
|
+
String(headersArg.connection ?? '').split(',').map((tokenArg) => tokenArg.trim().toLowerCase()).filter(Boolean),
|
|
13
|
+
);
|
|
14
|
+
const headers: plugins.http.OutgoingHttpHeaders = {};
|
|
15
|
+
for (const [name, value] of Object.entries(headersArg)) {
|
|
16
|
+
if (value === undefined || hopByHopHeaders.has(name) || connectionTokens.has(name)) continue;
|
|
17
|
+
headers[name] = value;
|
|
18
|
+
}
|
|
19
|
+
return headers;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Loopback TLS-terminating reverse proxy in front of one loopback HTTP
|
|
24
|
+
* upstream, configured the way a production proxy is: the client's Host header
|
|
25
|
+
* is preserved and `X-Forwarded-Proto: https` tells the forge the public scheme.
|
|
26
|
+
* It listens before the forge starts, so the forge can be configured with its
|
|
27
|
+
* exact external HTTPS origin.
|
|
28
|
+
*/
|
|
29
|
+
export class ForgeFixtureTlsTerminator {
|
|
30
|
+
readonly #server: plugins.https.Server;
|
|
31
|
+
readonly #agent = new plugins.http.Agent({ keepAlive: true, maxSockets: 32 });
|
|
32
|
+
#upstreamPort: number | undefined;
|
|
33
|
+
#port: number | undefined;
|
|
34
|
+
#closed = false;
|
|
35
|
+
#lastServerError: Error | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(certificateArg: IForgeFixtureServerCertificate) {
|
|
38
|
+
this.#server = plugins.https.createServer({
|
|
39
|
+
cert: certificateArg.certificatePem,
|
|
40
|
+
key: certificateArg.privateKeyPem,
|
|
41
|
+
minVersion: 'TLSv1.2',
|
|
42
|
+
}, (requestArg, responseArg) => this.#forward(requestArg, responseArg));
|
|
43
|
+
// Handshake failures concern only that client; the listener stays up.
|
|
44
|
+
this.#server.on('tlsClientError', (_errorArg, socketArg) => socketArg.destroy());
|
|
45
|
+
// A server error must never become an unhandled 'error' event that crashes
|
|
46
|
+
// the test process; it is recorded for diagnostics instead.
|
|
47
|
+
this.#server.on('error', (errorArg) => {
|
|
48
|
+
this.#lastServerError = errorArg;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Last error the listening server emitted, if any. */
|
|
53
|
+
public get lastServerError(): Error | undefined {
|
|
54
|
+
return this.#lastServerError;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Bound loopback port, available after `listen()`. */
|
|
58
|
+
public get port(): number {
|
|
59
|
+
if (this.#port === undefined) throw new Error('The TLS terminator is not listening.');
|
|
60
|
+
return this.#port;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public async listen(): Promise<number> {
|
|
64
|
+
if (this.#closed) throw new Error('The TLS terminator is closed.');
|
|
65
|
+
if (this.#port !== undefined) return this.#port;
|
|
66
|
+
await new Promise<void>((resolveArg, rejectArg) => {
|
|
67
|
+
const onError = (errorArg: Error) => rejectArg(errorArg);
|
|
68
|
+
this.#server.once('error', onError);
|
|
69
|
+
this.#server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => {
|
|
70
|
+
this.#server.off('error', onError);
|
|
71
|
+
resolveArg();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
const address = this.#server.address();
|
|
75
|
+
if (address === null || typeof address === 'string') throw new Error('The TLS terminator has no TCP address.');
|
|
76
|
+
this.#port = address.port;
|
|
77
|
+
return address.port;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Requests received before an upstream is set are answered with 503. */
|
|
81
|
+
public setUpstreamPort(portArg: number): void {
|
|
82
|
+
if (!Number.isSafeInteger(portArg) || portArg < 1 || portArg > 65535) {
|
|
83
|
+
throw new TypeError('The upstream port must be an integer from 1 through 65535.');
|
|
84
|
+
}
|
|
85
|
+
this.#upstreamPort = portArg;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stops listening and destroys every open client and upstream connection. Idempotent. */
|
|
89
|
+
public async close(): Promise<void> {
|
|
90
|
+
this.#closed = true;
|
|
91
|
+
this.#agent.destroy();
|
|
92
|
+
if (!this.#server.listening) return;
|
|
93
|
+
const closed = new Promise<void>((resolveArg, rejectArg) => {
|
|
94
|
+
this.#server.close((errorArg) => (errorArg ? rejectArg(errorArg) : resolveArg()));
|
|
95
|
+
});
|
|
96
|
+
this.#server.closeAllConnections();
|
|
97
|
+
await closed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#forward(requestArg: plugins.http.IncomingMessage, responseArg: plugins.http.ServerResponse): void {
|
|
101
|
+
const upstreamPort = this.#upstreamPort;
|
|
102
|
+
if (this.#closed || upstreamPort === undefined) {
|
|
103
|
+
responseArg.writeHead(503, { 'content-type': 'text/plain', connection: 'close' });
|
|
104
|
+
responseArg.end('The forge fixture is not ready.\n');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const headers = forwardableHeaders(requestArg.headers);
|
|
108
|
+
headers['x-forwarded-proto'] = 'https';
|
|
109
|
+
headers['x-forwarded-for'] = requestArg.socket.remoteAddress ?? '127.0.0.1';
|
|
110
|
+
const upstreamRequest = plugins.http.request({
|
|
111
|
+
host: '127.0.0.1',
|
|
112
|
+
port: upstreamPort,
|
|
113
|
+
method: requestArg.method,
|
|
114
|
+
path: requestArg.url,
|
|
115
|
+
headers,
|
|
116
|
+
agent: this.#agent,
|
|
117
|
+
}, (upstreamResponse) => {
|
|
118
|
+
responseArg.writeHead(upstreamResponse.statusCode ?? 502, forwardableHeaders(upstreamResponse.headers));
|
|
119
|
+
upstreamResponse.pipe(responseArg);
|
|
120
|
+
upstreamResponse.once('error', () => responseArg.destroy());
|
|
121
|
+
});
|
|
122
|
+
upstreamRequest.once('error', () => {
|
|
123
|
+
if (responseArg.headersSent) {
|
|
124
|
+
responseArg.destroy();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
responseArg.writeHead(502, { 'content-type': 'text/plain', connection: 'close' });
|
|
128
|
+
responseArg.end('The forge fixture upstream is unavailable.\n');
|
|
129
|
+
});
|
|
130
|
+
requestArg.once('error', () => upstreamRequest.destroy());
|
|
131
|
+
responseArg.once('close', () => {
|
|
132
|
+
if (!responseArg.writableFinished) upstreamRequest.destroy();
|
|
133
|
+
});
|
|
134
|
+
requestArg.pipe(upstreamRequest);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/ts/constants.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IForgeFixtureImage } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
/** Gitea release that code.foss.global serves (`/api/v1/version`). Rootless variant, uid 1000. */
|
|
4
|
+
export const giteaFixtureImage: IForgeFixtureImage = Object.freeze({
|
|
5
|
+
version: '1.27.3',
|
|
6
|
+
tag: 'gitea/gitea:1.27.3-rootless',
|
|
7
|
+
repoDigest: 'gitea/gitea@sha256:1c17ecaead42eb3b5391553d8708103a4beb0e86edf5b9ebc1eb269c318845f2',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** Self-managed GitLab Community Edition. The omnibus image runs as root, so it runs on a rootless daemon. */
|
|
11
|
+
export const gitlabFixtureImage: IForgeFixtureImage = Object.freeze({
|
|
12
|
+
version: '19.4.1',
|
|
13
|
+
tag: 'gitlab/gitlab-ce:19.4.1-ce.0',
|
|
14
|
+
repoDigest: 'gitlab/gitlab-ce@sha256:9b33b45b9f42d176bada85ee5ecb81ddab7e506c435f44cd582206e284b2809c',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Prefix of every Docker label this package writes. */
|
|
18
|
+
export const forgeFixtureLabelPrefix = 'global.foss.forgefixtures';
|
|
19
|
+
|
|
20
|
+
export const forgeFixtureLabels = Object.freeze({
|
|
21
|
+
managed: `${forgeFixtureLabelPrefix}.managed`,
|
|
22
|
+
lifecycleId: `${forgeFixtureLabelPrefix}.lifecycle`,
|
|
23
|
+
kind: `${forgeFixtureLabelPrefix}.kind`,
|
|
24
|
+
machineId: `${forgeFixtureLabelPrefix}.owner.machineid`,
|
|
25
|
+
hostname: `${forgeFixtureLabelPrefix}.owner.hostname`,
|
|
26
|
+
bootId: `${forgeFixtureLabelPrefix}.owner.bootid`,
|
|
27
|
+
pidNamespace: `${forgeFixtureLabelPrefix}.owner.pidns`,
|
|
28
|
+
pid: `${forgeFixtureLabelPrefix}.owner.pid`,
|
|
29
|
+
processStartTicks: `${forgeFixtureLabelPrefix}.owner.starttime`,
|
|
30
|
+
createdAt: `${forgeFixtureLabelPrefix}.createdat`,
|
|
31
|
+
expiresAt: `${forgeFixtureLabelPrefix}.expiresat`,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const defaultMaxLifetimeMs = 2 * 60 * 60 * 1000;
|
|
35
|
+
export const defaultPullTimeoutMs = 15 * 60 * 1000;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { IGiteaSeedSpec } from './interfaces.giteaseed.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A compact seed covering the Gitea behaviours inventory readers must handle:
|
|
5
|
+
* public, limited and private owners, private repositories, a public
|
|
6
|
+
* repository of a private organization (which Gitea reports as `internal`),
|
|
7
|
+
* issue and pull request numbers from one sequence, closed items, labels,
|
|
8
|
+
* milestones, assignees, comments, tags, releases, an LFS object and a
|
|
9
|
+
* deleted author whose content Gitea reattributes to its ghost user.
|
|
10
|
+
*/
|
|
11
|
+
export const createDefaultGiteaSeedSpec = (): IGiteaSeedSpec => ({
|
|
12
|
+
users: [
|
|
13
|
+
{ username: 'alice', fullName: 'Alice Example' },
|
|
14
|
+
{ username: 'bob', fullName: 'Bob Example' },
|
|
15
|
+
{ username: 'carol', visibility: 'private' },
|
|
16
|
+
{ username: 'dora' },
|
|
17
|
+
],
|
|
18
|
+
organizations: [
|
|
19
|
+
{ name: 'team-with-hyphens', visibility: 'public' },
|
|
20
|
+
{ name: 'limited-org', visibility: 'limited' },
|
|
21
|
+
{ name: 'private-org', visibility: 'private' },
|
|
22
|
+
],
|
|
23
|
+
repositories: [
|
|
24
|
+
{
|
|
25
|
+
owner: 'team-with-hyphens',
|
|
26
|
+
name: 'public-repo',
|
|
27
|
+
private: false,
|
|
28
|
+
description: 'Public organization repository',
|
|
29
|
+
collaborators: [{ username: 'bob', permission: 'write' }],
|
|
30
|
+
files: [{ path: 'src/index.ts', content: 'export const seeded = true;\n' }],
|
|
31
|
+
branches: [{ name: 'feature-a', files: [{ path: 'src/feature.ts', content: 'export const feature = 1;\n' }] }],
|
|
32
|
+
labels: [
|
|
33
|
+
{ name: 'bug', color: '#d73a4a' },
|
|
34
|
+
{ name: 'enhancement', color: '#a2eeef' },
|
|
35
|
+
],
|
|
36
|
+
milestones: [
|
|
37
|
+
{ title: 'v1', state: 'open' },
|
|
38
|
+
{ title: 'v0', state: 'closed' },
|
|
39
|
+
],
|
|
40
|
+
tags: [{ name: 'v1.0.0', target: 'main', message: 'First release' }],
|
|
41
|
+
issuesAndPullRequests: [
|
|
42
|
+
{
|
|
43
|
+
kind: 'issue', key: 'open-labelled', title: 'Open labelled issue', body: 'Body with #2 reference', author: 'alice',
|
|
44
|
+
state: 'open', labels: ['bug'], milestone: 'v1', assignees: ['bob'],
|
|
45
|
+
comments: [{ author: 'bob', body: 'Confirmed.' }],
|
|
46
|
+
},
|
|
47
|
+
{ kind: 'pullRequest', key: 'feature-pr', title: 'Add feature', author: 'bob', head: 'feature-a', base: 'main', state: 'open', labels: ['enhancement'] },
|
|
48
|
+
{
|
|
49
|
+
kind: 'issue', key: 'closed-by-deleted-user', title: 'Closed issue from a deleted user', author: 'dora', state: 'closed',
|
|
50
|
+
milestone: 'v0', comments: [{ author: 'dora', body: 'Comment from a user who will be deleted.' }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
releases: [{ tagName: 'v1.0.0', target: 'main', name: 'v1.0.0', body: 'Release notes' }],
|
|
54
|
+
lfsObjects: [{ path: 'assets/blob.bin', content: 'forgefixtures LFS payload\n' }],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
owner: 'limited-org',
|
|
58
|
+
name: 'limited-repo',
|
|
59
|
+
private: false,
|
|
60
|
+
issuesAndPullRequests: [{ kind: 'issue', key: 'limited-issue', title: 'Issue in a limited organization', author: 'alice', state: 'open' }],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
owner: 'private-org',
|
|
64
|
+
name: 'internal-repo',
|
|
65
|
+
private: false,
|
|
66
|
+
collaborators: [{ username: 'alice', permission: 'read' }],
|
|
67
|
+
issuesAndPullRequests: [{ kind: 'issue', key: 'internal-issue', title: 'Issue in a private organization', author: 'alice', state: 'open' }],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
owner: 'private-org',
|
|
71
|
+
name: 'secret-repo',
|
|
72
|
+
private: true,
|
|
73
|
+
collaborators: [{ username: 'alice', permission: 'read' }],
|
|
74
|
+
issuesAndPullRequests: [{ kind: 'issue', key: 'private-issue', title: 'Issue in a private repository', author: 'alice', state: 'open' }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
owner: 'alice',
|
|
78
|
+
name: 'personal-repo',
|
|
79
|
+
private: false,
|
|
80
|
+
branches: [{ name: 'topic', files: [{ path: 'notes.md', content: '# Notes\n' }] }],
|
|
81
|
+
issuesAndPullRequests: [
|
|
82
|
+
{ kind: 'pullRequest', key: 'closed-pr', title: 'Closed proposal', author: 'alice', head: 'topic', base: 'main', state: 'closed' },
|
|
83
|
+
{ kind: 'issue', key: 'after-pr', title: 'Issue numbered after a pull request', author: 'carol', state: 'closed' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
deleteUsers: ['dora'],
|
|
88
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { IGitlabSeedProject, IGitlabSeedSpec } from './interfaces.gitlabseed.js';
|
|
2
|
+
|
|
3
|
+
/** Projects created in `bulk-group` so project listings span more than one default page (20). */
|
|
4
|
+
export const defaultGitlabBulkProjectCount = 22;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A compact seed covering the GitLab behaviours inventory readers must handle:
|
|
8
|
+
* nested groups with public, internal and private visibility, a personal
|
|
9
|
+
* project, confidential issues and incidents, separate issue and merge request
|
|
10
|
+
* IID sequences, closed items, labels, milestones, assignees, notes, tags,
|
|
11
|
+
* releases, a deleted author whose content GitLab moves to its ghost user, and
|
|
12
|
+
* more projects than one page of a project listing.
|
|
13
|
+
*/
|
|
14
|
+
export const createDefaultGitlabSeedSpec = (): IGitlabSeedSpec => ({
|
|
15
|
+
users: [
|
|
16
|
+
{ username: 'alice', name: 'Alice Example' },
|
|
17
|
+
{ username: 'bob', name: 'Bob Example' },
|
|
18
|
+
{ username: 'carol' },
|
|
19
|
+
{ username: 'dora' },
|
|
20
|
+
],
|
|
21
|
+
groups: [
|
|
22
|
+
{ path: 'parent-group', visibility: 'public' },
|
|
23
|
+
{ path: 'sub-group', parent: 'parent-group', visibility: 'internal' },
|
|
24
|
+
{ path: 'deep-group', parent: 'parent-group/sub-group', visibility: 'private', members: [{ username: 'alice', accessLevel: 'reporter' }] },
|
|
25
|
+
{ path: 'bulk-group', visibility: 'public' },
|
|
26
|
+
],
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
namespace: 'parent-group/sub-group',
|
|
30
|
+
path: 'app',
|
|
31
|
+
visibility: 'internal',
|
|
32
|
+
description: 'Internal project in a subgroup',
|
|
33
|
+
members: [
|
|
34
|
+
{ username: 'alice', accessLevel: 'developer' },
|
|
35
|
+
{ username: 'bob', accessLevel: 'developer' },
|
|
36
|
+
],
|
|
37
|
+
files: [{ path: 'src/index.ts', content: 'export const seeded = true;\n' }],
|
|
38
|
+
branches: [{ name: 'feature-a', files: [{ path: 'src/feature.ts', content: 'export const feature = 1;\n' }] }],
|
|
39
|
+
labels: [
|
|
40
|
+
{ name: 'bug', color: '#d73a4a' },
|
|
41
|
+
{ name: 'enhancement', color: '#a2eeef' },
|
|
42
|
+
],
|
|
43
|
+
milestones: [
|
|
44
|
+
{ title: 'v1', state: 'active' },
|
|
45
|
+
{ title: 'v0', state: 'closed' },
|
|
46
|
+
],
|
|
47
|
+
tags: [{ name: 'v1.0.0', ref: 'main', message: 'First release' }],
|
|
48
|
+
issuesAndMergeRequests: [
|
|
49
|
+
{
|
|
50
|
+
kind: 'issue', key: 'open-labelled', title: 'Open labelled issue', description: 'See !1', author: 'alice',
|
|
51
|
+
state: 'opened', labels: ['bug'], milestone: 'v1', assignees: ['bob'], notes: [{ author: 'bob', body: 'Confirmed.' }],
|
|
52
|
+
},
|
|
53
|
+
{ kind: 'mergeRequest', key: 'feature-mr', title: 'Add feature', author: 'bob', sourceBranch: 'feature-a', targetBranch: 'main', state: 'opened', labels: ['enhancement'] },
|
|
54
|
+
{ kind: 'issue', key: 'confidential', title: 'Confidential issue', author: 'alice', state: 'opened', confidential: true },
|
|
55
|
+
{ kind: 'issue', key: 'incident', title: 'Production incident', author: 'bob', state: 'closed', issueType: 'incident' },
|
|
56
|
+
{
|
|
57
|
+
kind: 'issue', key: 'closed-by-deleted-user', title: 'Closed issue from a deleted user', author: 'dora', state: 'closed',
|
|
58
|
+
milestone: 'v0', notes: [{ author: 'dora', body: 'Note from a user who will be deleted.' }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
releases: [{ tagName: 'v1.0.0', ref: 'main', name: 'v1.0.0', description: 'Release notes' }],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
namespace: 'parent-group/sub-group/deep-group',
|
|
65
|
+
path: 'secret',
|
|
66
|
+
visibility: 'private',
|
|
67
|
+
issuesAndMergeRequests: [{ kind: 'issue', key: 'private-issue', title: 'Issue in a private project', author: 'alice', state: 'opened' }],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
namespace: 'alice',
|
|
71
|
+
path: 'personal',
|
|
72
|
+
visibility: 'public',
|
|
73
|
+
branches: [{ name: 'topic', files: [{ path: 'notes.md', content: '# Notes\n' }] }],
|
|
74
|
+
issuesAndMergeRequests: [
|
|
75
|
+
{ kind: 'mergeRequest', key: 'closed-mr', title: 'Closed proposal', author: 'alice', sourceBranch: 'topic', targetBranch: 'main', state: 'closed' },
|
|
76
|
+
{ kind: 'issue', key: 'carol-issue', title: 'Issue from another user', author: 'carol', state: 'closed' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
...Array.from({ length: defaultGitlabBulkProjectCount }, (_valueArg, indexArg): IGitlabSeedProject => ({
|
|
80
|
+
namespace: 'bulk-group',
|
|
81
|
+
path: `bulk-${String(indexArg + 1).padStart(2, '0')}`,
|
|
82
|
+
visibility: 'public',
|
|
83
|
+
})),
|
|
84
|
+
],
|
|
85
|
+
deleteUsers: ['dora'],
|
|
86
|
+
});
|
package/ts/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './00_commitinfo_data.js';
|
|
2
|
+
export * from './interfaces.js';
|
|
3
|
+
export * from './interfaces.giteaseed.js';
|
|
4
|
+
export * from './interfaces.gitlabseed.js';
|
|
5
|
+
export { giteaFixtureImage, gitlabFixtureImage, forgeFixtureLabelPrefix, forgeFixtureLabels } from './constants.js';
|
|
6
|
+
export * from './classes.certificateauthority.js';
|
|
7
|
+
export * from './classes.tlsterminator.js';
|
|
8
|
+
export * from './classes.httpclient.js';
|
|
9
|
+
export { ForgeFixtureReaper, listForgeFixtureResources } from './classes.reaper.js';
|
|
10
|
+
export type { IForgeFixtureReaperOptions, IForgeFixtureInventory } from './classes.reaper.js';
|
|
11
|
+
export { ForgeFixtureStoppedError } from './classes.containerlifecycle.js';
|
|
12
|
+
export * from './classes.giteafixture.js';
|
|
13
|
+
export * from './classes.gitlabfixture.js';
|
|
14
|
+
export * from './classes.giteaseed.js';
|
|
15
|
+
export * from './giteaseed.default.js';
|
|
16
|
+
export * from './classes.gitlabseed.js';
|
|
17
|
+
export * from './gitlabseed.default.js';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export type TGiteaVisibility = 'public' | 'limited' | 'private';
|
|
2
|
+
|
|
3
|
+
export interface IGiteaSeedFile {
|
|
4
|
+
path: string;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IGiteaSeedUser {
|
|
9
|
+
username: string;
|
|
10
|
+
fullName?: string;
|
|
11
|
+
visibility?: TGiteaVisibility;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IGiteaSeedOrganization {
|
|
15
|
+
/** Created by the fixture administrator, who becomes its owner. */
|
|
16
|
+
name: string;
|
|
17
|
+
visibility: TGiteaVisibility;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IGiteaSeedIssue {
|
|
21
|
+
kind: 'issue';
|
|
22
|
+
/** Spec-local identifier used to find this issue in the manifest. */
|
|
23
|
+
key: string;
|
|
24
|
+
title: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
/** Existing user who opens the issue (through the administrator's sudo). */
|
|
27
|
+
author: string;
|
|
28
|
+
state: 'open' | 'closed';
|
|
29
|
+
/** Applied by the administrator, so they are not dropped for authors without write access. */
|
|
30
|
+
labels?: string[];
|
|
31
|
+
milestone?: string;
|
|
32
|
+
assignees?: string[];
|
|
33
|
+
comments?: Array<{ author: string; body: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IGiteaSeedPullRequest {
|
|
37
|
+
kind: 'pullRequest';
|
|
38
|
+
key: string;
|
|
39
|
+
title: string;
|
|
40
|
+
body?: string;
|
|
41
|
+
author: string;
|
|
42
|
+
/** Branch in the same repository. */
|
|
43
|
+
head: string;
|
|
44
|
+
base: string;
|
|
45
|
+
state: 'open' | 'closed';
|
|
46
|
+
labels?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IGiteaSeedRepository {
|
|
50
|
+
/** A seeded user or organization. */
|
|
51
|
+
owner: string;
|
|
52
|
+
name: string;
|
|
53
|
+
private: boolean;
|
|
54
|
+
description?: string;
|
|
55
|
+
collaborators?: Array<{ username: string; permission: 'read' | 'write' | 'admin' }>;
|
|
56
|
+
/** Committed to the default branch (`main`) after auto-initialisation. */
|
|
57
|
+
files?: IGiteaSeedFile[];
|
|
58
|
+
/** Each branch is created by one commit of its files on top of `from` (default `main`). */
|
|
59
|
+
branches?: Array<{ name: string; from?: string; files: [IGiteaSeedFile, ...IGiteaSeedFile[]] }>;
|
|
60
|
+
tags?: Array<{ name: string; target: string; message?: string }>;
|
|
61
|
+
labels?: Array<{ name: string; color: string; description?: string }>;
|
|
62
|
+
milestones?: Array<{ title: string; description?: string; state?: 'open' | 'closed' }>;
|
|
63
|
+
/** Created in order; Gitea numbers issues and pull requests from one shared sequence. */
|
|
64
|
+
issuesAndPullRequests?: Array<IGiteaSeedIssue | IGiteaSeedPullRequest>;
|
|
65
|
+
releases?: Array<{ tagName: string; target: string; name: string; body?: string; draft?: boolean; prerelease?: boolean }>;
|
|
66
|
+
/** Uploaded through the Git LFS batch API; their pointers are committed to the default branch. */
|
|
67
|
+
lfsObjects?: IGiteaSeedFile[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IGiteaSeedSpec {
|
|
71
|
+
users: IGiteaSeedUser[];
|
|
72
|
+
organizations: IGiteaSeedOrganization[];
|
|
73
|
+
repositories: IGiteaSeedRepository[];
|
|
74
|
+
/** Deleted after everything else; content they authored is then attributed to Gitea's ghost user. */
|
|
75
|
+
deleteUsers?: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What Gitea itself reported after seeding, read back once all mutations finished. */
|
|
79
|
+
export interface IGiteaSeedManifest {
|
|
80
|
+
baseUrl: string;
|
|
81
|
+
version: string;
|
|
82
|
+
users: Array<{ username: string; id: number; deleted: boolean }>;
|
|
83
|
+
organizations: Array<{ name: string; id: number; visibility: TGiteaVisibility }>;
|
|
84
|
+
repositories: IGiteaSeedManifestRepository[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IGiteaSeedManifestAuthor {
|
|
88
|
+
id: number;
|
|
89
|
+
login: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface IGiteaSeedManifestRepository {
|
|
93
|
+
owner: string;
|
|
94
|
+
name: string;
|
|
95
|
+
fullName: string;
|
|
96
|
+
id: number;
|
|
97
|
+
ownerId: number;
|
|
98
|
+
private: boolean;
|
|
99
|
+
internal: boolean;
|
|
100
|
+
defaultBranch: string;
|
|
101
|
+
htmlUrl: string;
|
|
102
|
+
cloneUrl: string;
|
|
103
|
+
branches: Array<{ name: string; commitSha: string }>;
|
|
104
|
+
tags: Array<{ name: string; commitSha: string }>;
|
|
105
|
+
labels: Array<{ name: string; id: number }>;
|
|
106
|
+
milestones: Array<{ title: string; id: number; state: 'open' | 'closed' }>;
|
|
107
|
+
issues: Array<{
|
|
108
|
+
key: string;
|
|
109
|
+
id: number;
|
|
110
|
+
number: number;
|
|
111
|
+
state: 'open' | 'closed';
|
|
112
|
+
author: IGiteaSeedManifestAuthor;
|
|
113
|
+
labels: string[];
|
|
114
|
+
milestone: string | null;
|
|
115
|
+
assignees: string[];
|
|
116
|
+
comments: Array<{ id: number; author: IGiteaSeedManifestAuthor }>;
|
|
117
|
+
}>;
|
|
118
|
+
pullRequests: Array<{
|
|
119
|
+
key: string;
|
|
120
|
+
id: number;
|
|
121
|
+
number: number;
|
|
122
|
+
state: 'open' | 'closed';
|
|
123
|
+
author: IGiteaSeedManifestAuthor;
|
|
124
|
+
head: string;
|
|
125
|
+
base: string;
|
|
126
|
+
headSha: string;
|
|
127
|
+
}>;
|
|
128
|
+
releases: Array<{ tagName: string; id: number; draft: boolean; prerelease: boolean }>;
|
|
129
|
+
lfsObjects: Array<{ path: string; oid: string; size: number }>;
|
|
130
|
+
}
|