docker-entrypoint.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import fs from 'node:fs';
  2. import os from 'node:os';
  3. import path from 'node:path';
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  5. import {
  6. buildNodeFlags,
  7. chownRecursive,
  8. detectHeapSize,
  9. readCgroupLimit,
  10. setupDirectories,
  11. } from './docker-entrypoint';
  12. describe('chownRecursive', () => {
  13. let tmpDir: string;
  14. beforeEach(() => {
  15. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-test-'));
  16. });
  17. afterEach(() => {
  18. fs.rmSync(tmpDir, { recursive: true, force: true });
  19. });
  20. it('should chown a flat directory', () => {
  21. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
  22. chownRecursive(tmpDir, 1000, 1000);
  23. // Should chown the directory itself
  24. expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
  25. chownSyncSpy.mockRestore();
  26. });
  27. it('should chown nested directories and files recursively', () => {
  28. // Create nested structure
  29. const subDir = path.join(tmpDir, 'sub');
  30. fs.mkdirSync(subDir);
  31. fs.writeFileSync(path.join(tmpDir, 'file1.txt'), 'hello');
  32. fs.writeFileSync(path.join(subDir, 'file2.txt'), 'world');
  33. const chownedPaths: string[] = [];
  34. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation((p) => {
  35. chownedPaths.push(p as string);
  36. });
  37. chownRecursive(tmpDir, 1000, 1000);
  38. expect(chownedPaths).toContain(tmpDir);
  39. expect(chownedPaths).toContain(subDir);
  40. expect(chownedPaths).toContain(path.join(tmpDir, 'file1.txt'));
  41. expect(chownedPaths).toContain(path.join(subDir, 'file2.txt'));
  42. expect(chownedPaths).toHaveLength(4);
  43. chownSyncSpy.mockRestore();
  44. });
  45. it('should handle empty directory', () => {
  46. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
  47. chownRecursive(tmpDir, 1000, 1000);
  48. // Should only chown the directory itself
  49. expect(chownSyncSpy).toHaveBeenCalledTimes(1);
  50. expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
  51. chownSyncSpy.mockRestore();
  52. });
  53. });
  54. describe('readCgroupLimit', () => {
  55. it('should read cgroup v2 numeric limit', () => {
  56. const readSpy = vi
  57. .spyOn(fs, 'readFileSync')
  58. .mockReturnValue('1073741824\n');
  59. const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
  60. expect(result).toBe(1073741824);
  61. readSpy.mockRestore();
  62. });
  63. it('should return undefined for cgroup v2 "max" (unlimited)', () => {
  64. const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('max\n');
  65. const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
  66. expect(result).toBeUndefined();
  67. readSpy.mockRestore();
  68. });
  69. it('should return undefined when file does not exist', () => {
  70. const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
  71. throw new Error('ENOENT');
  72. });
  73. const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
  74. expect(result).toBeUndefined();
  75. readSpy.mockRestore();
  76. });
  77. it('should return undefined for NaN content', () => {
  78. const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid\n');
  79. const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
  80. expect(result).toBeUndefined();
  81. readSpy.mockRestore();
  82. });
  83. });
  84. describe('detectHeapSize', () => {
  85. const originalEnv = process.env;
  86. beforeEach(() => {
  87. process.env = { ...originalEnv };
  88. });
  89. afterEach(() => {
  90. process.env = originalEnv;
  91. });
  92. it('should use V8_MAX_HEAP_SIZE when set', () => {
  93. process.env.V8_MAX_HEAP_SIZE = '512';
  94. const readSpy = vi.spyOn(fs, 'readFileSync');
  95. const result = detectHeapSize();
  96. expect(result).toBe(512);
  97. // Should not attempt to read cgroup files
  98. expect(readSpy).not.toHaveBeenCalled();
  99. readSpy.mockRestore();
  100. });
  101. it('should return undefined for invalid V8_MAX_HEAP_SIZE', () => {
  102. process.env.V8_MAX_HEAP_SIZE = 'abc';
  103. const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
  104. throw new Error('ENOENT');
  105. });
  106. const result = detectHeapSize();
  107. expect(result).toBeUndefined();
  108. readSpy.mockRestore();
  109. });
  110. it('should return undefined for empty V8_MAX_HEAP_SIZE', () => {
  111. process.env.V8_MAX_HEAP_SIZE = '';
  112. const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
  113. throw new Error('ENOENT');
  114. });
  115. const result = detectHeapSize();
  116. expect(result).toBeUndefined();
  117. readSpy.mockRestore();
  118. });
  119. it('should auto-calculate from cgroup v2 at 60%', () => {
  120. delete process.env.V8_MAX_HEAP_SIZE;
  121. // 1GB = 1073741824 bytes → 60% ≈ 614 MB
  122. const readSpy = vi
  123. .spyOn(fs, 'readFileSync')
  124. .mockImplementation((filePath) => {
  125. if (filePath === '/sys/fs/cgroup/memory.max') return '1073741824\n';
  126. throw new Error('ENOENT');
  127. });
  128. const result = detectHeapSize();
  129. expect(result).toBe(Math.floor((1073741824 / 1024 / 1024) * 0.6));
  130. readSpy.mockRestore();
  131. });
  132. it('should fallback to cgroup v1 when v2 is unlimited', () => {
  133. delete process.env.V8_MAX_HEAP_SIZE;
  134. // v2 = max (unlimited), v1 = 2GB
  135. const readSpy = vi
  136. .spyOn(fs, 'readFileSync')
  137. .mockImplementation((filePath) => {
  138. if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
  139. if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
  140. return '2147483648\n';
  141. throw new Error('ENOENT');
  142. });
  143. const result = detectHeapSize();
  144. expect(result).toBe(Math.floor((2147483648 / 1024 / 1024) * 0.6));
  145. readSpy.mockRestore();
  146. });
  147. it('should treat cgroup v1 > 64GB as unlimited', () => {
  148. delete process.env.V8_MAX_HEAP_SIZE;
  149. const hugeValue = 128 * 1024 * 1024 * 1024; // 128GB
  150. const readSpy = vi
  151. .spyOn(fs, 'readFileSync')
  152. .mockImplementation((filePath) => {
  153. if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
  154. if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
  155. return `${hugeValue}\n`;
  156. throw new Error('ENOENT');
  157. });
  158. const result = detectHeapSize();
  159. expect(result).toBeUndefined();
  160. readSpy.mockRestore();
  161. });
  162. it('should return undefined when no cgroup limits detected', () => {
  163. delete process.env.V8_MAX_HEAP_SIZE;
  164. const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
  165. throw new Error('ENOENT');
  166. });
  167. const result = detectHeapSize();
  168. expect(result).toBeUndefined();
  169. readSpy.mockRestore();
  170. });
  171. it('should prioritize V8_MAX_HEAP_SIZE over cgroup', () => {
  172. process.env.V8_MAX_HEAP_SIZE = '256';
  173. const readSpy = vi
  174. .spyOn(fs, 'readFileSync')
  175. .mockReturnValue('1073741824\n');
  176. const result = detectHeapSize();
  177. expect(result).toBe(256);
  178. // Should not have read cgroup files
  179. expect(readSpy).not.toHaveBeenCalled();
  180. readSpy.mockRestore();
  181. });
  182. });
  183. describe('buildNodeFlags', () => {
  184. const originalEnv = process.env;
  185. beforeEach(() => {
  186. process.env = { ...originalEnv };
  187. });
  188. afterEach(() => {
  189. process.env = originalEnv;
  190. });
  191. it('should always include --expose_gc', () => {
  192. const flags = buildNodeFlags(undefined);
  193. expect(flags).toContain('--expose_gc');
  194. });
  195. it('should include --max-heap-size when heapSize is provided', () => {
  196. const flags = buildNodeFlags(512);
  197. expect(flags).toContain('--max-heap-size=512');
  198. });
  199. it('should not include --max-heap-size when heapSize is undefined', () => {
  200. const flags = buildNodeFlags(undefined);
  201. expect(flags.some((f) => f.startsWith('--max-heap-size'))).toBe(false);
  202. });
  203. it('should include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE=true', () => {
  204. process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
  205. const flags = buildNodeFlags(undefined);
  206. expect(flags).toContain('--optimize-for-size');
  207. });
  208. it('should not include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE is not true', () => {
  209. process.env.V8_OPTIMIZE_FOR_SIZE = 'false';
  210. const flags = buildNodeFlags(undefined);
  211. expect(flags).not.toContain('--optimize-for-size');
  212. });
  213. it('should include --lite-mode when V8_LITE_MODE=true', () => {
  214. process.env.V8_LITE_MODE = 'true';
  215. const flags = buildNodeFlags(undefined);
  216. expect(flags).toContain('--lite-mode');
  217. });
  218. it('should not include --lite-mode when V8_LITE_MODE is not true', () => {
  219. delete process.env.V8_LITE_MODE;
  220. const flags = buildNodeFlags(undefined);
  221. expect(flags).not.toContain('--lite-mode');
  222. });
  223. it('should combine all flags when all options enabled', () => {
  224. process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
  225. process.env.V8_LITE_MODE = 'true';
  226. const flags = buildNodeFlags(256);
  227. expect(flags).toContain('--expose_gc');
  228. expect(flags).toContain('--max-heap-size=256');
  229. expect(flags).toContain('--optimize-for-size');
  230. expect(flags).toContain('--lite-mode');
  231. });
  232. it('should not use --max_old_space_size', () => {
  233. const flags = buildNodeFlags(512);
  234. expect(flags.some((f) => f.includes('max_old_space_size'))).toBe(false);
  235. });
  236. });
  237. describe('setupDirectories', () => {
  238. let tmpDir: string;
  239. beforeEach(() => {
  240. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-setup-'));
  241. });
  242. afterEach(() => {
  243. fs.rmSync(tmpDir, { recursive: true, force: true });
  244. });
  245. it('should create uploads directory and symlink', () => {
  246. const uploadsDir = path.join(tmpDir, 'data', 'uploads');
  247. const publicUploads = path.join(tmpDir, 'public', 'uploads');
  248. fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
  249. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
  250. const lchownSyncSpy = vi
  251. .spyOn(fs, 'lchownSync')
  252. .mockImplementation(() => {});
  253. setupDirectories(
  254. uploadsDir,
  255. publicUploads,
  256. path.join(tmpDir, 'bulk-export'),
  257. );
  258. expect(fs.existsSync(uploadsDir)).toBe(true);
  259. expect(fs.lstatSync(publicUploads).isSymbolicLink()).toBe(true);
  260. expect(fs.readlinkSync(publicUploads)).toBe(uploadsDir);
  261. chownSyncSpy.mockRestore();
  262. lchownSyncSpy.mockRestore();
  263. });
  264. it('should not recreate symlink if it already exists', () => {
  265. const uploadsDir = path.join(tmpDir, 'data', 'uploads');
  266. const publicUploads = path.join(tmpDir, 'public', 'uploads');
  267. fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
  268. fs.mkdirSync(uploadsDir, { recursive: true });
  269. fs.symlinkSync(uploadsDir, publicUploads);
  270. const symlinkSpy = vi.spyOn(fs, 'symlinkSync');
  271. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
  272. const lchownSyncSpy = vi
  273. .spyOn(fs, 'lchownSync')
  274. .mockImplementation(() => {});
  275. setupDirectories(
  276. uploadsDir,
  277. publicUploads,
  278. path.join(tmpDir, 'bulk-export'),
  279. );
  280. expect(symlinkSpy).not.toHaveBeenCalled();
  281. symlinkSpy.mockRestore();
  282. chownSyncSpy.mockRestore();
  283. lchownSyncSpy.mockRestore();
  284. });
  285. it('should create bulk export directory with permissions', () => {
  286. const bulkExportDir = path.join(tmpDir, 'bulk-export');
  287. fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
  288. const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
  289. const lchownSyncSpy = vi
  290. .spyOn(fs, 'lchownSync')
  291. .mockImplementation(() => {});
  292. setupDirectories(
  293. path.join(tmpDir, 'data', 'uploads'),
  294. path.join(tmpDir, 'public', 'uploads'),
  295. bulkExportDir,
  296. );
  297. expect(fs.existsSync(bulkExportDir)).toBe(true);
  298. const stat = fs.statSync(bulkExportDir);
  299. expect(stat.mode & 0o777).toBe(0o700);
  300. chownSyncSpy.mockRestore();
  301. lchownSyncSpy.mockRestore();
  302. });
  303. });