vaultctl 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {vaultctl-0.1.2 → vaultctl-0.2.0}/PKG-INFO +1 -1
  2. {vaultctl-0.1.2 → vaultctl-0.2.0}/pyproject.toml +1 -1
  3. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/backup_raft_snapshot.py +59 -54
  4. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/bootstrap.py +6 -20
  5. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/login.py +2 -4
  6. vaultctl-0.2.0/src/vaultctl/commands/restore_raft_snapshot.py +133 -0
  7. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/options.py +23 -51
  8. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/utils.py +26 -32
  9. {vaultctl-0.1.2 → vaultctl-0.2.0}/uv.lock +1 -1
  10. vaultctl-0.1.2/src/vaultctl/commands/restore_raft_snapshot.py +0 -210
  11. {vaultctl-0.1.2 → vaultctl-0.2.0}/.devcontainer/devcontainer-lock.json +0 -0
  12. {vaultctl-0.1.2 → vaultctl-0.2.0}/.devcontainer/devcontainer.json +0 -0
  13. {vaultctl-0.1.2 → vaultctl-0.2.0}/.github/workflows/docker-publish.yaml +0 -0
  14. {vaultctl-0.1.2 → vaultctl-0.2.0}/.github/workflows/release.yaml +0 -0
  15. {vaultctl-0.1.2 → vaultctl-0.2.0}/.gitignore +0 -0
  16. {vaultctl-0.1.2 → vaultctl-0.2.0}/.renovaterc.json5 +0 -0
  17. {vaultctl-0.1.2 → vaultctl-0.2.0}/Dockerfile +0 -0
  18. {vaultctl-0.1.2 → vaultctl-0.2.0}/LICENSE +0 -0
  19. {vaultctl-0.1.2 → vaultctl-0.2.0}/README.md +0 -0
  20. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/__init__.py +0 -0
  21. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/__init__.py +0 -0
  22. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/pki/__init__.py +0 -0
  23. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/pki/rotate_issuing.py +0 -0
  24. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/main.py +0 -0
  25. {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vaultctl
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: vaultctl is a command-line interface (CLI) tool designed to facilitate operations with HashiCorp Vault.
5
5
  Author-email: d4rkfella <georgi.panov@darkfellanetwork.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vaultctl"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "vaultctl is a command-line interface (CLI) tool designed to facilitate operations with HashiCorp Vault."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -6,7 +6,7 @@ import io
6
6
  import tarfile
7
7
  from enum import Enum
8
8
  from pathlib import Path
9
- from typing import Annotated, cast
9
+ from typing import Annotated
10
10
 
11
11
  import hvac
12
12
  import typer
@@ -20,18 +20,22 @@ from vaultctl.options import (
20
20
  AwsProfileOption,
21
21
  AwsRegionOption,
22
22
  AwsSecretAccessKeyOption,
23
+ BackupDestinationOption,
23
24
  CACertOption,
24
25
  CAPathOption,
25
26
  K8sMountPointOption,
26
27
  K8sRoleOption,
27
- OutputFileOption,
28
- S3BucketNameOption,
29
- S3KeyPrefixOption,
30
28
  SkipVerifyOption,
31
29
  TimeoutOption,
32
30
  TokenOption,
33
31
  )
34
- from vaultctl.utils import handle_s3_authentication, handle_vault_authentication, logger
32
+ from vaultctl.utils import (
33
+ handle_s3_authentication,
34
+ handle_vault_authentication,
35
+ is_s3_uri,
36
+ logger,
37
+ parse_s3_uri,
38
+ )
35
39
 
36
40
  app = typer.Typer()
37
41
 
@@ -87,7 +91,7 @@ def verify_internal_checksums(snapshot_data: bytes) -> None:
87
91
  files_in_tar[member.name] = content
88
92
 
89
93
  if sha_sums_content is None:
90
- msg = "SHA256SUMS file not found in the Raft snapshot archive."
94
+ msg = "SHA256SUMS file not found in the snapshot archive."
91
95
  raise ValueError(
92
96
  msg,
93
97
  )
@@ -119,33 +123,34 @@ def verify_internal_checksums(snapshot_data: bytes) -> None:
119
123
 
120
124
 
121
125
  @app.command(
122
- help="Executes a complete workflow for obtaining a HashiCorp Vault Raft snapshot from a cluster, verifying its integrity, and uploading it securely to S3 storage. Provides flexible authentication options for both HashiCorp Vault and S3 APIs.",
126
+ help="Executes a complete workflow for obtaining a HashiCorp Vault Raft snapshot, verifying its integrity, and saving it to a local path or S3 URI. Provides flexible authentication options for both HashiCorp Vault and S3 APIs.",
123
127
  )
124
128
  def backup_raft_snapshot( # noqa: PLR0913
125
129
  address: AddressOption,
126
- s3_bucket_name: S3BucketNameOption = None,
127
- output_file: OutputFileOption = None,
130
+ destination: BackupDestinationOption = None,
128
131
  k8s_role: K8sRoleOption = None,
129
132
  k8s_mount_point: K8sMountPointOption = "kubernetes",
130
- token: TokenOption = None,
131
- ca_cert: CACertOption = None,
132
- ca_path: CAPathOption = None,
133
+ k8s_sa_token_path: Path = Path(
134
+ "/var/run/secrets/kubernetes.io/serviceaccount/token",
135
+ ),
133
136
  aws_profile: AwsProfileOption = None,
134
137
  aws_access_key_id: AwsAccessKeyIdOption = None,
135
138
  aws_secret_access_key: AwsSecretAccessKeyOption = None,
136
139
  aws_endpoint_url: AwsEndpointUrlOption = None,
137
140
  aws_region: AwsRegionOption = "us-east-1",
138
- s3_key_prefix: S3KeyPrefixOption = "",
139
141
  s3_checksum_algorithm: Annotated[
140
142
  S3ChecksumAlgorithm,
141
143
  typer.Option(help="The algorithm to use for s3 transport checksum."),
142
144
  ] = S3ChecksumAlgorithm.CRC64NVME,
145
+ token: TokenOption = None,
146
+ ca_cert: CACertOption = None,
147
+ ca_path: CAPathOption = None,
143
148
  timeout: TimeoutOption = 30,
144
149
  *,
145
150
  skip_verify: SkipVerifyOption = False,
146
151
  ) -> None:
147
- if not s3_bucket_name and not output_file:
148
- msg = "Either --s3-bucket-name or --output-file is required"
152
+ if not destination:
153
+ msg = "--to is required"
149
154
  raise typer.BadParameter(msg)
150
155
 
151
156
  vault_client = handle_vault_authentication(
@@ -163,6 +168,7 @@ def backup_raft_snapshot( # noqa: PLR0913
163
168
  token=token,
164
169
  k8s_role=k8s_role,
165
170
  k8s_mount_point=k8s_mount_point,
171
+ k8s_token_path=k8s_sa_token_path,
166
172
  )
167
173
 
168
174
  if vault_client.sys.is_sealed():
@@ -171,12 +177,12 @@ def backup_raft_snapshot( # noqa: PLR0913
171
177
 
172
178
  logger.info("Starting backup...")
173
179
 
174
- logger.info("Requesting raft snapshot...")
180
+ logger.info("Requesting snapshot...")
175
181
  response: Response = vault_client.sys.take_raft_snapshot()
176
182
 
177
183
  if response.status_code != 200: # noqa: PLR2004
178
184
  logger.error(
179
- "Raft snapshot request failed with status code %s.",
185
+ "Snapshot request failed with status code %s.",
180
186
  response.status_code,
181
187
  )
182
188
  logger.debug("Response body: %s", response.text)
@@ -184,53 +190,52 @@ def backup_raft_snapshot( # noqa: PLR0913
184
190
 
185
191
  snapshot_data: bytes = response.content
186
192
 
187
- logger.info("Raft snapshot retrieved.")
193
+ logger.info("Snapshot retrieved.")
188
194
 
189
195
  try:
190
196
  verify_internal_checksums(snapshot_data)
191
- except (tarfile.TarError, ValueError):
192
- logger.exception("Verifying raft snapshot integrity")
197
+ except tarfile.TarError, ValueError:
198
+ logger.exception("Verifying snapshot integrity")
193
199
  raise typer.Exit(1) from None
194
200
 
195
- if output_file:
196
- output_path = Path(output_file)
201
+ if not is_s3_uri(destination):
202
+ output_path = Path(destination)
197
203
  if output_path.is_dir():
198
204
  timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
199
205
  output_path = output_path / f"vault-snapshot-{timestamp}.snap"
200
206
  output_path.write_bytes(snapshot_data)
201
207
  logger.info("Snapshot written to %s", output_path)
202
- logger.info("Backup completed")
203
- return
204
-
205
- s3_bucket_name = cast("str", s3_bucket_name)
206
-
207
- s3_client = handle_s3_authentication(
208
- bucket_name=s3_bucket_name,
209
- aws_access_key_id=aws_access_key_id,
210
- aws_secret_access_key=aws_secret_access_key,
211
- aws_region=aws_region,
212
- aws_profile=aws_profile,
213
- endpoint_url=aws_endpoint_url,
214
- )
215
-
216
- timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
217
- snapshot_name = f"vault-snapshot-{timestamp}.snap"
218
- s3_key = f"{s3_key_prefix}{snapshot_name}"
208
+ else:
209
+ s3_bucket, s3_key = parse_s3_uri(destination)
219
210
 
220
- try:
221
- logger.info("Uploading raft snapshot to %s/%s...", s3_bucket_name, s3_key)
222
- s3_client.upload_fileobj(
223
- io.BytesIO(snapshot_data),
224
- s3_bucket_name,
225
- s3_key,
226
- ExtraArgs={
227
- "ContentType": "application/gzip",
228
- "ChecksumAlgorithm": s3_checksum_algorithm.value,
229
- },
211
+ if s3_key.endswith("/"):
212
+ timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
213
+ snapshot_name = f"vault-snapshot-{timestamp}.snap"
214
+ s3_key = f"{s3_key}{snapshot_name}"
215
+
216
+ s3_client = handle_s3_authentication(
217
+ bucket_name=s3_bucket,
218
+ aws_access_key_id=aws_access_key_id,
219
+ aws_secret_access_key=aws_secret_access_key,
220
+ aws_region=aws_region,
221
+ aws_profile=aws_profile,
222
+ endpoint_url=aws_endpoint_url,
230
223
  )
231
- except ClientError:
232
- logger.exception("Uploading raft snapshot")
233
- raise typer.Exit(1) from None
234
224
 
235
- logger.info("Raft snapshot uploaded.")
236
- logger.info("Backup completed")
225
+ try:
226
+ logger.info("Uploading snapshot to %s/%s...", s3_bucket, s3_key)
227
+ s3_client.upload_fileobj(
228
+ io.BytesIO(snapshot_data),
229
+ s3_bucket,
230
+ s3_key,
231
+ ExtraArgs={
232
+ "ContentType": "application/gzip",
233
+ "ChecksumAlgorithm": s3_checksum_algorithm.value,
234
+ },
235
+ )
236
+ logger.info("Snapshot uploaded.")
237
+ except ClientError:
238
+ logger.exception("Uploading snapshot")
239
+ raise typer.Exit(1) from None
240
+
241
+ logger.info("Backup completed successfully.")
@@ -14,12 +14,8 @@ from vaultctl.options import (
14
14
  AwsSecretAccessKeyOption,
15
15
  CACertOption,
16
16
  CAPathOption,
17
- S3BucketNameOption,
18
- S3KeyPrefixOption,
17
+ RestoreSourceOption,
19
18
  SkipVerifyOption,
20
- SnapshotFileOption,
21
- SnapshotNameOption,
22
- SnapshotNameRegexOption,
23
19
  TimeoutOption,
24
20
  )
25
21
 
@@ -29,20 +25,15 @@ AUTO_UNSEAL_MAX_ATTEMPT = 10
29
25
 
30
26
 
31
27
  @app.command(
32
- help="Init, unseal and force restore a hashicorp vault cluster from S3 storage using raft snapshots",
28
+ help="Init, unseal and force restore a HashiCorp Vault cluster from a Raft snapshot (S3 or local).",
33
29
  )
34
30
  def bootstrap( # noqa: PLR0913
35
- ctx: typer.Context,
36
31
  address: AddressOption,
37
- s3_bucket_name: S3BucketNameOption = None,
38
- snapshot_file: SnapshotFileOption = None,
32
+ source: RestoreSourceOption = None,
39
33
  *,
40
34
  ca_cert: CACertOption = None,
41
35
  ca_path: CAPathOption = None,
42
36
  skip_verify: SkipVerifyOption = False,
43
- s3_key_prefix: S3KeyPrefixOption = "",
44
- filename: SnapshotNameOption = None,
45
- filename_regex: SnapshotNameRegexOption = None,
46
37
  aws_profile: AwsProfileOption = None,
47
38
  aws_access_key_id: AwsAccessKeyIdOption = None,
48
39
  aws_secret_access_key: AwsSecretAccessKeyOption = None,
@@ -50,8 +41,8 @@ def bootstrap( # noqa: PLR0913
50
41
  aws_region: AwsRegionOption = "us-east-1",
51
42
  timeout: TimeoutOption = 30,
52
43
  ) -> None:
53
- if not s3_bucket_name and not snapshot_file:
54
- msg = "Either --s3-bucket-name or --snapshot-file is required"
44
+ if not source:
45
+ msg = "--from is required"
55
46
  raise typer.BadParameter(msg)
56
47
  client = hvac.Client(
57
48
  url=address,
@@ -102,16 +93,11 @@ def bootstrap( # noqa: PLR0913
102
93
  typer.echo("Vault is unsealed and ready. Starting restore...")
103
94
 
104
95
  restore_raft_snapshot(
105
- ctx=ctx,
106
96
  address=address,
97
+ source=source,
107
98
  ca_cert=ca_cert,
108
99
  ca_path=ca_path,
109
100
  skip_verify=skip_verify,
110
- s3_bucket_name=s3_bucket_name,
111
- snapshot_file=snapshot_file,
112
- s3_key_prefix=s3_key_prefix,
113
- filename=filename,
114
- filename_regex=filename_regex,
115
101
  aws_profile=aws_profile,
116
102
  aws_access_key_id=aws_access_key_id,
117
103
  aws_secret_access_key=aws_secret_access_key,
@@ -69,10 +69,6 @@ def login( # noqa: C901, PLR0913
69
69
  password = kv.get("password")
70
70
  token = kv.get("token")
71
71
 
72
- if method == "userpass" and not username:
73
- msg = "'username' must be specified"
74
- raise typer.BadParameter(msg)
75
-
76
72
  try:
77
73
  if method == "token":
78
74
  if not token:
@@ -86,6 +82,8 @@ def login( # noqa: C901, PLR0913
86
82
  token_info = lookup["data"]
87
83
 
88
84
  elif method == "userpass":
85
+ if not username:
86
+ username = typer.prompt("Username", type=str)
89
87
  if not password:
90
88
  password = typer.prompt(
91
89
  "Password (will be hidden)",
@@ -0,0 +1,133 @@
1
+ from pathlib import Path
2
+
3
+ import botocore.exceptions
4
+ import hvac
5
+ import typer
6
+ from hvac.api.system_backend import Raft
7
+ from hvac.exceptions import InvalidRequest, VaultError
8
+
9
+ from vaultctl.options import (
10
+ AddressOption,
11
+ AwsAccessKeyIdOption,
12
+ AwsEndpointUrlOption,
13
+ AwsProfileOption,
14
+ AwsRegionOption,
15
+ AwsSecretAccessKeyOption,
16
+ CACertOption,
17
+ CAPathOption,
18
+ K8sMountPointOption,
19
+ K8sRoleOption,
20
+ RestoreSourceOption,
21
+ SkipVerifyOption,
22
+ SnapshotForceRestoreOption,
23
+ TimeoutOption,
24
+ TokenOption,
25
+ )
26
+ from vaultctl.utils import (
27
+ handle_s3_authentication,
28
+ handle_vault_authentication,
29
+ is_s3_uri,
30
+ logger,
31
+ parse_s3_uri,
32
+ )
33
+
34
+ app = typer.Typer()
35
+
36
+
37
+ @app.command(
38
+ help="Restore a HashiCorp Vault cluster from a Raft snapshot (S3 or local).",
39
+ )
40
+ def restore_raft_snapshot( # noqa: PLR0913
41
+ address: AddressOption,
42
+ source: RestoreSourceOption = None,
43
+ k8s_role: K8sRoleOption = None,
44
+ k8s_mount_point: K8sMountPointOption = "kubernetes",
45
+ aws_profile: AwsProfileOption = None,
46
+ aws_access_key_id: AwsAccessKeyIdOption = None,
47
+ aws_secret_access_key: AwsSecretAccessKeyOption = None,
48
+ aws_endpoint_url: AwsEndpointUrlOption = None,
49
+ aws_region: AwsRegionOption = "us-east-1",
50
+ token: TokenOption = None,
51
+ ca_cert: CACertOption = None,
52
+ ca_path: CAPathOption = None,
53
+ timeout: TimeoutOption = 30,
54
+ *,
55
+ force_restore: SnapshotForceRestoreOption = False,
56
+ skip_verify: SkipVerifyOption = False,
57
+ ) -> None:
58
+ if not source:
59
+ msg = "--from is required"
60
+ raise typer.BadParameter(msg)
61
+
62
+ vault_client = handle_vault_authentication(
63
+ hvac.Client(
64
+ url=address,
65
+ timeout=timeout,
66
+ verify=(
67
+ str(ca_cert)
68
+ if ca_cert
69
+ else str(ca_path)
70
+ if ca_path
71
+ else (not skip_verify)
72
+ ),
73
+ ),
74
+ token=token,
75
+ k8s_role=k8s_role,
76
+ k8s_mount_point=k8s_mount_point,
77
+ )
78
+
79
+ if vault_client.sys.is_sealed():
80
+ logger.error("Vault is sealed. Cannot proceed.")
81
+ raise typer.Exit(code=1)
82
+
83
+ if not is_s3_uri(source):
84
+ snapshot_path = Path(source)
85
+ if snapshot_path.is_dir():
86
+ msg = "--from must be a file path, not a directory"
87
+ raise typer.BadParameter(msg)
88
+ logger.info("Reading snapshot from %s...", snapshot_path)
89
+ snapshot_data = snapshot_path.read_bytes()
90
+ else:
91
+ s3_bucket, s3_key = parse_s3_uri(source)
92
+ if not s3_key or s3_key.endswith("/"):
93
+ msg = "S3 URI must point to a specific object, not a prefix"
94
+ raise typer.BadParameter(msg)
95
+
96
+ logger.info("Getting snapshot from S3: %s...", s3_key)
97
+ s3_client = handle_s3_authentication(
98
+ bucket_name=s3_bucket,
99
+ aws_access_key_id=aws_access_key_id,
100
+ aws_secret_access_key=aws_secret_access_key,
101
+ aws_region=aws_region,
102
+ aws_profile=aws_profile,
103
+ endpoint_url=aws_endpoint_url,
104
+ )
105
+
106
+ try:
107
+ download_response = s3_client.get_object(Bucket=s3_bucket, Key=s3_key)
108
+ snapshot_data = download_response["Body"].read()
109
+ except botocore.exceptions.ClientError:
110
+ logger.exception("Getting snapshot from S3")
111
+ raise typer.Exit(1) from None
112
+
113
+ logger.info("Snapshot downloaded successfully.")
114
+
115
+ raft = Raft(adapter=vault_client.adapter)
116
+
117
+ logger.info("Restoring snapshot...")
118
+
119
+ if force_restore:
120
+ logger.warning("Force restore is ENABLED !")
121
+ try:
122
+ raft.force_restore_raft_snapshot(snapshot_data)
123
+ except VaultError, InvalidRequest:
124
+ logger.exception("Restoring snapshot")
125
+ raise typer.Exit(1) from None
126
+ else:
127
+ try:
128
+ raft.restore_raft_snapshot(snapshot_data)
129
+ except VaultError, InvalidRequest:
130
+ logger.exception("Restoring snapshot")
131
+ raise typer.Exit(1) from None
132
+
133
+ logger.info("Snapshot restored successfully!")
@@ -1,10 +1,9 @@
1
- import re
2
1
  from pathlib import Path
3
2
  from typing import Annotated
4
3
 
5
4
  from typer import Option
6
5
 
7
- from .utils import parse_regex, validate_address, validate_s3_key_prefix
6
+ from .utils import validate_address
8
7
 
9
8
  AwsEndpointUrlOption = Annotated[
10
9
  str | None,
@@ -37,21 +36,6 @@ AwsRegionOption = Annotated[
37
36
  Option(envvar="AWS_REGION", help="AWS Region (e.g., us-east-1)."),
38
37
  ]
39
38
 
40
- S3BucketNameOption = Annotated[
41
- str | None,
42
- Option(
43
- envvar="S3_BUCKET_NAME",
44
- help="Storage bucket where snapshots are stored",
45
- ),
46
- ]
47
-
48
- S3KeyPrefixOption = Annotated[
49
- str,
50
- Option(
51
- callback=validate_s3_key_prefix,
52
- ),
53
- ]
54
-
55
39
  AddressOption = Annotated[
56
40
  str,
57
41
  Option(
@@ -136,18 +120,34 @@ K8sMountPointOption = Annotated[
136
120
  Option(envvar="VAULT_K8S_MOUNT_POINT", help="Vault K8s auth backend mount path."),
137
121
  ]
138
122
 
139
- SnapshotNameOption = Annotated[
123
+ K8sSaTokenPathOption = Annotated[
124
+ Path,
125
+ Option(
126
+ envvar="VAULT_K8S_SA_TOKEN_PATH",
127
+ exists=True,
128
+ file_okay=True,
129
+ dir_okay=False,
130
+ readable=True,
131
+ writable=False,
132
+ resolve_path=True,
133
+ path_type=str,
134
+ help="Path to the Kubernetes service account token file used for Vault Kubernetes authentication. The default is /var/run/secrets/kubernetes.io/serviceaccount/token. This can also be specified via the VAULT_K8S_SA_TOKEN_PATH environment variable.",
135
+ ),
136
+ ]
137
+
138
+ RestoreSourceOption = Annotated[
140
139
  str | None,
141
140
  Option(
142
- help="Name of the Vault snapshot to restore.",
141
+ "--from",
142
+ help="Source path. Local file path or S3 URI (e.g., s3://bucket/key).",
143
143
  ),
144
144
  ]
145
145
 
146
- SnapshotNameRegexOption = Annotated[
147
- re.Pattern | None,
146
+ BackupDestinationOption = Annotated[
147
+ str | None,
148
148
  Option(
149
- parser=parse_regex,
150
- help="Regex pattern to match Vault snapshot names.",
149
+ "--to",
150
+ help="Destination path. Local file/directory or S3 URI (e.g., s3://bucket/prefix/).",
151
151
  ),
152
152
  ]
153
153
 
@@ -170,31 +170,3 @@ TimeoutOption = Annotated[
170
170
  help="HTTP request timeout in seconds for Vault API calls.",
171
171
  ),
172
172
  ]
173
-
174
- OutputFileOption = Annotated[
175
- Path | None,
176
- Option(
177
- "--output-file",
178
- "-o",
179
- help="Path to write the snapshot to locally. If a directory, a timestamped filename is generated.",
180
- dir_okay=True,
181
- writable=True,
182
- resolve_path=True,
183
- path_type=str,
184
- ),
185
- ]
186
-
187
- SnapshotFileOption = Annotated[
188
- Path | None,
189
- Option(
190
- "--snapshot-file",
191
- "-f",
192
- help="Path to a local snapshot file to restore.",
193
- exists=True,
194
- file_okay=True,
195
- dir_okay=False,
196
- readable=True,
197
- resolve_path=True,
198
- path_type=str,
199
- ),
200
- ]
@@ -38,34 +38,6 @@ def parse_regex(value: str) -> re.Pattern:
38
38
  raise typer.BadParameter(msg) from e
39
39
 
40
40
 
41
- def validate_s3_key_prefix(
42
- value: str,
43
- ) -> str:
44
- """Validate S3 key prefix format."""
45
- if not value:
46
- return ""
47
- if value.startswith("/"):
48
- msg = "S3 key prefix must not start with '/'. Example: backups/2026/"
49
- raise typer.BadParameter(
50
- msg,
51
- )
52
-
53
- pattern = re.compile(r"^[A-Za-z0-9/_\-.]+$")
54
- if not pattern.match(value):
55
- msg = (
56
- "S3 key prefix contains invalid characters. "
57
- "Allowed: letters, numbers, '/', '-', '_', '.'"
58
- )
59
- raise typer.BadParameter(
60
- msg,
61
- )
62
-
63
- if not value.endswith("/"):
64
- value = value + "/"
65
-
66
- return value
67
-
68
-
69
41
  def validate_address(
70
42
  ctx: typer.Context, # noqa: ARG001
71
43
  param: object, # noqa: ARG001
@@ -81,6 +53,19 @@ def validate_address(
81
53
  return value
82
54
 
83
55
 
56
+ def is_s3_uri(path: str) -> bool:
57
+ return path.startswith("s3://")
58
+
59
+
60
+ def parse_s3_uri(path: str) -> tuple[str, str]:
61
+ if not path.startswith("s3://"):
62
+ msg = f"Invalid S3 URI: {path}"
63
+ raise ValueError(msg)
64
+ rest = path[5:]
65
+ bucket, _sep, key = rest.partition("/")
66
+ return bucket, key
67
+
68
+
84
69
  def handle_vault_authentication(
85
70
  client: hvac.Client,
86
71
  token: str | None,
@@ -91,19 +76,27 @@ def handle_vault_authentication(
91
76
  token_filepath = Path.home() / ".vault-token"
92
77
 
93
78
  if token:
79
+ logger.info("Authenticating to Vault using provided token...")
94
80
  client.token = token
95
81
  return client
96
82
 
97
83
  if token_filepath.exists() and (saved_token := token_filepath.read_text().strip()):
84
+ logger.info(
85
+ "Authenticating to Vault using existing token from %s...",
86
+ token_filepath,
87
+ )
98
88
  client.token = saved_token
99
89
  return client
100
90
 
101
91
  if k8s_role:
102
- logger.info("Attempting Kubernetes Auth for role: %s...", k8s_role)
92
+ logger.info(
93
+ "Authenticating to Vault using Kubernetes Auth method with role: %s...",
94
+ k8s_role,
95
+ )
103
96
 
104
97
  if not k8s_token_path.exists():
105
98
  logger.error(
106
- "K8s token file not found at %s.",
99
+ "Token file not found at %s.",
107
100
  k8s_token_path,
108
101
  )
109
102
  raise typer.Exit(code=1)
@@ -122,13 +115,14 @@ def handle_vault_authentication(
122
115
  else:
123
116
  return client
124
117
 
125
- logger.error("Vault client authentication failed.")
118
+ logger.error(
119
+ "Vault authentication failed. No valid authentication method found. Please provide a token, use the 'login' command, or configure Kubernetes authentication by providing the role and mount point to use.",
120
+ )
126
121
  raise typer.Exit(code=1)
127
122
 
128
123
 
129
124
  def handle_s3_authentication( # noqa: PLR0913
130
125
  bucket_name: str,
131
- *,
132
126
  aws_access_key_id: str | None = None,
133
127
  aws_secret_access_key: str | None = None,
134
128
  aws_session_token: str | None = None,
@@ -411,7 +411,7 @@ wheels = [
411
411
 
412
412
  [[package]]
413
413
  name = "vaultctl"
414
- version = "0.1.0"
414
+ version = "0.2.0"
415
415
  source = { editable = "." }
416
416
  dependencies = [
417
417
  { name = "boto3" },
@@ -1,210 +0,0 @@
1
- from datetime import datetime
2
- from pathlib import Path
3
- from typing import TYPE_CHECKING, cast
4
-
5
- import botocore.exceptions
6
- import hvac
7
- import typer
8
- from dateutil.parser import parse as parse_datetime
9
- from hvac.api.system_backend import Raft
10
- from hvac.exceptions import InvalidRequest, VaultError
11
-
12
- from vaultctl.options import (
13
- AddressOption,
14
- AwsAccessKeyIdOption,
15
- AwsEndpointUrlOption,
16
- AwsProfileOption,
17
- AwsRegionOption,
18
- AwsSecretAccessKeyOption,
19
- CACertOption,
20
- CAPathOption,
21
- K8sMountPointOption,
22
- K8sRoleOption,
23
- S3BucketNameOption,
24
- S3KeyPrefixOption,
25
- SkipVerifyOption,
26
- SnapshotFileOption,
27
- SnapshotForceRestoreOption,
28
- SnapshotNameOption,
29
- SnapshotNameRegexOption,
30
- TimeoutOption,
31
- TokenOption,
32
- )
33
- from vaultctl.utils import handle_s3_authentication, handle_vault_authentication, logger
34
-
35
- if TYPE_CHECKING:
36
- import re
37
- from collections.abc import Mapping, Sequence
38
-
39
- app = typer.Typer()
40
-
41
-
42
- def select_snapshot(
43
- contents: Sequence[Mapping[str, object]],
44
- filename_regex: re.Pattern | None,
45
- ) -> str:
46
- if filename_regex:
47
- valid_objects = []
48
-
49
- for o in contents:
50
- key = o.get("Key")
51
- if not isinstance(key, str):
52
- continue
53
-
54
- match = filename_regex.match(key)
55
- if not match:
56
- continue
57
-
58
- ts_str = match.group(1)
59
- try:
60
- ts = parse_datetime(ts_str)
61
- valid_objects.append({"Key": key, "Timestamp": ts})
62
- except ValueError:
63
- continue
64
-
65
- if not valid_objects:
66
- msg = "No valid snapshots found matching the filename regex with parseable timestamp"
67
- raise ValueError(
68
- msg,
69
- )
70
-
71
- latest_obj = max(valid_objects, key=lambda o: cast("datetime", o["Timestamp"]))
72
- return cast("str", latest_obj["Key"])
73
-
74
- valid_objects = [
75
- o
76
- for o in contents
77
- if isinstance(o.get("Key"), str) and isinstance(o.get("LastModified"), datetime)
78
- ]
79
- if not valid_objects:
80
- msg = "No valid snapshots with LastModified found"
81
- raise RuntimeError(msg)
82
-
83
- latest_obj = max(valid_objects, key=lambda o: cast("datetime", o["LastModified"]))
84
- return cast("str", latest_obj["Key"])
85
-
86
-
87
- @app.command(help="Restore a HashiCorp Vault cluster from an S3 Raft snapshot.")
88
- def restore_raft_snapshot( # noqa: C901, PLR0912, PLR0913, PLR0915
89
- address: AddressOption,
90
- s3_bucket_name: S3BucketNameOption = None,
91
- snapshot_file: SnapshotFileOption = None,
92
- k8s_role: K8sRoleOption = None,
93
- k8s_mount_point: K8sMountPointOption = "kubernetes",
94
- filename: SnapshotNameOption = None,
95
- filename_regex: SnapshotNameRegexOption = None,
96
- aws_profile: AwsProfileOption = None,
97
- aws_access_key_id: AwsAccessKeyIdOption = None,
98
- aws_secret_access_key: AwsSecretAccessKeyOption = None,
99
- aws_endpoint_url: AwsEndpointUrlOption = None,
100
- aws_region: AwsRegionOption = "us-east-1",
101
- s3_key_prefix: S3KeyPrefixOption = "",
102
- token: TokenOption = None,
103
- ca_cert: CACertOption = None,
104
- ca_path: CAPathOption = None,
105
- timeout: TimeoutOption = 30,
106
- *,
107
- force_restore: SnapshotForceRestoreOption = False,
108
- skip_verify: SkipVerifyOption = False,
109
- ) -> None:
110
- if not s3_bucket_name and not snapshot_file:
111
- msg = "Either --s3-bucket-name or --snapshot-file is required"
112
- raise typer.BadParameter(msg)
113
-
114
- vault_client = handle_vault_authentication(
115
- hvac.Client(
116
- url=address,
117
- timeout=timeout,
118
- verify=(
119
- str(ca_cert)
120
- if ca_cert
121
- else str(ca_path)
122
- if ca_path
123
- else (not skip_verify)
124
- ),
125
- ),
126
- token=token,
127
- k8s_role=k8s_role,
128
- k8s_mount_point=k8s_mount_point,
129
- )
130
-
131
- if vault_client.sys.is_sealed():
132
- logger.error("Vault is sealed. Cannot proceed.")
133
- raise typer.Exit(code=1)
134
-
135
- if snapshot_file:
136
- logger.info("Reading snapshot from %s...", snapshot_file)
137
- snapshot_data = Path(snapshot_file).read_bytes()
138
- else:
139
- s3_bucket_name = cast("str", s3_bucket_name)
140
- s3_client = handle_s3_authentication(
141
- bucket_name=s3_bucket_name,
142
- aws_access_key_id=aws_access_key_id,
143
- aws_secret_access_key=aws_secret_access_key,
144
- aws_region=aws_region,
145
- aws_profile=aws_profile,
146
- endpoint_url=aws_endpoint_url,
147
- )
148
-
149
- if filename:
150
- s3_key = f"{s3_key_prefix}{filename}"
151
- logger.info("Using snapshot: %s", s3_key)
152
- else:
153
- logger.info("Selecting latest snapshot from S3...")
154
-
155
- try:
156
- list_response = s3_client.list_objects_v2(
157
- Bucket=s3_bucket_name,
158
- Prefix=s3_key_prefix,
159
- )
160
- except botocore.exceptions.ClientError:
161
- logger.exception("Listing S3 bucket contents")
162
- raise typer.Exit(1) from None
163
-
164
- if list_response.get("KeyCount", 0) == 0:
165
- logger.error(
166
- "No snapshots found in bucket '%s' with prefix '%s'.",
167
- s3_bucket_name,
168
- s3_key_prefix,
169
- )
170
- raise typer.Exit(1)
171
-
172
- contents = list_response.get("Contents", [])
173
- try:
174
- s3_key = select_snapshot(contents, filename_regex)
175
- except (ValueError, RuntimeError):
176
- logger.exception("Selecting snapshot")
177
- raise typer.Exit(1) from None
178
-
179
- logger.info("Selected snapshot: %s", s3_key)
180
-
181
- logger.info("Downloading snapshot from S3: %s...", s3_key)
182
-
183
- try:
184
- download_response = s3_client.get_object(Bucket=s3_bucket_name, Key=s3_key)
185
- snapshot_data = download_response["Body"].read()
186
- except botocore.exceptions.ClientError:
187
- logger.exception("Downloading snapshot")
188
- raise typer.Exit(1) from None
189
-
190
- logger.info("Snapshot downloaded.")
191
-
192
- raft = Raft(adapter=vault_client.adapter)
193
-
194
- if force_restore:
195
- logger.warning("Restoring snapshot with force...")
196
- try:
197
- raft.force_restore_raft_snapshot(snapshot_data)
198
- except (VaultError, InvalidRequest):
199
- logger.exception("Restoring snapshot")
200
- raise typer.Exit(1) from None
201
- else:
202
- logger.info("Restoring snapshot...")
203
- try:
204
- raft.restore_raft_snapshot(snapshot_data)
205
- except (VaultError, InvalidRequest):
206
- logger.exception("Restoring snapshot")
207
- raise typer.Exit(1) from None
208
-
209
- logger.info("Snapshot restored.")
210
- logger.info("Restore completed")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes