Repository: radiocosmology/alpenhorn

Issue:
Implement project quota under Lustre
We've been getting by with group quota fairly well, but really we need to look at project quota on cedar.  So, we should allow that here.  (Project quota solves the mystery of where the cedar /nearline quota limit comes from).

Discussion:

Solution patch:
diff --git a/alpenhorn/io/lfs.py b/alpenhorn/io/lfs.py
index 47c104440..0c5ce0914 100644
--- a/alpenhorn/io/lfs.py
+++ b/alpenhorn/io/lfs.py
@@ -92,14 +92,16 @@ class LFS:
 
     Parameters:
     -----------
-    quota_group : str
-        The name of the group to use when running quota queries
+    quota_id : str
+        The username, uid, group name, gid, or project id to query quota for.
+        The interpretation of this parameter is set by the value of quota_type.
+    quota_type : str
+        One of "user", "group", "project", indicating the type of quota to fetch.
     fixed_quota : integer, optional
-        If not None, a quota size in kiB which will override the
-        maximum quota reported by "lfs quota".
+        If not None, a quota size in kiB which will override the maximum quota
+        reported by "lfs quota".
     lfs : string, optional
-        The name of the lfs(1) command; may include a path.
-        Defaults to "lfs".
+        The name of the lfs(1) command; may include a path.  Defaults to "lfs".
     path : string, optional
         If not None, the search path to use to look for the lfs(1)
         commnad.  If None, the "PATH" environmental variable is used.
@@ -117,15 +119,26 @@ class LFS:
 
     def __init__(
         self,
-        quota_group: str,
+        quota_id: str,
+        quota_type: str,
         fixed_quota: int | None = None,
         lfs: str = "lfs",
         path: str | None = None,
         timeout: int | None = None,
     ) -> None:
-        self._quota_group = quota_group
+        self._quota_id = quota_id
         self._fixed_quota = fixed_quota
 
+        # Set flag based on quota type
+        if quota_type == "group":
+            self._quota_type = "-g"
+        elif quota_type == "project":
+            self._quota_type = "-p"
+        elif quota_type == "user":
+            self._quota_type = "-u"
+        else:
+            raise ValueError(f"invalid quota_type: {quota_type}")
+
         if timeout is None:
             self._timeout = 60
         else:
@@ -218,9 +231,9 @@ def quota_remaining(self, path: str | os.PathLike) -> int | None:
             but no fixed_quota was specified.
         """
 
-        # If possible, "lfs quota -q -g <group> <path>" will output quota
+        # If possible, "lfs quota -q <type> <id> <path>" will output quota
         # information on the first line.  But, if the path is too long
-        # (more than 15 characters), the path will be printed by itelf
+        # (more than 15 characters), the path will be printed by itself
         # on the first line and then the quota information will end up on
         # the second line.
         #
@@ -243,7 +256,9 @@ def quota_remaining(self, path: str | os.PathLike) -> int | None:
         # Stringify path
         path = str(path)
 
-        stdout = self.run_lfs("quota", "-q", "-g", self._quota_group, path)["output"]
+        stdout = self.run_lfs("quota", "-q", self._quota_type, self._quota_id, path)[
+            "output"
+        ]
         if stdout is None:
             return None  # Command returned error
 
diff --git a/alpenhorn/io/lustrehsm.py b/alpenhorn/io/lustrehsm.py
index 8b5029a3e..b5c794b1f 100644
--- a/alpenhorn/io/lustrehsm.py
+++ b/alpenhorn/io/lustrehsm.py
@@ -69,10 +69,12 @@ class LustreHSMNodeIO(LustreQuotaNodeIO):
     """LustreHSM node I/O.
 
     Required io_config keys:
-        * quota_group : string
-            the user group to query quota for
         * headroom: float
             the amount of space in kiB to keep empty on the disk.
+        * quota_id: the id (username, uid, group name, gid, or project id) to query
+            quota for.
+        * quota_type: One of "user", "group" or "project" indicating how to
+            interpret the value of quota_id.
 
     Optional io_config keys:
         * lfs : string
@@ -82,6 +84,8 @@ class LustreHSMNodeIO(LustreQuotaNodeIO):
             seconds if not given.
         * fixed_quota : integer
             the quota, in kiB, on the Lustre disk backing the HSM system
+        * quota_type: One of "user", "group" or "project" indicating how to
+            interpret the value of quota_id.  If omitted, "group" is assumed.
         * release_check_count : integer
             The number of files to check at a time when doing idle HSM status
             update (see idle_update()).  Default is 100.
diff --git a/alpenhorn/io/lustrequota.py b/alpenhorn/io/lustrequota.py
index 4aa8fc5fd..8ea36a2cd 100644
--- a/alpenhorn/io/lustrequota.py
+++ b/alpenhorn/io/lustrequota.py
@@ -32,7 +32,10 @@ class LustreQuotaNodeIO(DefaultNodeIO):
     free space, rather than stat.
 
     Required io_config keys:
-        * quota_group: the user group to query quota for
+        * quota_id: the id (username, uid, group name, gid, or project id) to query
+            quota for.
+        * quota_type: One of "user", "group" or "project" indicating how to
+            interpret the value of quota_id.
 
     Optional io_config keys:
         * fixed_quota: a fixed number of kiB to use to override the max quota
@@ -44,10 +47,10 @@ class LustreQuotaNodeIO(DefaultNodeIO):
 
     Notes
     -----
-    If the provided `quota_group` is using the default block quota on the
-    filesystem, then block quota max cannot be determined from the lfs(1)
-    output.  In this case, `fixed_quota` _must_ be used to specify the default
-    quota for any meaninful free space value to be returned.
+    If the provided `quota_id` is using the default block quota on the filesystem,
+    then block quota max cannot be determined from the lfs(1) output.  In this case,
+    `fixed_quota` _must_ be used to specify the default quota for any meaningful free
+    space value to be returned.
     """
 
     def __init__(
@@ -55,15 +58,35 @@ def __init__(
     ) -> None:
         super().__init__(node, config, queue)
 
-        # Make alpenhornd crash if the io_config is incomplete.
-        if "quota_group" not in config:
-            raise KeyError(
-                f'"quota_group" missing from StorageNode {node.name} io_config'
-            )
+        quota_id = config.get("quota_id", None)
+        quota_type = config.get("quota_type", None)
+
+        # Make alpenhornd crash if the io_config is incomplete, but allow
+        # "quota_group" legacy support
+        if quota_id is None or quota_type is None:
+            try:
+                quota_id = config["quota_group"]
+                quota_type = "group"
+                log.warning(
+                    "Using deprecated 'quota_group' in "
+                    f"StorageNode {node.name} io_config"
+                )
+            except KeyError:
+                pass
+
+            if quota_id is None:
+                raise KeyError(
+                    f'"quota_id" missing from StorageNode {node.name} io_config'
+                )
+            if quota_type is None:
+                raise KeyError(
+                    f'"quota_type" missing from StorageNode {node.name} io_config'
+                )
 
         # Initialise the lfs(1) wrapper
         self._lfs = LFS(
-            quota_group=config["quota_group"],
+            quota_id=quota_id,
+            quota_type=quota_type,
             fixed_quota=config.get("fixed_quota", None),
             lfs=config.get("lfs", "lfs"),
             timeout=config.get("lfs_timeout", None),


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 69a880ac0..cfd5bcc56 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -588,9 +588,11 @@ def mockgroupandnode(hostname, queue, storagenode, storagegroup, mockio):
     mockio.group.set_nodes = lambda nodes: nodes
 
     node = UpdateableNode(queue, stnode)
-    yield mockio, UpdateableGroup(
-        queue=queue, group=stgroup, nodes=[node], idle=True
-    ), node
+    yield (
+        mockio,
+        UpdateableGroup(queue=queue, group=stgroup, nodes=[node], idle=True),
+        node,
+    )
 
 
 @pytest.fixture
diff --git a/tests/daemon/test_auto_import.py b/tests/daemon/test_auto_import.py
index 0ac8e2c7c..f9bcd0fd2 100644
--- a/tests/daemon/test_auto_import.py
+++ b/tests/daemon/test_auto_import.py
@@ -67,7 +67,9 @@ def test_import_file_not_ready(dbtables, xfs, queue, simplenode, mock_lfs):
 
     # Set up node for LustreHSMIO
     simplenode.io_class = "LustreHSM"
-    simplenode.io_config = '{"quota_group": "qgroup", "headroom": 300000}'
+    simplenode.io_config = (
+        '{"quota_id": "qid", "quota_type": "group", "headroom": 300000}'
+    )
     unode = UpdateableNode(queue, simplenode)
 
     xfs.create_file("/node/acq/file")
@@ -83,7 +85,7 @@ def test_import_file_not_ready(dbtables, xfs, queue, simplenode, mock_lfs):
     )
 
     # File is being restored
-    assert mock_lfs("").hsm_state("/node/acq/file") == HSMState.RESTORING
+    assert mock_lfs("", "group").hsm_state("/node/acq/file") == HSMState.RESTORING
 
 
 def test_import_file_no_ext(dbtables, unode):
diff --git a/tests/daemon/test_entry.py b/tests/daemon/test_entry.py
index 12a524891..3d54dc7ab 100644
--- a/tests/daemon/test_entry.py
+++ b/tests/daemon/test_entry.py
@@ -116,7 +116,10 @@ def e2e_db(xfs, clidb_noinit, hostname):
         host=hostname,
         active=True,
         avail_gb=2000.0 / 2**30,
-        io_config='{"quota_group": "qgroup", "headroom": 10, "restore_wait": 1}',
+        io_config=(
+            '{"quota_id": "qid", "quota_type": "project", '
+            '"headroom": 10, "restore_wait": 1}'
+        ),
     )
     sf1 = StorageNode.create(
         name="sf1", group=nlgrp, root="/sf1", host=hostname, active=True
@@ -133,7 +136,10 @@ def e2e_db(xfs, clidb_noinit, hostname):
         root="/nl2",
         host=hostname,
         active=True,
-        io_config='{"quota_group": "qgroup", "headroom": 1, "release_check_count": 1}',
+        io_config=(
+            '{"quota_id": "qid", "quota_type": "project", '
+            '"headroom": 1, "release_check_count": 1}'
+        ),
         avail_gb=2000.0 / 2**30,
     )
     StorageNode.create(name="sf2", group=nlgrp, root="/sf2", host=hostname, active=True)
@@ -328,7 +334,7 @@ def test_cli(e2e_db, e2e_config, mock_lfs, mock_rsync):
     assert result.exit_code == 0
 
     # Check HSM
-    lfs = mock_lfs(quota_group="qgroup")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state("/nl2/acq1/correct.me") == lfs.HSM_RESTORED
     assert lfs.hsm_state("/nl1/acq1/restore.me") == lfs.HSM_RESTORED
     assert lfs.hsm_state("/nl1/acq1/release.me") == lfs.HSM_RELEASED
diff --git a/tests/daemon/test_update.py b/tests/daemon/test_update.py
index 86772c948..657210b13 100644
--- a/tests/daemon/test_update.py
+++ b/tests/daemon/test_update.py
@@ -181,8 +181,8 @@ def test_ioload(storagegroup, storagenode, mock_lfs):
     for ioclass, ioconfig in [
         ("Default", None),
         ("Polling", None),
-        ("LustreQuota", '{"quota_group": "qgroup"}'),
-        ("LustreHSM", '{"quota_group": "qgroup", "headroom": 100000}'),
+        ("LustreQuota", '{"quota_id": "qid", "quota_type": "project"}'),
+        ("LustreHSM", '{"quota_id": "qid", "quota_type": "group", "headroom": 100000}'),
         (None, None),
     ]:
         storagenode(
diff --git a/tests/io/test_lfs.py b/tests/io/test_lfs.py
index b69a68ff7..02801c56b 100644
--- a/tests/io/test_lfs.py
+++ b/tests/io/test_lfs.py
@@ -7,12 +7,17 @@
 from alpenhorn.io.lfs import LFS
 
 
+@pytest.fixture
+def lfs(have_lfs):
+    """Returns a minimally-configured LFS instance."""
+
+    return LFS("quota_id", "group")
+
+
 @pytest.mark.run_command_result(0, "lfs_out", "lfs_err")
-def test_run_lfs_success(have_lfs, mock_run_command):
+def test_run_lfs_success(lfs, mock_run_command):
     """Test successful invocation of lfs.run_lfs."""
 
-    lfs = LFS(None)
-
     assert lfs.run_lfs("arg1", "arg2") == {
         "failed": False,
         "missing": False,
@@ -27,11 +32,9 @@ def test_run_lfs_success(have_lfs, mock_run_command):
 
 
 @pytest.mark.run_command_result(0, "lfs_out", "lfs_err")
-def test_run_lfs_stringify(have_lfs, mock_run_command):
+def test_run_lfs_stringify(lfs, mock_run_command):
     """run_lfs should stringify arguments."""
 
-    lfs = LFS(None)
-
     assert lfs.run_lfs(pathlib.Path("path"), 2)["output"] == "lfs_out"
     assert mock_run_command() == {
         "cmd": ["LFS", "path", "2"],
@@ -41,11 +44,9 @@ def test_run_lfs_stringify(have_lfs, mock_run_command):
 
 
 @pytest.mark.run_command_result(1, "lfs_out", "lfs_err")
-def test_run_lfs_fail(have_lfs, mock_run_command):
+def test_run_lfs_fail(lfs, mock_run_command):
     """Test failed invocation of lfs.run_lfs."""
 
-    lfs = LFS(None)
-
     assert lfs.run_lfs("arg1", "arg2") == {
         "failed": True,
         "missing": False,
@@ -60,11 +61,9 @@ def test_run_lfs_fail(have_lfs, mock_run_command):
 
 
 @pytest.mark.run_command_result(None, "lfs_out", "lfs_err")
-def test_run_lfs_timeout(have_lfs, mock_run_command):
+def test_run_lfs_timeout(lfs, mock_run_command):
     """Test timed out invocation of lfs.run_lfs."""
 
-    lfs = LFS(None)
-
     assert lfs.run_lfs("arg1", "arg2") == {
         "failed": False,
         "missing": False,
@@ -81,11 +80,9 @@ def test_run_lfs_timeout(have_lfs, mock_run_command):
 @pytest.mark.run_command_result(
     2, "lfs_out", "Something didn't work: No such file or directory"
 )
-def test_run_lfs_missing(have_lfs, mock_run_command):
+def test_run_lfs_missing(lfs, mock_run_command):
     """Test ENOENT from lfs.run_fls."""
 
-    lfs = LFS(None)
-
     assert lfs.run_lfs("arg1", "arg2") == {
         "failed": False,
         "missing": True,
@@ -100,11 +97,9 @@ def test_run_lfs_missing(have_lfs, mock_run_command):
 
 
 @pytest.mark.run_command_result(0, "/path", None)
-def test_quota_auto_syntax_short(have_lfs, mock_run_command):
+def test_quota_auto_syntax_short(lfs, mock_run_command):
     """Test quota syntax error: too few lines."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path") is None
 
 
@@ -118,7 +113,7 @@ def test_quota_auto_syntax_short(have_lfs, mock_run_command):
 def test_quota_auto_with_default(have_lfs, mock_run_command):
     """Test getting quota with default settings."""
 
-    lfs = LFS("qgroup", fixed_quota=2500)
+    lfs = LFS("quota_id", "group", fixed_quota=2500)
 
     assert lfs.quota_remaining("/path") == (2500 - 1234) * 2**10
 
@@ -130,57 +125,47 @@ def test_quota_auto_with_default(have_lfs, mock_run_command):
     "gid 345678 is using default file quota setting",
     None,
 )
-def test_quota_auto_default_no_fixed(have_lfs, mock_run_command):
+def test_quota_auto_default_no_fixed(lfs, mock_run_command):
     """Test getting quota with default settings without a fixed quota."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path") is None
 
 
 @pytest.mark.run_command_result(0, "/path           1234", None)
-def test_quota_auto_syntax_quota(have_lfs, mock_run_command):
+def test_quota_auto_syntax_quota(lfs, mock_run_command):
     """Test quota syntax error: bad second line"""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path") is None
 
 
 @pytest.mark.run_command_result(
     0, "/path           1234* 1000 3000 - 100 200 300 -", None
 )
-def test_quota_over(have_lfs, mock_run_command):
+def test_quota_over(lfs, mock_run_command):
     """Test getting quota from lfs.quota_remaining() when over quota."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path") == (1000 - 1234) * 2**10
-    assert "qgroup" in mock_run_command()["cmd"]
+    assert "quota_id" in mock_run_command()["cmd"]
 
 
 @pytest.mark.run_command_result(
     0, "/path           1234 2000 3000 - 100 200 300 -", None
 )
-def test_quota_auto(have_lfs, mock_run_command):
+def test_quota_auto(lfs, mock_run_command):
     """Test getting quota from lfs.quota_remaining() with no fixed_quota."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path") == (2000 - 1234) * 2**10
-    assert "qgroup" in mock_run_command()["cmd"]
+    assert "quota_id" in mock_run_command()["cmd"]
 
 
 @pytest.mark.run_command_result(
     0, "/path-so-long-it-wraps\n                1234 2000 3000 - 100 200 300 -", None
 )
-def test_quota_longpath(have_lfs, mock_run_command):
+def test_quota_longpath(lfs, mock_run_command):
     """Test getting quota from lfs.quota_remaining() with a long path."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.quota_remaining("/path-so-long-it-wraps") == (2000 - 1234) * 2**10
-    assert "qgroup" in mock_run_command()["cmd"]
+    assert "quota_id" in mock_run_command()["cmd"]
 
 
 @pytest.mark.run_command_result(
@@ -189,67 +174,118 @@ def test_quota_longpath(have_lfs, mock_run_command):
 def test_quota_fixed(have_lfs, mock_run_command):
     """Test getting quota_remaining from lfs.quota() with fixed quota."""
 
-    lfs = LFS("qgroup", fixed_quota=2500)
+    lfs = LFS("quota_id", "group", fixed_quota=2500)
 
     assert lfs.quota_remaining("/path") == (2500 - 1234) * 2**10
 
 
+def test_user_quota_type(have_lfs, mock_run_command):
+    """Test lfs with quota_type == "user"."""
+
+    lfs = LFS("quota_id", "user")
+
+    # None becuase of bad data from run_command
+    assert lfs.quota_remaining("/path") is None
+
+    assert mock_run_command()["cmd"] == [
+        "LFS",
+        "quota",
+        "-q",
+        "-u",
+        "quota_id",
+        "/path",
+    ]
+
+
+def test_group_quota_type(have_lfs, mock_run_command):
+    """Test lfs with quota_type == "group"."""
+
+    lfs = LFS("quota_id", "group")
+
+    # None becuase of bad data from run_command
+    assert lfs.quota_remaining("/path") is None
+
+    assert mock_run_command()["cmd"] == [
+        "LFS",
+        "quota",
+        "-q",
+        "-g",
+        "quota_id",
+        "/path",
+    ]
+
+
+def test_project_quota_type(have_lfs, mock_run_command):
+    """Test lfs with quota_type == "project"."""
+
+    lfs = LFS("quota_id", "project")
+
+    # None becuase of bad data from run_command
+    assert lfs.quota_remaining("/path") is None
+
+    assert mock_run_command()["cmd"] == [
+        "LFS",
+        "quota",
+        "-q",
+        "-p",
+        "quota_id",
+        "/path",
+    ]
+
+
+def test_bad_quota_type(have_lfs, mock_run_command):
+    """Test lfs with invalid quota_type."""
+
+    with pytest.raises(ValueError):
+        LFS("quota_id", "pool")
+
+
 @pytest.mark.run_command_result(
     2, "", "Something didn't work: No such file or directory"
 )
-def test_hsm_state_missing(xfs, have_lfs, mock_run_command):
+def test_hsm_state_missing(xfs, lfs, mock_run_command):
     """Test hsm_state on a missing file."""
 
-    lfs = LFS("qgroup")
-
     assert lfs.hsm_state("/path") == lfs.HSM_MISSING
 
 
 @pytest.mark.run_command_result(0, "/poth: (0x00000000)", None)
-def test_hsm_state_syntax(xfs, have_lfs, mock_run_command):
+def test_hsm_state_syntax(xfs, lfs, mock_run_command):
     """Test syntax error in hsm_state output."""
 
     xfs.create_file("/path")
 
-    lfs = LFS("qgroup")
-
     assert lfs.hsm_state("/path") is None
 
 
 @pytest.mark.run_command_result(0, "/path: (0x00000000)", None)
-def test_hsm_state_unarchived(xfs, have_lfs, mock_run_command):
+def test_hsm_state_unarchived(xfs, lfs, mock_run_command):
     """Test hsm_state on an unarchived file."""
 
     xfs.create_file("/path")
 
-    lfs = LFS("qgroup")
-
     assert lfs.hsm_state("/path") == lfs.HSM_UNARCHIVED
 
 
 @pytest.mark.run_command_result(
     0, "/path: (0x00000009) exists archived, archive_id:2", None
 )
-def test_hsm_state_restored(xfs, have_lfs, mock_run_command):
+def test_hsm_state_restored(xfs, lfs, mock_run_command):
     """Test hsm_state on a restored file."""
 
     xfs.create_file("/path")
 
-    lfs = LFS("qgroup")
-
     assert lfs.hsm_state("/path") == lfs.HSM_RESTORED
 
 
 @pytest.mark.run_command_result(
     0, "/path: (0x0000000d) released exists archived, archive_id:2", None
 )
-def test_hsm_state_released(xfs, have_lfs, mock_run_command):
+def test_hsm_state_released(xfs, lfs, mock_run_command):
     """Test hsm_state on a released file."""
 
     xfs.create_file("/path")
 
-    lfs = LFS("qgroup")
-
     assert lfs.hsm_state("/path") == lfs.HSM_RELEASED
 
 
@@ -264,7 +300,7 @@ def test_hsm_state_released(xfs, have_lfs, mock_run_command):
 def test_hsm_archived(mock_lfs):
     """Test hsm_archived()."""
 
-    lfs = LFS("qgroup")
+    lfs = LFS("quota_id", "group")
 
     assert not lfs.hsm_archived("/missing")
     assert not lfs.hsm_archived("/unarchived")
@@ -284,7 +320,7 @@ def test_hsm_archived(mock_lfs):
 def test_hsm_released(mock_lfs):
     """Test hsm_released()."""
 
-    lfs = LFS("qgroup")
+    lfs = LFS("quota_id", "group")
 
     assert not lfs.hsm_released("/missing")
     assert not lfs.hsm_released("/unarchived")
@@ -306,7 +342,7 @@ def test_hsm_released(mock_lfs):
 def test_hsm_restore(mock_lfs, mock_run_command):
     """Test hsm_restore()."""
 
-    lfs = LFS("qgroup")
+    lfs = LFS("quota_id", "group")
 
     assert lfs.hsm_restore("/missing") is False
     assert lfs.hsm_restore("/unarchived")
@@ -327,7 +363,7 @@ def test_hsm_restore(mock_lfs, mock_run_command):
 def test_hsm_restore_timeout(mock_lfs, mock_run_command):
     """Test hsm_restore() timeout."""
 
-    lfs = LFS("qgroup")
+    lfs = LFS("quota_id", "group")
 
     assert lfs.hsm_restore("/released") is None
 
@@ -345,7 +381,7 @@ def test_hsm_restore_timeout(mock_lfs, mock_run_command):
 def test_hsm_release(mock_lfs, mock_run_command):
     """Test hsm_restore()."""
 
-    lfs = LFS("qgroup")
+    lfs = LFS("quota_id", "group")
 
     assert not lfs.hsm_release("/missing")
     assert not lfs.hsm_release("/unarchived")
diff --git a/tests/io/test_lustrehsmgroup.py b/tests/io/test_lustrehsmgroup.py
index bf1fd6397..b60e9b995 100644
--- a/tests/io/test_lustrehsmgroup.py
+++ b/tests/io/test_lustrehsmgroup.py
@@ -22,7 +22,7 @@ def group(xfs, mock_lfs, hostname, queue, storagegroup, storagenode):
             storage_type="A",
             root="/hsm",
             host=hostname,
-            io_config='{"quota_group": "qgroup", "headroom": 10250}',
+            io_config='{"quota_id": "qid", "quota_type": "group", "headroom": 10250}',
         ),
     )
     smallfile = UpdateableNode(
@@ -75,7 +75,7 @@ def test_init(storagegroup, storagenode, queue, mock_lfs):
             name="hsm",
             group=stgroup,
             io_class="LustreHSM",
-            io_config='{"quota_group": "qgroup", "headroom": 10250}',
+            io_config='{"quota_id": "qid", "quota_type": "group", "headroom": 10250}',
         ),
     )
     smallfile = UpdateableNode(
diff --git a/tests/io/test_lustrehsmnode.py b/tests/io/test_lustrehsmnode.py
index bc4106b4a..d519e8218 100644
--- a/tests/io/test_lustrehsmnode.py
+++ b/tests/io/test_lustrehsmnode.py
@@ -22,7 +22,8 @@ def node(
     simplenode.io_class = "LustreHSM"
 
     simplenode.io_config = (
-        '{"quota_group": "qgroup", "headroom": 10250, "release_check_count": 7}'
+        '{"quota_id": "qid", "quota_type": "group", '
+        '"headroom": 10250, "release_check_count": 7}'
     )
 
     # Some files
@@ -52,7 +53,7 @@ def test_init_no_headroom(have_lfs, simplenode):
     """No headroom is an error"""
 
     simplenode.io_class = "LustreHSM"
-    simplenode.io_config = '{"quota_group": "qgroup"}'
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "group"}'
 
     with pytest.raises(KeyError):
         UpdateableNode(None, simplenode)
@@ -63,7 +64,8 @@ def test_init_bad_release_count(simplenode, have_lfs):
 
     simplenode.io_class = "LustreHSM"
     simplenode.io_config = (
-        '{"quota_group": "qgroup", "headroom": 300000, "release_check_count": -1}'
+        '{"quota_id": "qgroup", "quota_type": "group", '
+        '"headroom": 300000, "release_check_count": -1}'
     )
 
     with pytest.raises(ValueError):
@@ -75,7 +77,8 @@ def test_init_bad_restore_wait(simplenode, have_lfs):
 
     simplenode.io_class = "LustreHSM"
     simplenode.io_config = (
-        '{"quota_group": "qgroup", "headroom": 300000, "restore_wait": -1}'
+        '{"quota_id": "qgroup", "quota_type": "group", '
+        '"headroom": 300000, "restore_wait": -1}'
     )
 
     with pytest.raises(ValueError):
@@ -153,7 +156,7 @@ def test_release_files(queue, mock_lfs, node):
     assert ArchiveFileCopy.get(id=7).last_update == 5
 
     # Check hsm_relase was actually called
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state("/node/simpleacq/file1") == lfs.HSM_RELEASED
     assert lfs.hsm_state("/node/simpleacq/file2") == lfs.HSM_RELEASED
     assert lfs.hsm_state("/node/simpleacq/file3") == lfs.HSM_RELEASED
@@ -288,7 +291,7 @@ def test_check_ready_restored(xfs, queue, node, mock_lfs):
 
     # File is still restored
     assert ArchiveFileCopy.get(id=1).ready
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RESTORED
 
 
@@ -332,7 +335,7 @@ def test_check_released(xfs, queue, mock_lfs, node):
     async_mock.assert_called_once()
 
     # File has been re-released
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RELEASED
 
 
@@ -372,7 +375,7 @@ def test_check_ready_released(xfs, queue, mock_lfs, node):
     async_mock.assert_called_once()
 
     # File has been restored
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RESTORED
 
 
@@ -396,7 +399,7 @@ def test_ready_path(mock_lfs, node):
     assert not node.io.ready_path("/node/simpleacq/file5")
 
     # But now released file is recalled.
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state("/node/simpleacq/file1") == lfs.HSM_RESTORED
     assert lfs.hsm_state("/node/simpleacq/file2") == lfs.HSM_RESTORING
     assert lfs.hsm_state("/node/simpleacq/file3") == lfs.HSM_UNARCHIVED
@@ -436,7 +439,7 @@ def test_ready_pull_restored(mock_lfs, node, queue, archivefilecopyrequest):
     assert ArchiveFileCopy.get(id=1).last_update >= before
 
     # File is restored
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RESTORED
 
 
@@ -469,7 +472,7 @@ def test_ready_pull_restoring(mock_lfs, node, queue, archivefilecopyrequest):
     assert not ArchiveFileCopy.get(id=1).ready
 
     # File is still being restored
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RESTORING
 
 
@@ -508,7 +511,7 @@ def test_ready_pull_released(mock_lfs, node, queue, archivefilecopyrequest):
     task()
 
     # File is being restored
-    lfs = mock_lfs("")
+    lfs = mock_lfs("", "group")
     assert lfs.hsm_state(copy.path) == lfs.HSM_RESTORING
 
 
diff --git a/tests/io/test_lustrequota.py b/tests/io/test_lustrequota.py
index 9050f2079..0f683f650 100644
--- a/tests/io/test_lustrequota.py
+++ b/tests/io/test_lustrequota.py
@@ -10,13 +10,13 @@
 def node(simplenode, mock_lfs):
     """A LustreQuota node for testing"""
     simplenode.io_class = "LustreQuota"
-    simplenode.io_config = '{"quota_group": "qgroup"}'
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "group"}'
 
     return UpdateableNode(None, simplenode)
 
 
-def test_no_quota_group(simplenode):
-    """Test instantiating I/O without specifying quota_group."""
+def test_no_quota_id(simplenode):
+    """Test instantiating I/O without specifying quota_id."""
 
     simplenode.io_class = "LustreQuota"
 
@@ -28,13 +28,13 @@ def test_no_lfs(simplenode):
     """Test crashing if lfs(1) can't be found."""
 
     simplenode.io_class = "LustreQuota"
-    simplenode.io_config = '{"quota_group": "qgroup"}'
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "group"}'
 
     with pytest.raises(RuntimeError):
         UpdateableNode(None, simplenode)
 
 
-def test_quota_group(node):
+def test_quota_id(node):
     """Test instantiating I/O."""
 
     assert isinstance(node.io, LustreQuotaNodeIO)
@@ -45,3 +45,37 @@ def test_bytes_avail(node):
     """Test LustreQuotaNodeIO.bytes_avail"""
 
     assert node.io.bytes_avail() == 1234
+
+
+@pytest.mark.lfs_dont_mock("quota_remaining")
+def test_quota_type(mock_lfs, simplenode, mock_run_command):
+    """Test quota_type handling."""
+
+    simplenode.io_class = "LustreQuota"
+
+    # User quota
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "user"}'
+    unode = UpdateableNode(None, simplenode)
+    unode.io.bytes_avail()
+    assert "-u qid" in " ".join(mock_run_command()["cmd"])
+
+    # Group quota
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "group"}'
+    unode = UpdateableNode(None, simplenode)
+    unode.io.bytes_avail()
+    assert "-g qid" in " ".join(mock_run_command()["cmd"])
+
+    # Project quota
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "project"}'
+    unode = UpdateableNode(None, simplenode)
+    unode.io.bytes_avail()
+    assert "-p qid" in " ".join(mock_run_command()["cmd"])
+
+
+def test_bad_quota_type(mock_lfs, simplenode):
+    """Test bad quota_type handling."""
+
+    simplenode.io_class = "LustreQuota"
+    simplenode.io_config = '{"quota_id": "qid", "quota_type": "pool"}'
+    with pytest.raises(ValueError):
+        UpdateableNode(None, simplenode)