Allow unsetting settings when configuring `vault`
Marco Ricci

Marco Ricci commited on 2024-12-20 17:00:48
Zeige 3 geänderte Dateien mit 94 Einfügungen und 3 Löschungen.


When configuring a `vault` service, or the global settings, a new
configuration option `--unset FIELD` will unset the specified `FIELD`
from the service or global settings prior to and addition to applying
the requested settings changes.  This way, the user can update all
settings on the command-line (except for the "notes") without manually
editing and reimporting a configuration export.  (It is permissible to
only unset settings, without applying any other configuration changes.)
... ...
@@ -0,0 +1,6 @@
1
+### Added
2
+
3
+  - `derivepassphrase vault --config` now supports an `--unset` option which
4
+    unsets any given named setting prior to applying any other configuration
5
+    changes.
6
+
... ...
@@ -1635,6 +1635,28 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1635 1635
     help='overwrite or merge (default) the existing configuration',
1636 1636
     cls=CompatibilityOption,
1637 1637
 )
1638
+@click.option(
1639
+    '--unset',
1640
+    'unset_settings',
1641
+    multiple=True,
1642
+    type=click.Choice([
1643
+        'phrase',
1644
+        'key',
1645
+        'length',
1646
+        'repeat',
1647
+        'lower',
1648
+        'upper',
1649
+        'number',
1650
+        'space',
1651
+        'dash',
1652
+        'symbol',
1653
+    ]),
1654
+    help=(
1655
+        'with --config, also unsets the given setting; '
1656
+        'may be specified multiple times'
1657
+    ),
1658
+    cls=CompatibilityOption,
1659
+)
1638 1660
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
1639 1661
 @standard_logging_options
1640 1662
 @click.argument('service', required=False)
... ...
@@ -1662,6 +1684,7 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1662 1684
     export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
1663 1685
     import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
1664 1686
     overwrite_config: bool = False,
1687
+    unset_settings: Sequence[str] = (),
1665 1688
 ) -> None:
1666 1689
     """Derive a passphrase using the vault(1) derivation scheme.
1667 1690
 
... ...
@@ -1758,6 +1781,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1758 1781
             `--merge-existing` (False).  Controls whether config saving
1759 1782
             and config importing overwrite existing configurations, or
1760 1783
             merge them section-wise instead.
1784
+        unset_settings:
1785
+            Command-line argument `--unset`.  If given together with
1786
+            `--config`, unsets the specified settings (in addition to
1787
+            any other changes requested).
1761 1788
 
1762 1789
     """  # noqa: D301
1763 1790
     logger = logging.getLogger(PROG_NAME)
... ...
@@ -2189,13 +2216,20 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2189 2216
                             'Setting a global passphrase is ineffective '
2190 2217
                             'because a key is also set.'
2191 2218
                         )
2192
-            if not view.maps[0]:
2219
+            if not view.maps[0] and not unset_settings:
2193 2220
                 settings_type = 'service' if service else 'global'
2194 2221
                 msg = (
2195 2222
                     f'Cannot update {settings_type} settings without '
2196 2223
                     f'actual settings'
2197 2224
                 )
2198 2225
                 raise click.UsageError(msg)
2226
+            for setting in unset_settings:
2227
+                if setting in view.maps[0]:
2228
+                    msg = (
2229
+                        f'Attempted to unset and set --{setting} '
2230
+                        f'at the same time.'
2231
+                    )
2232
+                    raise click.UsageError(msg)
2199 2233
             subtree: dict[str, Any] = (
2200 2234
                 configuration['services'].setdefault(service, {})  # type: ignore[assignment]
2201 2235
                 if service
... ...
@@ -2203,6 +2237,9 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2203 2237
             )
2204 2238
             if overwrite_config:
2205 2239
                 subtree.clear()
2240
+            else:
2241
+                for setting in unset_settings:
2242
+                    subtree.pop(setting, None)
2206 2243
             subtree.update(view)
2207 2244
             assert _types.is_vault_config(
2208 2245
                 configuration
... ...
@@ -1304,6 +1304,32 @@ contents go here
1304 1304
             error=custom_error
1305 1305
         ), 'expected error exit and known error message'
1306 1306
 
1307
+    def test_225f_store_config_fail_unset_and_set_same_settings(
1308
+        self,
1309
+        monkeypatch: pytest.MonkeyPatch,
1310
+    ) -> None:
1311
+        runner = click.testing.CliRunner(mix_stderr=False)
1312
+        with tests.isolated_vault_config(
1313
+            monkeypatch=monkeypatch,
1314
+            runner=runner,
1315
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1316
+        ):
1317
+            _result = runner.invoke(
1318
+                cli.derivepassphrase_vault,
1319
+                [
1320
+                    '--config',
1321
+                    '--unset=length',
1322
+                    '--length=15',
1323
+                    '--',
1324
+                    DUMMY_SERVICE,
1325
+                ],
1326
+                catch_exceptions=False,
1327
+            )
1328
+        result = tests.ReadableResult.parse(_result)
1329
+        assert result.error_exit(
1330
+            error='Attempted to unset and set --length at the same time.'
1331
+        ), 'expected error exit and known error message'
1332
+
1307 1333
     def test_226_no_arguments(self, monkeypatch: pytest.MonkeyPatch) -> None:
1308 1334
         runner = click.testing.CliRunner(mix_stderr=False)
1309 1335
         with tests.isolated_config(
... ...
@@ -2752,17 +2778,27 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2752 2778
         target=configuration,
2753 2779
         config=configuration,
2754 2780
         setting=setting.filter(bool),
2781
+        maybe_unset=strategies.sets(
2782
+            strategies.sampled_from(_valid_properties),
2783
+            max_size=3,
2784
+        ),
2755 2785
         overwrite=strategies.booleans(),
2756 2786
     )
2757 2787
     def set_globals(
2758 2788
         self,
2759 2789
         config: _types.VaultConfig,
2760 2790
         setting: _types.VaultConfigGlobalSettings,
2791
+        maybe_unset: set[str],
2761 2792
         overwrite: bool,
2762 2793
     ) -> _types.VaultConfig:
2763 2794
         cli._save_config(config)
2795
+        config_global = config.get('global', {})
2796
+        maybe_unset = set(maybe_unset) - setting.keys()
2764 2797
         if overwrite:
2765
-            config['global'] = {}
2798
+            config['global'] = config_global = {}
2799
+        elif maybe_unset:
2800
+            for key in maybe_unset:
2801
+                config_global.pop(key, None)  # type: ignore[misc]
2766 2802
         config.setdefault('global', {}).update(setting)
2767 2803
         assert _types.is_vault_config(config)
2768 2804
         # NOTE: This relies on settings_obj containing only the keys
... ...
@@ -2774,6 +2810,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2774 2810
                 '--config',
2775 2811
                 '--overwrite-existing' if overwrite else '--merge-existing',
2776 2812
             ]
2813
+            + [f'--unset={key}' for key in maybe_unset]
2777 2814
             + [
2778 2815
                 f'--{key}={value}'
2779 2816
                 for key, value in setting.items()
... ...
@@ -2791,6 +2828,10 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2791 2828
         config=configuration,
2792 2829
         service=strategies.sampled_from(_known_services),
2793 2830
         setting=setting.filter(bool),
2831
+        maybe_unset=strategies.sets(
2832
+            strategies.sampled_from(_valid_properties),
2833
+            max_size=3,
2834
+        ),
2794 2835
         overwrite=strategies.booleans(),
2795 2836
     )
2796 2837
     def set_service(
... ...
@@ -2798,11 +2839,17 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2798 2839
         config: _types.VaultConfig,
2799 2840
         service: str,
2800 2841
         setting: _types.VaultConfigServicesSettings,
2842
+        maybe_unset: set[str],
2801 2843
         overwrite: bool,
2802 2844
     ) -> _types.VaultConfig:
2803 2845
         cli._save_config(config)
2846
+        config_service = config['services'].get(service, {})
2847
+        maybe_unset = set(maybe_unset) - setting.keys()
2804 2848
         if overwrite:
2805
-            config['services'][service] = {}
2849
+            config['services'][service] = config_service = {}
2850
+        elif maybe_unset:
2851
+            for key in maybe_unset:
2852
+                config_service.pop(key, None)  # type: ignore[misc]
2806 2853
         config['services'].setdefault(service, {}).update(setting)
2807 2854
         assert _types.is_vault_config(config)
2808 2855
         # NOTE: This relies on settings_obj containing only the keys
... ...
@@ -2814,6 +2861,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2814 2861
                 '--config',
2815 2862
                 '--overwrite-existing' if overwrite else '--merge-existing',
2816 2863
             ]
2864
+            + [f'--unset={key}' for key in maybe_unset]
2817 2865
             + [
2818 2866
                 f'--{key}={value}'
2819 2867
                 for key, value in setting.items()
2820 2868