allow concept of "home regions" (#3699)

* allow concept of "home regions"

* remove extraneous config value not applicable to repo
This commit is contained in:
Neal Shah
2026-09-02 14:11:55 -04:00
committed by GitHub
parent 7ec674d33b
commit 262f1dc5c5
14 changed files with 363 additions and 18 deletions
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 69;
const CURRENT_SCHEMA_VERSION = 70;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -103,6 +103,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[66, ['0071_share_issuer_index.sql']],
[67, ['0072_notification-scope.sql']],
[68, ['0073_notification-created-at.sql']],
[69, ['0074_add_user_home.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1060,6 +1060,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`signup_server` varchar(255) DEFAULT NULL,
`metadata` json DEFAULT (json_object()),
`reputation` smallint DEFAULT '100',
`home` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uid` (`uuid`),
UNIQUE KEY `username` (`username`),
@@ -1151,6 +1152,7 @@ CALL _puter_add_col('user', 'signup_user_agent', '`signup_user_agent` varchar(51
CALL _puter_add_col('user', 'signup_origin', '`signup_origin` varchar(255) DEFAULT NULL');
CALL _puter_add_col('user', 'signup_server', '`signup_server` varchar(255) DEFAULT NULL');
CALL _puter_add_col('user', 'metadata', '`metadata` json DEFAULT (json_object())');
CALL _puter_add_col('user', 'home', '`home` varchar(64) DEFAULT NULL');
CALL _puter_add_col('user', 'reputation', '`reputation` smallint DEFAULT ''100''');
--
@@ -0,0 +1,25 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Home region column. Mirrors SQLite migration 0074. Home region this
-- account's data belongs in — a node id, not a storage region. NULL for
-- accounts that predate the column, which fall back to `signup_server`.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations).
CALL _puter_add_col('user', 'home', '`home` VARCHAR(64) DEFAULT NULL');
@@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS "user" (
signup_origin varchar(255),
signup_server varchar(255),
metadata jsonb DEFAULT '{}'::jsonb,
reputation smallint DEFAULT 100
reputation smallint DEFAULT 100,
home varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Home region column. Mirrors SQLite migration 0074. Home region this
-- account's data belongs in — a node id, not a storage region. NULL for
-- accounts that predate the column, which fall back to `signup_server`.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS home varchar(64);
@@ -0,0 +1,22 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Home region this account's data belongs in — a node id, not a storage
-- region. NULL for every account that predates the column, which falls back to
-- `signup_server`. What a name maps to is deployment config, so adding a
-- region needs no migration.
ALTER TABLE `user` ADD COLUMN `home` VARCHAR(64) DEFAULT NULL;
+9
View File
@@ -281,6 +281,15 @@ export const extension = {
return configContainer;
},
/**
* Where each node stores files, keyed by node id. See `IConfig.servers`. A
* getter over live config rather than a copy, so it follows the same
* lazy-population rule as `config` above. Empty when none is configured.
*/
get servers(): NonNullable<IConfig['servers']> {
return configContainer.servers ?? {};
},
// -- Event subscription -------------------------------------------
on: <P extends ListenKey>(
+158 -14
View File
@@ -2202,18 +2202,21 @@ describe('FSService mkdir, touch, rename and shortcuts', () => {
);
await writeFile(user, `${user.home}/Documents/taken.txt`, 'x');
expect((await caught(() => fs.rename(user.userId, entry, 'a/b'))).message).toBe(
'Name cannot contain a slash',
);
expect((await caught(() => fs.rename(user.userId, entry, ' '))).message).toBe(
'Name cannot be empty',
);
expect(
(await caught(() => fs.rename(user.userId, entry, 'taken.txt'))).statusCode,
(await caught(() => fs.rename(user.userId, entry, 'a/b'))).message,
).toBe('Name cannot contain a slash');
expect(
(await caught(() => fs.rename(user.userId, entry, ' '))).message,
).toBe('Name cannot be empty');
expect(
(await caught(() => fs.rename(user.userId, entry, 'taken.txt')))
.statusCode,
).toBe(409);
// Renaming to the current name is a no-op that returns the same row.
await expect(fs.rename(user.userId, entry, 'ren.txt')).resolves.toBe(entry);
await expect(fs.rename(user.userId, entry, 'ren.txt')).resolves.toBe(
entry,
);
});
it("refuses to rename another user's entry without write on it", async () => {
@@ -3030,7 +3033,11 @@ describe('FSService restructuring a shared tree', () => {
});
it('refuses rename to a recipient who holds only read', async () => {
const file = await writeFile(owner, `${owner.home}/lookdonttouch.txt`, 'x');
const file = await writeFile(
owner,
`${owner.home}/lookdonttouch.txt`,
'x',
);
await server.services.acl.setUserUser(
owner.actor,
holder.actor,
@@ -3186,7 +3193,6 @@ describe('FSService ownership in a shared tree', () => {
);
expect(inside?.userId).toBe(owner.userId);
});
});
describe('FSService storage allowance in a shared tree', () => {
@@ -3219,7 +3225,9 @@ describe('FSService storage allowance in a shared tree', () => {
limitedServer.stores.user,
created,
);
const refreshed = (await limitedServer.stores.user.getById(created.id))!;
const refreshed = (await limitedServer.stores.user.getById(
created.id,
))!;
return {
userId: refreshed.id,
home: `/${username}`,
@@ -3246,8 +3254,7 @@ describe('FSService storage allowance in a shared tree', () => {
holder.actor,
{
path: shared.path,
resolveAncestors: () =>
limitedFs.getAncestorChain(shared.path),
resolveAncestors: () => limitedFs.getAncestorChain(shared.path),
},
'write',
);
@@ -3643,7 +3650,9 @@ describe('FSService — cross-app AppData access', () => {
asCalendar(() => fs.remove(owner.userId, { entry: contactsFile })),
).rejects.toMatchObject({ statusCode: 403 });
await expect(
asCalendar(() => fs.rename(owner.userId, contactsFile, 'renamed.json')),
asCalendar(() =>
fs.rename(owner.userId, contactsFile, 'renamed.json'),
),
).rejects.toMatchObject({ statusCode: 403 });
const desktop = (await server.stores.fsEntry.getEntryByPath(
@@ -3732,3 +3741,138 @@ describe('FSService — cross-app AppData access', () => {
).toBeFalsy();
});
});
describe('FSService home-region placement', () => {
let regionServer: PuterServer;
let regionFs: FSService;
// A deployment that knows about two nodes. `node-far` is the home region
// under test; `node-here` stands in for this node's own bucket, which is
// configured separately below so the two are distinguishable.
beforeAll(async () => {
regionServer = await setupTestServer({
s3_bucket: 'puter-local',
s3_region: 'us-west-2',
servers: {
'node-far': {
bucket: 'puter-far',
bucketRegion: 'eu-central-1',
},
'node-here': {
bucket: 'puter-local',
bucketRegion: 'us-west-2',
},
},
} as never);
regionFs = regionServer.services.fs as unknown as FSService;
await regionServer.clients.s3
.get('eu-central-1')
.send(new CreateBucketCommand({ Bucket: 'puter-far' }));
});
afterAll(async () => {
await regionServer?.shutdown();
});
const regionUser = async () => {
const username = `fsr-${Math.random().toString(36).slice(2, 10)}`;
const created = await regionServer.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
regionServer.clients.db,
regionServer.stores.user,
created,
);
return { userId: created.id, home: `/${username}` };
};
const writeIn = async (
user: { userId: number; home: string },
name: string,
body: string,
homeRegion?: string,
extra: Record<string, unknown> = {},
) => {
const { fsEntry } = await regionFs.write(
user.userId,
{
fileMetadata: {
path: `${user.home}/Documents/${name}`,
size: Buffer.byteLength(body),
contentType: 'text/plain',
...extra,
},
fileContent: body,
},
undefined,
undefined,
homeRegion,
);
return fsEntry;
};
const readFrom = async (entry: FSEntry): Promise<string> => {
const result = await regionFs.readContent(entry, {});
const chunks: Buffer[] = [];
for await (const chunk of result.body) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString();
};
it("stores a write in the home region's bucket and reads it back", async () => {
const user = await regionUser();
const entry = await writeIn(user, 'mail.eml', 'hello', 'node-far');
expect(entry.bucket).toBe('puter-far');
expect(entry.bucketRegion).toBe('eu-central-1');
// The row is what reads resolve from, so a foreign bucket has to be
// readable through the ordinary path.
expect(await readFrom(entry)).toBe('hello');
});
it('falls back to this server’s bucket for a region it does not know', async () => {
const user = await regionUser();
// A node id this deployment's config says nothing about.
const entry = await writeIn(user, 'unmapped.eml', 'hi', 'node-absent');
expect(entry.bucket).toBe('puter-local');
expect(entry.bucketRegion).toBe('us-west-2');
expect(await readFrom(entry)).toBe('hi');
});
it('places a write with no home region exactly as before', async () => {
const user = await regionUser();
const entry = await writeIn(user, 'plain.txt', 'unchanged');
expect(entry.bucket).toBe('puter-local');
expect(entry.bucketRegion).toBe('us-west-2');
});
it('keeps an overwrite where the row already points, home region or not', async () => {
const user = await regionUser();
const first = await writeIn(user, 'pinned.eml', 'first', 'node-far');
expect(first.bucket).toBe('puter-far');
// The entry outranks the hint: moving the bytes would repoint the row
// and strand the original object.
const second = await writeIn(
user,
'pinned.eml',
'second',
'node-here',
{
overwrite: true,
},
);
expect(second.uuid).toBe(first.uuid);
expect(second.bucket).toBe('puter-far');
expect(second.bucketRegion).toBe('eu-central-1');
expect(await readFrom(second)).toBe('second');
});
});
+34 -2
View File
@@ -460,9 +460,37 @@ export class FSService extends PuterService {
return bucketRegion;
}
/**
* Where a write's bytes should land. A home region this deployment knows
* about names its own bucket; anything else — an unmapped region, a
* single-node deployment, no hint at all — falls back to this server's own
* bucket, because placement is an optimisation and storing the bytes is
* not.
*/
#resolvePlacement(homeRegion?: string): {
bucket: string;
bucketRegion: string;
} {
const place = homeRegion
? this.config.servers?.[homeRegion]
: undefined;
if (homeRegion && !place) {
console.warn(
`[fs] no storage configured for region '${homeRegion}'; using this server's bucket`,
);
}
return (
place ?? {
bucket: this.#resolveBucket(),
bucketRegion: this.#resolveBucketRegion(),
}
);
}
#normalizeWriteInput(
userId: number,
metadata: FSEntryWriteInput,
homeRegion?: string,
): NormalizedWriteInput {
const normalizedPath = this.#normalizePath(metadata.path);
if (normalizedPath === '/') {
@@ -498,8 +526,7 @@ export class FSService extends PuterService {
immutable: Boolean(metadata.immutable),
isPublic: metadata.isPublic,
multipartPartSize: metadata.multipartPartSize,
bucket: this.#resolveBucket(),
bucketRegion: this.#resolveBucketRegion(),
...this.#resolvePlacement(homeRegion),
};
}
@@ -2798,10 +2825,15 @@ export class FSService extends PuterService {
writeRequest: WriteRequest,
uploadTracker?: UploadProgressTrackerLike,
storageAllowanceMax?: number,
homeRegion?: string,
): Promise<WriteResponse> {
// Server-only, and a positional argument for that reason: the
// controllers build `writeRequest` out of the parsed request body, so a
// field there would let a caller choose its own bucket.
let normalizedInput = this.#normalizeWriteInput(
userId,
writeRequest.fileMetadata,
homeRegion,
);
const [resolvedTarget] = await this.#resolveWriteTargets(userId, [
{
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_HOME_REGION, resolveHomeRegion } from './homeRegion.js';
describe('resolveHomeRegion', () => {
it("prefers the account's own home region", () => {
expect(
resolveHomeRegion({ home: 'node-a', signup_server: 'node-b' }),
).toBe('node-a');
});
it('falls back to the server that served the signup', () => {
// Every account that predates the `home` column is this case.
expect(resolveHomeRegion({ home: null, signup_server: 'node-b' })).toBe(
'node-b',
);
expect(resolveHomeRegion({ signup_server: 'node-c' })).toBe('node-c');
});
it('falls back to the primary region when neither is set', () => {
expect(resolveHomeRegion({ home: null, signup_server: null })).toBe(
'oregon',
);
expect(resolveHomeRegion({})).toBe(DEFAULT_HOME_REGION);
});
it('treats an empty string as unset rather than as a region', () => {
expect(resolveHomeRegion({ home: '', signup_server: 'node-d' })).toBe(
'node-d',
);
expect(resolveHomeRegion({ home: '', signup_server: '' })).toBe(
'oregon',
);
});
});
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Which home region an account's data belongs in.
*
* A home region is a node id, not a storage region: `IConfig.servers` maps one
* to the bucket that holds it. The ids are the same ones `config.serverId` and
* `user.signup_server` already use, so no translation is needed between them.
*/
/** Where an account's data belongs when nothing else says otherwise. */
export const DEFAULT_HOME_REGION = 'oregon';
/**
* An account's own `home`, else the server that served its signup, else the
* deployment default. Empty strings count as unset: a blank column is not a
* region.
*/
export const resolveHomeRegion = (user: {
home?: string | null;
signup_server?: string | null;
}): string => user.home || user.signup_server || DEFAULT_HOME_REGION;
+7
View File
@@ -73,6 +73,13 @@ export interface UserRow {
* did.
*/
card_fingerprint?: string | null;
/**
* Home region this account's data belongs in - a node id from
* `IConfig.servers`, not a storage region. Null for accounts that predate
* the column, which fall back to `signup_server`; `resolveHomeRegion` is
* the one place that precedence lives.
*/
home?: string | null;
password?: string;
[k: string]: unknown;
}
+6
View File
@@ -928,6 +928,12 @@ interface IConfigOptional {
s3_region: string;
/** Fallback AWS region. */
region: string;
/**
* Where each node stores files, keyed by node id - the same ids `user.home`
* names. A write may pass one to place its bytes there; absent, or absent
* an entry, every write uses `s3_bucket` / `s3_region`.
*/
servers?: Record<string, { bucket: string; bucketRegion: string }>;
/** Default storage capacity per user (bytes). */
storage_capacity: number;
/** When false, storage is effectively unlimited (bounded by device space). */