@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.
Files changed (65) hide show
  1. package/.smartconfig.json +49 -0
  2. package/changelog.md +17 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/classes.certificateauthority.d.ts +22 -0
  6. package/dist_ts/classes.certificateauthority.js +93 -0
  7. package/dist_ts/classes.containerlifecycle.d.ts +88 -0
  8. package/dist_ts/classes.containerlifecycle.js +383 -0
  9. package/dist_ts/classes.giteafixture.d.ts +41 -0
  10. package/dist_ts/classes.giteafixture.js +182 -0
  11. package/dist_ts/classes.giteaseed.d.ts +13 -0
  12. package/dist_ts/classes.giteaseed.js +432 -0
  13. package/dist_ts/classes.gitlabfixture.d.ts +49 -0
  14. package/dist_ts/classes.gitlabfixture.js +237 -0
  15. package/dist_ts/classes.gitlabseed.d.ts +13 -0
  16. package/dist_ts/classes.gitlabseed.js +466 -0
  17. package/dist_ts/classes.httpclient.d.ts +53 -0
  18. package/dist_ts/classes.httpclient.js +116 -0
  19. package/dist_ts/classes.reaper.d.ts +25 -0
  20. package/dist_ts/classes.reaper.js +102 -0
  21. package/dist_ts/classes.tlsterminator.d.ts +21 -0
  22. package/dist_ts/classes.tlsterminator.js +131 -0
  23. package/dist_ts/constants.d.ts +22 -0
  24. package/dist_ts/constants.js +30 -0
  25. package/dist_ts/giteaseed.default.d.ts +10 -0
  26. package/dist_ts/giteaseed.default.js +87 -0
  27. package/dist_ts/gitlabseed.default.d.ts +12 -0
  28. package/dist_ts/gitlabseed.default.js +84 -0
  29. package/dist_ts/index.d.ts +17 -0
  30. package/dist_ts/index.js +17 -0
  31. package/dist_ts/interfaces.d.ts +92 -0
  32. package/dist_ts/interfaces.giteaseed.d.ts +182 -0
  33. package/dist_ts/interfaces.giteaseed.js +2 -0
  34. package/dist_ts/interfaces.gitlabseed.d.ts +183 -0
  35. package/dist_ts/interfaces.gitlabseed.js +2 -0
  36. package/dist_ts/interfaces.js +2 -0
  37. package/dist_ts/ownership.d.ts +29 -0
  38. package/dist_ts/ownership.js +140 -0
  39. package/dist_ts/plugins.d.ts +13 -0
  40. package/dist_ts/plugins.js +18 -0
  41. package/dist_ts/responses.d.ts +7 -0
  42. package/dist_ts/responses.js +30 -0
  43. package/license.md +21 -0
  44. package/package.json +65 -0
  45. package/readme.md +206 -0
  46. package/ts/00_commitinfo_data.ts +8 -0
  47. package/ts/classes.certificateauthority.ts +117 -0
  48. package/ts/classes.containerlifecycle.ts +432 -0
  49. package/ts/classes.giteafixture.ts +205 -0
  50. package/ts/classes.giteaseed.ts +486 -0
  51. package/ts/classes.gitlabfixture.ts +258 -0
  52. package/ts/classes.gitlabseed.ts +502 -0
  53. package/ts/classes.httpclient.ts +156 -0
  54. package/ts/classes.reaper.ts +126 -0
  55. package/ts/classes.tlsterminator.ts +136 -0
  56. package/ts/constants.ts +35 -0
  57. package/ts/giteaseed.default.ts +88 -0
  58. package/ts/gitlabseed.default.ts +86 -0
  59. package/ts/index.ts +17 -0
  60. package/ts/interfaces.giteaseed.ts +130 -0
  61. package/ts/interfaces.gitlabseed.ts +135 -0
  62. package/ts/interfaces.ts +94 -0
  63. package/ts/ownership.ts +160 -0
  64. package/ts/plugins.ts +23 -0
  65. package/ts/responses.ts +33 -0
@@ -0,0 +1,205 @@
1
+ import * as plugins from './plugins.js';
2
+ import { giteaFixtureImage } from './constants.js';
3
+ import { ForgeFixtureContainerLifecycle } from './classes.containerlifecycle.js';
4
+ import type { ForgeFixtureHttpClient } from './classes.httpclient.js';
5
+ import type { IForgeFixtureLifecycleOptions, IForgeFixtureReapReport, IGiteaFixtureRuntime } from './interfaces.js';
6
+
7
+ export interface IGiteaFixtureOptions extends IForgeFixtureLifecycleOptions {
8
+ /** Container memory limit; swap is disabled. Defaults to 512 MiB. */
9
+ memoryBytes?: number;
10
+ /** CPU limit in billionths of a CPU. Defaults to 2 CPUs. */
11
+ nanoCpus?: number;
12
+ }
13
+
14
+ export interface IGiteaAccessTokenRequest {
15
+ username: string;
16
+ /** Unique per user. */
17
+ tokenName: string;
18
+ /** Gitea scopes such as `all`, `public-only` or `read:repository`. */
19
+ scopes: string[];
20
+ }
21
+
22
+ const httpPort = 3000;
23
+ const dataDirectory = '/var/lib/gitea';
24
+ /** The configuration lives in the owned data volume, whose directories belong to the Gitea user. */
25
+ const appIni = `${dataDirectory}/conf/app.ini`;
26
+ const giteaBinary = '/usr/local/bin/gitea';
27
+ const adminUsername = 'forgeadmin';
28
+ const usernamePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,38}$/;
29
+ const scopePattern = /^(all|public-only|(read|write):[a-z]+)$/;
30
+ const tokenPattern = /^[0-9a-f]{40}$/;
31
+ const execTimeoutMs = 60_000;
32
+
33
+ const sleep = (msArg: number) => new Promise<void>((resolveArg) => setTimeout(resolveArg, msArg));
34
+
35
+ /**
36
+ * A disposable Gitea instance from the pinned rootless image, served over
37
+ * verified loopback TLS, with SQLite and all data in one owned named volume.
38
+ */
39
+ export class GiteaFixture {
40
+ readonly #options: IGiteaFixtureOptions;
41
+ readonly #lifecycle: ForgeFixtureContainerLifecycle;
42
+ #runtime: IGiteaFixtureRuntime | undefined;
43
+ #starting: Promise<IGiteaFixtureRuntime> | undefined;
44
+
45
+ constructor(optionsArg: IGiteaFixtureOptions = {}) {
46
+ this.#options = { ...optionsArg };
47
+ this.#lifecycle = new ForgeFixtureContainerLifecycle('gitea', optionsArg, 120_000);
48
+ }
49
+
50
+ public get runtime(): IGiteaFixtureRuntime {
51
+ if (!this.#runtime) throw new Error('The Gitea fixture is not running.');
52
+ return this.#runtime;
53
+ }
54
+
55
+ /** HTTPS client scoped to this fixture's origin and CA. */
56
+ public get http(): ForgeFixtureHttpClient {
57
+ return this.#lifecycle.http;
58
+ }
59
+
60
+ /** Report of the stale-fixture reaper run that preceded this start. */
61
+ public get reapReport(): IForgeFixtureReapReport | undefined {
62
+ return this.#lifecycle.lastReapReport;
63
+ }
64
+
65
+ /**
66
+ * Starts the fixture and returns once Gitea answers with the pinned version
67
+ * and an admin token exists. A fixture starts at most once.
68
+ */
69
+ public start(): Promise<IGiteaFixtureRuntime> {
70
+ if (this.#starting) return Promise.reject(new Error('The Gitea fixture was already started.'));
71
+ this.#starting = this.#start();
72
+ return this.#starting;
73
+ }
74
+
75
+ async #start(): Promise<IGiteaFixtureRuntime> {
76
+ const endpoint = await this.#lifecycle.prepare();
77
+ try {
78
+ await this.#lifecycle.startContainer({
79
+ image: giteaFixtureImage,
80
+ user: '1000:1000',
81
+ command: [giteaBinary, '--config', appIni, 'web'],
82
+ env: {
83
+ GITEA_APP_INI: appIni,
84
+ GITEA__security__INSTALL_LOCK: 'true',
85
+ GITEA__security__SECRET_KEY: plugins.crypto.randomBytes(32).toString('hex'),
86
+ GITEA__server__ROOT_URL: `${endpoint.baseUrl}/`,
87
+ GITEA__server__DOMAIN: '127.0.0.1',
88
+ GITEA__server__SSH_DOMAIN: '127.0.0.1',
89
+ GITEA__server__HTTP_PORT: String(httpPort),
90
+ GITEA__server__LFS_START_SERVER: 'true',
91
+ GITEA__server__OFFLINE_MODE: 'true',
92
+ GITEA__service__DISABLE_REGISTRATION: 'true',
93
+ GITEA__database__DB_TYPE: 'sqlite3',
94
+ GITEA__log__LEVEL: 'Warn',
95
+ },
96
+ httpPort,
97
+ // Git hooks live in the data directory and must be executable, so it is an owned
98
+ // named volume (Docker mounts tmpfs noexec). It is removed with the lifecycle.
99
+ volumes: [{ purpose: 'data', target: dataDirectory }],
100
+ // Declared image volume, unused because the configuration lives in the data volume;
101
+ // mounted so Docker creates no anonymous volume for it.
102
+ tmpfsMounts: [{ target: '/etc/gitea', sizeBytes: 1024 * 1024 }],
103
+ memoryBytes: this.#options.memoryBytes ?? 512 * 1024 * 1024,
104
+ nanoCpus: this.#options.nanoCpus ?? 2_000_000_000,
105
+ pidsLimit: 512,
106
+ stopTimeoutSeconds: 10,
107
+ });
108
+ await this.#waitForHealth(Date.now() + this.#lifecycle.startupTimeoutMs);
109
+ const version = await this.#readVersion();
110
+ this.#lifecycle.assertNotStopping();
111
+ if (version !== giteaFixtureImage.version) {
112
+ throw new Error(`Gitea reports version ${version}; the pinned image is ${giteaFixtureImage.version}.`);
113
+ }
114
+ await this.#giteaCli([
115
+ 'admin', 'user', 'create', '--username', adminUsername, '--email', `${adminUsername}@example.com`,
116
+ '--random-password', '--admin', '--must-change-password=false',
117
+ ], 'create the fixture administrator');
118
+ const token = await this.createAccessToken({ username: adminUsername, tokenName: 'forgefixtures-admin', scopes: ['all'] });
119
+ this.#lifecycle.assertNotStopping();
120
+ this.#runtime = {
121
+ kind: 'gitea',
122
+ lifecycleId: this.#lifecycle.lifecycleId,
123
+ baseUrl: endpoint.baseUrl,
124
+ caCertificatePem: endpoint.caCertificatePem,
125
+ version,
126
+ image: giteaFixtureImage,
127
+ admin: { username: adminUsername, token },
128
+ };
129
+ return this.#runtime;
130
+ } catch (error) {
131
+ const reported = this.#lifecycle.stopAwareError(error);
132
+ try {
133
+ await this.#lifecycle.stop();
134
+ } catch (cleanupError) {
135
+ throw new AggregateError([reported, cleanupError], 'The Gitea fixture failed to start and to clean up.');
136
+ }
137
+ throw reported;
138
+ }
139
+ }
140
+
141
+ /** Creates an access token through Gitea's supported admin CLI. The token is returned, never logged. */
142
+ public async createAccessToken(requestArg: IGiteaAccessTokenRequest): Promise<string> {
143
+ if (!usernamePattern.test(requestArg.username) || !/^[A-Za-z0-9_.-]{1,64}$/.test(requestArg.tokenName)
144
+ || requestArg.scopes.length === 0 || !requestArg.scopes.every((scopeArg) => scopePattern.test(scopeArg))) {
145
+ throw new TypeError('The access token request is malformed.');
146
+ }
147
+ const stdout = await this.#giteaCli([
148
+ 'admin', 'user', 'generate-access-token', '--username', requestArg.username,
149
+ '--token-name', requestArg.tokenName, '--scopes', requestArg.scopes.join(','), '--raw',
150
+ ], `create an access token for ${requestArg.username}`);
151
+ const token = stdout.trim();
152
+ if (!tokenPattern.test(token)) throw new Error('Gitea returned an access token in an unexpected format.');
153
+ return token;
154
+ }
155
+
156
+ /**
157
+ * Removes every resource of this fixture. A start in progress is cancelled at
158
+ * its next checkpoint and awaited first, so nothing it created survives.
159
+ * Idempotent; retries incomplete cleanup.
160
+ */
161
+ public async stop(): Promise<void> {
162
+ this.#runtime = undefined;
163
+ this.#lifecycle.requestStop();
164
+ const starting = this.#starting;
165
+ if (starting) await starting.catch(() => undefined);
166
+ await this.#lifecycle.stop();
167
+ }
168
+
169
+ async #waitForHealth(deadlineArg: number): Promise<void> {
170
+ let lastFailure = 'no attempt';
171
+ while (Date.now() < deadlineArg) {
172
+ this.#lifecycle.assertNotStopping();
173
+ await this.#lifecycle.assertContainerRunning();
174
+ try {
175
+ const response = await this.#lifecycle.http.request({
176
+ method: 'GET', url: '/api/healthz', expectedStatus: [200, 503], timeoutMs: 5_000,
177
+ });
178
+ if (response.status === 200) return;
179
+ lastFailure = `status ${response.status}`;
180
+ } catch (error) {
181
+ lastFailure = error instanceof Error ? error.message : String(error);
182
+ }
183
+ await sleep(500);
184
+ }
185
+ throw new Error(
186
+ `Gitea did not become healthy within ${this.#lifecycle.startupTimeoutMs} ms (${lastFailure}). Last logs:\n${await this.#lifecycle.logsTail(40)}`,
187
+ );
188
+ }
189
+
190
+ async #readVersion(): Promise<string> {
191
+ const body = await this.#lifecycle.http.requestJson({ method: 'GET', url: '/api/v1/version', expectedStatus: [200] });
192
+ const version: unknown = typeof body === 'object' && body !== null ? Reflect.get(body, 'version') : undefined;
193
+ if (typeof version !== 'string') throw new Error('Gitea answered /api/v1/version without a version.');
194
+ return version;
195
+ }
196
+
197
+ /** Runs the Gitea CLI as the Gitea user. Output is returned but never included in errors. */
198
+ async #giteaCli(argsArg: string[], purposeArg: string): Promise<string> {
199
+ const result = await this.#lifecycle.exec([giteaBinary, '--config', appIni, ...argsArg], { timeoutMs: execTimeoutMs });
200
+ if (result.exitCode !== 0) {
201
+ throw new Error(`Gitea CLI failed to ${purposeArg} (exit ${result.exitCode}): ${result.stderr.slice(0, 1024)}`);
202
+ }
203
+ return result.stdout;
204
+ }
205
+ }