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.
- {vaultctl-0.1.2 → vaultctl-0.2.0}/PKG-INFO +1 -1
- {vaultctl-0.1.2 → vaultctl-0.2.0}/pyproject.toml +1 -1
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/backup_raft_snapshot.py +59 -54
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/bootstrap.py +6 -20
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/login.py +2 -4
- vaultctl-0.2.0/src/vaultctl/commands/restore_raft_snapshot.py +133 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/options.py +23 -51
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/utils.py +26 -32
- {vaultctl-0.1.2 → vaultctl-0.2.0}/uv.lock +1 -1
- vaultctl-0.1.2/src/vaultctl/commands/restore_raft_snapshot.py +0 -210
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.devcontainer/devcontainer-lock.json +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.devcontainer/devcontainer.json +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.github/workflows/docker-publish.yaml +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.github/workflows/release.yaml +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.gitignore +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/.renovaterc.json5 +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/Dockerfile +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/LICENSE +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/README.md +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/__init__.py +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/__init__.py +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/pki/__init__.py +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/commands/pki/rotate_issuing.py +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/main.py +0 -0
- {vaultctl-0.1.2 → vaultctl-0.2.0}/src/vaultctl/version.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
output_file: OutputFileOption = None,
|
|
130
|
+
destination: BackupDestinationOption = None,
|
|
128
131
|
k8s_role: K8sRoleOption = None,
|
|
129
132
|
k8s_mount_point: K8sMountPointOption = "kubernetes",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
148
|
-
msg = "
|
|
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
|
|
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
|
-
"
|
|
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("
|
|
193
|
+
logger.info("Snapshot retrieved.")
|
|
188
194
|
|
|
189
195
|
try:
|
|
190
196
|
verify_internal_checksums(snapshot_data)
|
|
191
|
-
except
|
|
192
|
-
logger.exception("Verifying
|
|
197
|
+
except tarfile.TarError, ValueError:
|
|
198
|
+
logger.exception("Verifying snapshot integrity")
|
|
193
199
|
raise typer.Exit(1) from None
|
|
194
200
|
|
|
195
|
-
if
|
|
196
|
-
output_path = Path(
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
54
|
-
msg = "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
"--from",
|
|
142
|
+
help="Source path. Local file path or S3 URI (e.g., s3://bucket/key).",
|
|
143
143
|
),
|
|
144
144
|
]
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
BackupDestinationOption = Annotated[
|
|
147
|
+
str | None,
|
|
148
148
|
Option(
|
|
149
|
-
|
|
150
|
-
help="
|
|
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(
|
|
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
|
-
"
|
|
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(
|
|
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,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|