csw-tools 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csw_tools/__init__.py +1 -0
- csw_tools/__main__.py +6 -0
- csw_tools/change_control.py +64 -0
- csw_tools/cli.py +291 -0
- csw_tools/commands/__init__.py +1 -0
- csw_tools/commands/clean_stale_labels/README.md +91 -0
- csw_tools/commands/clean_stale_labels/__init__.py +5 -0
- csw_tools/commands/clean_stale_labels/command.py +272 -0
- csw_tools/commands/common.py +55 -0
- csw_tools/commands/configure_credentials/README.md +64 -0
- csw_tools/commands/configure_credentials/__init__.py +5 -0
- csw_tools/commands/configure_credentials/command.py +94 -0
- csw_tools/commands/convert_labels/README.md +103 -0
- csw_tools/commands/convert_labels/__init__.py +5 -0
- csw_tools/commands/convert_labels/command.py +197 -0
- csw_tools/commands/create_scopes/README.md +156 -0
- csw_tools/commands/create_scopes/__init__.py +5 -0
- csw_tools/commands/create_scopes/command.py +673 -0
- csw_tools/commands/init/README.md +59 -0
- csw_tools/commands/init/__init__.py +5 -0
- csw_tools/commands/init/command.py +120 -0
- csw_tools/commands/prune_agents/README.md +169 -0
- csw_tools/commands/prune_agents/__init__.py +5 -0
- csw_tools/commands/prune_agents/api.py +145 -0
- csw_tools/commands/prune_agents/command.py +271 -0
- csw_tools/commands/prune_agents/execution.py +410 -0
- csw_tools/commands/prune_agents/planner.py +375 -0
- csw_tools/commands/prune_policy/README.md +19 -0
- csw_tools/commands/prune_policy/__init__.py +5 -0
- csw_tools/commands/prune_policy/command.py +20 -0
- csw_tools/commands/sync_collection_rules/README.md +19 -0
- csw_tools/commands/sync_collection_rules/__init__.py +5 -0
- csw_tools/commands/sync_collection_rules/command.py +20 -0
- csw_tools/config.example.toml +45 -0
- csw_tools/config.py +204 -0
- csw_tools/config_defaults.py +36 -0
- csw_tools/context.py +90 -0
- csw_tools/csw_api.py +201 -0
- csw_tools/dashboard.py +137 -0
- csw_tools/dashboard_context.py +52 -0
- csw_tools/interaction.py +37 -0
- csw_tools/keyring_store.py +39 -0
- csw_tools/output_logging.py +271 -0
- csw_tools/project-python-version.txt +1 -0
- csw_tools/project_info.py +121 -0
- csw_tools-0.3.0.dist-info/METADATA +184 -0
- csw_tools-0.3.0.dist-info/RECORD +50 -0
- csw_tools-0.3.0.dist-info/WHEEL +4 -0
- csw_tools-0.3.0.dist-info/entry_points.txt +3 -0
- csw_tools-0.3.0.dist-info/licenses/LICENSE +201 -0
csw_tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared framework for Cisco Secure Workload utilities."""
|
csw_tools/__main__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Backups and rollback validation for mutating commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from csw_tools.dashboard import Dashboard
|
|
10
|
+
|
|
11
|
+
BACKUP_FORMAT_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackupError(ValueError):
|
|
15
|
+
"""Raised when a backup cannot be safely created or consumed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_backup(
|
|
19
|
+
*, command: str, dashboard: Dashboard, operations: list[dict[str, object]]
|
|
20
|
+
) -> dict[str, object]:
|
|
21
|
+
return {
|
|
22
|
+
"format_version": BACKUP_FORMAT_VERSION,
|
|
23
|
+
"command": command,
|
|
24
|
+
"dashboard": dashboard.name,
|
|
25
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
26
|
+
"operations": operations,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def backup_path(directory: Path, command: str) -> Path:
|
|
31
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
32
|
+
return directory.expanduser().resolve() / f"{command}-{timestamp}.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_backup(path: Path, backup: dict[str, object]) -> None:
|
|
36
|
+
"""Atomically write a backup, including incremental operation results."""
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
41
|
+
temporary.write_text(
|
|
42
|
+
json.dumps(backup, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
temporary.replace(path)
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise BackupError(f"Could not write backup: {path}") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_backup(path: Path, *, command: str, dashboard: Dashboard) -> dict[str, object]:
|
|
50
|
+
try:
|
|
51
|
+
backup = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
53
|
+
raise BackupError(f"Could not read backup: {path}") from exc
|
|
54
|
+
if not isinstance(backup, dict):
|
|
55
|
+
raise BackupError("Backup root must be a JSON object")
|
|
56
|
+
if backup.get("format_version") != BACKUP_FORMAT_VERSION:
|
|
57
|
+
raise BackupError("Unsupported backup format version")
|
|
58
|
+
if backup.get("command") != command:
|
|
59
|
+
raise BackupError(f"Backup was not created by '{command}'")
|
|
60
|
+
if backup.get("dashboard") != dashboard.name:
|
|
61
|
+
raise BackupError("Backup dashboard does not match the active CSW dashboard")
|
|
62
|
+
if not isinstance(backup.get("operations"), list):
|
|
63
|
+
raise BackupError("Backup operations must be a JSON array")
|
|
64
|
+
return backup
|
csw_tools/cli.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Root command-line interface for csw-tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from csw_tools import config as config_module
|
|
14
|
+
from csw_tools.commands.clean_stale_labels import (
|
|
15
|
+
command as clean_stale_labels_command,
|
|
16
|
+
)
|
|
17
|
+
from csw_tools.commands.configure_credentials import (
|
|
18
|
+
command as configure_credentials_command,
|
|
19
|
+
)
|
|
20
|
+
from csw_tools.commands.convert_labels import command as convert_labels_command
|
|
21
|
+
from csw_tools.commands.create_scopes import command as create_scopes_command
|
|
22
|
+
from csw_tools.commands.init import command as init_command
|
|
23
|
+
from csw_tools.commands.prune_agents import command as prune_agents_command
|
|
24
|
+
from csw_tools.commands.prune_policy import command as prune_policy_command
|
|
25
|
+
from csw_tools.commands.sync_collection_rules import (
|
|
26
|
+
command as sync_collection_rules_command,
|
|
27
|
+
)
|
|
28
|
+
from csw_tools.config import (
|
|
29
|
+
AppConfig,
|
|
30
|
+
ConfigError,
|
|
31
|
+
load_config,
|
|
32
|
+
resolve_setting,
|
|
33
|
+
)
|
|
34
|
+
from csw_tools.config_defaults import (
|
|
35
|
+
DEFAULT_DASHBOARD_VERIFY_TLS,
|
|
36
|
+
DEFAULT_KEYRING_SERVICE_NAME,
|
|
37
|
+
DEFAULT_LOG_CLI_OUTPUT,
|
|
38
|
+
DEFAULT_OUTPUT_DIRECTORY,
|
|
39
|
+
)
|
|
40
|
+
from csw_tools.context import AppContext
|
|
41
|
+
from csw_tools.dashboard import DASHBOARD, Dashboard
|
|
42
|
+
from csw_tools.keyring_store import KeyringStore
|
|
43
|
+
from csw_tools.output_logging import (
|
|
44
|
+
OutputLogError,
|
|
45
|
+
disable_failed_output_logging,
|
|
46
|
+
prepare_output_logging,
|
|
47
|
+
start_output_logging,
|
|
48
|
+
stop_output_logging,
|
|
49
|
+
)
|
|
50
|
+
from csw_tools.project_info import format_version_info
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OutputDirectoryPath(click.Path):
|
|
54
|
+
"""Reject an explicitly empty directory while preserving valid '.'."""
|
|
55
|
+
|
|
56
|
+
def convert(
|
|
57
|
+
self,
|
|
58
|
+
value: str | bytes | Path,
|
|
59
|
+
param: click.Parameter | None,
|
|
60
|
+
ctx: click.Context | None,
|
|
61
|
+
) -> Path:
|
|
62
|
+
if isinstance(value, str) and not value.strip():
|
|
63
|
+
self.fail("output directory cannot be empty", param, ctx)
|
|
64
|
+
return cast(Path, super().convert(value, param, ctx))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FullCommandHelpGroup(click.Group):
|
|
68
|
+
"""Wrap command summaries instead of shortening them with ellipses."""
|
|
69
|
+
|
|
70
|
+
def main(
|
|
71
|
+
self,
|
|
72
|
+
args: Sequence[str] | None = None,
|
|
73
|
+
prog_name: str | None = None,
|
|
74
|
+
complete_var: str | None = None,
|
|
75
|
+
standalone_mode: bool = True,
|
|
76
|
+
windows_expand_args: bool = True,
|
|
77
|
+
**extra: Any,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Keep transcript streams active through Click's final error output."""
|
|
80
|
+
|
|
81
|
+
log_token = prepare_output_logging()
|
|
82
|
+
try:
|
|
83
|
+
return super().main(
|
|
84
|
+
args=args,
|
|
85
|
+
prog_name=prog_name,
|
|
86
|
+
complete_var=complete_var,
|
|
87
|
+
standalone_mode=standalone_mode,
|
|
88
|
+
windows_expand_args=windows_expand_args,
|
|
89
|
+
**extra,
|
|
90
|
+
)
|
|
91
|
+
except OutputLogError as exc:
|
|
92
|
+
disable_failed_output_logging()
|
|
93
|
+
error = click.ClickException(str(exc))
|
|
94
|
+
if not standalone_mode:
|
|
95
|
+
raise error from exc
|
|
96
|
+
error.show()
|
|
97
|
+
raise SystemExit(error.exit_code) from exc
|
|
98
|
+
finally:
|
|
99
|
+
stop_output_logging(log_token)
|
|
100
|
+
|
|
101
|
+
def format_commands(
|
|
102
|
+
self, ctx: click.Context, formatter: click.HelpFormatter
|
|
103
|
+
) -> None:
|
|
104
|
+
rows = []
|
|
105
|
+
for name in self.list_commands(ctx):
|
|
106
|
+
command = self.get_command(ctx, name)
|
|
107
|
+
if command is None or command.hidden:
|
|
108
|
+
continue
|
|
109
|
+
# Preserve Click's summary and deprecation handling, but allow the
|
|
110
|
+
# complete summary through to the formatter's word wrapping.
|
|
111
|
+
summary = command.get_short_help_str(limit=len(command.help or ""))
|
|
112
|
+
rows.append((name, summary))
|
|
113
|
+
if rows:
|
|
114
|
+
with formatter.section("Commands"):
|
|
115
|
+
formatter.write_dl(rows)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def show_version(ctx: click.Context, _parameter: click.Parameter, value: bool) -> None:
|
|
119
|
+
"""Print project and interpreter information, then exit."""
|
|
120
|
+
|
|
121
|
+
if not value or ctx.resilient_parsing:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
output = format_version_info()
|
|
125
|
+
except (OSError, KeyError, RuntimeError, tomllib.TOMLDecodeError) as exc:
|
|
126
|
+
raise click.ClickException(
|
|
127
|
+
f"Could not load project version information: {exc}"
|
|
128
|
+
) from exc
|
|
129
|
+
click.echo(output)
|
|
130
|
+
ctx.exit()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@click.group(
|
|
134
|
+
cls=FullCommandHelpGroup,
|
|
135
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
136
|
+
)
|
|
137
|
+
@click.option(
|
|
138
|
+
"--config",
|
|
139
|
+
"config_path",
|
|
140
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
141
|
+
help=(
|
|
142
|
+
"Use an alternate config file location instead of the per-user "
|
|
143
|
+
"default (config.toml)."
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--keyring-service-name",
|
|
148
|
+
help="Override the default keyring service name (of 'csw-tools')",
|
|
149
|
+
)
|
|
150
|
+
@click.option(
|
|
151
|
+
"-d",
|
|
152
|
+
"--dashboard",
|
|
153
|
+
type=DASHBOARD,
|
|
154
|
+
help="Use this Secure Workload dashboard (SaaS name, FQDN, or HTTPS origin).",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--dashboard-verify-tls/--no-dashboard-verify-tls",
|
|
158
|
+
default=None,
|
|
159
|
+
help="Enable or disable TLS certificate verification for dashboard APIs.",
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--output-dir",
|
|
163
|
+
type=OutputDirectoryPath(path_type=Path, file_okay=False),
|
|
164
|
+
help=(
|
|
165
|
+
"Directory for command-generated backups, journals, reports, and CLI "
|
|
166
|
+
f"logs. Default: {DEFAULT_OUTPUT_DIRECTORY}."
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
@click.option(
|
|
170
|
+
"--log-cli-output/--no-log-cli-output",
|
|
171
|
+
default=None,
|
|
172
|
+
help=(
|
|
173
|
+
"Save or do not save a combined stdout/stderr transcript for the "
|
|
174
|
+
"selected command. Enabled by default."
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
@click.option(
|
|
178
|
+
"--version",
|
|
179
|
+
is_flag=True,
|
|
180
|
+
is_eager=True,
|
|
181
|
+
expose_value=False,
|
|
182
|
+
callback=show_version,
|
|
183
|
+
help="Show project and Python version information, then exit.",
|
|
184
|
+
)
|
|
185
|
+
@click.pass_context
|
|
186
|
+
def cli(
|
|
187
|
+
ctx: click.Context,
|
|
188
|
+
config_path: Path | None,
|
|
189
|
+
keyring_service_name: str | None,
|
|
190
|
+
dashboard: Dashboard | None,
|
|
191
|
+
dashboard_verify_tls: bool | None,
|
|
192
|
+
output_dir: Path | None,
|
|
193
|
+
log_cli_output: bool | None,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""A collection of automation utilities for Cisco Secure Workload (CSW)"""
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
selected_config_path = (
|
|
199
|
+
(
|
|
200
|
+
config_path
|
|
201
|
+
if config_path is not None
|
|
202
|
+
else config_module.default_config_path()
|
|
203
|
+
)
|
|
204
|
+
.expanduser()
|
|
205
|
+
.resolve()
|
|
206
|
+
)
|
|
207
|
+
if ctx.invoked_subcommand == "init":
|
|
208
|
+
try:
|
|
209
|
+
config = load_config(selected_config_path, explicit=False)
|
|
210
|
+
except ConfigError:
|
|
211
|
+
# init must remain able to replace malformed configuration.
|
|
212
|
+
config = AppConfig()
|
|
213
|
+
else:
|
|
214
|
+
config = load_config(
|
|
215
|
+
selected_config_path,
|
|
216
|
+
explicit=config_path is not None,
|
|
217
|
+
)
|
|
218
|
+
resolved_service_name = resolve_setting(
|
|
219
|
+
name="keyring service name",
|
|
220
|
+
cli_value=keyring_service_name,
|
|
221
|
+
config_value=config.common.keyring_service_name,
|
|
222
|
+
default=DEFAULT_KEYRING_SERVICE_NAME,
|
|
223
|
+
)
|
|
224
|
+
if not resolved_service_name or not resolved_service_name.strip():
|
|
225
|
+
raise ConfigError("The keyring service name cannot be empty")
|
|
226
|
+
resolved_service_name = resolved_service_name.strip()
|
|
227
|
+
resolved_dashboard = resolve_setting(
|
|
228
|
+
name="CSW dashboard",
|
|
229
|
+
cli_value=dashboard,
|
|
230
|
+
config_value=config.common.dashboard,
|
|
231
|
+
)
|
|
232
|
+
resolved_dashboard_verify_tls = resolve_setting(
|
|
233
|
+
name="dashboard TLS verification",
|
|
234
|
+
cli_value=dashboard_verify_tls,
|
|
235
|
+
config_value=config.common.dashboard_verify_tls,
|
|
236
|
+
default=DEFAULT_DASHBOARD_VERIFY_TLS,
|
|
237
|
+
)
|
|
238
|
+
if resolved_dashboard_verify_tls is None:
|
|
239
|
+
raise ConfigError("Dashboard TLS verification could not be resolved")
|
|
240
|
+
resolved_output_dir = resolve_setting(
|
|
241
|
+
name="output directory",
|
|
242
|
+
cli_value=output_dir,
|
|
243
|
+
config_value=config.common.output_dir,
|
|
244
|
+
default=DEFAULT_OUTPUT_DIRECTORY,
|
|
245
|
+
)
|
|
246
|
+
if resolved_output_dir is None:
|
|
247
|
+
raise ConfigError("The output directory could not be resolved")
|
|
248
|
+
try:
|
|
249
|
+
resolved_output_dir = resolved_output_dir.expanduser().resolve()
|
|
250
|
+
except (OSError, RuntimeError) as exc:
|
|
251
|
+
raise ConfigError(
|
|
252
|
+
f"Could not resolve output directory: {resolved_output_dir}"
|
|
253
|
+
) from exc
|
|
254
|
+
resolved_log_cli_output = resolve_setting(
|
|
255
|
+
name="CLI output logging",
|
|
256
|
+
cli_value=log_cli_output,
|
|
257
|
+
config_value=config.common.log_cli_output,
|
|
258
|
+
default=DEFAULT_LOG_CLI_OUTPUT,
|
|
259
|
+
)
|
|
260
|
+
if resolved_log_cli_output is None:
|
|
261
|
+
raise ConfigError("CLI output logging could not be resolved")
|
|
262
|
+
except ConfigError as exc:
|
|
263
|
+
raise click.ClickException(str(exc)) from exc
|
|
264
|
+
|
|
265
|
+
ctx.default_map = config.as_click_default_map()
|
|
266
|
+
if resolved_log_cli_output:
|
|
267
|
+
command_name = ctx.invoked_subcommand
|
|
268
|
+
assert command_name is not None
|
|
269
|
+
output_log = start_output_logging(resolved_output_dir, command_name)
|
|
270
|
+
click.echo(f"CLI output log: '{output_log.path}'", err=True)
|
|
271
|
+
ctx.obj = AppContext(
|
|
272
|
+
config=config,
|
|
273
|
+
config_path=selected_config_path,
|
|
274
|
+
keyring_service_name=resolved_service_name,
|
|
275
|
+
dashboard=resolved_dashboard,
|
|
276
|
+
dashboard_verify_tls=resolved_dashboard_verify_tls,
|
|
277
|
+
output_dir=resolved_output_dir,
|
|
278
|
+
console=Console(),
|
|
279
|
+
error_console=Console(stderr=True),
|
|
280
|
+
_keyring_factory=KeyringStore,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
cli.add_command(configure_credentials_command)
|
|
285
|
+
cli.add_command(clean_stale_labels_command)
|
|
286
|
+
cli.add_command(convert_labels_command)
|
|
287
|
+
cli.add_command(create_scopes_command)
|
|
288
|
+
cli.add_command(init_command)
|
|
289
|
+
cli.add_command(prune_agents_command)
|
|
290
|
+
cli.add_command(prune_policy_command)
|
|
291
|
+
cli.add_command(sync_collection_rules_command)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Click commands provided by csw-tools."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# `csw-tools clean-stale-labels`
|
|
2
|
+
|
|
3
|
+
Status: DEV/TESTING.
|
|
4
|
+
|
|
5
|
+
Compares scope-independent static workload-label records with current CSW
|
|
6
|
+
inventory and removes eligible stale records.
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
Select the intended dashboard and configure an API key with
|
|
11
|
+
`flow_inventory_query` and `user_data_upload` access. The key must be able to
|
|
12
|
+
see the complete relevant inventory; incomplete visibility can make an active
|
|
13
|
+
address appear absent.
|
|
14
|
+
|
|
15
|
+
This command requires an interactive terminal. It defaults to `--dry-run`.
|
|
16
|
+
|
|
17
|
+
## Eligibility rules
|
|
18
|
+
|
|
19
|
+
A static-label record is eligible only when both conditions are true:
|
|
20
|
+
|
|
21
|
+
1. No currently observed inventory IP belongs to the label record's IP address
|
|
22
|
+
or subnet.
|
|
23
|
+
2. The record's `updatedAt` timestamp is at least `--minimum-age DAYS` old.
|
|
24
|
+
|
|
25
|
+
The default minimum age is 30 days. Numeric epoch timestamps in seconds or
|
|
26
|
+
milliseconds and ISO timestamps are accepted. Records without a usable
|
|
27
|
+
`updatedAt` value are reported and skipped.
|
|
28
|
+
|
|
29
|
+
Deletion removes the entire static-label record and all of its attributes for
|
|
30
|
+
that IP address or subnet; it does not remove individual keys from the record.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Preview with the default threshold, then apply a 60-day threshold:
|
|
35
|
+
|
|
36
|
+
```console
|
|
37
|
+
csw-tools -d my-company clean-stale-labels --dry-run
|
|
38
|
+
csw-tools -d my-company clean-stale-labels --minimum-age 60 --apply
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By default the command examines all IPv4 and IPv6 static labels. Use repeatable
|
|
42
|
+
`--ip-range CIDR` options to limit discovery:
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
csw-tools -d my-company clean-stale-labels \
|
|
46
|
+
--ip-range 10.0.0.0/8 \
|
|
47
|
+
--ip-range 2001:db8::/32 \
|
|
48
|
+
--dry-run
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Option | Meaning |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `--minimum-age DAYS` | Required age before deletion; default 30 |
|
|
54
|
+
| `--ip-range CIDR` | Limit static-label discovery; repeat as needed |
|
|
55
|
+
| `--page-size INTEGER` | Current inventory records per API page; default 500 |
|
|
56
|
+
| `--apply` / `--dry-run` | Delete eligible records or preview; default dry-run |
|
|
57
|
+
| `--rollback BACKUP` | Recreate deleted records; requires `--apply` |
|
|
58
|
+
|
|
59
|
+
`minimum_age` and `page_size` can be set in the `[clean-stale-labels]`
|
|
60
|
+
`config.toml` table. Backups use `[common].output_dir`, whose default is
|
|
61
|
+
`csw-tools-outputs`. Global options such as `--output-dir` must precede the
|
|
62
|
+
command.
|
|
63
|
+
|
|
64
|
+
## Plan, backup, and rollback
|
|
65
|
+
|
|
66
|
+
The plan displays each eligible IP/subnet, its timestamp, and its complete
|
|
67
|
+
attributes. It separately reports records skipped because their timestamp could
|
|
68
|
+
not be interpreted. A dry run makes no API mutations.
|
|
69
|
+
|
|
70
|
+
`--apply` writes every original attribute map to a timestamped JSON backup
|
|
71
|
+
before the first deletion and updates that file around each attempt. Rollback
|
|
72
|
+
recreates the deleted records from those saved attributes:
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
csw-tools -d my-company clean-stale-labels \
|
|
76
|
+
--apply --rollback csw-tools-outputs/clean-stale-labels-TIMESTAMP.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The backup must have been produced by this command for the same normalized
|
|
80
|
+
dashboard. Keep it until the resulting inventory labels have been validated.
|
|
81
|
+
|
|
82
|
+
## Developer notes
|
|
83
|
+
|
|
84
|
+
[`command.py`](command.py) normalizes observed IPs with `ipaddress`, deduplicates
|
|
85
|
+
records returned by overlapping search ranges, and tests address membership
|
|
86
|
+
within same-family networks. `plan_cleanup()` is side-effect free apart from API
|
|
87
|
+
reads; deletion begins only after planning and backup creation.
|
|
88
|
+
|
|
89
|
+
Use the shared API and change-control contracts in
|
|
90
|
+
[DEV-GUIDELINES.md](../../../../DEV-GUIDELINES.md). Preserve the fail-safe rule
|
|
91
|
+
that an unparseable timestamp is never eligible.
|