Merge pull request #8797 from ThomasWaldmann/remove-quota

remove remainders of quota support
This commit is contained in:
TW 2025-04-28 17:46:02 +02:00 committed by GitHub
commit 94df5eee21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 21 additions and 192 deletions

View file

@ -6,7 +6,6 @@ Hosting repositories
====================
This sections shows how to provide repository storage securely for users.
Optionally, each user can have a storage quota.
Repositories are accessed through SSH. Each user of the service should
have her own login which is only able to access the user's files.
@ -56,18 +55,6 @@ multiple times to permit access to more than one repository.
The repository may not exist yet; it can be initialized by the user,
which allows for encryption.
**Storage quotas** can be enabled by adding the ``--storage-quota`` option
to the ``borg serve`` command line::
restrict,command="borg serve --storage-quota 20G ..." ...
The storage quotas of repositories are completely independent. If a
client is able to access multiple repositories, each repository
can be filled to the specified quota.
If storage quotas are used, ensure that all deployed Borg releases
support storage quotas.
**Specificities: Append-only repositories**
Running ``borg init`` via a ``borg serve --append-only`` server will **not**

View file

@ -610,8 +610,6 @@ Errors
The parent path of the repo directory [{}] does not exist.
Repository.PathAlreadyExists rc: 19 traceback: no
There is already something at {}.
Repository.StorageQuotaExceeded rc: 20 traceback: no
The storage quota ({}) has been exceeded ({}). Try deleting some archives.
Repository.PathPermissionDenied rc: 21 traceback: no
Permission denied to {}.

View file

@ -47,7 +47,7 @@ Also helpful:
does not have free space any more.
- if you use LVM: use a LV + a filesystem that you can resize later and have
some unallocated PEs you can add to the LV.
- consider using quotas
- consider using quotas (e.g. fs quota, quota settings of storage provider)
- use `prune` and `compact` regularly

View file

@ -89,7 +89,6 @@ complete -c borg -l 'rsh' -d 'Use COMMAND instead of s
set -l encryption_modes "none keyfile keyfile-blake2 repokey repokey-blake2 authenticated authenticated-blake2"
complete -c borg -f -s e -l 'encryption' -d 'Encryption key MODE' -a "$encryption_modes" -n "__fish_seen_subcommand_from init"
complete -c borg -f -l 'append-only' -d 'Create an append-only mode repository' -n "__fish_seen_subcommand_from init"
complete -c borg -f -l 'storage-quota' -d 'Set storage QUOTA of the repository' -n "__fish_seen_subcommand_from init"
complete -c borg -f -l 'make-parent-dirs' -d 'Create parent directories' -n "__fish_seen_subcommand_from init"
# borg create options
@ -316,7 +315,6 @@ complete -c borg -f -l 'strip-components' -d 'Remove NUMBER of leading
complete -c borg -l 'restrict-to-path' -d 'Restrict repository access to PATH' -n "__fish_seen_subcommand_from serve"
complete -c borg -l 'restrict-to-repository' -d 'Restrict repository access at PATH' -n "__fish_seen_subcommand_from serve"
complete -c borg -f -l 'append-only' -d 'Only allow appending to repository' -n "__fish_seen_subcommand_from serve"
complete -c borg -f -l 'storage-quota' -d 'Override storage QUOTA of the repository' -n "__fish_seen_subcommand_from serve"
# borg config
complete -c borg -f -s c -l 'cache' -d 'Get/set/list values in the repo cache' -n "__fish_seen_subcommand_from config"

View file

@ -401,7 +401,7 @@ class Archiver(
# client is allowed to specify the allowlisted options,
# everything else comes from the forced "borg serve" command (or the defaults).
# stuff from denylist must never be used from the client.
denylist = {"restrict_to_paths", "restrict_to_repositories", "append_only", "storage_quota", "umask"}
denylist = {"restrict_to_paths", "restrict_to_repositories", "append_only", "umask"}
allowlist = {"debug_topics", "lock_wait", "log_level"}
not_present = object()
for attr_name in allowlist:

View file

@ -30,7 +30,7 @@ from ..logger import create_logger
logger = create_logger(__name__)
def get_repository(location, *, create, exclusive, lock_wait, lock, append_only, storage_quota, args, v1_or_v2):
def get_repository(location, *, create, exclusive, lock_wait, lock, append_only, args, v1_or_v2):
if location.proto in ("ssh", "socket"):
RemoteRepoCls = LegacyRemoteRepository if v1_or_v2 else RemoteRepository
repository = RemoteRepoCls(
@ -45,25 +45,13 @@ def get_repository(location, *, create, exclusive, lock_wait, lock, append_only,
elif location.proto in ("sftp", "file", "rclone") and not v1_or_v2: # stuff directly supported by borgstore
repository = Repository(
location,
create=create,
exclusive=exclusive,
lock_wait=lock_wait,
lock=lock,
append_only=append_only,
storage_quota=storage_quota,
location, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock, append_only=append_only
)
else:
RepoCls = LegacyRepository if v1_or_v2 else Repository
repository = RepoCls(
location.path,
create=create,
exclusive=exclusive,
lock_wait=lock_wait,
lock=lock,
append_only=append_only,
storage_quota=storage_quota,
location.path, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock, append_only=append_only
)
return repository
@ -127,7 +115,6 @@ def with_repository(
assert isinstance(exclusive, bool)
lock = getattr(args, "lock", _lock)
append_only = getattr(args, "append_only", False)
storage_quota = getattr(args, "storage_quota", None)
repository = get_repository(
location,
@ -136,7 +123,6 @@ def with_repository(
lock_wait=self.lock_wait,
lock=lock,
append_only=append_only,
storage_quota=storage_quota,
args=args,
v1_or_v2=False,
)
@ -205,7 +191,6 @@ def with_other_repository(manifest=False, cache=False, compatibility=None):
lock_wait=self.lock_wait,
lock=True,
append_only=False,
storage_quota=None,
args=args,
v1_or_v2=v1_or_v2,
)

View file

@ -6,7 +6,6 @@ from ..constants import * # NOQA
from ..crypto.key import key_creator, key_argument_names
from ..helpers import CancelledByUser, CommandError
from ..helpers import location_validator, Location
from ..helpers import parse_storage_quota
from ..manifest import Manifest
from ..logger import create_logger
@ -19,8 +18,6 @@ class RepoCreateMixIn:
@with_other_repository(manifest=True, compatibility=(Manifest.Operation.READ,))
def do_repo_create(self, args, repository, *, other_repository=None, other_manifest=None):
"""Create a new, empty repository"""
if args.storage_quota is not None:
raise CommandError("storage-quota is not supported (yet?)")
if args.append_only:
raise CommandError("append-only is not supported (yet?)")
other_key = other_manifest.key if other_manifest is not None else None
@ -236,15 +233,6 @@ class RepoCreateMixIn:
"or `prune` will still be allowed. See :ref:`append_only_mode` in "
"Additional Notes for more details.",
)
subparser.add_argument(
"--storage-quota",
metavar="QUOTA",
dest="storage_quota",
default=None,
type=parse_storage_quota,
action=Highlander,
help="Set storage quota of the new repository (e.g. 5G, 1.5T). Default: no quota.",
)
subparser.add_argument(
"--copy-crypt-key",
dest="copy_crypt_key",

View file

@ -3,7 +3,7 @@ import textwrap
from ._common import with_repository
from ..constants import * # NOQA
from ..helpers import bin_to_hex, json_print, basic_json_data, format_file_size
from ..helpers import bin_to_hex, json_print, basic_json_data
from ..manifest import Manifest
from ..logger import create_logger
@ -50,15 +50,6 @@ class RepoInfoMixIn:
)
)
response = repository.info()
storage_quota = response["storage_quota"]
used = format_file_size(response["storage_quota_use"], iec=args.iec)
output += f"\nStorage quota: {used} used"
if storage_quota:
output += f" out of {format_file_size(storage_quota, iec=args.iec)}"
output += "\n"
if hasattr(info["cache"], "path"):
output += "Cache: {cache.path}\n".format(**info)
output += "Security dir: {security_dir}\n".format(**info)

View file

@ -1,8 +1,7 @@
import argparse
from ._common import Highlander
from ..constants import * # NOQA
from ..helpers import parse_storage_quota, CommandError
from ..helpers import CommandError
from ..remote import RepositoryServer
from ..logger import create_logger
@ -15,13 +14,10 @@ class ServeMixIn:
"""Start in server mode. This command is usually not used manually."""
if args.append_only:
raise CommandError("append-only is not supported (yet?)")
if args.storage_quota is not None:
raise CommandError("storage-quota is not supported (yet?)")
RepositoryServer(
restrict_to_paths=args.restrict_to_paths,
restrict_to_repositories=args.restrict_to_repositories,
append_only=args.append_only,
storage_quota=args.storage_quota,
use_socket=args.use_socket,
).serve()
@ -84,14 +80,3 @@ class ServeMixIn:
"or `prune` will still be allowed. See :ref:`append_only_mode` in Additional "
"Notes for more details.",
)
subparser.add_argument(
"--storage-quota",
metavar="QUOTA",
dest="storage_quota",
type=parse_storage_quota,
default=None,
action=Highlander,
help="Override storage quota of the repository (e.g. 5G, 1.5T). "
"When a new repository is initialized, sets the storage quota on the new "
"repository as well. Default: no quota.",
)

View file

@ -30,7 +30,7 @@ from .parseformat import bin_to_hex, hex_to_bin, safe_encode, safe_decode
from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd
from .parseformat import eval_escapes, decode_dict, positive_int_validator, interval
from .parseformat import PathSpec, SortBySpec, ChunkerParams, FilesCacheMode, partial_format, DatetimeWrapper
from .parseformat import format_file_size, parse_file_size, FileSize, parse_storage_quota
from .parseformat import format_file_size, parse_file_size, FileSize
from .parseformat import sizeof_fmt, sizeof_fmt_iec, sizeof_fmt_decimal, Location, text_validator
from .parseformat import format_line, replace_placeholders, PlaceholderError, relative_time_marker_validator
from .parseformat import format_archive, parse_stringified_list, clean_lines

View file

@ -357,13 +357,6 @@ def parse_file_size(s):
return int(float(s) * factor)
def parse_storage_quota(storage_quota):
parsed = parse_file_size(storage_quota)
if parsed < parse_file_size("10M"):
raise argparse.ArgumentTypeError("quota is too small (%s). At least 10M are required." % storage_quota)
return parsed
def sizeof_fmt(num, suffix="B", units=None, power=None, sep="", precision=2, sign=False):
sign = "+" if sign and num > 0 else ""
fmt = "{0:{1}.{2}f}{3}{4}{5}"

View file

@ -409,9 +409,6 @@ class LegacyRemoteRepository:
topic = "borg.debug." + topic
if "repository" in topic:
opts.append("--debug-topic=%s" % topic)
if "storage_quota" in args and args.storage_quota:
opts.append("--storage-quota=%s" % args.storage_quota)
env_vars = []
if testing:
return env_vars + [sys.executable, "-m", "borg", "serve"] + opts + self.extra_test_args

View file

@ -183,10 +183,7 @@ class LegacyRepository:
exit_mcode = 19
class StorageQuotaExceeded(Error):
"""The storage quota ({}) has been exceeded ({}). Try deleting some archives."""
exit_mcode = 20
# StorageQuotaExceeded was exit_mcode = 20
class PathPermissionDenied(Error):
"""Permission denied to {}."""
@ -194,15 +191,7 @@ class LegacyRepository:
exit_mcode = 21
def __init__(
self,
path,
create=False,
exclusive=False,
lock_wait=None,
lock=True,
append_only=False,
storage_quota=None,
send_log_cb=None,
self, path, create=False, exclusive=False, lock_wait=None, lock=True, append_only=False, send_log_cb=None
):
self.path = os.path.abspath(path)
self._location = Location("file://%s" % self.path)
@ -230,8 +219,6 @@ class LegacyRepository:
self.created = False
self.exclusive = exclusive
self.append_only = append_only
self.storage_quota = storage_quota
self.storage_quota_use = 0
self.transaction_doomed = None
# v2 is the default repo version for borg 2.0
# v1 repos must only be used in a read-only way, e.g. for
@ -290,13 +277,9 @@ class LegacyRepository:
"""
Raise an exception if a repository already exists at *path* or any parent directory.
Checking parent directories is done for two reasons:
(1) It's just a weird thing to do, and usually not intended. A Borg using the "parent" repository
may be confused, or we may accidentally put stuff into the "data/" or "data/<n>/" directories.
(2) When implementing repository quotas (which we currently don't), it's important to prohibit
folks from creating quota-free repositories. Since no one can create a repository within another
repository, user's can only use the quota'd repository, when their --restrict-to-path points
at the user's repository.
Checking parent directories is done because it's just a weird thing to do, and usually not intended.
A Borg using the "parent" repository may be confused, or we may accidentally put stuff into the "data/" or
"data/<n>/" directories.
"""
try:
st = os.stat(path)
@ -345,10 +328,6 @@ class LegacyRepository:
config.set("repository", "segments_per_dir", str(DEFAULT_SEGMENTS_PER_DIR))
config.set("repository", "max_segment_size", str(DEFAULT_MAX_SEGMENT_SIZE))
config.set("repository", "append_only", str(int(self.append_only)))
if self.storage_quota:
config.set("repository", "storage_quota", str(self.storage_quota))
else:
config.set("repository", "storage_quota", "0")
config.set("repository", "additional_free_space", "0")
config.set("repository", "id", bin_to_hex(os.urandom(32)))
self.save_config(path, config)
@ -492,9 +471,6 @@ class LegacyRepository:
# append_only can be set in the constructor
# it shouldn't be overridden (True -> False) here
self.append_only = self.append_only or self.config.getboolean("repository", "append_only", fallback=False)
if self.storage_quota is None:
# self.storage_quota is None => no explicit storage_quota was specified, use repository setting.
self.storage_quota = parse_file_size(self.config.get("repository", "storage_quota", fallback=0))
self.id = hex_to_bin(self.config.get("repository", "id").strip(), length=32)
self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
@ -504,15 +480,12 @@ class LegacyRepository:
return
hints = self._unpack_hints(transaction_id)
self.version = hints["version"]
self.storage_quota_use = hints["storage_quota_use"]
self.shadow_index = hints["shadow_index"]
def info(self):
"""return some infos about the repo (must be opened first)"""
info = dict(id=self.id, version=self.version, append_only=self.append_only)
self._load_hints()
info["storage_quota"] = self.storage_quota
info["storage_quota_use"] = self.storage_quota_use
return info
def close(self):
@ -604,7 +577,6 @@ class LegacyRepository:
if transaction_id is None:
self.segments = {} # XXX bad name: usage_count_of_segment_x = self.segments[x]
self.compact = FreeSpace() # XXX bad name: freeable_space_of_segment_x = self.compact[x]
self.storage_quota_use = 0
self.shadow_index.clear()
else:
if do_cleanup:
@ -626,7 +598,6 @@ class LegacyRepository:
logger.debug("Upgrading from v1 hints.%d", transaction_id)
self.segments = hints["segments"]
self.compact = FreeSpace()
self.storage_quota_use = 0
self.shadow_index = {}
for segment in sorted(hints["compact"]):
logger.debug("Rebuilding sparse info for segment %d", segment)
@ -637,7 +608,6 @@ class LegacyRepository:
else:
self.segments = hints["segments"]
self.compact = FreeSpace(hints["compact"])
self.storage_quota_use = hints.get("storage_quota_use", 0)
self.shadow_index = hints.get("shadow_index", {})
# Drop uncommitted segments in the shadow index
for key, shadowed_segments in self.shadow_index.items():
@ -653,13 +623,7 @@ class LegacyRepository:
def rename_tmp(file):
os.replace(file + ".tmp", file)
hints = {
"version": 2,
"segments": self.segments,
"compact": self.compact,
"storage_quota_use": self.storage_quota_use,
"shadow_index": self.shadow_index,
}
hints = {"version": 2, "segments": self.segments, "compact": self.compact, "shadow_index": self.shadow_index}
integrity = {
# Integrity version started at 2, the current hints version.
# Thus, integrity version == hints version, for now.
@ -783,7 +747,6 @@ class LegacyRepository:
if not self.compact:
logger.debug("Nothing to do: compact empty")
return
quota_use_before = self.storage_quota_use
index_transaction_id = self.get_index_transaction_id()
segments = self.segments
unused = [] # list of segments, that are not used anymore
@ -855,9 +818,6 @@ class LegacyRepository:
segments.setdefault(new_segment, 0)
segments[new_segment] += 1
segments[segment] -= 1
if tag == TAG_PUT:
# old tag is PUT, but new will be PUT2 and use a bit more storage
self.storage_quota_use += self.io.ENTRY_HASH_SIZE
elif tag in (TAG_PUT2, TAG_PUT) and not is_index_object:
# If this is a PUT shadowed by a later tag, then it will be gone when this segment is deleted after
# this loop. Therefore it is removed from the shadow index.
@ -867,7 +827,6 @@ class LegacyRepository:
# do not remove entry with empty shadowed_segments list here,
# it is needed for shadowed_put_exists code (see below)!
pass
self.storage_quota_use -= header_size(tag) + len(data)
elif tag == TAG_DELETE and not in_index:
# If the shadow index doesn't contain this key, then we can't say if there's a shadowed older tag,
# therefore we do not drop the delete, but write it to a current segment.
@ -945,8 +904,6 @@ class LegacyRepository:
self._send_log()
complete_xfer(intermediate=False)
self.io.clear_empty_dirs()
quota_use_after = self.storage_quota_use
logger.info("Compaction freed about %s repository space.", format_file_size(quota_use_before - quota_use_after))
logger.debug("Compaction completed.")
def replay_segments(self, index_transaction_id, segments_transaction_id):
@ -990,7 +947,6 @@ class LegacyRepository:
pass
self.index[key] = NSIndex1Entry(segment, offset)
self.segments[segment] += 1
self.storage_quota_use += header_size(tag) + size
elif tag == TAG_DELETE:
try:
# if the deleted PUT is not in the index, there is nothing to clean up
@ -1232,20 +1188,14 @@ class LegacyRepository:
pass
else:
# this put call supersedes a previous put to same id.
# it is essential to do a delete first to get correct quota bookkeeping
# and also a correctly updated shadow_index, so that the compaction code
# does not wrongly resurrect an old PUT by dropping a DEL that is still needed.
# it is essential to do a delete first to get a correctly updated shadow_index,
# so that the compaction code does not wrongly resurrect an old PUT by
# dropping a DEL that is still needed.
self._delete(id, in_index.segment, in_index.offset, 0)
segment, offset = self.io.write_put(id, data)
self.storage_quota_use += header_size(TAG_PUT2) + len(data)
self.segments.setdefault(segment, 0)
self.segments[segment] += 1
self.index[id] = NSIndex1Entry(segment, offset)
if self.storage_quota and self.storage_quota_use > self.storage_quota:
self.transaction_doomed = self.StorageQuotaExceeded(
format_file_size(self.storage_quota), format_file_size(self.storage_quota_use)
)
raise self.transaction_doomed
def delete(self, id, wait=True):
"""delete a repo object

View file

@ -183,7 +183,7 @@ class RepositoryServer: # pragma: no cover
"store_move",
)
def __init__(self, restrict_to_paths, restrict_to_repositories, append_only, storage_quota, use_socket):
def __init__(self, restrict_to_paths, restrict_to_repositories, append_only, use_socket):
self.repository = None
self.RepoCls = None
self.rpc_methods = ("open", "close", "negotiate")
@ -194,7 +194,6 @@ class RepositoryServer: # pragma: no cover
# whatever the client wants, except when initializing a new repository
# (see RepositoryServer.open below).
self.append_only = append_only
self.storage_quota = storage_quota
self.client_version = None # we update this after client sends version information
if use_socket is False:
self.socket_path = None
@ -276,7 +275,6 @@ class RepositoryServer: # pragma: no cover
Repository.PathAlreadyExists,
PathNotAllowed,
Repository.InsufficientFreeSpaceError,
Repository.StorageQuotaExceeded,
)
# logger.exception(e)
ex_short = traceback.format_exception_only(e.__class__, e)
@ -407,7 +405,6 @@ class RepositoryServer: # pragma: no cover
lock_wait=lock_wait,
lock=lock,
append_only=append_only,
storage_quota=self.storage_quota,
exclusive=exclusive,
send_log_cb=self.send_queued_log,
)
@ -735,9 +732,6 @@ class RemoteRepository:
topic = "borg.debug." + topic
if "repository" in topic:
opts.append("--debug-topic=%s" % topic)
if "storage_quota" in args and args.storage_quota:
opts.append("--storage-quota=%s" % args.storage_quota)
env_vars = []
if testing:
return env_vars + [sys.executable, "-m", "borg", "serve"] + opts + self.extra_test_args
@ -834,8 +828,6 @@ class RemoteRepository:
raise Repository.InsufficientFreeSpaceError(args[0], args[1])
elif error == "InvalidRepositoryConfig":
raise Repository.InvalidRepositoryConfig(self.location.processed, args[1])
elif error == "StorageQuotaExceeded":
raise Repository.StorageQuotaExceeded(args[0], args[1])
else:
raise self.RPCError(unpacked)

View file

@ -86,10 +86,7 @@ class Repository:
exit_mcode = 19
class StorageQuotaExceeded(Error):
"""The storage quota ({}) has been exceeded ({}). Try deleting some archives."""
exit_mcode = 20
# StorageQuotaExceeded was exit_mcode = 20
class PathPermissionDenied(Error):
"""Permission denied to {}."""
@ -104,7 +101,6 @@ class Repository:
lock_wait=1.0,
lock=True,
append_only=False,
storage_quota=None,
send_log_cb=None,
):
if isinstance(path_or_location, Location):
@ -144,8 +140,6 @@ class Repository:
self.acceptable_repo_versions = (3,)
self.opened = False
self.append_only = append_only # XXX not implemented / not implementable
self.storage_quota = storage_quota # XXX not implemented
self.storage_quota_use = 0 # XXX not implemented
self.lock = None
self.do_lock = lock
self.lock_wait = lock_wait
@ -260,13 +254,7 @@ class Repository:
"""return some infos about the repo (must be opened first)"""
# note: don't do anything expensive here or separate the lock refresh into a separate method.
self._lock_refresh() # do not remove, see do_with_lock()
info = dict(
id=self.id,
version=self.version,
storage_quota_use=self.storage_quota_use,
storage_quota=self.storage_quota,
append_only=self.append_only,
)
info = dict(id=self.id, version=self.version, append_only=self.append_only)
return info
def check(self, repair=False, max_duration=0):

View file

@ -1,7 +1,6 @@
import argparse
import pytest
from ...helpers import parse_storage_quota
from . import Archiver, RK_ENCRYPTION, cmd
@ -187,9 +186,3 @@ class TestCommonOptions:
}
assert parse_vars_from_line(*line) == result
def test_parse_storage_quota():
assert parse_storage_quota("50M") == 50 * 1000**2
with pytest.raises(argparse.ArgumentTypeError):
parse_storage_quota("5M")

View file

@ -1051,15 +1051,7 @@ def test_remote_borg_cmd(remote_repository):
"--debug-topic=borg.debug.repository_compaction",
]
args = _get_mock_args()
args.storage_quota = 0
assert remote_repository.borg_cmd(args, testing=False) == ["borg", "serve", "--info"]
args.storage_quota = 314159265
assert remote_repository.borg_cmd(args, testing=False) == [
"borg",
"serve",
"--info",
"--storage-quota=314159265",
]
args.rsh = "ssh -i foo"
remote_repository._args = args
assert remote_repository.ssh_cmd(Location("ssh://example.com/foo")) == ["ssh", "-i", "foo", "example.com"]

View file

@ -41,7 +41,7 @@ class TestSleepingBandwidthLimiter:
now = 100
it = SleepingBandwidthLimiter(100)
it = SleepingBandwidthLimiter(100) # bandwidth quota
# all fits
self.expect_write(5, b"test")

View file

@ -263,15 +263,7 @@ def test_remote_borg_cmd(remote_repository):
"--debug-topic=borg.debug.repository_compaction",
]
args = _get_mock_args()
args.storage_quota = 0
assert remote_repository.borg_cmd(args, testing=False) == ["borg", "serve", "--info"]
args.storage_quota = 314159265
assert remote_repository.borg_cmd(args, testing=False) == [
"borg",
"serve",
"--info",
"--storage-quota=314159265",
]
args.rsh = "ssh -i foo"
remote_repository._args = args
assert remote_repository.ssh_cmd(Location("ssh://example.com/foo")) == ["ssh", "-i", "foo", "example.com"]