Skip to content

Commit 1d79301

Browse files
author
Joachim Jablon
authored
Merge pull request #181 from soualid/feature/cp
implemented a cp function
2 parents c7cc548 + d99c863 commit 1d79301

File tree

5 files changed

+208
-6
lines changed

5 files changed

+208
-6
lines changed

docs/howto/organize.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ content of the vault.
77
Copy secrets and folders
88
------------------------
99

10-
This is planned but not implemented yet. Please refer to `#119`__
10+
.. code:: console
11+
12+
$ vault-cli set a b=c
1113
12-
.. __: https://github.com/peopledoc/vault-cli/issues/119
14+
$ vault-cli cp a d/e
15+
Copy 'a' to 'd/e'
16+
17+
``vault-cli cp`` follows the ``safe-write`` parameter (see :ref:`safe-write`) and
18+
has a ``--force`` flag, like ``vault-cli set``.
1319

1420
Move secrets and folders
1521
------------------------

tests/unit/test_cli.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,64 @@ def test_mv_mix_secrets_folders(cli_runner, vault_with_token):
588588
assert result.exit_code != 0
589589

590590

591+
def test_cp(cli_runner, vault_with_token):
592+
vault_with_token.db = {
593+
"a/b": {"value": "c"},
594+
"d/e": {"value": "f"},
595+
"d/g": {"value": "h"},
596+
}
597+
598+
result = cli_runner.invoke(cli.cli, ["cp", "d", "a"])
599+
600+
assert result.output.splitlines() == ["Copy 'd/e' to 'a/e'", "Copy 'd/g' to 'a/g'"]
601+
assert vault_with_token.db == {
602+
"a/b": {"value": "c"},
603+
"a/e": {"value": "f"},
604+
"a/g": {"value": "h"},
605+
"d/e": {"value": "f"},
606+
"d/g": {"value": "h"},
607+
}
608+
assert result.exit_code == 0
609+
610+
611+
def test_cp_overwrite_safe(cli_runner, vault_with_token):
612+
vault_with_token.db = {"a/b": {"value": "c"}, "d/b": {"value": "f"}}
613+
614+
vault_with_token.safe_write = True
615+
616+
result = cli_runner.invoke(cli.cli, ["cp", "d", "a"])
617+
618+
assert vault_with_token.db == {"a/b": {"value": "c"}, "d/b": {"value": "f"}}
619+
assert result.exit_code != 0
620+
621+
622+
def test_cp_overwrite_force(cli_runner, vault_with_token):
623+
vault_with_token.db = {"a/b": {"value": "c"}, "d/b": {"value": "f"}}
624+
625+
result = cli_runner.invoke(cli.cli, ["cp", "d", "a", "--force"])
626+
627+
assert vault_with_token.db == {"a/b": {"value": "f"}, "d/b": {"value": "f"}}
628+
assert result.exit_code == 0
629+
630+
631+
def test_cp_mix_folders_secrets(cli_runner, vault_with_token):
632+
vault_with_token.db = {"a/b": {"value": "c"}, "d": {"value": "e"}}
633+
634+
result = cli_runner.invoke(cli.cli, ["cp", "d", "a"])
635+
636+
assert vault_with_token.db == {"a/b": {"value": "c"}, "d": {"value": "e"}}
637+
assert result.exit_code != 0
638+
639+
640+
def test_cp_mix_secrets_folders(cli_runner, vault_with_token):
641+
vault_with_token.db = {"a/b": {"value": "c"}, "d": {"value": "e"}}
642+
643+
result = cli_runner.invoke(cli.cli, ["cp", "a", "d"])
644+
645+
assert vault_with_token.db == {"a/b": {"value": "c"}, "d": {"value": "e"}}
646+
assert result.exit_code != 0
647+
648+
591649
def test_template_from_stdin(cli_runner, vault_with_token):
592650
vault_with_token.db = {"a/b": {"value": "c"}}
593651

tests/unit/test_client_base.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,70 @@ def test_vault_client_move_secrets_overwrite_force(vault):
392392
assert vault.db == {"b": {"value": "c"}}
393393

394394

395+
def test_vault_client_copy_secrets(vault):
396+
397+
vault.db = {"a/b": {"value": "c"}, "a/d": {"value": "e"}}
398+
399+
vault.copy_secrets("a", "d")
400+
401+
assert vault.db == {
402+
"a/b": {"value": "c"},
403+
"a/d": {"value": "e"},
404+
"d/b": {"value": "c"},
405+
"d/d": {"value": "e"},
406+
}
407+
408+
409+
def test_vault_client_copy_secrets_generator(vault):
410+
411+
vault.db = {"a/b": {"value": "c"}, "a/d": {"value": "e"}}
412+
413+
result = vault.copy_secrets("a", "f", generator=True)
414+
415+
assert next(result) == ("a/b", "f/b")
416+
417+
assert vault.db == {"a/b": {"value": "c"}, "a/d": {"value": "e"}}
418+
419+
assert next(result) == ("a/d", "f/d")
420+
421+
assert vault.db == {
422+
"f/b": {"value": "c"},
423+
"a/b": {"value": "c"},
424+
"a/d": {"value": "e"},
425+
}
426+
427+
with pytest.raises(StopIteration):
428+
next(result)
429+
430+
assert vault.db == {
431+
"a/b": {"value": "c"},
432+
"a/d": {"value": "e"},
433+
"f/b": {"value": "c"},
434+
"f/d": {"value": "e"},
435+
}
436+
437+
438+
def test_vault_client_copy_secrets_overwrite_safe(vault):
439+
440+
vault.db = {"a": {"value": "c"}, "b": {"value": "d"}}
441+
442+
vault.safe_write = True
443+
444+
with pytest.raises(exceptions.VaultOverwriteSecretError):
445+
vault.copy_secrets("a", "b")
446+
447+
assert vault.db == {"a": {"value": "c"}, "b": {"value": "d"}}
448+
449+
450+
def test_vault_client_copy_secrets_overwrite_force(vault):
451+
452+
vault.db = {"a": {"value": "c"}, "b": {"value": "d"}}
453+
454+
vault.copy_secrets("a", "b", force=True)
455+
456+
assert vault.db == {"a": {"value": "c"}, "b": {"value": "c"}}
457+
458+
395459
def test_vault_client_base_render_template(vault):
396460

397461
vault.db = {"a/b": {"value": "c"}}

vault_cli/cli.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def repr_octal(value: Optional[int]) -> Optional[str]:
132132
"--safe-write/--unsafe-write",
133133
default=settings.DEFAULTS.safe_write,
134134
help="When activated, you can't overwrite a secret without "
135-
'passing "--force" (in commands "set", "mv", etc)',
135+
'passing "--force" (in commands "set", "mv", "cp", etc)',
136136
)
137137
@click.option(
138138
"--render/--no-render",
@@ -551,6 +551,38 @@ def mv(
551551
raise click.ClickException(str(exc))
552552

553553

554+
@cli.command()
555+
@click.argument("source", required=True)
556+
@click.argument("dest", required=True)
557+
@click.option(
558+
"--force/--no-force",
559+
"-f",
560+
is_flag=True,
561+
default=None,
562+
help="In case the path already holds a secret, allow overwriting it "
563+
"(this is necessary only if --safe-write is set).",
564+
)
565+
@click.pass_obj
566+
@handle_errors()
567+
def cp(
568+
client_obj: client.VaultClientBase, source: str, dest: str, force: Optional[bool]
569+
) -> None:
570+
"""
571+
Recursively copy secrets from source to destination path.
572+
"""
573+
try:
574+
for old_path, new_path in client_obj.copy_secrets(
575+
source=source, dest=dest, force=force, generator=True
576+
):
577+
click.echo(f"Copy '{old_path}' to '{new_path}'")
578+
except exceptions.VaultOverwriteSecretError as exc:
579+
raise click.ClickException(
580+
f"Secret already exists at {exc.path}. Use -f to force overwriting."
581+
)
582+
except exceptions.VaultMixSecretAndFolder as exc:
583+
raise click.ClickException(str(exc))
584+
585+
554586
@cli.command()
555587
@click.argument(
556588
"template",

vault_cli/client.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,12 @@ def delete_all_secrets(self, *paths: str, generator: bool = False) -> Iterable[s
427427
return iterator
428428
return list(iterator)
429429

430-
def move_secrets_iter(
431-
self, source: str, dest: str, force: Optional[bool] = None
430+
def copy_secrets_iter(
431+
self,
432+
source: str,
433+
dest: str,
434+
force: Optional[bool] = None,
435+
delete_source: Optional[bool] = False,
432436
) -> Iterable[Tuple[str, str]]:
433437

434438
source_secrets = self.get_secrets(path=source, render=False)
@@ -441,7 +445,13 @@ def move_secrets_iter(
441445

442446
secret_ = cast(types.JSONDict, secret)
443447
self.set_secret(new_path, secret_, force=force)
444-
self.delete_secret(old_path)
448+
if delete_source:
449+
self.delete_secret(old_path)
450+
451+
def move_secrets_iter(
452+
self, source: str, dest: str, force: Optional[bool] = None
453+
) -> Iterable[Tuple[str, str]]:
454+
return self.copy_secrets_iter(source, dest, force, delete_source=True)
445455

446456
def move_secrets(
447457
self,
@@ -475,6 +485,38 @@ def move_secrets(
475485
return iterator
476486
return list(iterator)
477487

488+
def copy_secrets(
489+
self,
490+
source: str,
491+
dest: str,
492+
force: Optional[bool] = None,
493+
generator: bool = False,
494+
) -> Iterable[Tuple[str, str]]:
495+
"""
496+
Yield current and new paths, then copy a secret or a folder
497+
to a new path
498+
499+
Parameters
500+
----------
501+
source : str
502+
Path of the secret to move
503+
dest : str
504+
New path for the secret
505+
force : Optional[bool], optional
506+
Allow overwriting exiting secret, if safe_mode is True
507+
generator : bool, optional
508+
Whether of not to yield before move, by default False
509+
510+
Returns
511+
-------
512+
Iterable[Tuple[str, str]]
513+
[(Current path, new path)]
514+
"""
515+
iterator = self.copy_secrets_iter(source=source, dest=dest, force=force)
516+
if generator:
517+
return iterator
518+
return list(iterator)
519+
478520
template_prefix = "!template!"
479521

480522
def _render_template_value(self, secret: types.JSONValue) -> types.JSONValue:

0 commit comments

Comments
 (0)