Skip to content

Commit f877bcc

Browse files
committed
revamp cloud filesystem creation; add maintenance task to ensure there's always a purchase for any cloud filesystem
1 parent 0c3066e commit f877bcc

File tree

6 files changed

+143
-118
lines changed

6 files changed

+143
-118
lines changed

src/packages/server/compute/cloud-filesystem/create.ts

Lines changed: 52 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -67,57 +67,6 @@ export interface Options extends CreateCloudFilesystem {
6767
account_id: string;
6868
}
6969

70-
// creates bucket -- a race condition here would result in creating an extra bucket (garbage).
71-
// mutates input
72-
export async function ensureBucketExists(
73-
cloudFilesystem: Partial<CloudFilesystem>,
74-
): Promise<string> {
75-
const { id } = cloudFilesystem;
76-
if (!id) {
77-
throw Error("ensureBucketExists failed -- id must be specified");
78-
}
79-
if (cloudFilesystem.bucket) {
80-
// already created
81-
return cloudFilesystem.bucket;
82-
}
83-
logger.debug("ensureBucketExists: creating for ", id);
84-
try {
85-
// randomized bucket name -- all GCS buckets are in a single global
86-
// namespace, but by using a uuid it's extremely unlikely that
87-
// a bucket name would ever not be avialable; also nobody will
88-
// ever guess a bucket name, which is an extra level of security.
89-
// If there is a conflict, it would be an error and the user
90-
// would just retry creating their bucket (it's much more likely
91-
// to hit a random networking error).
92-
const s = `-${id}-${uuid()}`;
93-
const bucket = `${(await getGoogleCloudPrefix()).slice(
94-
0,
95-
63 - s.length - 1,
96-
)}${s}`;
97-
logger.debug("ensureBucketExists", { bucket });
98-
// in place mutate!
99-
cloudFilesystem.bucket = bucket;
100-
101-
// create storage bucket -- for now only support google
102-
// cloud storage, as mentioned above.
103-
await createBucket(bucket, bucketOptions(cloudFilesystem));
104-
const pool = getPool();
105-
await pool.query("UPDATE cloud_filesystems SET bucket=$1 WHERE id=$2", [
106-
bucket,
107-
id,
108-
]);
109-
return bucket;
110-
} catch (err) {
111-
logger.debug("ensureBucketExists: error ", err);
112-
const pool = getPool();
113-
await pool.query("UPDATE cloud_filesystems SET error=$1 WHERE id=$2", [
114-
`${err}`,
115-
id,
116-
]);
117-
throw err;
118-
}
119-
}
120-
12170
// - create service account that has access to storage bucket
12271
// - mutates input
12372
// - race condition would create identical service account twice, so not a problem.
@@ -193,6 +142,8 @@ export async function ensureServiceAccountExists(
193142
}
194143
}
195144

145+
const zeroPad = (num, places) => String(num).padStart(places, "0");
146+
196147
export async function createCloudFilesystem(opts: Options): Promise<number> {
197148
logger.debug("createCloudFilesystem", opts);
198149
// copy to avoid mutating
@@ -210,10 +161,6 @@ export async function createCloudFilesystem(opts: Options): Promise<number> {
210161
assertValidPath(opts.mountpoint);
211162
}
212163

213-
// always set mount to false during creation, so that it doesn't try to mount *while* we're creating the bucket.
214-
const mount_orig = opts.mount;
215-
opts.mount = false;
216-
217164
if (
218165
opts["block_size"] < MIN_BLOCK_SIZE ||
219166
opts["block_size"] > MAX_BLOCK_SIZE
@@ -273,86 +220,76 @@ export async function createCloudFilesystem(opts: Options): Promise<number> {
273220
// there could be a race condition if user tries to make two cloud filesystems at
274221
// same time for same project -- one would fail and they get an error due to
275222
// database uniqueness constraint. That's fine for now.
276-
const project_specific_id = await getAvailabelProjectSpecificId(
223+
const project_specific_id = await getAvailableProjectSpecificId(
277224
opts.project_id,
278225
);
279226
push("project_specific_id", project_specific_id);
227+
logger.debug("createCloudFilesystem", { cloudFilesystem });
280228

281229
const query = `INSERT INTO cloud_filesystems(${fields.join(
282230
",",
283231
)}) VALUES(${dollars.join(",")}) RETURNING id`;
284232
const pool = getPool();
285233
const { rows } = await pool.query(query, params);
286234
const { id } = rows[0];
287-
288-
cloudFilesystem.id = id;
289-
if (cloudFilesystem.id == null) {
235+
if (id == null) {
290236
throw Error("bug");
291237
}
238+
cloudFilesystem.id = id;
292239

293-
logger.debug("createCloudFilesystem: start the purchase");
294240
try {
241+
const bucket = await createRandomBucketName(id);
242+
await pool.query("UPDATE cloud_filesystems SET bucket=$1 WHERE id=$2", [
243+
bucket,
244+
id,
245+
]);
246+
247+
logger.debug("createCloudFilesystem: start the purchase");
295248
await createCloudStoragePurchase({
296-
cloud_filesystem_id: cloudFilesystem.id,
249+
cloud_filesystem_id: id,
297250
account_id: opts.account_id,
298-
project_id: cloudFilesystem.project_id,
299-
bucket: cloudFilesystem.bucket,
251+
project_id: opts.project_id,
252+
bucket,
300253
});
301-
} catch (err) {
302-
logger.debug(
303-
"createCloudFilesystem: ERROR -- failed to create purchase",
304-
err,
305-
);
306-
await pool.query("DELETE FROM cloud_filesystems WHERE id=$1", [id]);
307-
throw err;
308-
}
309254

310-
// NOTE: no matter what, be sure to create the bucket but NOT the service account because
311-
// creating the bucket twice at once could lead to waste via
312-
// a race condition (e.g., multiple compute servers causing creating in different hubs),
313-
// with multiple bucket names and garbage. However, creating the service
314-
// account is canonical so no worries about the race condition.
315-
// Also, in general we will delete the service account completely when the
316-
// filesystem isn't active, to avoid having too many service accounts and
317-
// role bindings, but we obviously can't just delete the bucket when
318-
// it isn't active!
319-
try {
320-
const bucket = await ensureBucketExists(cloudFilesystem);
321-
if (mount_orig) {
322-
// only now that the bucket exists do we actually mount.
323-
await pool.query("UPDATE cloud_filesystems SET mount=TRUE WHERE id=$1", [
324-
id,
325-
]);
326-
}
327-
cloudFilesystem.bucket = bucket;
255+
// NOTE: no matter what, be sure to create the bucket but NOT the service account because
256+
// creating the bucket twice at once could lead to waste via
257+
// a race condition (e.g., multiple compute servers causing creating in different hubs),
258+
// with multiple bucket names and garbage. However, creating the service
259+
// account is canonical so no worries about the race condition.
260+
// Also, in general we will delete the service account completely when the
261+
// filesystem isn't active, to avoid having too many service accounts and
262+
// role bindings, but we obviously can't just delete the bucket when
263+
// it isn't active!
264+
await createBucket(bucket, bucketOptions(cloudFilesystem));
328265
} catch (err) {
329-
await pool.query(
330-
"UPDATE cloud_filesystems SET error=$1,mount=FALSE WHERE id=$2",
331-
[`${err}`, id],
332-
);
266+
logger.debug("createCloudFilesystem: failed -- ", err);
267+
await pool.query("DELETE FROM cloud_filesystems WHERE id=$1", [id]);
333268
throw err;
334269
}
335270

336271
return id;
337272
}
338273

339-
async function createCloudStoragePurchase({
274+
export async function createCloudStoragePurchase({
340275
cloud_filesystem_id,
341276
account_id,
342277
project_id,
343278
bucket,
279+
period_start,
344280
}: {
345281
cloud_filesystem_id: number;
346-
account_id;
347-
project_id;
348-
bucket;
282+
account_id: string;
283+
project_id: string;
284+
bucket: string;
285+
period_start?: Date;
349286
}) {
350287
const purchase_id = await createPurchase({
351288
client: null,
352289
account_id,
353290
project_id,
354291
service: "compute-server-storage",
355-
period_start: new Date(),
292+
period_start: period_start ?? new Date(),
356293
description: {
357294
type: "compute-server-storage",
358295
cloud: "google-cloud",
@@ -420,7 +357,7 @@ function bucketOptions({
420357
} as CreateBucketRequest;
421358
}
422359

423-
export async function getAvailabelProjectSpecificId(project_id: string) {
360+
export async function getAvailableProjectSpecificId(project_id: string) {
424361
const pool = getPool();
425362
const { rows } = await pool.query(
426363
"SELECT project_specific_id FROM cloud_filesystems WHERE project_id=$1",
@@ -435,3 +372,19 @@ export async function getAvailabelProjectSpecificId(project_id: string) {
435372
}
436373
return id;
437374
}
375+
376+
async function createRandomBucketName(id: number): Promise<string> {
377+
// randomized bucket name -- all GCS buckets are in a single global
378+
// namespace, but by using a uuid it's sufficiently unlikely that
379+
// a bucket name would ever not be available; also nobody will
380+
// ever guess a bucket name, which is an extra level of security.
381+
// If there is a conflict, it would be an error and the user
382+
// would just retry creating their bucket (it's much more likely
383+
// to hit a random networking error).
384+
const s = `-${zeroPad(id, 8)}-${uuid()}`;
385+
const bucket = `${(await getGoogleCloudPrefix()).slice(
386+
0,
387+
63 - s.length - 1,
388+
)}${s}`;
389+
return bucket;
390+
}

src/packages/server/compute/cloud-filesystem/delete.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,14 @@ export async function deleteCloudFilesystem(id) {
145145

146146
const { bucket } = cloudFilesystem;
147147
if (bucket) {
148-
logger.debug("deleteCloudFilesystem: delete the Google cloud bucket");
148+
// bucket should always be non-null
149+
logger.debug("deleteCloudFilesystem: delete the Google cloud bucket", {
150+
bucket,
151+
});
149152
await deleteBucket({
150153
bucketName: bucket,
151154
useTransferService: true,
152155
});
153-
await pool.query("UPDATE cloud_filesystems SET bucket=NULL WHERE id=$1", [
154-
id,
155-
]);
156156
}
157157
logger.debug("deleteCloudFilesystem: delete the database record");
158158
await pool.query("DELETE FROM cloud_filesystems WHERE id=$1", [id]);

src/packages/server/compute/cloud-filesystem/edit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { isEqual } from "lodash";
1616
import { len } from "@cocalc/util/misc";
1717
import isCollaborator from "@cocalc/server/projects/is-collaborator";
1818
import { setDefaultStorageClass } from "@cocalc/server/compute/cloud/google-cloud/storage";
19-
import { getAvailabelProjectSpecificId } from "./create";
19+
import { getAvailableProjectSpecificId } from "./create";
2020

2121
const logger = getLogger("server:compute:cloud-filesystem:edit");
2222

@@ -91,7 +91,7 @@ export async function userEditCloudFilesystem(
9191
}
9292
// also when moving to a different project we have to re-allocate
9393
// the project_specific_id, since the current one is probably not be valid!
94-
changes.project_specific_id = await getAvailabelProjectSpecificId(
94+
changes.project_specific_id = await getAvailableProjectSpecificId(
9595
changes.project_id,
9696
);
9797
}

src/packages/server/compute/cloud-filesystem/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import getLogger from "@cocalc/backend/logger";
77
import { getTag } from "@cocalc/server/compute/cloud/startup-script";
88
import { getImages } from "@cocalc/server/compute/images";
99
import type { CloudFilesystem } from "@cocalc/util/db-schema/cloud-filesystems";
10-
import { ensureBucketExists, ensureServiceAccountExists } from "./create";
10+
import { ensureServiceAccountExists } from "./create";
1111
import { getProjectSpecificId } from "@cocalc/server/compute/project-specific-id";
1212

1313
// last_edited gets updated about this frequently when filesystem actively mounted.
@@ -66,13 +66,7 @@ async function getMountedCloudFilesystems(
6666

6767
// update last_edited in the database for these filesystems:
6868
await updateLastEdited(filesystems);
69-
// create any buckets, if they don't exist -- this may mutate rows.
70-
// NOTE: this case absolutely should never happen since we create the bucket
71-
// when creating the filesystem. However, just in case, we leave it in,
72-
// since it's a trivial check.
73-
for (const filesystem of filesystems) {
74-
await ensureBucketExists(filesystem);
75-
}
69+
7670
// create any service accounts that don't exist -- this may mutate rows
7771
// and do NOT do in parallel since we do not want to encourage a race conditions
7872
// when setting role bindings.

src/packages/server/compute/maintenance/purchases/close.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import getPool, {
88
import getLogger from "@cocalc/backend/logger";
99
import type { Purchase } from "@cocalc/util/db-schema/purchases";
1010
import type { ComputeServer } from "@cocalc/util/db-schema/compute-servers";
11+
import type { CloudFilesystem } from "@cocalc/util/db-schema/cloud-filesystems";
1112
import { cloneDeep } from "lodash";
1213
import createPurchase from "@cocalc/server/purchases/create-purchase";
1314
import { MIN_NETWORK_CLOSE_DELAY_MS } from "./manage-purchases";
@@ -289,10 +290,10 @@ export async function closeAndContinueCloudStoragePurchase({
289290

290291
const now = new Date();
291292

292-
const exists = await cloudFilesystemExists(
293+
const cloudFilesystem = await getCloudFilesystem(
293294
purchase.description.cloud_filesystem_id,
294295
);
295-
if (!exists) {
296+
if (cloudFilesystem == null) {
296297
logger.debug(
297298
"closeAndContinueCloudStoragePurchase: filesystem no longer exists, so just end current purchase",
298299
);
@@ -314,6 +315,10 @@ export async function closeAndContinueCloudStoragePurchase({
314315
const newPurchase = cloneDeep(purchase);
315316
newPurchase.time = now;
316317
newPurchase.period_start = now;
318+
// the filesystem might have moded, so update project. The project doesn't actually
319+
// impact pricing or billing at all, so it's not crucial to handle this right when the
320+
// filesystem moves.
321+
newPurchase.project_id = cloudFilesystem.project_id;
317322

318323
logger.debug(
319324
"closeAndContinueCloudStoragePurchase -- creating newPurchase=",
@@ -357,11 +362,13 @@ export async function closeAndContinueCloudStoragePurchase({
357362
}
358363
}
359364

360-
async function cloudFilesystemExists(id: number) {
365+
async function getCloudFilesystem(
366+
id: number,
367+
): Promise<undefined | CloudFilesystem> {
361368
const pool = getPool();
362369
const { rows } = await pool.query(
363-
"SELECT COUNT(*) AS count FROM cloud_filesystems WHERE id=$1",
370+
"SELECT * FROM cloud_filesystems WHERE id=$1",
364371
[id],
365372
);
366-
return rows[0].count > 0;
373+
return rows[0];
367374
}

0 commit comments

Comments
 (0)