Skip to content

Commit 4533724

Browse files
committed
Merge branch 'master' of github.com:openforis/arena-server into feat/auto-scaling
2 parents 11ae857 + cafb8ad commit 4533724

12 files changed

Lines changed: 1611 additions & 1482 deletions

File tree

.env.template

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ RATE_LIMIT_WINDOW_MS=900000
2222
RATE_LIMIT_MAX=100
2323
## File upload limit in bytes (default: 1GB)
2424
FILE_UPLOAD_LIMIT=1073741824
25+
## Optional local log folder used for rolling files and S3 shipping
26+
LOG_FOLDER=./logs
27+
LOG_MAX_SIZE_BYTES=10485760
28+
LOG_RETENTION_DAYS=30
29+
LOG_UPLOAD_INTERVAL_MS=60000
30+
LOG_S3_PREFIX=logs
31+
## Optional S3-backed log shipping (requires LOG_S3_ENABLED=true and file storage S3 to be enabled)
32+
LOG_S3_ENABLED=false
33+
FILE_STORAGE_AWS_ACCESS_KEY=
34+
FILE_STORAGE_AWS_SECRET_ACCESS_KEY=
35+
FILE_STORAGE_AWS_S3_BUCKET_NAME=
36+
FILE_STORAGE_AWS_S3_BUCKET_REGION=
2537
# Security - IMPORTANT: Use strong random secrets in production (e.g., 64+ character random string)
2638
USER_AUTH_TOKEN_SECRET=user-auth-token-secret
2739
USER_2FA_SECRET=user-2fa-secret

.yarn/install-state.gz

1.28 KB
Binary file not shown.

.yarnrc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ enableScripts: true
55

66
nodeLinker: node-modules
77

8+
npmMinimalAgeGate: 0
9+
810
npmPreapprovedPackages:
911
- "@openforis/arena-core"
1012

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "OpenForis",
55
"email": "openforis.arena@gmail.com"
66
},
7-
"version": "2.0.2",
7+
"version": "2.0.3",
88
"description": "",
99
"main": "dist/index.js",
1010
"types": "dist/index.d.ts",
@@ -44,6 +44,7 @@
4444
"typescript-eslint": "^8.59.2"
4545
},
4646
"dependencies": {
47+
"@aws-sdk/client-s3": "^3.1112.0",
4748
"@godaddy/terminus": "^4.12.1",
4849
"@openforis/arena-core": "^1.5.0",
4950
"bcryptjs": "^3.0.3",

src/fileStorage/s3Storage.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
2+
3+
import { ProcessEnv } from '../processEnv'
4+
5+
export type S3StorageOptions = {
6+
bucketName: string
7+
region: string | undefined
8+
accessKeyId: string
9+
secretAccessKey: string
10+
endpoint?: string
11+
forcePathStyle?: boolean
12+
}
13+
14+
export class S3Storage {
15+
static fromEnvironment(): S3Storage | null {
16+
if (!ProcessEnv.fileStorageAwsEnabled) return null
17+
18+
return new S3Storage({
19+
bucketName: ProcessEnv.fileStorageAwsS3BucketName!,
20+
region: ProcessEnv.fileStorageAwsS3BucketRegion,
21+
accessKeyId: ProcessEnv.fileStorageAwsAccessKey!,
22+
secretAccessKey: ProcessEnv.fileStorageAwsSecretAccessKey!,
23+
})
24+
}
25+
26+
private readonly client: S3Client
27+
28+
constructor(private readonly options: S3StorageOptions) {
29+
this.client = new S3Client({
30+
region: options.region,
31+
endpoint: options.endpoint,
32+
forcePathStyle: options.forcePathStyle,
33+
credentials: {
34+
accessKeyId: options.accessKeyId,
35+
secretAccessKey: options.secretAccessKey,
36+
},
37+
})
38+
}
39+
40+
async putFile(key: string, body: Buffer | Uint8Array | string, contentType?: string): Promise<void> {
41+
await this.client.send(
42+
new PutObjectCommand({
43+
Bucket: this.options.bucketName,
44+
Key: key,
45+
Body: body,
46+
ContentType: contentType,
47+
})
48+
)
49+
}
50+
51+
async deleteFile(key: string): Promise<void> {
52+
await this.client.send(
53+
new DeleteObjectCommand({
54+
Bucket: this.options.bucketName,
55+
Key: key,
56+
})
57+
)
58+
}
59+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { S3Storage } from '../s3Storage'
2+
3+
const sendMock = jest.fn()
4+
5+
jest.mock('@aws-sdk/client-s3', () => {
6+
return {
7+
S3Client: jest.fn().mockImplementation(() => ({ send: sendMock })),
8+
PutObjectCommand: jest.fn().mockImplementation((params) => params),
9+
DeleteObjectCommand: jest.fn().mockImplementation((params) => params),
10+
}
11+
})
12+
13+
describe('S3Storage', () => {
14+
const originalEnv = { ...process.env }
15+
16+
afterEach(() => {
17+
process.env = { ...originalEnv }
18+
jest.clearAllMocks()
19+
})
20+
21+
test('uploadFile sends a PutObjectCommand with the given payload and key', async () => {
22+
const storage = new S3Storage({
23+
bucketName: 'my-bucket',
24+
region: 'eu-central-1',
25+
accessKeyId: 'key',
26+
secretAccessKey: 'secret',
27+
})
28+
29+
await storage.putFile('logs/app.log', Buffer.from('hello world'), 'text/plain; charset=utf-8')
30+
31+
expect(sendMock).toHaveBeenCalledTimes(1)
32+
expect(sendMock.mock.calls[0][0]).toMatchObject({
33+
Bucket: 'my-bucket',
34+
Key: 'logs/app.log',
35+
ContentType: 'text/plain; charset=utf-8',
36+
Body: Buffer.from('hello world'),
37+
})
38+
})
39+
})

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export type { JobContext, JobMessageIn, JobMessageOut } from './job'
3636

3737
export { Logger } from './log'
3838

39+
export { S3Storage } from './fileStorage/s3Storage'
40+
export type { S3StorageOptions } from './fileStorage/s3Storage'
41+
3942
export { ProcessEnv, NodeEnv } from './processEnv'
4043

4144
export { NodeDefRepository } from './repository'

src/log/log4js.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
1+
import path from 'node:path'
2+
13
import { configure, Logger } from 'log4js'
24

5+
import { ProcessEnv } from '../processEnv'
6+
import { startLogUploadPolling } from './logFileS3Upload'
7+
38
// Only display color for terminals:
49
const layout = process.stdout.isTTY ? { type: 'colored' } : { type: 'basic' }
510

11+
startLogUploadPolling()
12+
613
export const getLogger = (category?: string): Logger => {
14+
const fileAppender = {
15+
type: 'file',
16+
filename: path.join(path.resolve(ProcessEnv.logFolder), 'arena.log'),
17+
maxLogSize: ProcessEnv.logMaxSizeBytes,
18+
backups: 5,
19+
compress: true,
20+
}
21+
722
const log4js = configure({
823
appenders: {
924
console: { type: 'console', layout },
10-
// { file: { type: 'file', filename: 'arena.log' }
25+
file: fileAppender,
1126
},
1227
categories: {
1328
default: {
14-
appenders: ['console'],
29+
appenders: ['console', 'file'],
1530
level: 'debug',
16-
// Appenders: ['file'], level: 'error'
1731
},
1832
},
1933
})

src/log/logFileS3Upload.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { mkdir, readFile, readdir, rm, stat, unlink } from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
import { S3Storage } from '../fileStorage/s3Storage'
5+
import { ProcessEnv } from '../processEnv'
6+
7+
const withLogFolder = async (): Promise<string> => {
8+
const logFolder = path.resolve(ProcessEnv.logFolder)
9+
await mkdir(logFolder, { recursive: true })
10+
return logFolder
11+
}
12+
13+
const trimSlashes = (value: string): string => {
14+
let start = 0
15+
let end = value.length
16+
17+
while (start < end && value[start] === '/') start += 1
18+
while (end > start && value[end - 1] === '/') end -= 1
19+
20+
return value.slice(start, end)
21+
}
22+
23+
const uploadLogFileToS3 = async (logFolder: string, fileName: string, s3Storage: S3Storage): Promise<void> => {
24+
const shouldDeleteAfterUpload = fileName !== 'arena.log'
25+
26+
const absolutePath = path.join(logFolder, fileName)
27+
const fileStats = await stat(absolutePath)
28+
if (shouldDeleteAfterUpload && fileStats.size === 0) return
29+
30+
const key = `${trimSlashes(ProcessEnv.logS3Prefix)}/${fileName}`
31+
const body = await readFile(absolutePath)
32+
const contentType = fileName.endsWith('.gz') ? 'application/gzip' : 'text/plain; charset=utf-8'
33+
34+
await s3Storage.putFile(key, body, contentType)
35+
36+
if (shouldDeleteAfterUpload) {
37+
await unlink(absolutePath)
38+
}
39+
}
40+
41+
const uploadPendingLogFilesToS3 = async (logFolder: string, s3Storage: S3Storage): Promise<void> => {
42+
const entries = await readdir(logFolder, { withFileTypes: true })
43+
44+
for (const entry of entries) {
45+
if (!entry.isFile()) continue
46+
await uploadLogFileToS3(logFolder, entry.name, s3Storage)
47+
}
48+
}
49+
50+
const cleanupStaleLogFiles = async (logFolder: string): Promise<void> => {
51+
const cutoffTimestamp = Date.now() - ProcessEnv.logRetentionDays * 24 * 60 * 60 * 1000
52+
const entries = await readdir(logFolder, { withFileTypes: true })
53+
54+
for (const entry of entries) {
55+
if (!entry.isFile()) continue
56+
57+
const absolutePath = path.join(logFolder, entry.name)
58+
const fileStats = await stat(absolutePath)
59+
if (fileStats.mtimeMs < cutoffTimestamp) {
60+
await rm(absolutePath, { force: true })
61+
}
62+
}
63+
}
64+
65+
const uploadLogFilesToS3 = async (): Promise<void> => {
66+
if (!ProcessEnv.logS3Enabled) return
67+
68+
const logFolder = await withLogFolder()
69+
const s3Storage = S3Storage.fromEnvironment()
70+
if (!s3Storage) return
71+
72+
await uploadPendingLogFilesToS3(logFolder, s3Storage)
73+
await cleanupStaleLogFiles(logFolder)
74+
}
75+
76+
export const startLogUploadPolling = (): void => {
77+
if (!ProcessEnv.logS3Enabled) return
78+
79+
void uploadLogFilesToS3()
80+
const timer = setInterval(() => {
81+
void uploadLogFilesToS3()
82+
}, ProcessEnv.logUploadIntervalMs)
83+
timer.unref?.()
84+
}

0 commit comments

Comments
 (0)