Skip to content

Commit 47f97e1

Browse files
authored
UBERF-12032 Readonly storage config (#9412)
Signed-off-by: Alexander Onnikov <[email protected]>
1 parent 22d54d4 commit 47f97e1

File tree

6 files changed

+136
-7
lines changed

6 files changed

+136
-7
lines changed

server/core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ export interface StorageConfig {
521521
kind: string
522522
endpoint: string
523523
port?: number
524+
readonly?: string
524525
}
525526

526527
export class NoSuchKeyError extends Error {

server/server-storage/src/readonly.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// Copyright © 2025 Hardcore Engineering Inc.
3+
//
4+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License. You may
6+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
//
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
import type { WorkspaceIds, Blob, MeasureContext } from '@hcengineering/core'
17+
import type { BlobStorageIterator, BucketInfo, StorageAdapter, UploadedObjectInfo } from '@hcengineering/storage'
18+
import { type Readable } from 'stream'
19+
20+
class ReadonlyError extends Error {
21+
constructor () {
22+
super('Readonly mode')
23+
this.name = 'ReadonlyError'
24+
}
25+
}
26+
27+
export class ReadonlyStorageAdapter implements StorageAdapter {
28+
constructor (private readonly adapter: StorageAdapter) {}
29+
30+
async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
31+
await this.adapter.initialize(ctx, wsIds)
32+
}
33+
34+
async close (): Promise<void> {
35+
await this.adapter.close()
36+
}
37+
38+
async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<boolean> {
39+
return await this.adapter.exists(ctx, wsIds)
40+
}
41+
42+
async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
43+
throw new ReadonlyError()
44+
}
45+
46+
async listBuckets (ctx: MeasureContext): Promise<BucketInfo[]> {
47+
return await this.adapter.listBuckets(ctx)
48+
}
49+
50+
async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
51+
throw new ReadonlyError()
52+
}
53+
54+
async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise<void> {
55+
throw new ReadonlyError()
56+
}
57+
58+
async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<BlobStorageIterator> {
59+
return await this.adapter.listStream(ctx, wsIds)
60+
}
61+
62+
async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise<Blob | undefined> {
63+
return await this.adapter.stat(ctx, wsIds, objectName)
64+
}
65+
66+
async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise<Readable> {
67+
return await this.adapter.get(ctx, wsIds, objectName)
68+
}
69+
70+
async partial (
71+
ctx: MeasureContext,
72+
wsIds: WorkspaceIds,
73+
objectName: string,
74+
offset: number,
75+
length?: number | undefined
76+
): Promise<Readable> {
77+
return await this.adapter.partial(ctx, wsIds, objectName, offset, length)
78+
}
79+
80+
async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise<Buffer[]> {
81+
return await this.adapter.read(ctx, wsIds, objectName)
82+
}
83+
84+
put (
85+
ctx: MeasureContext,
86+
wsIds: WorkspaceIds,
87+
objectName: string,
88+
stream: string | Readable | Buffer,
89+
contentType: string,
90+
size?: number | undefined
91+
): Promise<UploadedObjectInfo> {
92+
throw new ReadonlyError()
93+
}
94+
95+
async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise<string> {
96+
return await this.adapter.getUrl(ctx, wsIds, name)
97+
}
98+
}

server/server-storage/src/starter.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CONFIG_KIND as MINIO_CONFIG_KIND, MinioConfig, MinioService, addMinioFa
33
import { CONFIG_KIND as S3_CONFIG_KIND, S3Service, type S3Config } from '@hcengineering/s3'
44
import { StorageAdapter, StorageConfiguration, type StorageConfig } from '@hcengineering/server-core'
55
import { FallbackStorageAdapter, buildStorage } from './fallback'
6+
import { ReadonlyStorageAdapter } from './readonly'
67

78
/*
89
@@ -76,28 +77,35 @@ export function parseStorageEnv (storageEnv: string, storageConfig: StorageConfi
7677
}
7778

7879
export function createStorageFromConfig (config: StorageConfig): StorageAdapter {
80+
let adapter: StorageAdapter
7981
const kind = config.kind
8082
if (kind === MINIO_CONFIG_KIND) {
8183
const c = config as MinioConfig
8284
if (c.endpoint == null || c.accessKey == null || c.secretKey == null) {
8385
throw new Error('One of endpoint/accessKey/secretKey values are not specified')
8486
}
85-
return new MinioService(c)
87+
adapter = new MinioService(c)
8688
} else if (kind === S3_CONFIG_KIND) {
8789
const c = config as S3Config
8890
if (c.endpoint == null || c.accessKey == null || c.secretKey == null) {
8991
throw new Error('One of endpoint/accessKey/secretKey values are not specified')
9092
}
91-
return new S3Service(c)
93+
adapter = new S3Service(c)
9294
} else if (kind === DATALAKE_CONFIG_KIND) {
9395
const c = config as DatalakeConfig
9496
if (c.endpoint == null) {
9597
throw new Error('Endpoint value is not specified')
9698
}
97-
return new DatalakeService(c)
99+
adapter = new DatalakeService(c)
98100
} else {
99101
throw new Error('Unsupported storage kind:' + kind)
100102
}
103+
104+
if (config.readonly === 'true') {
105+
adapter = new ReadonlyStorageAdapter(adapter)
106+
}
107+
108+
return adapter
101109
}
102110

103111
export function buildStorageFromConfig (config: StorageConfiguration): FallbackStorageAdapter {

services/datalake/pod-datalake/src/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Config {
2929
DbUrl: string
3030
Buckets: BucketConfig[]
3131
CleanupInterval: number
32+
Readonly: boolean
3233
}
3334

3435
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@@ -77,7 +78,8 @@ const config: Config = (() => {
7778
Secret: process.env.SECRET,
7879
AccountsUrl: process.env.ACCOUNTS_URL,
7980
DbUrl: process.env.DB_URL,
80-
Buckets: parseBucketsConfig(process.env.BUCKETS)
81+
Buckets: parseBucketsConfig(process.env.BUCKETS),
82+
Readonly: process.env.READONLY === 'true'
8183
}
8284

8385
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)

services/datalake/pod-datalake/src/middleware.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
// limitations under the License.
1414
//
1515

16+
import { systemAccountUuid } from '@hcengineering/core'
1617
import { extractToken } from '@hcengineering/server-client'
18+
import { Token } from '@hcengineering/server-token'
1719
import { type Response, type Request, type NextFunction, RequestHandler } from 'express'
1820
import { ApiError } from './error'
19-
import { Token } from '@hcengineering/server-token'
20-
import { systemAccountUuid } from '@hcengineering/core'
2121

2222
export interface KeepAliveOptions {
2323
timeout: number
@@ -106,3 +106,12 @@ export const withBlob = (req: RequestWithAuth, res: Response, next: NextFunction
106106

107107
next()
108108
}
109+
110+
export const withReadonly = (req: RequestWithAuth, res: Response, next: NextFunction): void => {
111+
if (req.method === 'GET' || req.method === 'HEAD') {
112+
next()
113+
return
114+
}
115+
116+
next(new ApiError(403, 'Service is in read-only mode'))
117+
}

services/datalake/pod-datalake/src/server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ import onHeaders from 'on-headers'
2828
import { cacheControl } from './const'
2929
import { createDb } from './datalake/db'
3030
import { ApiError } from './error'
31-
import { keepAlive, withAdminAuthorization, withAuthorization, withBlob, withWorkspace } from './middleware'
31+
import {
32+
keepAlive,
33+
withAdminAuthorization,
34+
withAuthorization,
35+
withBlob,
36+
withWorkspace,
37+
withReadonly
38+
} from './middleware'
3239
import {
3340
handleBlobDelete,
3441
handleBlobDeleteList,
@@ -152,6 +159,10 @@ export async function createServer (
152159

153160
app.use(morgan('short', { stream: new LogStream() }))
154161

162+
if (config.Readonly) {
163+
app.use(withReadonly)
164+
}
165+
155166
app.get(
156167
'/stats/:workspace',
157168
withAdminAuthorization,

0 commit comments

Comments
 (0)