Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf838f938a | |||
| 32bf9bae0e | |||
| 038ceb976f | |||
| 0c96bbc281 | |||
| 14d9ade4d0 | |||
| 332281746e | |||
| 99d9a0a581 | |||
| ee0640bb80 | |||
| 90ac853ab4 | |||
| 449278b672 |
@@ -48,7 +48,7 @@ RUN deno cache mod.ts
|
||||
# Create storage directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 9000 3000
|
||||
EXPOSE 9000 3000 4433
|
||||
VOLUME ["/data"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["deno", "run", "--allow-all", "mod.ts", "server"]
|
||||
|
||||
30
changelog.md
30
changelog.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-22 - 1.7.0 - feat(cluster)
|
||||
add cluster configuration support across server, CLI, and admin UI
|
||||
|
||||
- add environment variable and CLI support for cluster, QUIC, seed node, drive, erasure coding, and heartbeat settings
|
||||
- pass cluster-aware configuration into smartstorage and expose the QUIC port in Docker
|
||||
- extend config APIs and admin UI to display cluster, erasure coding, and storage drive configuration
|
||||
- upgrade @push.rocks/smartstorage to ^6.3.1 to support the new cluster capabilities
|
||||
|
||||
## 2026-03-21 - 1.6.0 - feat(scripts)
|
||||
add release script for committing and pushing docker images
|
||||
|
||||
- Adds a new npm release script to automate git commit and Docker image push steps.
|
||||
|
||||
## 2026-03-21 - 1.5.1 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-03-21 - 1.5.0 - feat(test)
|
||||
add end-to-end test coverage for container lifecycle, auth, buckets, objects, policies, credentials, status, and S3 compatibility
|
||||
|
||||
- adds a reusable test helper for creating isolated ObjectStorageContainer instances and logging in as admin
|
||||
- introduces a test script in package.json to run the new Deno test suite
|
||||
- covers core management API flows including authentication, bucket operations, object operations, named policies, credentials, and server status/config
|
||||
- verifies S3 SDK interoperability including bucket and object operations plus credential rejection cases
|
||||
|
||||
## 2026-03-15 - 1.4.2 - fix(license)
|
||||
add missing license file
|
||||
|
||||
- Adds a repository license file.
|
||||
|
||||
## 2026-03-15 - 1.4.1 - fix(readme)
|
||||
refresh README with clearer feature documentation, UI screenshots, and client usage examples
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lossless.zone/objectstorage",
|
||||
"version": "1.4.1",
|
||||
"version": "1.7.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "pnpm run watch"
|
||||
},
|
||||
"imports": {
|
||||
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.0.1",
|
||||
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.3.1",
|
||||
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0",
|
||||
"@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.937.0",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
|
||||
41
deno.lock
generated
41
deno.lock
generated
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.19",
|
||||
"jsr:@std/assert@^1.0.17": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||
"jsr:@std/testing@*": "1.0.17",
|
||||
"npm:@api.global/typedrequest-interfaces@^3.0.19": "3.0.19",
|
||||
"npm:@api.global/typedrequest@^3.2.6": "3.3.0",
|
||||
"npm:@api.global/typedserver@^8.3.1": "8.4.2_@push.rocks+smartserve@2.0.1",
|
||||
@@ -13,7 +17,25 @@
|
||||
"npm:@push.rocks/smartbucket@^4.3.0": "4.5.1",
|
||||
"npm:@push.rocks/smartguard@^3.1.0": "3.1.0",
|
||||
"npm:@push.rocks/smartjwt@^2.2.1": "2.2.1",
|
||||
"npm:@push.rocks/smartstorage@^6.0.1": "6.0.1"
|
||||
"npm:@push.rocks/smartstorage@^6.3.1": "6.3.1"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/testing@1.0.17": {
|
||||
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.17",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@api.global/typedrequest-interfaces@2.0.2": {
|
||||
@@ -1929,14 +1951,14 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartstate/-/smartstate-2.2.1.tgz"
|
||||
},
|
||||
"@push.rocks/smartstorage@6.0.1": {
|
||||
"integrity": "sha512-W5PEVwO0J2K9YUZRTbKXadC11h6/IBzzqU+P0TIE/xpJZC4K1duEXwEhxGWcbfhCkPRRa51xH8Z5mAmzzm8qxA==",
|
||||
"@push.rocks/smartstorage@6.3.1": {
|
||||
"integrity": "sha512-Emxwyeb/BH//q32IB2Z34Clz0yR3mjWKgd+qhnAKA6UADRmF3DMemdz5cQVkmpQeD1YE8hu633gHmynsLTFMRA==",
|
||||
"dependencies": [
|
||||
"@push.rocks/smartpath@6.0.0",
|
||||
"@push.rocks/smartrust",
|
||||
"@tsclass/tsclass@9.4.0"
|
||||
"@tsclass/tsclass@9.5.0"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartstorage/-/smartstorage-6.0.1.tgz"
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartstorage/-/smartstorage-6.3.1.tgz"
|
||||
},
|
||||
"@push.rocks/smartstream@2.0.8": {
|
||||
"integrity": "sha512-GlF/9cCkvBHwKa3DK4DO5wjfSgqkj6gAS4TrY9uD5NMHu9RQv4WiNrElTYj7iCEpnZgUnLO3tzw1JA3NRIMnnA==",
|
||||
@@ -3108,6 +3130,13 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@tsclass/tsclass/-/tsclass-9.4.0.tgz"
|
||||
},
|
||||
"@tsclass/tsclass@9.5.0": {
|
||||
"integrity": "sha512-HwMVwkrBnEFMjwOsMkGwWN/q+XEczSpf4a/PBAXgkDdV6sXdxAMFXUH1tW8Y5ecuvXFYMvFry4X57MCCT7Dm8A==",
|
||||
"dependencies": [
|
||||
"type-fest@5.4.4"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@tsclass/tsclass/-/tsclass-9.5.0.tgz"
|
||||
},
|
||||
"@tybys/wasm-util@0.10.1": {
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dependencies": [
|
||||
@@ -6028,7 +6057,7 @@
|
||||
"npm:@push.rocks/smartbucket@^4.3.0",
|
||||
"npm:@push.rocks/smartguard@^3.1.0",
|
||||
"npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"npm:@push.rocks/smartstorage@^6.0.1"
|
||||
"npm:@push.rocks/smartstorage@^6.3.1"
|
||||
],
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
|
||||
19
license
Normal file
19
license
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "@lossless.zone/objectstorage",
|
||||
"version": "1.4.1",
|
||||
"version": "1.7.0",
|
||||
"description": "object storage server with management UI powered by smartstorage",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "deno task test",
|
||||
"watch": "tswatch",
|
||||
"build": "tsbundle",
|
||||
"bundle": "tsbundle",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release": "gitzone commit -yp && tsdocker push --verbose",
|
||||
"start:docker": "docker stop objectstorage 2>/dev/null; docker rm objectstorage 2>/dev/null; docker build --load -t objectstorage:latest . && docker run --rm --name objectstorage -p 9000:9000 -p 3000:3000 -v objectstorage-data:/data objectstorage:latest"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
|
||||
49
test/helpers/server.helper.ts
Normal file
49
test/helpers/server.helper.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ObjectStorageContainer } from '../../ts/index.ts';
|
||||
import type { IObjectStorageConfig } from '../../ts/types.ts';
|
||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import type { IReq_AdminLoginWithUsernameAndPassword } from '../../ts_interfaces/requests/admin.ts';
|
||||
|
||||
export const TEST_ADMIN_PASSWORD = 'testpassword';
|
||||
export const TEST_ACCESS_KEY = 'testkey';
|
||||
export const TEST_SECRET_KEY = 'testsecret';
|
||||
|
||||
export function getTestPorts(index: number): { objstPort: number; uiPort: number } {
|
||||
return {
|
||||
objstPort: 19000 + index * 10,
|
||||
uiPort: 19001 + index * 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestContainer(
|
||||
index: number,
|
||||
extraConfig?: Partial<IObjectStorageConfig>,
|
||||
): ObjectStorageContainer {
|
||||
const ports = getTestPorts(index);
|
||||
return new ObjectStorageContainer({
|
||||
objstPort: ports.objstPort,
|
||||
uiPort: ports.uiPort,
|
||||
storageDirectory: `.nogit/testdata-${index}`,
|
||||
accessCredentials: [{ accessKeyId: TEST_ACCESS_KEY, secretAccessKey: TEST_SECRET_KEY }],
|
||||
adminPassword: TEST_ADMIN_PASSWORD,
|
||||
region: 'us-east-1',
|
||||
...extraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginAndGetIdentity(
|
||||
uiPort: number,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
|
||||
`http://localhost:${uiPort}/typedrequest`,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
const response = await req.fire({
|
||||
username: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
if (!response.identity) {
|
||||
throw new Error('Login failed: no identity returned');
|
||||
}
|
||||
return response.identity;
|
||||
}
|
||||
103
test/test.auth.test.ts
Normal file
103
test/test.auth.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { createTestContainer, getTestPorts, loginAndGetIdentity, TEST_ADMIN_PASSWORD } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type { IReq_AdminLoginWithUsernameAndPassword } from '../ts_interfaces/requests/admin.ts';
|
||||
import type { IReq_VerifyIdentity } from '../ts_interfaces/requests/admin.ts';
|
||||
import type { IReq_AdminLogout } from '../ts_interfaces/requests/admin.ts';
|
||||
import type { IReq_GetServerStatus } from '../ts_interfaces/requests/status.ts';
|
||||
|
||||
const PORT_INDEX = 1;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
|
||||
describe('Authentication', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should login with valid credentials', async () => {
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
assertExists(identity.jwt);
|
||||
assertEquals(identity.userId, 'admin');
|
||||
assertEquals(identity.username, 'admin');
|
||||
assertEquals(identity.role, 'admin');
|
||||
assertEquals(identity.expiresAt > Date.now(), true);
|
||||
});
|
||||
|
||||
it('should reject login with wrong password', async () => {
|
||||
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
|
||||
url,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
let threw = false;
|
||||
try {
|
||||
await req.fire({ username: 'admin', password: 'wrongpassword' });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertEquals(threw, true);
|
||||
});
|
||||
|
||||
it('should reject login with wrong username', async () => {
|
||||
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
|
||||
url,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
let threw = false;
|
||||
try {
|
||||
await req.fire({ username: 'notadmin', password: TEST_ADMIN_PASSWORD });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertEquals(threw, true);
|
||||
});
|
||||
|
||||
it('should verify a valid identity', async () => {
|
||||
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.valid, true);
|
||||
assertExists(response.identity);
|
||||
assertEquals(response.identity!.userId, 'admin');
|
||||
});
|
||||
|
||||
it('should reject verification with tampered JWT', async () => {
|
||||
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
|
||||
const tamperedIdentity = { ...identity, jwt: identity.jwt + 'tampered' };
|
||||
const response = await req.fire({ identity: tamperedIdentity });
|
||||
assertEquals(response.valid, false);
|
||||
});
|
||||
|
||||
it('should reject verification with missing identity', async () => {
|
||||
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
|
||||
const response = await req.fire({ identity: null as any });
|
||||
assertEquals(response.valid, false);
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
const req = new TypedRequest<IReq_AdminLogout>(url, 'adminLogout');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should reject protected endpoint without identity', async () => {
|
||||
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
|
||||
let threw = false;
|
||||
try {
|
||||
await req.fire({ identity: null as any });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertEquals(threw, true);
|
||||
});
|
||||
});
|
||||
133
test/test.buckets.test.ts
Normal file
133
test/test.buckets.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type {
|
||||
IReq_ListBuckets,
|
||||
IReq_CreateBucket,
|
||||
IReq_DeleteBucket,
|
||||
IReq_GetBucketPolicy,
|
||||
IReq_PutBucketPolicy,
|
||||
IReq_DeleteBucketPolicy,
|
||||
} from '../ts_interfaces/requests/buckets.ts';
|
||||
|
||||
const PORT_INDEX = 2;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
|
||||
describe('Bucket management', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should list buckets (initially empty)', async () => {
|
||||
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.buckets.length, 0);
|
||||
});
|
||||
|
||||
it('should create a bucket', async () => {
|
||||
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should create a second bucket', async () => {
|
||||
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-2' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should list both buckets', async () => {
|
||||
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.buckets.length, 2);
|
||||
const names = response.buckets.map((b) => b.name).sort();
|
||||
assertEquals(names, ['test-bucket-1', 'test-bucket-2']);
|
||||
});
|
||||
|
||||
it('should have valid bucket metadata', async () => {
|
||||
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
|
||||
const response = await req.fire({ identity });
|
||||
for (const bucket of response.buckets) {
|
||||
assertEquals(bucket.objectCount, 0);
|
||||
assertEquals(bucket.totalSizeBytes, 0);
|
||||
assertEquals(bucket.creationDate > 0, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should delete a bucket', async () => {
|
||||
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-2' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should list one remaining bucket', async () => {
|
||||
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.buckets.length, 1);
|
||||
assertEquals(response.buckets[0].name, 'test-bucket-1');
|
||||
});
|
||||
|
||||
it('should get bucket policy (initially null)', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertEquals(response.policy, null);
|
||||
});
|
||||
|
||||
it('should put a bucket policy', async () => {
|
||||
const policy = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: 'arn:aws:s3:::test-bucket-1/*',
|
||||
},
|
||||
],
|
||||
});
|
||||
const req = new TypedRequest<IReq_PutBucketPolicy>(url, 'putBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1', policy });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should get the bucket policy back', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
assertExists(parsed.Statement);
|
||||
assertEquals(parsed.Statement[0].Sid, 'PublicRead');
|
||||
});
|
||||
|
||||
it('should delete bucket policy', async () => {
|
||||
const req = new TypedRequest<IReq_DeleteBucketPolicy>(url, 'deleteBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should confirm policy is deleted', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertEquals(response.policy, null);
|
||||
});
|
||||
|
||||
it('cleanup: delete remaining bucket', async () => {
|
||||
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
});
|
||||
56
test/test.container-lifecycle.test.ts
Normal file
56
test/test.container-lifecycle.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { createTestContainer, getTestPorts } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import { defaultConfig } from '../ts/types.ts';
|
||||
|
||||
const PORT_INDEX = 0;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
|
||||
describe('ObjectStorageContainer lifecycle', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
|
||||
it('should create container with default config values', () => {
|
||||
const c = new ObjectStorageContainer();
|
||||
assertEquals(c.config.objstPort, defaultConfig.objstPort);
|
||||
assertEquals(c.config.uiPort, defaultConfig.uiPort);
|
||||
assertEquals(c.config.region, defaultConfig.region);
|
||||
assertEquals(c.config.adminPassword, defaultConfig.adminPassword);
|
||||
});
|
||||
|
||||
it('should create container with custom config overrides', () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
assertEquals(container.config.objstPort, ports.objstPort);
|
||||
assertEquals(container.config.uiPort, ports.uiPort);
|
||||
assertEquals(container.config.adminPassword, 'testpassword');
|
||||
assertEquals(container.config.accessCredentials[0].accessKeyId, 'testkey');
|
||||
});
|
||||
|
||||
it('should start successfully', async () => {
|
||||
await container.start();
|
||||
assertEquals(container.startedAt > 0, true);
|
||||
});
|
||||
|
||||
it('should have s3Client initialized after start', () => {
|
||||
assertExists(container.s3Client);
|
||||
});
|
||||
|
||||
it('should have smartstorageInstance initialized after start', () => {
|
||||
assertExists(container.smartstorageInstance);
|
||||
});
|
||||
|
||||
it('should have policyManager with empty policies', () => {
|
||||
assertExists(container.policyManager);
|
||||
const policies = container.policyManager.listPolicies();
|
||||
assertEquals(policies.length, 0);
|
||||
});
|
||||
|
||||
it('should have opsServer started', () => {
|
||||
assertExists(container.opsServer);
|
||||
assertExists(container.opsServer.server);
|
||||
});
|
||||
|
||||
it('should stop cleanly', async () => {
|
||||
await container.stop();
|
||||
});
|
||||
});
|
||||
93
test/test.credentials.test.ts
Normal file
93
test/test.credentials.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { assertEquals } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import {
|
||||
createTestContainer,
|
||||
getTestPorts,
|
||||
loginAndGetIdentity,
|
||||
TEST_ACCESS_KEY,
|
||||
} from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type {
|
||||
IReq_GetCredentials,
|
||||
IReq_AddCredential,
|
||||
IReq_RemoveCredential,
|
||||
} from '../ts_interfaces/requests/credentials.ts';
|
||||
|
||||
const PORT_INDEX = 6;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
|
||||
describe('Credential management', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should get credentials with masked secrets', async () => {
|
||||
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.credentials.length, 1);
|
||||
assertEquals(response.credentials[0].accessKeyId, TEST_ACCESS_KEY);
|
||||
// Secret should be masked: first 4 chars + ****
|
||||
assertEquals(response.credentials[0].secretAccessKey.includes('****'), true);
|
||||
});
|
||||
|
||||
it('should add a new credential', async () => {
|
||||
const req = new TypedRequest<IReq_AddCredential>(url, 'addCredential');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
accessKeyId: 'newkey',
|
||||
secretAccessKey: 'newsecret',
|
||||
});
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should list credentials showing both', async () => {
|
||||
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.credentials.length, 2);
|
||||
const keys = response.credentials.map((c) => c.accessKeyId);
|
||||
assertEquals(keys.includes(TEST_ACCESS_KEY), true);
|
||||
assertEquals(keys.includes('newkey'), true);
|
||||
});
|
||||
|
||||
it('should verify new credential is stored in config', () => {
|
||||
const creds = container.config.accessCredentials;
|
||||
const newCred = creds.find((c) => c.accessKeyId === 'newkey');
|
||||
assertEquals(newCred?.secretAccessKey, 'newsecret');
|
||||
});
|
||||
|
||||
it('should remove a credential', async () => {
|
||||
const req = new TypedRequest<IReq_RemoveCredential>(url, 'removeCredential');
|
||||
const response = await req.fire({ identity, accessKeyId: 'newkey' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should list credentials showing one remaining', async () => {
|
||||
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.credentials.length, 1);
|
||||
assertEquals(response.credentials[0].accessKeyId, TEST_ACCESS_KEY);
|
||||
});
|
||||
|
||||
it('should reject removing the last credential', async () => {
|
||||
const req = new TypedRequest<IReq_RemoveCredential>(url, 'removeCredential');
|
||||
let threw = false;
|
||||
try {
|
||||
await req.fire({ identity, accessKeyId: TEST_ACCESS_KEY });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertEquals(threw, true);
|
||||
});
|
||||
});
|
||||
215
test/test.objects.test.ts
Normal file
215
test/test.objects.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type { IReq_CreateBucket, IReq_DeleteBucket } from '../ts_interfaces/requests/buckets.ts';
|
||||
import type {
|
||||
IReq_ListObjects,
|
||||
IReq_PutObject,
|
||||
IReq_GetObject,
|
||||
IReq_DeleteObject,
|
||||
IReq_DeletePrefix,
|
||||
IReq_GetObjectUrl,
|
||||
IReq_MoveObject,
|
||||
IReq_MovePrefix,
|
||||
} from '../ts_interfaces/requests/objects.ts';
|
||||
|
||||
const PORT_INDEX = 3;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
const BUCKET = 'obj-test-bucket';
|
||||
|
||||
function toBase64(text: string): string {
|
||||
return btoa(text);
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
return atob(b64);
|
||||
}
|
||||
|
||||
describe('Object operations', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
|
||||
// Create test bucket
|
||||
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
|
||||
await req.fire({ identity, bucketName: BUCKET });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Delete test bucket
|
||||
try {
|
||||
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
await req.fire({ identity, bucketName: BUCKET });
|
||||
} catch { /* bucket may already be empty/deleted */ }
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should list objects (initially empty)', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET });
|
||||
assertEquals(response.result.objects.length, 0);
|
||||
assertEquals(response.result.commonPrefixes.length, 0);
|
||||
});
|
||||
|
||||
it('should put a text object', async () => {
|
||||
const req = new TypedRequest<IReq_PutObject>(url, 'putObject');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'hello.txt',
|
||||
base64Content: toBase64('Hello World'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should put nested objects', async () => {
|
||||
const req = new TypedRequest<IReq_PutObject>(url, 'putObject');
|
||||
await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'folder/nested.txt',
|
||||
base64Content: toBase64('Nested content'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'folder/a.txt',
|
||||
base64Content: toBase64('File A'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'folder/b.txt',
|
||||
base64Content: toBase64('File B'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'folder/sub/c.txt',
|
||||
base64Content: toBase64('File C'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should list objects at root with delimiter', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, delimiter: '/' });
|
||||
// Root should have hello.txt as direct object
|
||||
const rootKeys = response.result.objects.map((o) => o.key);
|
||||
assertEquals(rootKeys.includes('hello.txt'), true);
|
||||
// folder/ should appear as a common prefix
|
||||
assertEquals(response.result.commonPrefixes.includes('folder/'), true);
|
||||
});
|
||||
|
||||
it('should list objects with prefix', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
prefix: 'folder/',
|
||||
delimiter: '/',
|
||||
});
|
||||
const keys = response.result.objects.map((o) => o.key);
|
||||
assertEquals(keys.includes('folder/nested.txt'), true);
|
||||
assertEquals(keys.includes('folder/a.txt'), true);
|
||||
assertEquals(keys.includes('folder/b.txt'), true);
|
||||
// sub/ should be a common prefix
|
||||
assertEquals(response.result.commonPrefixes.includes('folder/sub/'), true);
|
||||
});
|
||||
|
||||
it('should get a text object', async () => {
|
||||
const req = new TypedRequest<IReq_GetObject>(url, 'getObject');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, key: 'hello.txt' });
|
||||
assertEquals(fromBase64(response.content), 'Hello World');
|
||||
assertEquals(response.size > 0, true);
|
||||
assertExists(response.lastModified);
|
||||
});
|
||||
|
||||
it('should get object URL', async () => {
|
||||
const req = new TypedRequest<IReq_GetObjectUrl>(url, 'getObjectUrl');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, key: 'hello.txt' });
|
||||
assertExists(response.url);
|
||||
assertEquals(response.url.includes(BUCKET), true);
|
||||
assertEquals(response.url.includes('hello.txt'), true);
|
||||
});
|
||||
|
||||
it('should move an object', async () => {
|
||||
const req = new TypedRequest<IReq_MoveObject>(url, 'moveObject');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
sourceKey: 'hello.txt',
|
||||
destKey: 'moved-hello.txt',
|
||||
});
|
||||
assertEquals(response.success, true);
|
||||
});
|
||||
|
||||
it('should verify moved object exists at new key', async () => {
|
||||
const req = new TypedRequest<IReq_GetObject>(url, 'getObject');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, key: 'moved-hello.txt' });
|
||||
assertEquals(fromBase64(response.content), 'Hello World');
|
||||
});
|
||||
|
||||
it('should verify source key no longer exists after move', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, delimiter: '/' });
|
||||
const rootKeys = response.result.objects.map((o) => o.key);
|
||||
assertEquals(rootKeys.includes('hello.txt'), false);
|
||||
assertEquals(rootKeys.includes('moved-hello.txt'), true);
|
||||
});
|
||||
|
||||
it('should move a prefix', async () => {
|
||||
const req = new TypedRequest<IReq_MovePrefix>(url, 'movePrefix');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
sourcePrefix: 'folder/',
|
||||
destPrefix: 'renamed/',
|
||||
});
|
||||
assertEquals(response.success, true);
|
||||
assertEquals((response.movedCount ?? 0) >= 4, true);
|
||||
});
|
||||
|
||||
it('should verify moved prefix contents', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'renamed/' });
|
||||
assertEquals(response.result.objects.length >= 1, true);
|
||||
});
|
||||
|
||||
it('should verify old prefix is empty', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'folder/' });
|
||||
assertEquals(response.result.objects.length, 0);
|
||||
assertEquals(response.result.commonPrefixes.length, 0);
|
||||
});
|
||||
|
||||
it('should delete a single object', async () => {
|
||||
const req = new TypedRequest<IReq_DeleteObject>(url, 'deleteObject');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, key: 'moved-hello.txt' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should delete a prefix', async () => {
|
||||
const req = new TypedRequest<IReq_DeletePrefix>(url, 'deletePrefix');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'renamed/' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should verify bucket is empty after cleanup', async () => {
|
||||
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
|
||||
const response = await req.fire({ identity, bucketName: BUCKET });
|
||||
assertEquals(response.result.objects.length, 0);
|
||||
});
|
||||
});
|
||||
258
test/test.policies.test.ts
Normal file
258
test/test.policies.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type { IReq_CreateBucket, IReq_DeleteBucket, IReq_GetBucketPolicy } from '../ts_interfaces/requests/buckets.ts';
|
||||
import type {
|
||||
IReq_ListNamedPolicies,
|
||||
IReq_CreateNamedPolicy,
|
||||
IReq_UpdateNamedPolicy,
|
||||
IReq_DeleteNamedPolicy,
|
||||
IReq_GetBucketNamedPolicies,
|
||||
IReq_AttachPolicyToBucket,
|
||||
IReq_DetachPolicyFromBucket,
|
||||
IReq_GetPolicyBuckets,
|
||||
IReq_SetPolicyBuckets,
|
||||
} from '../ts_interfaces/requests/policies.ts';
|
||||
|
||||
const PORT_INDEX = 5;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
|
||||
describe('Named policy management', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
let policy1Id: string;
|
||||
let policy2Id: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
|
||||
// Create test buckets
|
||||
const createBucket = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
|
||||
await createBucket.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
await createBucket.fire({ identity, bucketName: 'pol-bucket-2' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
try {
|
||||
const del = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
await del.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
} catch { /* may already be deleted */ }
|
||||
try {
|
||||
const del = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
await del.fire({ identity, bucketName: 'pol-bucket-2' });
|
||||
} catch { /* may already be deleted */ }
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should list policies (initially empty)', async () => {
|
||||
const req = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.policies.length, 0);
|
||||
});
|
||||
|
||||
it('should create a named policy', async () => {
|
||||
const req = new TypedRequest<IReq_CreateNamedPolicy>(url, 'createNamedPolicy');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
name: 'Public Read',
|
||||
description: 'Allows public read access',
|
||||
statements: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: 'arn:aws:s3:::${bucket}/*',
|
||||
},
|
||||
],
|
||||
});
|
||||
assertExists(response.policy.id);
|
||||
assertEquals(response.policy.name, 'Public Read');
|
||||
assertEquals(response.policy.statements.length, 1);
|
||||
assertEquals(response.policy.createdAt > 0, true);
|
||||
policy1Id = response.policy.id;
|
||||
});
|
||||
|
||||
it('should create a second policy', async () => {
|
||||
const req = new TypedRequest<IReq_CreateNamedPolicy>(url, 'createNamedPolicy');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
name: 'Deny Delete',
|
||||
description: 'Denies delete actions',
|
||||
statements: [
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:DeleteObject',
|
||||
Resource: 'arn:aws:s3:::${bucket}/*',
|
||||
},
|
||||
],
|
||||
});
|
||||
policy2Id = response.policy.id;
|
||||
assertExists(policy2Id);
|
||||
});
|
||||
|
||||
it('should list both policies', async () => {
|
||||
const req = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.policies.length, 2);
|
||||
});
|
||||
|
||||
it('should get bucket named policies (none attached)', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketNamedPolicies>(url, 'getBucketNamedPolicies');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertEquals(response.attachedPolicies.length, 0);
|
||||
assertEquals(response.availablePolicies.length, 2);
|
||||
});
|
||||
|
||||
it('should attach policy to bucket', async () => {
|
||||
const req = new TypedRequest<IReq_AttachPolicyToBucket>(url, 'attachPolicyToBucket');
|
||||
const response = await req.fire({ identity, policyId: policy1Id, bucketName: 'pol-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should verify bucket policy was applied with placeholder replaced', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
const resource = parsed.Statement[0].Resource;
|
||||
// Resource may be a string or array depending on the S3 engine
|
||||
const resourceStr = Array.isArray(resource) ? resource.join(' ') : resource;
|
||||
// ${bucket} should be replaced with actual bucket name
|
||||
assertEquals(resourceStr.includes('pol-bucket-1'), true);
|
||||
assertEquals(resourceStr.includes('${bucket}'), false);
|
||||
});
|
||||
|
||||
it('should get bucket named policies (one attached)', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketNamedPolicies>(url, 'getBucketNamedPolicies');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertEquals(response.attachedPolicies.length, 1);
|
||||
assertEquals(response.availablePolicies.length, 1);
|
||||
assertEquals(response.attachedPolicies[0].id, policy1Id);
|
||||
});
|
||||
|
||||
it('should attach second policy to same bucket', async () => {
|
||||
const req = new TypedRequest<IReq_AttachPolicyToBucket>(url, 'attachPolicyToBucket');
|
||||
const response = await req.fire({ identity, policyId: policy2Id, bucketName: 'pol-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should verify merged policy has statements from both', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
assertEquals(parsed.Statement.length >= 2, true);
|
||||
const sids = parsed.Statement.map((s: any) => s.Sid);
|
||||
assertEquals(sids.includes('PublicRead'), true);
|
||||
assertEquals(sids.includes('DenyDelete'), true);
|
||||
});
|
||||
|
||||
it('should get policy buckets', async () => {
|
||||
const req = new TypedRequest<IReq_GetPolicyBuckets>(url, 'getPolicyBuckets');
|
||||
const response = await req.fire({ identity, policyId: policy1Id });
|
||||
assertEquals(response.attachedBuckets.includes('pol-bucket-1'), true);
|
||||
assertEquals(response.availableBuckets.includes('pol-bucket-2'), true);
|
||||
});
|
||||
|
||||
it('should set policy buckets (batch)', async () => {
|
||||
const req = new TypedRequest<IReq_SetPolicyBuckets>(url, 'setPolicyBuckets');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
policyId: policy1Id,
|
||||
bucketNames: ['pol-bucket-1', 'pol-bucket-2'],
|
||||
});
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should verify policy applied to second bucket', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-2' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
const resource = parsed.Statement[0].Resource;
|
||||
const resourceStr = Array.isArray(resource) ? resource.join(' ') : resource;
|
||||
assertEquals(resourceStr.includes('pol-bucket-2'), true);
|
||||
});
|
||||
|
||||
it('should update a policy', async () => {
|
||||
const req = new TypedRequest<IReq_UpdateNamedPolicy>(url, 'updateNamedPolicy');
|
||||
const response = await req.fire({
|
||||
identity,
|
||||
policyId: policy1Id,
|
||||
name: 'Public Read Updated',
|
||||
description: 'Updated policy',
|
||||
statements: [
|
||||
{
|
||||
Sid: 'PublicReadUpdated',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject', 's3:ListBucket'],
|
||||
Resource: 'arn:aws:s3:::${bucket}/*',
|
||||
},
|
||||
],
|
||||
});
|
||||
assertEquals(response.policy.name, 'Public Read Updated');
|
||||
assertEquals(response.policy.updatedAt >= response.policy.createdAt, true);
|
||||
});
|
||||
|
||||
it('should verify updated policy cascaded to attached buckets', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
const sids = parsed.Statement.map((s: any) => s.Sid);
|
||||
assertEquals(sids.includes('PublicReadUpdated'), true);
|
||||
});
|
||||
|
||||
it('should detach policy from bucket', async () => {
|
||||
const req = new TypedRequest<IReq_DetachPolicyFromBucket>(url, 'detachPolicyFromBucket');
|
||||
const response = await req.fire({ identity, policyId: policy2Id, bucketName: 'pol-bucket-1' });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should verify bucket policy updated after detach', async () => {
|
||||
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
|
||||
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
assertExists(response.policy);
|
||||
const parsed = JSON.parse(response.policy!);
|
||||
const sids = parsed.Statement.map((s: any) => s.Sid);
|
||||
assertEquals(sids.includes('DenyDelete'), false);
|
||||
assertEquals(sids.includes('PublicReadUpdated'), true);
|
||||
});
|
||||
|
||||
it('should delete a named policy', async () => {
|
||||
const req = new TypedRequest<IReq_DeleteNamedPolicy>(url, 'deleteNamedPolicy');
|
||||
const response = await req.fire({ identity, policyId: policy2Id });
|
||||
assertEquals(response.ok, true);
|
||||
});
|
||||
|
||||
it('should handle bucket deletion cleaning up attachments', async () => {
|
||||
// Delete pol-bucket-1 which has policy1 attached
|
||||
const delBucket = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
await delBucket.fire({ identity, bucketName: 'pol-bucket-1' });
|
||||
|
||||
// Verify policy1 no longer lists pol-bucket-1
|
||||
const req = new TypedRequest<IReq_GetPolicyBuckets>(url, 'getPolicyBuckets');
|
||||
const response = await req.fire({ identity, policyId: policy1Id });
|
||||
assertEquals(response.attachedBuckets.includes('pol-bucket-1'), false);
|
||||
});
|
||||
|
||||
it('cleanup: delete remaining policy and bucket', async () => {
|
||||
const delPolicy = new TypedRequest<IReq_DeleteNamedPolicy>(url, 'deleteNamedPolicy');
|
||||
await delPolicy.fire({ identity, policyId: policy1Id });
|
||||
|
||||
const listPolicies = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
|
||||
const response = await listPolicies.fire({ identity });
|
||||
assertEquals(response.policies.length, 0);
|
||||
});
|
||||
});
|
||||
132
test/test.s3-compat.test.ts
Normal file
132
test/test.s3-compat.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { createTestContainer, getTestPorts, TEST_ACCESS_KEY, TEST_SECRET_KEY } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
|
||||
const PORT_INDEX = 4;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const BUCKET = 's3-test-bucket';
|
||||
|
||||
describe('S3 SDK compatibility', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let s3: S3Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
s3 = new S3Client({
|
||||
endpoint: `http://localhost:${ports.objstPort}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: TEST_ACCESS_KEY,
|
||||
secretAccessKey: TEST_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
s3.destroy();
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should list buckets (empty)', async () => {
|
||||
const response = await s3.send(new ListBucketsCommand({}));
|
||||
assertEquals((response.Buckets || []).length, 0);
|
||||
});
|
||||
|
||||
it('should create bucket via S3', async () => {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
const response = await s3.send(new ListBucketsCommand({}));
|
||||
const names = (response.Buckets || []).map((b) => b.Name);
|
||||
assertEquals(names.includes(BUCKET), true);
|
||||
});
|
||||
|
||||
it('should put object via S3', async () => {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'test.txt',
|
||||
Body: new TextEncoder().encode('Hello from S3 SDK'),
|
||||
ContentType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get object via S3', async () => {
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test.txt' }),
|
||||
);
|
||||
const body = await response.Body!.transformToString();
|
||||
assertEquals(body, 'Hello from S3 SDK');
|
||||
});
|
||||
|
||||
it('should list objects via S3', async () => {
|
||||
const response = await s3.send(
|
||||
new ListObjectsV2Command({ Bucket: BUCKET }),
|
||||
);
|
||||
const keys = (response.Contents || []).map((o) => o.Key);
|
||||
assertEquals(keys.includes('test.txt'), true);
|
||||
});
|
||||
|
||||
it('should copy object via S3', async () => {
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
CopySource: `${BUCKET}/test.txt`,
|
||||
Key: 'copy.txt',
|
||||
}),
|
||||
);
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'copy.txt' }),
|
||||
);
|
||||
const body = await response.Body!.transformToString();
|
||||
assertEquals(body, 'Hello from S3 SDK');
|
||||
});
|
||||
|
||||
it('should delete object via S3', async () => {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'test.txt' }));
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'copy.txt' }));
|
||||
const response = await s3.send(
|
||||
new ListObjectsV2Command({ Bucket: BUCKET }),
|
||||
);
|
||||
assertEquals((response.Contents || []).length, 0);
|
||||
});
|
||||
|
||||
it('should reject requests with wrong credentials', async () => {
|
||||
const badS3 = new S3Client({
|
||||
endpoint: `http://localhost:${ports.objstPort}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'wrongkey',
|
||||
secretAccessKey: 'wrongsecret',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
let threw = false;
|
||||
try {
|
||||
await badS3.send(new ListBucketsCommand({}));
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
badS3.destroy();
|
||||
assertEquals(threw, true);
|
||||
});
|
||||
|
||||
it('cleanup: delete bucket', async () => {
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
const response = await s3.send(new ListBucketsCommand({}));
|
||||
assertEquals((response.Buckets || []).length, 0);
|
||||
});
|
||||
});
|
||||
112
test/test.status-config.test.ts
Normal file
112
test/test.status-config.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
|
||||
import { ObjectStorageContainer } from '../ts/index.ts';
|
||||
import type * as interfaces from '../ts_interfaces/index.ts';
|
||||
import type { IReq_CreateBucket, IReq_DeleteBucket } from '../ts_interfaces/requests/buckets.ts';
|
||||
import type { IReq_PutObject, IReq_DeletePrefix } from '../ts_interfaces/requests/objects.ts';
|
||||
import type { IReq_GetServerStatus } from '../ts_interfaces/requests/status.ts';
|
||||
import type { IReq_GetServerConfig } from '../ts_interfaces/requests/config.ts';
|
||||
|
||||
const PORT_INDEX = 7;
|
||||
const ports = getTestPorts(PORT_INDEX);
|
||||
const url = `http://localhost:${ports.uiPort}/typedrequest`;
|
||||
const BUCKET = 'stats-bucket';
|
||||
|
||||
describe('Status and config', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let container: ObjectStorageContainer;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = createTestContainer(PORT_INDEX);
|
||||
await container.start();
|
||||
identity = await loginAndGetIdentity(ports.uiPort);
|
||||
|
||||
// Create bucket with objects for stats testing
|
||||
const createBucket = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
|
||||
await createBucket.fire({ identity, bucketName: BUCKET });
|
||||
|
||||
const putObj = new TypedRequest<IReq_PutObject>(url, 'putObject');
|
||||
await putObj.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'file1.txt',
|
||||
base64Content: btoa('Content of file 1'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await putObj.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'file2.txt',
|
||||
base64Content: btoa('Content of file 2'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
const delPrefix = new TypedRequest<IReq_DeletePrefix>(url, 'deletePrefix');
|
||||
await delPrefix.fire({ identity, bucketName: BUCKET, prefix: '' });
|
||||
const delBucket = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
|
||||
await delBucket.fire({ identity, bucketName: BUCKET });
|
||||
} catch { /* cleanup best-effort */ }
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should get server status', async () => {
|
||||
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
|
||||
const response = await req.fire({ identity });
|
||||
const status = response.status;
|
||||
|
||||
assertEquals(status.running, true);
|
||||
assertEquals(status.objstPort, ports.objstPort);
|
||||
assertEquals(status.uiPort, ports.uiPort);
|
||||
assertEquals(status.bucketCount, 1);
|
||||
assertEquals(status.totalObjectCount, 2);
|
||||
assertEquals(status.totalStorageBytes > 0, true);
|
||||
assertEquals(status.uptime >= 0, true);
|
||||
assertEquals(status.startedAt > 0, true);
|
||||
assertEquals(status.region, 'us-east-1');
|
||||
assertEquals(status.authEnabled, true);
|
||||
});
|
||||
|
||||
it('should get connection info from status', async () => {
|
||||
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
|
||||
const response = await req.fire({ identity });
|
||||
const info = response.connectionInfo;
|
||||
|
||||
assertExists(info.endpoint);
|
||||
assertEquals(info.port, ports.objstPort);
|
||||
assertExists(info.accessKey);
|
||||
assertEquals(info.region, 'us-east-1');
|
||||
});
|
||||
|
||||
it('should get server config', async () => {
|
||||
const req = new TypedRequest<IReq_GetServerConfig>(url, 'getServerConfig');
|
||||
const response = await req.fire({ identity });
|
||||
const config = response.config;
|
||||
|
||||
assertEquals(config.objstPort, ports.objstPort);
|
||||
assertEquals(config.uiPort, ports.uiPort);
|
||||
assertEquals(config.region, 'us-east-1');
|
||||
assertEquals(config.authEnabled, true);
|
||||
assertEquals(config.corsEnabled, false);
|
||||
});
|
||||
|
||||
it('should reflect correct stats after adding objects', async () => {
|
||||
// Add a third object
|
||||
const putObj = new TypedRequest<IReq_PutObject>(url, 'putObject');
|
||||
await putObj.fire({
|
||||
identity,
|
||||
bucketName: BUCKET,
|
||||
key: 'file3.txt',
|
||||
base64Content: btoa('Content of file 3'),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
|
||||
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
|
||||
const response = await req.fire({ identity });
|
||||
assertEquals(response.status.totalObjectCount, 3);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@lossless.zone/objectstorage',
|
||||
version: '1.4.1',
|
||||
version: '1.7.0',
|
||||
description: 'object storage server with management UI powered by smartstorage'
|
||||
}
|
||||
|
||||
@@ -39,6 +39,37 @@ export class ObjectStorageContainer {
|
||||
const envRegion = Deno.env.get('OBJST_REGION');
|
||||
if (envRegion) this.config.region = envRegion;
|
||||
|
||||
// Cluster environment variables
|
||||
const envClusterEnabled = Deno.env.get('OBJST_CLUSTER_ENABLED');
|
||||
if (envClusterEnabled) this.config.clusterEnabled = envClusterEnabled === 'true' || envClusterEnabled === '1';
|
||||
|
||||
const envClusterNodeId = Deno.env.get('OBJST_CLUSTER_NODE_ID');
|
||||
if (envClusterNodeId) this.config.clusterNodeId = envClusterNodeId;
|
||||
|
||||
const envClusterQuicPort = Deno.env.get('OBJST_CLUSTER_QUIC_PORT');
|
||||
if (envClusterQuicPort) this.config.clusterQuicPort = parseInt(envClusterQuicPort, 10);
|
||||
|
||||
const envClusterSeedNodes = Deno.env.get('OBJST_CLUSTER_SEED_NODES');
|
||||
if (envClusterSeedNodes) this.config.clusterSeedNodes = envClusterSeedNodes.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const envDrivePaths = Deno.env.get('OBJST_DRIVE_PATHS');
|
||||
if (envDrivePaths) this.config.drivePaths = envDrivePaths.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const envErasureDataShards = Deno.env.get('OBJST_ERASURE_DATA_SHARDS');
|
||||
if (envErasureDataShards) this.config.erasureDataShards = parseInt(envErasureDataShards, 10);
|
||||
|
||||
const envErasureParityShards = Deno.env.get('OBJST_ERASURE_PARITY_SHARDS');
|
||||
if (envErasureParityShards) this.config.erasureParityShards = parseInt(envErasureParityShards, 10);
|
||||
|
||||
const envErasureChunkSize = Deno.env.get('OBJST_ERASURE_CHUNK_SIZE');
|
||||
if (envErasureChunkSize) this.config.erasureChunkSizeBytes = parseInt(envErasureChunkSize, 10);
|
||||
|
||||
const envHeartbeatInterval = Deno.env.get('OBJST_HEARTBEAT_INTERVAL_MS');
|
||||
if (envHeartbeatInterval) this.config.clusterHeartbeatIntervalMs = parseInt(envHeartbeatInterval, 10);
|
||||
|
||||
const envHeartbeatTimeout = Deno.env.get('OBJST_HEARTBEAT_TIMEOUT_MS');
|
||||
if (envHeartbeatTimeout) this.config.clusterHeartbeatTimeoutMs = parseInt(envHeartbeatTimeout, 10);
|
||||
|
||||
this.opsServer = new OpsServer(this);
|
||||
this.policyManager = new PolicyManager(this);
|
||||
}
|
||||
@@ -49,9 +80,17 @@ export class ObjectStorageContainer {
|
||||
console.log(` UI port: ${this.config.uiPort}`);
|
||||
console.log(` Storage: ${this.config.storageDirectory}`);
|
||||
console.log(` Region: ${this.config.region}`);
|
||||
console.log(` Cluster: ${this.config.clusterEnabled ? 'enabled' : 'disabled'}`);
|
||||
if (this.config.clusterEnabled) {
|
||||
console.log(` Node ID: ${this.config.clusterNodeId || '(auto-generated)'}`);
|
||||
console.log(` QUIC Port: ${this.config.clusterQuicPort}`);
|
||||
console.log(` Seed Nodes: ${this.config.clusterSeedNodes.join(', ') || '(none)'}`);
|
||||
console.log(` Drives: ${this.config.drivePaths.length > 0 ? this.config.drivePaths.join(', ') : this.config.storageDirectory}`);
|
||||
console.log(` Erasure: ${this.config.erasureDataShards}+${this.config.erasureParityShards}`);
|
||||
}
|
||||
|
||||
// Start smartstorage
|
||||
this.smartstorageInstance = await plugins.smartstorage.SmartStorage.createAndStart({
|
||||
// Build smartstorage config
|
||||
const smartstorageConfig: any = {
|
||||
server: {
|
||||
port: this.config.objstPort,
|
||||
address: '0.0.0.0',
|
||||
@@ -64,7 +103,31 @@ export class ObjectStorageContainer {
|
||||
enabled: true,
|
||||
credentials: this.config.accessCredentials,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (this.config.clusterEnabled) {
|
||||
smartstorageConfig.cluster = {
|
||||
enabled: true,
|
||||
nodeId: this.config.clusterNodeId || crypto.randomUUID().slice(0, 8),
|
||||
quicPort: this.config.clusterQuicPort,
|
||||
seedNodes: this.config.clusterSeedNodes,
|
||||
erasure: {
|
||||
dataShards: this.config.erasureDataShards,
|
||||
parityShards: this.config.erasureParityShards,
|
||||
chunkSizeBytes: this.config.erasureChunkSizeBytes,
|
||||
},
|
||||
drives: {
|
||||
paths: this.config.drivePaths.length > 0
|
||||
? this.config.drivePaths
|
||||
: [this.config.storageDirectory],
|
||||
},
|
||||
heartbeatIntervalMs: this.config.clusterHeartbeatIntervalMs,
|
||||
heartbeatTimeoutMs: this.config.clusterHeartbeatTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Start smartstorage
|
||||
this.smartstorageInstance = await plugins.smartstorage.SmartStorage.createAndStart(smartstorageConfig);
|
||||
|
||||
this.startedAt = Date.now();
|
||||
console.log(`Storage server started on port ${this.config.objstPort}`);
|
||||
|
||||
64
ts/cli.ts
64
ts/cli.ts
@@ -29,6 +29,27 @@ export async function runCli(): Promise<void> {
|
||||
// Use a temp directory for storage
|
||||
configOverrides.storageDirectory = './.nogit/objstdata';
|
||||
break;
|
||||
case '--cluster-enabled':
|
||||
configOverrides.clusterEnabled = true;
|
||||
break;
|
||||
case '--cluster-node-id':
|
||||
configOverrides.clusterNodeId = args[++i];
|
||||
break;
|
||||
case '--cluster-quic-port':
|
||||
configOverrides.clusterQuicPort = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--cluster-seed-nodes':
|
||||
configOverrides.clusterSeedNodes = args[++i].split(',').map(s => s.trim()).filter(Boolean);
|
||||
break;
|
||||
case '--drive-paths':
|
||||
configOverrides.drivePaths = args[++i].split(',').map(s => s.trim()).filter(Boolean);
|
||||
break;
|
||||
case '--erasure-data-shards':
|
||||
configOverrides.erasureDataShards = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--erasure-parity-shards':
|
||||
configOverrides.erasureParityShards = parseInt(args[++i], 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,19 +80,38 @@ ObjectStorage - S3-compatible object storage server with management UI
|
||||
Usage:
|
||||
objectstorage server [options]
|
||||
|
||||
Options:
|
||||
--ephemeral Use local .nogit/objstdata for storage
|
||||
--storage-port PORT Storage API port (default: 9000, env: OBJST_PORT)
|
||||
--ui-port PORT Management UI port (default: 3000, env: UI_PORT)
|
||||
--storage-dir DIR Storage directory (default: /data, env: OBJST_STORAGE_DIR)
|
||||
Server Options:
|
||||
--ephemeral Use local .nogit/objstdata for storage
|
||||
--storage-port PORT Storage API port (default: 9000, env: OBJST_PORT)
|
||||
--ui-port PORT Management UI port (default: 3000, env: UI_PORT)
|
||||
--storage-dir DIR Storage directory (default: /data, env: OBJST_STORAGE_DIR)
|
||||
|
||||
Clustering Options:
|
||||
--cluster-enabled Enable cluster mode (env: OBJST_CLUSTER_ENABLED)
|
||||
--cluster-node-id ID Unique node identifier (env: OBJST_CLUSTER_NODE_ID)
|
||||
--cluster-quic-port PORT QUIC transport port (default: 4433, env: OBJST_CLUSTER_QUIC_PORT)
|
||||
--cluster-seed-nodes LIST Comma-separated seed node addresses (env: OBJST_CLUSTER_SEED_NODES)
|
||||
--drive-paths LIST Comma-separated drive mount paths (env: OBJST_DRIVE_PATHS)
|
||||
--erasure-data-shards N Erasure coding data shards (default: 4, env: OBJST_ERASURE_DATA_SHARDS)
|
||||
--erasure-parity-shards N Erasure coding parity shards (default: 2, env: OBJST_ERASURE_PARITY_SHARDS)
|
||||
|
||||
Environment Variables:
|
||||
OBJST_PORT Storage API port
|
||||
UI_PORT Management UI port
|
||||
OBJST_STORAGE_DIR Storage directory
|
||||
OBJST_ACCESS_KEY Access key (default: admin)
|
||||
OBJST_SECRET_KEY Secret key (default: admin)
|
||||
OBJST_ADMIN_PASSWORD Admin UI password (default: admin)
|
||||
OBJST_REGION Storage region (default: us-east-1)
|
||||
OBJST_PORT Storage API port
|
||||
UI_PORT Management UI port
|
||||
OBJST_STORAGE_DIR Storage directory
|
||||
OBJST_ACCESS_KEY Access key (default: admin)
|
||||
OBJST_SECRET_KEY Secret key (default: admin)
|
||||
OBJST_ADMIN_PASSWORD Admin UI password (default: admin)
|
||||
OBJST_REGION Storage region (default: us-east-1)
|
||||
OBJST_CLUSTER_ENABLED Enable clustering (true/false)
|
||||
OBJST_CLUSTER_NODE_ID Unique node identifier
|
||||
OBJST_CLUSTER_QUIC_PORT QUIC transport port (default: 4433)
|
||||
OBJST_CLUSTER_SEED_NODES Comma-separated seed node addresses
|
||||
OBJST_DRIVE_PATHS Comma-separated drive mount paths
|
||||
OBJST_ERASURE_DATA_SHARDS Erasure data shards (default: 4)
|
||||
OBJST_ERASURE_PARITY_SHARDS Erasure parity shards (default: 2)
|
||||
OBJST_ERASURE_CHUNK_SIZE Erasure chunk size in bytes (default: 4194304)
|
||||
OBJST_HEARTBEAT_INTERVAL_MS Cluster heartbeat interval (default: 5000)
|
||||
OBJST_HEARTBEAT_TIMEOUT_MS Cluster heartbeat timeout (default: 30000)
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ export class ConfigHandler {
|
||||
storageDirectory: containerConfig.storageDirectory,
|
||||
authEnabled: true,
|
||||
corsEnabled: false,
|
||||
clusterEnabled: containerConfig.clusterEnabled,
|
||||
clusterNodeId: containerConfig.clusterNodeId,
|
||||
clusterQuicPort: containerConfig.clusterQuicPort,
|
||||
clusterSeedNodes: containerConfig.clusterSeedNodes,
|
||||
erasureDataShards: containerConfig.erasureDataShards,
|
||||
erasureParityShards: containerConfig.erasureParityShards,
|
||||
erasureChunkSizeBytes: containerConfig.erasureChunkSizeBytes,
|
||||
drivePaths: containerConfig.drivePaths,
|
||||
clusterHeartbeatIntervalMs: containerConfig.clusterHeartbeatIntervalMs,
|
||||
clusterHeartbeatTimeoutMs: containerConfig.clusterHeartbeatTimeoutMs,
|
||||
};
|
||||
return { config };
|
||||
},
|
||||
|
||||
24
ts/types.ts
24
ts/types.ts
@@ -5,6 +5,20 @@ export interface IObjectStorageConfig {
|
||||
accessCredentials: Array<{ accessKeyId: string; secretAccessKey: string }>;
|
||||
adminPassword: string;
|
||||
region: string;
|
||||
// Cluster
|
||||
clusterEnabled: boolean;
|
||||
clusterNodeId: string;
|
||||
clusterQuicPort: number;
|
||||
clusterSeedNodes: string[];
|
||||
// Erasure coding
|
||||
erasureDataShards: number;
|
||||
erasureParityShards: number;
|
||||
erasureChunkSizeBytes: number;
|
||||
// Multi-drive
|
||||
drivePaths: string[];
|
||||
// Cluster heartbeat
|
||||
clusterHeartbeatIntervalMs: number;
|
||||
clusterHeartbeatTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const defaultConfig: IObjectStorageConfig = {
|
||||
@@ -14,4 +28,14 @@ export const defaultConfig: IObjectStorageConfig = {
|
||||
accessCredentials: [{ accessKeyId: 'admin', secretAccessKey: 'admin' }],
|
||||
adminPassword: 'admin',
|
||||
region: 'us-east-1',
|
||||
clusterEnabled: false,
|
||||
clusterNodeId: '',
|
||||
clusterQuicPort: 4433,
|
||||
clusterSeedNodes: [],
|
||||
erasureDataShards: 4,
|
||||
erasureParityShards: 2,
|
||||
erasureChunkSizeBytes: 4194304,
|
||||
drivePaths: [],
|
||||
clusterHeartbeatIntervalMs: 5000,
|
||||
clusterHeartbeatTimeoutMs: 30000,
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,6 +19,20 @@ export interface IServerConfig {
|
||||
storageDirectory: string;
|
||||
authEnabled: boolean;
|
||||
corsEnabled: boolean;
|
||||
// Cluster
|
||||
clusterEnabled: boolean;
|
||||
clusterNodeId: string;
|
||||
clusterQuicPort: number;
|
||||
clusterSeedNodes: string[];
|
||||
// Erasure coding
|
||||
erasureDataShards: number;
|
||||
erasureParityShards: number;
|
||||
erasureChunkSizeBytes: number;
|
||||
// Multi-drive
|
||||
drivePaths: string[];
|
||||
// Cluster heartbeat
|
||||
clusterHeartbeatIntervalMs: number;
|
||||
clusterHeartbeatTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface IObjstCredential {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@lossless.zone/objectstorage',
|
||||
version: '1.4.1',
|
||||
version: '1.7.0',
|
||||
description: 'object storage server with management UI powered by smartstorage'
|
||||
}
|
||||
|
||||
@@ -36,12 +36,84 @@ export class ObjstViewConfig extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.sectionSpacer {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.infoPanel {
|
||||
margin-top: 32px;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a4a')};
|
||||
}
|
||||
.infoPanel h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
.infoPanel p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
.infoPanel .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.infoPanel .label {
|
||||
min-width: 260px;
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#e3f2fd', '#1a237e30')};
|
||||
}
|
||||
.infoPanel .value {
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-left: 12px;
|
||||
}
|
||||
.driveList {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.driveList .driveItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.driveList .driveIndex {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#e8eaf6', '#1a237e40')};
|
||||
color: ${cssManager.bdTheme('#3f51b5', '#7986cb')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.driveList .drivePath {
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#e8e8e8', '#252540')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const config = this.configState.config;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
const serverTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'objstPort',
|
||||
title: 'Storage API Port',
|
||||
@@ -92,20 +164,166 @@ export class ObjstViewConfig extends DeesElement {
|
||||
},
|
||||
];
|
||||
|
||||
const clusterTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'clusterStatus',
|
||||
title: 'Cluster Status',
|
||||
value: config?.clusterEnabled ? 'Enabled' : 'Disabled',
|
||||
type: 'text',
|
||||
icon: 'lucide:network',
|
||||
color: config?.clusterEnabled ? '#4caf50' : '#ff9800',
|
||||
},
|
||||
{
|
||||
id: 'nodeId',
|
||||
title: 'Node ID',
|
||||
value: config?.clusterNodeId || '(auto)',
|
||||
type: 'text',
|
||||
icon: 'lucide:fingerprint',
|
||||
color: '#607d8b',
|
||||
},
|
||||
{
|
||||
id: 'quicPort',
|
||||
title: 'QUIC Port',
|
||||
value: config?.clusterQuicPort ?? 4433,
|
||||
type: 'number',
|
||||
icon: 'lucide:radio',
|
||||
color: '#00bcd4',
|
||||
},
|
||||
{
|
||||
id: 'seedNodes',
|
||||
title: 'Seed Nodes',
|
||||
value: config?.clusterSeedNodes?.length ?? 0,
|
||||
type: 'number',
|
||||
icon: 'lucide:gitBranch',
|
||||
color: '#3f51b5',
|
||||
description: config?.clusterSeedNodes?.length
|
||||
? config.clusterSeedNodes.join(', ')
|
||||
: 'No seed nodes configured',
|
||||
},
|
||||
{
|
||||
id: 'heartbeatInterval',
|
||||
title: 'Heartbeat Interval',
|
||||
value: `${config?.clusterHeartbeatIntervalMs ?? 5000}ms`,
|
||||
type: 'text',
|
||||
icon: 'lucide:heartPulse',
|
||||
color: '#e91e63',
|
||||
},
|
||||
{
|
||||
id: 'heartbeatTimeout',
|
||||
title: 'Heartbeat Timeout',
|
||||
value: `${config?.clusterHeartbeatTimeoutMs ?? 30000}ms`,
|
||||
type: 'text',
|
||||
icon: 'lucide:timer',
|
||||
color: '#ff5722',
|
||||
},
|
||||
];
|
||||
|
||||
const erasureTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'dataShards',
|
||||
title: 'Data Shards',
|
||||
value: config?.erasureDataShards ?? 4,
|
||||
type: 'number',
|
||||
icon: 'lucide:layers',
|
||||
color: '#2196f3',
|
||||
},
|
||||
{
|
||||
id: 'parityShards',
|
||||
title: 'Parity Shards',
|
||||
value: config?.erasureParityShards ?? 2,
|
||||
type: 'number',
|
||||
icon: 'lucide:shieldCheck',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
id: 'chunkSize',
|
||||
title: 'Chunk Size',
|
||||
value: this.formatBytes(config?.erasureChunkSizeBytes ?? 4194304),
|
||||
type: 'text',
|
||||
icon: 'lucide:puzzle',
|
||||
color: '#9c27b0',
|
||||
description: `${config?.erasureDataShards ?? 4}+${config?.erasureParityShards ?? 2} = ${Math.round(((config?.erasureParityShards ?? 2) / (config?.erasureDataShards ?? 4)) * 100)}% overhead`,
|
||||
},
|
||||
];
|
||||
|
||||
const drivePaths = config?.drivePaths?.length
|
||||
? config.drivePaths
|
||||
: config?.storageDirectory
|
||||
? [config.storageDirectory]
|
||||
: ['/data'];
|
||||
|
||||
const driveTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'driveCount',
|
||||
title: 'Drive Count',
|
||||
value: drivePaths.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:hardDrive',
|
||||
color: '#3f51b5',
|
||||
},
|
||||
];
|
||||
|
||||
const refreshAction = {
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
action: async () => {
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
|
||||
},
|
||||
};
|
||||
|
||||
return html`
|
||||
<objst-sectionheading>Configuration</objst-sectionheading>
|
||||
<objst-sectionheading>Server Configuration</objst-sectionheading>
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
action: async () => {
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
.tiles=${serverTiles}
|
||||
.gridActions=${[refreshAction]}
|
||||
></dees-statsgrid>
|
||||
|
||||
<div class="sectionSpacer">
|
||||
<objst-sectionheading>Cluster Configuration</objst-sectionheading>
|
||||
</div>
|
||||
<dees-statsgrid .tiles=${clusterTiles}></dees-statsgrid>
|
||||
|
||||
${config?.clusterEnabled ? html`
|
||||
<div class="sectionSpacer">
|
||||
<objst-sectionheading>Erasure Coding</objst-sectionheading>
|
||||
</div>
|
||||
<dees-statsgrid .tiles=${erasureTiles}></dees-statsgrid>
|
||||
` : ''}
|
||||
|
||||
<div class="sectionSpacer">
|
||||
<objst-sectionheading>Storage Drives</objst-sectionheading>
|
||||
</div>
|
||||
<dees-statsgrid .tiles=${driveTiles}></dees-statsgrid>
|
||||
<div class="driveList">
|
||||
${drivePaths.map((path, i) => html`
|
||||
<div class="driveItem">
|
||||
<div class="driveIndex">${i + 1}</div>
|
||||
<span class="drivePath">${path}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="infoPanel">
|
||||
<h2>Configuration Reference</h2>
|
||||
<p>Cluster and drive settings are applied at server startup. To change them, set the environment variables and restart the server.</p>
|
||||
<div class="row"><span class="label">OBJST_CLUSTER_ENABLED</span><span class="value">Enable clustering (true/false)</span></div>
|
||||
<div class="row"><span class="label">OBJST_CLUSTER_NODE_ID</span><span class="value">Unique node identifier</span></div>
|
||||
<div class="row"><span class="label">OBJST_CLUSTER_QUIC_PORT</span><span class="value">QUIC transport port (default: 4433)</span></div>
|
||||
<div class="row"><span class="label">OBJST_CLUSTER_SEED_NODES</span><span class="value">Comma-separated seed node addresses</span></div>
|
||||
<div class="row"><span class="label">OBJST_DRIVE_PATHS</span><span class="value">Comma-separated drive mount paths</span></div>
|
||||
<div class="row"><span class="label">OBJST_ERASURE_DATA_SHARDS</span><span class="value">Data shards for erasure coding (default: 4)</span></div>
|
||||
<div class="row"><span class="label">OBJST_ERASURE_PARITY_SHARDS</span><span class="value">Parity shards for erasure coding (default: 2)</span></div>
|
||||
<div class="row"><span class="label">OBJST_ERASURE_CHUNK_SIZE</span><span class="value">Chunk size in bytes (default: 4194304)</span></div>
|
||||
<div class="row"><span class="label">OBJST_HEARTBEAT_INTERVAL_MS</span><span class="value">Heartbeat interval in ms (default: 5000)</span></div>
|
||||
<div class="row"><span class="label">OBJST_HEARTBEAT_TIMEOUT_MS</span><span class="value">Heartbeat timeout in ms (default: 30000)</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user