@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,117 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
export interface IForgeFixtureServerCertificate {
|
|
4
|
+
certificatePem: string;
|
|
5
|
+
privateKeyPem: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IForgeFixtureServerCertificateRequest {
|
|
9
|
+
ipAddresses: string[];
|
|
10
|
+
dnsNames: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const certificateBackdateMs = 5 * 60 * 1000;
|
|
14
|
+
const signingAlgorithm = { name: 'ECDSA', hash: 'SHA-256' };
|
|
15
|
+
|
|
16
|
+
const generateP256KeyPair = async (): Promise<CryptoKeyPair> =>
|
|
17
|
+
globalThis.crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
18
|
+
|
|
19
|
+
const exportPrivateKeyPem = async (privateKeyArg: CryptoKey): Promise<string> => {
|
|
20
|
+
const der = await globalThis.crypto.subtle.exportKey('pkcs8', privateKeyArg);
|
|
21
|
+
return plugins.crypto.createPrivateKey({ key: Buffer.from(der), format: 'der', type: 'pkcs8' })
|
|
22
|
+
.export({ format: 'pem', type: 'pkcs8' }).toString();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A certificate authority that exists only in the memory of one fixture
|
|
27
|
+
* lifecycle. Its private key is never exported; callers receive the public CA
|
|
28
|
+
* certificate to trust and freshly issued server leaves.
|
|
29
|
+
*/
|
|
30
|
+
export class ForgeFixtureCertificateAuthority {
|
|
31
|
+
public static async create(
|
|
32
|
+
lifecycleIdArg: string,
|
|
33
|
+
validityMsArg: number,
|
|
34
|
+
): Promise<ForgeFixtureCertificateAuthority> {
|
|
35
|
+
if (!lifecycleIdArg || /[,=+<>#;"\\]/.test(lifecycleIdArg)) {
|
|
36
|
+
throw new TypeError('The certificate authority lifecycle id is malformed.');
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isSafeInteger(validityMsArg) || validityMsArg < 60_000) {
|
|
39
|
+
throw new TypeError('The certificate authority validity must be an integer of at least 60000 ms.');
|
|
40
|
+
}
|
|
41
|
+
const keys = await generateP256KeyPair();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const certificate = await plugins.x509.X509CertificateGenerator.createSelfSigned({
|
|
44
|
+
serialNumber: plugins.crypto.randomBytes(16).toString('hex'),
|
|
45
|
+
name: `CN=forgefixtures ${lifecycleIdArg}`,
|
|
46
|
+
notBefore: new Date(now - certificateBackdateMs),
|
|
47
|
+
notAfter: new Date(now + validityMsArg),
|
|
48
|
+
signingAlgorithm,
|
|
49
|
+
keys,
|
|
50
|
+
extensions: [
|
|
51
|
+
new plugins.x509.BasicConstraintsExtension(true, 0, true),
|
|
52
|
+
new plugins.x509.KeyUsagesExtension(
|
|
53
|
+
plugins.x509.KeyUsageFlags.keyCertSign | plugins.x509.KeyUsageFlags.cRLSign,
|
|
54
|
+
true,
|
|
55
|
+
),
|
|
56
|
+
await plugins.x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
|
|
57
|
+
await plugins.x509.AuthorityKeyIdentifierExtension.create(keys.publicKey),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
return new ForgeFixtureCertificateAuthority(certificate, keys.privateKey);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
readonly #certificate: plugins.x509.X509Certificate;
|
|
64
|
+
readonly #signingKey: CryptoKey;
|
|
65
|
+
|
|
66
|
+
private constructor(certificateArg: plugins.x509.X509Certificate, signingKeyArg: CryptoKey) {
|
|
67
|
+
this.#certificate = certificateArg;
|
|
68
|
+
this.#signingKey = signingKeyArg;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Public CA certificate for scoped trust by fixture clients. */
|
|
72
|
+
public get certificatePem(): string {
|
|
73
|
+
return this.#certificate.toString('pem');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Issues a server leaf within the CA validity for exactly the requested names. */
|
|
77
|
+
public async issueServerCertificate(
|
|
78
|
+
requestArg: IForgeFixtureServerCertificateRequest,
|
|
79
|
+
): Promise<IForgeFixtureServerCertificate> {
|
|
80
|
+
const names = [
|
|
81
|
+
...requestArg.ipAddresses.map((ipArg) => {
|
|
82
|
+
if (plugins.net.isIP(ipArg) === 0) throw new TypeError(`"${ipArg}" is not an IP address.`);
|
|
83
|
+
return { type: 'ip' as const, value: ipArg };
|
|
84
|
+
}),
|
|
85
|
+
...requestArg.dnsNames.map((dnsNameArg) => {
|
|
86
|
+
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/.test(dnsNameArg)) {
|
|
87
|
+
throw new TypeError(`"${dnsNameArg}" is not an exact lowercase DNS name.`);
|
|
88
|
+
}
|
|
89
|
+
return { type: 'dns' as const, value: dnsNameArg };
|
|
90
|
+
}),
|
|
91
|
+
];
|
|
92
|
+
if (names.length === 0) throw new TypeError('A server certificate needs at least one name.');
|
|
93
|
+
const keys = await generateP256KeyPair();
|
|
94
|
+
const certificate = await plugins.x509.X509CertificateGenerator.create({
|
|
95
|
+
serialNumber: plugins.crypto.randomBytes(16).toString('hex'),
|
|
96
|
+
subject: `CN=${names[0]!.value}`,
|
|
97
|
+
issuer: this.#certificate.subjectName,
|
|
98
|
+
notBefore: this.#certificate.notBefore,
|
|
99
|
+
notAfter: this.#certificate.notAfter,
|
|
100
|
+
signingAlgorithm,
|
|
101
|
+
publicKey: keys.publicKey,
|
|
102
|
+
signingKey: this.#signingKey,
|
|
103
|
+
extensions: [
|
|
104
|
+
new plugins.x509.BasicConstraintsExtension(false, undefined, true),
|
|
105
|
+
new plugins.x509.KeyUsagesExtension(plugins.x509.KeyUsageFlags.digitalSignature, true),
|
|
106
|
+
new plugins.x509.ExtendedKeyUsageExtension([plugins.x509.ExtendedKeyUsage.serverAuth]),
|
|
107
|
+
new plugins.x509.SubjectAlternativeNameExtension(names),
|
|
108
|
+
await plugins.x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
|
|
109
|
+
await plugins.x509.AuthorityKeyIdentifierExtension.create(this.#certificate.publicKey),
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
certificatePem: certificate.toString('pem'),
|
|
114
|
+
privateKeyPem: await exportPrivateKeyPem(keys.privateKey),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { defaultMaxLifetimeMs, defaultPullTimeoutMs } from './constants.js';
|
|
3
|
+
import { ForgeFixtureCertificateAuthority } from './classes.certificateauthority.js';
|
|
4
|
+
import { ForgeFixtureHttpClient } from './classes.httpclient.js';
|
|
5
|
+
import { createForgeFixtureDockerHost, reapStaleForgeFixtures } from './classes.reaper.js';
|
|
6
|
+
import { ForgeFixtureTlsTerminator } from './classes.tlsterminator.js';
|
|
7
|
+
import { createForgeFixtureOwner, forgeFixtureOwnerLabels } from './ownership.js';
|
|
8
|
+
import type {
|
|
9
|
+
IForgeFixtureEndpoint,
|
|
10
|
+
IForgeFixtureImage,
|
|
11
|
+
IForgeFixtureLifecycleOptions,
|
|
12
|
+
IForgeFixtureOwner,
|
|
13
|
+
IForgeFixtureReapReport,
|
|
14
|
+
TForgeFixtureKind,
|
|
15
|
+
} from './interfaces.js';
|
|
16
|
+
|
|
17
|
+
/** What a forge driver asks the lifecycle to run. */
|
|
18
|
+
export interface IForgeFixtureContainerSpec {
|
|
19
|
+
image: IForgeFixtureImage;
|
|
20
|
+
/** Explicit container user. Root requires `allowRootOnRootless` and a verified rootless daemon. */
|
|
21
|
+
user: string;
|
|
22
|
+
allowRootOnRootless?: true;
|
|
23
|
+
command: plugins.docker.TContainerCommand;
|
|
24
|
+
env: Record<string, string>;
|
|
25
|
+
/** Plain-HTTP port inside the container that the TLS terminator forwards to. */
|
|
26
|
+
httpPort: number;
|
|
27
|
+
tmpfsMounts?: plugins.docker.IContainerTmpfsMount[];
|
|
28
|
+
/** Named volumes created and removed by this lifecycle, mounted at `target`. */
|
|
29
|
+
volumes?: Array<{ purpose: string; target: string }>;
|
|
30
|
+
memoryBytes: number;
|
|
31
|
+
nanoCpus: number;
|
|
32
|
+
pidsLimit: number;
|
|
33
|
+
shmSizeBytes?: number;
|
|
34
|
+
stopTimeoutSeconds: number;
|
|
35
|
+
healthcheck?: plugins.docker.IContainerHealthcheck;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TForgeFixtureContainerHealth = 'none' | 'starting' | 'healthy' | 'unhealthy';
|
|
39
|
+
|
|
40
|
+
export type TForgeFixtureLifecycleState = 'idle' | 'preparing' | 'prepared' | 'starting' | 'running' | 'stopping' | 'stopped';
|
|
41
|
+
|
|
42
|
+
const maxPortAttempts = 5;
|
|
43
|
+
|
|
44
|
+
/** Asks the kernel for a currently free loopback port. The caller must tolerate losing a race for it. */
|
|
45
|
+
const reserveLoopbackPort = async (): Promise<number> => {
|
|
46
|
+
const server = plugins.net.createServer();
|
|
47
|
+
await new Promise<void>((resolveArg, rejectArg) => {
|
|
48
|
+
server.once('error', rejectArg);
|
|
49
|
+
server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => resolveArg());
|
|
50
|
+
});
|
|
51
|
+
const address = server.address();
|
|
52
|
+
await new Promise<void>((resolveArg) => server.close(() => resolveArg()));
|
|
53
|
+
if (address === null || typeof address === 'string') throw new Error('The loopback port probe has no TCP address.');
|
|
54
|
+
return address.port;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const isPortConflict = (errorArg: unknown): boolean =>
|
|
58
|
+
errorArg instanceof Error && /address already in use|port is already allocated/.test(errorArg.message);
|
|
59
|
+
|
|
60
|
+
/** Raised by lifecycle steps that observe a stop request. */
|
|
61
|
+
export class ForgeFixtureStoppedError extends Error {
|
|
62
|
+
constructor(kindArg: TForgeFixtureKind, optionsArg?: ErrorOptions) {
|
|
63
|
+
super(`The ${kindArg} fixture was stopped.`, optionsArg);
|
|
64
|
+
this.name = 'ForgeFixtureStoppedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Owns one fixture's Docker resources, local CA, TLS terminator and scoped
|
|
70
|
+
* HTTPS client. Every resource carries ownership labels, so resources left by a
|
|
71
|
+
* crashed process are removed by the next lifecycle's reaper run.
|
|
72
|
+
*/
|
|
73
|
+
export class ForgeFixtureContainerLifecycle {
|
|
74
|
+
readonly #kind: TForgeFixtureKind;
|
|
75
|
+
readonly #options: Required<Omit<IForgeFixtureLifecycleOptions, 'dockerSocketPath'>> & { dockerSocketPath?: string };
|
|
76
|
+
#state: TForgeFixtureLifecycleState = 'idle';
|
|
77
|
+
#dockerHost: plugins.docker.DockerHost | undefined;
|
|
78
|
+
#owner: IForgeFixtureOwner | undefined;
|
|
79
|
+
#terminator: ForgeFixtureTlsTerminator | undefined;
|
|
80
|
+
#http: ForgeFixtureHttpClient | undefined;
|
|
81
|
+
#endpoint: IForgeFixtureEndpoint | undefined;
|
|
82
|
+
#container: plugins.docker.DockerContainer | undefined;
|
|
83
|
+
#network: plugins.docker.DockerNetwork | undefined;
|
|
84
|
+
readonly #volumes: plugins.docker.DockerVolume[] = [];
|
|
85
|
+
#lastReapReport: IForgeFixtureReapReport | undefined;
|
|
86
|
+
readonly #stopController = new AbortController();
|
|
87
|
+
/** The prepare or container start currently running; `stop()` waits for it before cleaning up. */
|
|
88
|
+
#inflight: Promise<unknown> | undefined;
|
|
89
|
+
/** Serialises cleanup runs so concurrent stops never remove the same resource twice. */
|
|
90
|
+
#cleanupQueue: Promise<void> = Promise.resolve();
|
|
91
|
+
|
|
92
|
+
constructor(kindArg: TForgeFixtureKind, optionsArg: IForgeFixtureLifecycleOptions, startupTimeoutMsArg: number) {
|
|
93
|
+
this.#kind = kindArg;
|
|
94
|
+
this.#options = {
|
|
95
|
+
...(optionsArg.dockerSocketPath === undefined ? {} : { dockerSocketPath: optionsArg.dockerSocketPath }),
|
|
96
|
+
maxLifetimeMs: optionsArg.maxLifetimeMs ?? defaultMaxLifetimeMs,
|
|
97
|
+
pullTimeoutMs: optionsArg.pullTimeoutMs ?? defaultPullTimeoutMs,
|
|
98
|
+
startupTimeoutMs: optionsArg.startupTimeoutMs ?? startupTimeoutMsArg,
|
|
99
|
+
};
|
|
100
|
+
for (const [name, value] of Object.entries(this.#options)) {
|
|
101
|
+
if (name !== 'dockerSocketPath' && (!Number.isSafeInteger(value) || (value as number) < 1)) {
|
|
102
|
+
throw new TypeError(`${name} must be a positive integer.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public get state(): TForgeFixtureLifecycleState {
|
|
108
|
+
return this.#state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public get startupTimeoutMs(): number {
|
|
112
|
+
return this.#options.startupTimeoutMs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public get lifecycleId(): string {
|
|
116
|
+
return this.#require(this.#owner, 'lifecycle owner').lifecycleId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public get endpoint(): IForgeFixtureEndpoint {
|
|
120
|
+
return this.#require(this.#endpoint, 'endpoint');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public get http(): ForgeFixtureHttpClient {
|
|
124
|
+
return this.#require(this.#http, 'HTTPS client');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public get dockerHost(): plugins.docker.DockerHost {
|
|
128
|
+
return this.#require(this.#dockerHost, 'Docker host');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Aborted once `stop()` or `requestStop()` was called. */
|
|
132
|
+
public get stopSignal(): AbortSignal {
|
|
133
|
+
return this.#stopController.signal;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Throws {@link ForgeFixtureStoppedError} once a stop was requested. */
|
|
137
|
+
public assertNotStopping(): void {
|
|
138
|
+
if (this.#stopController.signal.aborted) throw new ForgeFixtureStoppedError(this.#kind);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Once a stop was requested, any failure it provoked (an aborted pull, a
|
|
143
|
+
* refused request) is reported as {@link ForgeFixtureStoppedError} with the
|
|
144
|
+
* original failure as its cause.
|
|
145
|
+
*/
|
|
146
|
+
public stopAwareError(errorArg: unknown): unknown {
|
|
147
|
+
if (!this.#stopController.signal.aborted || errorArg instanceof ForgeFixtureStoppedError) return errorArg;
|
|
148
|
+
return new ForgeFixtureStoppedError(this.#kind, { cause: errorArg });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Marks the lifecycle as stopping without waiting. Running steps observe it
|
|
153
|
+
* at their next checkpoint, an image pull is cancelled, and the lifecycle can
|
|
154
|
+
* no longer be prepared or started.
|
|
155
|
+
*/
|
|
156
|
+
public requestStop(): void {
|
|
157
|
+
if (!this.#stopController.signal.aborted) this.#stopController.abort(new ForgeFixtureStoppedError(this.#kind));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Report of the reaper run that preceded this lifecycle. */
|
|
161
|
+
public get lastReapReport(): IForgeFixtureReapReport | undefined {
|
|
162
|
+
return this.#lastReapReport;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reaps stale fixtures, creates ownership, CA and TLS terminator. The
|
|
167
|
+
* returned endpoint is final: its port is bound before the forge starts.
|
|
168
|
+
*/
|
|
169
|
+
public async prepare(): Promise<IForgeFixtureEndpoint> {
|
|
170
|
+
this.assertNotStopping();
|
|
171
|
+
if (this.#state !== 'idle') throw new Error(`Cannot prepare a ${this.#state} lifecycle.`);
|
|
172
|
+
this.#state = 'preparing';
|
|
173
|
+
return this.#track(this.#prepare());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async #prepare(): Promise<IForgeFixtureEndpoint> {
|
|
177
|
+
try {
|
|
178
|
+
this.#dockerHost = createForgeFixtureDockerHost(this.#options.dockerSocketPath);
|
|
179
|
+
this.#lastReapReport = await reapStaleForgeFixtures(this.#dockerHost);
|
|
180
|
+
this.assertNotStopping();
|
|
181
|
+
this.#owner = await createForgeFixtureOwner(this.#kind, this.#options.maxLifetimeMs);
|
|
182
|
+
const authority = await ForgeFixtureCertificateAuthority.create(
|
|
183
|
+
this.#owner.lifecycleId, this.#options.maxLifetimeMs + 24 * 60 * 60 * 1000,
|
|
184
|
+
);
|
|
185
|
+
const certificate = await authority.issueServerCertificate({ ipAddresses: ['127.0.0.1'], dnsNames: ['localhost'] });
|
|
186
|
+
this.assertNotStopping();
|
|
187
|
+
this.#terminator = new ForgeFixtureTlsTerminator(certificate);
|
|
188
|
+
const port = await this.#terminator.listen();
|
|
189
|
+
this.assertNotStopping();
|
|
190
|
+
this.#endpoint = { baseUrl: `https://127.0.0.1:${port}`, caCertificatePem: authority.certificatePem };
|
|
191
|
+
this.#http = new ForgeFixtureHttpClient({ baseUrl: this.#endpoint.baseUrl, caCertificatePem: authority.certificatePem });
|
|
192
|
+
this.#state = 'prepared';
|
|
193
|
+
return this.#endpoint;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const reported = this.stopAwareError(error);
|
|
196
|
+
await this.#failAndCleanup(reported);
|
|
197
|
+
throw reported;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Pulls the pinned image, creates the owned network and container, starts it and wires the terminator. */
|
|
202
|
+
public async startContainer(specArg: IForgeFixtureContainerSpec): Promise<void> {
|
|
203
|
+
this.assertNotStopping();
|
|
204
|
+
if (this.#state !== 'prepared') throw new Error(`Cannot start a container in a ${this.#state} lifecycle.`);
|
|
205
|
+
this.#state = 'starting';
|
|
206
|
+
return this.#track(this.#startContainer(specArg));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #startContainer(specArg: IForgeFixtureContainerSpec): Promise<void> {
|
|
210
|
+
const dockerHost = this.dockerHost;
|
|
211
|
+
const owner = this.#require(this.#owner, 'lifecycle owner');
|
|
212
|
+
const labels = forgeFixtureOwnerLabels(owner);
|
|
213
|
+
const name = `forgefixtures-${owner.kind}-${owner.lifecycleId}`;
|
|
214
|
+
try {
|
|
215
|
+
const image = await dockerHost.pullImage({
|
|
216
|
+
reference: specArg.image.repoDigest,
|
|
217
|
+
expectedRepoDigest: specArg.image.repoDigest,
|
|
218
|
+
signal: AbortSignal.any([AbortSignal.timeout(this.#options.pullTimeoutMs), this.#stopController.signal]),
|
|
219
|
+
});
|
|
220
|
+
this.assertNotStopping();
|
|
221
|
+
this.#network = await dockerHost.createNetwork({ Name: name, Driver: 'bridge', Labels: labels });
|
|
222
|
+
this.assertNotStopping();
|
|
223
|
+
for (const volume of specArg.volumes ?? []) {
|
|
224
|
+
if (!/^[a-z][a-z0-9-]{0,30}$/.test(volume.purpose)) throw new TypeError(`Volume purpose "${volume.purpose}" is malformed.`);
|
|
225
|
+
this.#volumes.push(await dockerHost.createVolume({ name: `${name}-${volume.purpose}`, labels }));
|
|
226
|
+
this.assertNotStopping();
|
|
227
|
+
}
|
|
228
|
+
// The host port is chosen here rather than by Docker: a rootless daemon allocates
|
|
229
|
+
// "ephemeral" ports inside its own namespace, blind to host listeners, and then
|
|
230
|
+
// fails to bind them on the host. A conflict recreates the container on a new port.
|
|
231
|
+
for (let attempt = 1; ; attempt++) {
|
|
232
|
+
const hostPort = await reserveLoopbackPort();
|
|
233
|
+
this.#container = await dockerHost.createContainer({
|
|
234
|
+
name,
|
|
235
|
+
imageId: image.Id,
|
|
236
|
+
imageReference: specArg.image.repoDigest,
|
|
237
|
+
user: specArg.user,
|
|
238
|
+
...(specArg.allowRootOnRootless ? { allowRootOnRootless: true as const } : {}),
|
|
239
|
+
command: specArg.command,
|
|
240
|
+
env: specArg.env,
|
|
241
|
+
labels,
|
|
242
|
+
stopTimeout: specArg.stopTimeoutSeconds,
|
|
243
|
+
memoryBytes: specArg.memoryBytes,
|
|
244
|
+
memorySwapBytes: specArg.memoryBytes,
|
|
245
|
+
nanoCpus: specArg.nanoCpus,
|
|
246
|
+
pidsLimit: specArg.pidsLimit,
|
|
247
|
+
...(specArg.shmSizeBytes === undefined ? {} : { shmSize: specArg.shmSizeBytes }),
|
|
248
|
+
...(specArg.tmpfsMounts === undefined ? {} : { tmpfsMounts: specArg.tmpfsMounts }),
|
|
249
|
+
...(specArg.healthcheck === undefined ? {} : { healthcheck: specArg.healthcheck }),
|
|
250
|
+
namedVolumeMounts: (specArg.volumes ?? []).map((volumeArg, indexArg) => ({
|
|
251
|
+
source: this.#volumes[indexArg]!.Name, target: volumeArg.target,
|
|
252
|
+
})),
|
|
253
|
+
networkEndpoints: [{ networkId: this.#network.Id }],
|
|
254
|
+
portBindings: [{ containerPort: specArg.httpPort, hostPort, hostIp: '127.0.0.1', protocol: 'tcp' }],
|
|
255
|
+
});
|
|
256
|
+
this.assertNotStopping();
|
|
257
|
+
try {
|
|
258
|
+
await this.#container.start();
|
|
259
|
+
break;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (attempt >= maxPortAttempts || !isPortConflict(error)) throw error;
|
|
262
|
+
await this.#container.remove({ force: true, removeAnonymousVolumes: true });
|
|
263
|
+
this.#container = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
this.assertNotStopping();
|
|
267
|
+
const publishedPort = await this.#publishedPort(this.#container, specArg.httpPort);
|
|
268
|
+
this.assertNotStopping();
|
|
269
|
+
this.#require(this.#terminator, 'TLS terminator').setUpstreamPort(publishedPort);
|
|
270
|
+
this.#state = 'running';
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const reported = this.stopAwareError(error);
|
|
273
|
+
await this.#failAndCleanup(reported);
|
|
274
|
+
throw reported;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Runs argv in the container as its configured user. Never logs stdin or output. */
|
|
279
|
+
public async exec(
|
|
280
|
+
commandArg: plugins.docker.TContainerCommand,
|
|
281
|
+
optionsArg: plugins.docker.IContainerExecOptions = {},
|
|
282
|
+
): Promise<plugins.docker.IContainerExecResult> {
|
|
283
|
+
if (this.#state !== 'running') throw new Error(`Cannot exec in a ${this.#state} lifecycle.`);
|
|
284
|
+
return this.#require(this.#container, 'container').exec(commandArg, optionsArg);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Fails with the container's exit state and recent logs when it stopped running. */
|
|
288
|
+
public async assertContainerRunning(): Promise<void> {
|
|
289
|
+
const container = this.#require(this.#container, 'container');
|
|
290
|
+
const state = await container.inspectState();
|
|
291
|
+
if (state.Running) return;
|
|
292
|
+
const logs = await container.logs({ tail: 40 });
|
|
293
|
+
throw new Error(
|
|
294
|
+
`The ${this.#kind} fixture container is ${state.Status} (exit ${state.ExitCode}, OOM ${state.OOMKilled}). Last logs:\n${logs}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Docker's view of the container healthcheck (`none` without a healthcheck). */
|
|
299
|
+
public async containerHealth(): Promise<TForgeFixtureContainerHealth> {
|
|
300
|
+
const inspection = await this.#require(this.#container, 'container').inspect();
|
|
301
|
+
const state = inspection.State;
|
|
302
|
+
const health: unknown = typeof state === 'object' && state !== null ? Reflect.get(state, 'Health') : undefined;
|
|
303
|
+
if (health === undefined || health === null) return 'none';
|
|
304
|
+
const status: unknown = typeof health === 'object' ? Reflect.get(health, 'Status') : undefined;
|
|
305
|
+
if (status === 'starting' || status === 'healthy' || status === 'unhealthy') return status;
|
|
306
|
+
throw new Error('Docker reported a malformed container health status.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Recent container logs, for diagnostics after a failed readiness wait. */
|
|
310
|
+
public async logsTail(linesArg: number): Promise<string> {
|
|
311
|
+
return this.#require(this.#container, 'container').logs({ tail: linesArg });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Requests a stop, waits for a running prepare or container start to observe
|
|
316
|
+
* it, then removes every owned resource in dependency order, including those
|
|
317
|
+
* that step created. Idempotent. When a removal fails, the remaining
|
|
318
|
+
* resources stay recorded and a later call retries them; the failures are
|
|
319
|
+
* thrown together.
|
|
320
|
+
*/
|
|
321
|
+
public async stop(): Promise<void> {
|
|
322
|
+
this.requestStop();
|
|
323
|
+
const inflight = this.#inflight;
|
|
324
|
+
if (inflight) await inflight.catch(() => undefined);
|
|
325
|
+
await this.#cleanup();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async #track<T>(operationArg: Promise<T>): Promise<T> {
|
|
329
|
+
this.#inflight = operationArg;
|
|
330
|
+
try {
|
|
331
|
+
return await operationArg;
|
|
332
|
+
} finally {
|
|
333
|
+
if (this.#inflight === operationArg) this.#inflight = undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#cleanup(): Promise<void> {
|
|
338
|
+
const run = this.#cleanupQueue.catch(() => undefined).then(() => this.#removeResources());
|
|
339
|
+
this.#cleanupQueue = run;
|
|
340
|
+
return run;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async #removeResources(): Promise<void> {
|
|
344
|
+
if (!this.#hasResources()) {
|
|
345
|
+
this.#http = undefined;
|
|
346
|
+
this.#endpoint = undefined;
|
|
347
|
+
this.#state = 'stopped';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.#state = 'stopping';
|
|
351
|
+
const failures: unknown[] = [];
|
|
352
|
+
const attempt = async (operationArg: () => Promise<void>) => {
|
|
353
|
+
try {
|
|
354
|
+
await operationArg();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
failures.push(error);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
this.#http?.close();
|
|
360
|
+
this.#http = undefined;
|
|
361
|
+
await attempt(async () => {
|
|
362
|
+
await this.#terminator?.close();
|
|
363
|
+
this.#terminator = undefined;
|
|
364
|
+
});
|
|
365
|
+
await attempt(async () => {
|
|
366
|
+
await this.#container?.remove({ force: true, removeAnonymousVolumes: true });
|
|
367
|
+
this.#container = undefined;
|
|
368
|
+
});
|
|
369
|
+
await attempt(async () => {
|
|
370
|
+
if (this.#container) return;
|
|
371
|
+
await this.#network?.remove();
|
|
372
|
+
this.#network = undefined;
|
|
373
|
+
});
|
|
374
|
+
while (this.#volumes.length > 0 && !this.#container) {
|
|
375
|
+
const volume = this.#volumes[0]!;
|
|
376
|
+
try {
|
|
377
|
+
await volume.remove({ force: true });
|
|
378
|
+
this.#volumes.shift();
|
|
379
|
+
} catch (error) {
|
|
380
|
+
failures.push(error);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!this.#hasResources()) {
|
|
385
|
+
await attempt(async () => {
|
|
386
|
+
await this.#dockerHost?.stop();
|
|
387
|
+
this.#dockerHost = undefined;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
this.#endpoint = undefined;
|
|
391
|
+
this.#state = this.#hasResources() ? 'stopping' : 'stopped';
|
|
392
|
+
if (failures.length > 0) {
|
|
393
|
+
throw new AggregateError(failures, `The ${this.#kind} fixture ${this.#owner?.lifecycleId ?? ''} did not clean up completely.`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#hasResources(): boolean {
|
|
398
|
+
return this.#terminator !== undefined || this.#container !== undefined
|
|
399
|
+
|| this.#network !== undefined || this.#volumes.length > 0 || this.#dockerHost !== undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async #failAndCleanup(errorArg: unknown): Promise<void> {
|
|
403
|
+
try {
|
|
404
|
+
await this.#cleanup();
|
|
405
|
+
} catch (cleanupError) {
|
|
406
|
+
throw new AggregateError([errorArg, cleanupError], `The ${this.#kind} fixture failed to start and to clean up.`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async #publishedPort(containerArg: plugins.docker.DockerContainer, containerPortArg: number): Promise<number> {
|
|
411
|
+
const inspection = await containerArg.inspect();
|
|
412
|
+
const settings = inspection.NetworkSettings;
|
|
413
|
+
const ports = typeof settings === 'object' && settings !== null ? Reflect.get(settings, 'Ports') : undefined;
|
|
414
|
+
const bindings: unknown = typeof ports === 'object' && ports !== null ? Reflect.get(ports, `${containerPortArg}/tcp`) : undefined;
|
|
415
|
+
if (Array.isArray(bindings)) {
|
|
416
|
+
for (const binding of bindings) {
|
|
417
|
+
if (typeof binding !== 'object' || binding === null) continue;
|
|
418
|
+
const hostIp: unknown = Reflect.get(binding, 'HostIp');
|
|
419
|
+
const hostPort: unknown = Reflect.get(binding, 'HostPort');
|
|
420
|
+
if (hostIp === '127.0.0.1' && typeof hostPort === 'string' && /^[0-9]{1,5}$/.test(hostPort)) {
|
|
421
|
+
return Number(hostPort);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
throw new Error(`Docker did not publish container port ${containerPortArg} on 127.0.0.1.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#require<T>(valueArg: T | undefined, nameArg: string): T {
|
|
429
|
+
if (valueArg === undefined) throw new Error(`The ${this.#kind} fixture has no ${nameArg} in state ${this.#state}.`);
|
|
430
|
+
return valueArg;
|
|
431
|
+
}
|
|
432
|
+
}
|