Skip to content

Commit a13b133

Browse files
committed
Add the ability to process quota config
1 parent 534f7be commit a13b133

File tree

4 files changed

+253
-9
lines changed

4 files changed

+253
-9
lines changed

src/index.mjs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import './patch.mjs';
55
import path from "path";
66
import { $, stdin, argv, question } from "zx";
77
import { readFile } from "node:fs/promises";
8-
import { getExistingDirectory, parseConfig, diffProperties, deepEqual, getSSHKeys } from "./utils.mjs";
8+
import { getExistingDirectory, parseConfig, diffProperties, deepEqual, getSSHKeys, getDiskQuota, unique } from "./utils.mjs";
99
import { validateConfig } from "./schema.mjs";
1010

1111

@@ -25,6 +25,10 @@ if (!argv.config) {
2525
printUsageAndExit(1);
2626
}
2727

28+
/////////////////////////////////////////////////////////////////////////////////////////////////
29+
// Load, validate, and parse config
30+
/////////////////////////////////////////////////////////////////////////////////////////////////
31+
2832
console.time("readConfig");
2933
let config;
3034
if (argv.config === "-") {
@@ -44,6 +48,24 @@ if (!validateConfig(config)) {
4448
}
4549
console.timeLog("validateConfig");
4650

51+
console.log("Parsing config");
52+
console.time("parseConfig");
53+
const {
54+
configGroups,
55+
configUsers,
56+
configPasswords,
57+
configSSHKeys,
58+
configUpdatePassword,
59+
configLinger,
60+
configDiskUserQuotaOverrides,
61+
xfsDefaultDiskUserQuota,
62+
} = parseConfig(config);
63+
console.timeLog("parseConfig");
64+
65+
/////////////////////////////////////////////////////////////////////////////////////////////////
66+
// Load existing directory
67+
/////////////////////////////////////////////////////////////////////////////////////////////////
68+
4769
console.log("Loading existing directory...");
4870
console.time("getExistingDirectory");
4971
const { users, passwords, groups, lingerStates } = await getExistingDirectory();
@@ -61,10 +83,24 @@ console.time("getSSHKeys");
6183
const sshKeys = await getSSHKeys(Object.values(users), config.user_ssh_key_base_dir);
6284
console.timeLog("getSSHKeys");
6385

64-
console.log("Parsing config");
65-
console.time("parseConfig");
66-
const { configGroups, configUsers, configPasswords, configSSHKeys, configUpdatePassword, configLinger } = parseConfig(config);
67-
console.timeLog("parseConfig");
86+
console.log("Loading existing disk quota");
87+
console.time("getDiskQuota");
88+
const diskQuota = await getDiskQuota(unique([...config.xfs_default_user_quota.map(q => q.path), ...Object.keys(configDiskUserQuotaOverrides)]));
89+
console.timeLog("getDiskQuota");
90+
91+
// TODO: default user quota changes (per path)
92+
console.log("diskQuota", diskQuota);
93+
console.log(diskQuota['/var/lib/cluster'][0])
94+
console.log(xfsDefaultDiskUserQuota['/var/lib/cluster'])
95+
96+
// TODO: user quota changes (per path)
97+
// new: in config but not existing
98+
// delete: existing (within the managed uid range) but not in config
99+
// update: in config and existing, but different
100+
101+
/////////////////////////////////////////////////////////////////////////////////////////////////
102+
// Calculate changes
103+
/////////////////////////////////////////////////////////////////////////////////////////////////
68104

69105
console.log("Calculating changes");
70106
console.time("calculateChanges");

src/schema.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,12 @@ export const configSchema = {
102102
description: "Base directory for user SSH keys. Supports templating with %u (username) and %U (uid)",
103103
default: "/home/%u/.ssh",
104104
},
105-
// sudo xfs_quota <path> -x -c "limit -d bsoft=<bsoft> bhard=<bhard> isoft=<isoft> ihard=<ihard>"
106-
xfs_default_quotas: {
105+
// XFS default quota can be set using `sudo xfs_quota <path> -x -c "limit -d bsoft=<bsoft> bhard=<bhard> isoft=<isoft> ihard=<ihard>"`
106+
xfs_default_user_quota: {
107107
type: "array",
108108
items: quotaSpec,
109109
default: [],
110-
}
110+
},
111111
},
112112
required: ["users", "groups", "managed_uid_range", "managed_gid_range"],
113113
additionalProperties: false,

src/utils.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,82 @@
11
import { readFile, readdir } from 'node:fs/promises';
22

3+
// Converts a human-readable size string to a number of bytes
4+
// E.g. "1Ki" -> 1024, "1Mi" -> 1048576, "1Gi" -> 1073741824
5+
export function parseIECSize(s) {
6+
const match = s.match(/^([0-9]+)([kKmMgGtT])?i?$/);
7+
if (!match) {
8+
throw new Error(`Invalid size string: ${s}`);
9+
}
10+
const size = Number(match[1]);
11+
const unit = match[2]?.toLowerCase();
12+
if (!unit) {
13+
return size;
14+
}
15+
const unitMap = {
16+
k: 1024,
17+
m: Math.pow(1024, 2),
18+
g: Math.pow(1024, 3),
19+
t: Math.pow(1024, 4),
20+
};
21+
return size * unitMap[unit];
22+
}
23+
24+
// Converts a human-readable size string to a number
25+
// E.g. "1K" -> 1000, "1M" -> 1000000, "1G" -> 1000000000
26+
export function parseSISize(s) {
27+
const match = s.match(/^([0-9]+)([kKmMgGtT])?$/);
28+
if (!match) {
29+
throw new Error(`Invalid size string: ${s}`);
30+
}
31+
const size = Number(match[1]);
32+
const unit = match[2]?.toLowerCase();
33+
if (!unit) {
34+
return size;
35+
}
36+
const unitMap = {
37+
k: 1000,
38+
m: Math.pow(1000, 2),
39+
g: Math.pow(1000, 3),
40+
t: Math.pow(1000, 4),
41+
};
42+
return size * unitMap[unit];
43+
}
44+
45+
export function normalizeDiskQuota(q) {
46+
return {
47+
path: q.path,
48+
bytes_soft_limit: parseIECSize(q.bytes_soft_limit),
49+
bytes_hard_limit: parseIECSize(q.bytes_hard_limit),
50+
inodes_soft_limit: parseSISize(q.inodes_soft_limit),
51+
inodes_hard_limit: parseSISize(q.inodes_hard_limit),
52+
};
53+
}
54+
355
export function parseConfig(config) {
456
const configGroups = Object.fromEntries(config.groups.map((g) => [g.groupname, g]));
557
const configUpdatePassword = Object.fromEntries(config.users.map((u) => [u.username, u.update_password]));
658
const configPasswords = Object.fromEntries(config.users.map((u) => [u.username, u.password]));
759
const configSSHKeys = Object.fromEntries(config.users.map((u) => [u.username, u.ssh_authorized_keys]));
860
const configLinger = Object.fromEntries(config.users.map((u) => [u.username, u.linger]));
61+
// Object of the form { <path>: { <uid>: { ...quotaConfig } } }
62+
const configDiskUserQuotaOverrides = objectMap(
63+
groupBy(
64+
config.users.flatMap((u) =>
65+
u.disk_quota_overrides.map(normalizeDiskQuota).map((d) => {
66+
const { path, ...quotaConfig } = d;
67+
return [path, u.uid, quotaConfig];
68+
})
69+
),
70+
// group by path
71+
(x) => x[0]
72+
),
73+
// convert to object of the form { <uid>: { ...quotaConfig } }
74+
(v) => Object.fromEntries(v.map((x) => [x[1], x[2]]))
75+
);
76+
const xfsDefaultDiskUserQuota = Object.fromEntries(config.xfs_default_user_quota.map(normalizeDiskQuota).map((d) => {
77+
const { path, ...quotaConfig } = d
78+
return [path, quotaConfig];
79+
}));
980

1081
const configUsers = config.users.reduce((out, u) => {
1182
const {
@@ -14,6 +85,7 @@ export function parseConfig(config) {
1485
update_password: _update_password,
1586
ssh_authorized_keys: _ssh_authorized_keys,
1687
linger: _linger,
88+
disk_quota_overrides,
1789
...rest
1890
} = u;
1991
out[u.username] = {
@@ -30,6 +102,8 @@ export function parseConfig(config) {
30102
configSSHKeys,
31103
configUpdatePassword,
32104
configLinger,
105+
configDiskUserQuotaOverrides,
106+
xfsDefaultDiskUserQuota,
33107
};
34108
}
35109

@@ -157,6 +231,45 @@ export function diffProperties(obj1, obj2) {
157231
return out;
158232
}
159233

234+
/**
235+
* Maps over the properties of an object and applies a function to each value.
236+
* Returns a new object with the transformed values.
237+
* Derived from: https://stackoverflow.com/a/14810722
238+
*
239+
* @param {Object} obj - The object to map over.
240+
* @param {Function} fn - The function to apply to each value.
241+
* @returns {Object} - The new object with transformed values.
242+
*
243+
* @example
244+
* const obj = { a: 1, b: 2, c: 3 };
245+
* const double = (value) => value * 2;
246+
* const transformedObj = mapObjectValues(obj, double);
247+
* // transformedObj: { a: 2, b: 4, c: 6 }
248+
*
249+
* @example
250+
* const obj = { name: 'John', age: 30 };
251+
* const capitalize = (value) => value.toUpperCase();
252+
* const transformedObj = mapObjectValues(obj, capitalize);
253+
* // transformedObj: { name: 'JOHN', age: '30' }
254+
*/
255+
export function objectMap(obj, fn) {
256+
return Object.fromEntries(
257+
Object.entries(obj).map(
258+
([k, v], i) => [k, fn(v, k, i)]
259+
)
260+
)
261+
}
262+
263+
// Implementation of Object.groupBy according to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy
264+
export function groupBy(arr, fn) {
265+
return arr.reduce((out, x) => {
266+
const key = fn(x);
267+
if (!(key in out)) out[key] = [];
268+
out[key].push(x);
269+
return out;
270+
}, {})
271+
}
272+
160273
export async function getSSHKeys(users, baseDir) {
161274
const sshKeyFiles = await Promise.all(
162275
users.map(async (u) => {
@@ -168,4 +281,38 @@ export async function getSSHKeys(users, baseDir) {
168281
);
169282

170283
return Object.fromEntries(sshKeyFiles);
284+
}
285+
286+
/**
287+
* Retrieves the disk quota information for a given path.
288+
* @param {string} path - The path for which to retrieve the disk quota.
289+
* @returns {Promise<Object>} - A promise that resolves to an object of the form { <uid>: { bytes_soft_limit, bytes_hard_limit, inodes_soft_limit, inodes_hard_limit } }
290+
*/
291+
export async function getDiskQuotaForPath(path) {
292+
// outputs <uid> <block soft> <block hard> <inode soft> <inode hard> where block is in 1KiB units
293+
const repquotaResult = await $`repquota ${path} --user --no-names --raw-grace | grep '^#' | awk '{print $1,$4,$5,$8,$9}' | cut -c2-`;
294+
const lines = repquotaResult.stdout
295+
.split("\n")
296+
.filter((l) => l)
297+
.map((l) => l.split(" "));
298+
299+
return Object.fromEntries(lines.map((l) => [Number(l[0]), {
300+
bytes_soft_limit: Number(l[1]),
301+
bytes_hard_limit: Number(l[2]),
302+
inodes_soft_limit: Number(l[3]),
303+
inodes_hard_limit: Number(l[4]),
304+
}]));
305+
}
306+
307+
/**
308+
* Retrieves the disk quota for multiple paths.
309+
* @param {string[]} paths - An array of paths for which to retrieve the disk quota.
310+
* @returns {Promise<Object>} - A promise that resolves to an object of the form { <path>: { <uid>: { bytes_soft_limit, bytes_hard_limit, inodes_soft_limit, inodes_hard_limit } } }
311+
*/
312+
export async function getDiskQuota(paths) {
313+
return Object.fromEntries(await Promise.all(paths.map(async (p) => [p, await getDiskQuotaForPath(p)])));
314+
}
315+
316+
export function unique(arr) {
317+
return [...new Set(arr)];
171318
}

src/utils.test.mjs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fsPromises from "node:fs/promises";
2-
import { deepEqual, getSSHKeys, diffProperties, getExistingDirectory } from "./utils";
2+
import { deepEqual, getSSHKeys, diffProperties, getExistingDirectory, parseIECSize, objectMap } from "./utils";
33

44
describe("deepEqual", () => {
55
// Test case: Comparing two identical objects
@@ -215,3 +215,64 @@ describe("getExistingDirectory", () => {
215215
await expect(getExistingDirectory()).rejects.toThrow("user and password lists don't match up! users: user1, passwords: user1,user2");
216216
});
217217
});
218+
219+
describe("objectMap", () => {
220+
// Test case: Mapping empty object
221+
test("should return an empty object when mapping an empty object", () => {
222+
const obj = {};
223+
const result = objectMap(obj, (value) => value);
224+
const expected = {};
225+
expect(result).toEqual(expected);
226+
});
227+
228+
// Test case: Mapping object values
229+
test("should map object values using the provided function", () => {
230+
const obj = { a: 1, b: 2, c: 3 };
231+
const result = objectMap(obj, (value) => value * 2);
232+
const expected = { a: 2, b: 4, c: 6 };
233+
expect(result).toEqual(expected);
234+
});
235+
});
236+
237+
describe("parseIECSize", () => {
238+
// Test case: Valid size string without unit
239+
test("should return the parsed size when given a valid size string without unit", () => {
240+
const sizeString = "100";
241+
const expectedSize = 100;
242+
expect(parseIECSize(sizeString)).toBe(expectedSize);
243+
});
244+
245+
// Test case: Valid size string with unit 'k'
246+
test("should return the parsed size when given a valid size string with unit 'k'", () => {
247+
const sizeString = "10k";
248+
const expectedSize = 10240;
249+
expect(parseIECSize(sizeString)).toBe(expectedSize);
250+
});
251+
252+
// Test case: Valid size string with unit 'm'
253+
test("should return the parsed size when given a valid size string with unit 'm'", () => {
254+
const sizeString = "5m";
255+
const expectedSize = 5242880;
256+
expect(parseIECSize(sizeString)).toBe(expectedSize);
257+
});
258+
259+
// Test case: Valid size string with unit 'g'
260+
test("should return the parsed size when given a valid size string with unit 'g'", () => {
261+
const sizeString = "2g";
262+
const expectedSize = 2147483648;
263+
expect(parseIECSize(sizeString)).toBe(expectedSize);
264+
});
265+
266+
// Test case: Valid size string with unit 't'
267+
test("should return the parsed size when given a valid size string with unit 't'", () => {
268+
const sizeString = "1t";
269+
const expectedSize = 1099511627776;
270+
expect(parseIECSize(sizeString)).toBe(expectedSize);
271+
});
272+
273+
// Test case: Invalid size string
274+
test("should throw an error when given an invalid size string", () => {
275+
const sizeString = "abc";
276+
expect(() => parseIECSize(sizeString)).toThrow("Invalid size string: abc");
277+
});
278+
});

0 commit comments

Comments
 (0)