Skip to content

Commit 44a364a

Browse files
authored
Merge pull request #555 from josibake/premined-signet-chain
create and load tank snapshots
2 parents 6ac8f9f + 366465c commit 44a364a

File tree

6 files changed

+334
-3
lines changed

6 files changed

+334
-3
lines changed

docs/snapshots.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Creating and loading warnet snapshots
2+
3+
The `snapshot` command allows users to create snapshots of Bitcoin data directories from active bitcoin nodes. These snapshots can be used for backup purposes, to recreate specific network states, or to quickly initialize new bitcoin nodes with existing data.
4+
5+
## Usage Examples
6+
7+
### Snapshot a Specific bitcoin node
8+
9+
To create a snapshot of a specific bitcoin node:
10+
11+
```bash
12+
warnet snapshot my-node-name -o <snapshots_dir>
13+
```
14+
15+
This will create a snapshot of the bitcoin node named "my-node-name" in the `<snapshots_dir>`. If a directory the directory does not exist, it will be created. If no directory is specified, snapshots will be placed in `./warnet-snapshots` by default.
16+
17+
### Snapshot all nodes
18+
19+
To snapshot all running bitcoin nodes:
20+
21+
```bash
22+
warnet snapshot --all -o `<snapshots_dir>`
23+
```
24+
25+
### Use Filters
26+
27+
In the previous examples, everything in the bitcoin datadir was included in the snapshot, e.g., peers.dat. But there maybe use cases where only certain directories are needed. For example, assuming you only want to save the chain up to that point, you can use the filter argument:
28+
29+
```bash
30+
warnet snapshot my-node-name --filter "chainstate,blocks"
31+
```
32+
33+
This will create a snapshot containing only the 'blocks' and 'chainstate' directories. You would only need to snapshot this for a single node since the rest of the nodes will get this data via IBD when this snapshot is later loaded. A few other useful filters are detailed below:
34+
35+
```bash
36+
# snapshot the logs from all nodes
37+
warnet snapshot --all -f debug.log -o ./node-logs
38+
39+
# snapshot the chainstate and wallet from a mining node
40+
# this is particularly userful for premining a signet chain that
41+
# can be used later for starting a signet network
42+
warnet snapshot mining-node -f "chainstate,blocks,wallets"
43+
44+
# snapshot only the wallets from a node
45+
warnet snapshot my-node -f wallets
46+
47+
# snapshot a specific wallet
48+
warnet snapshot my-node -f mining_wallet
49+
```
50+
51+
## End-to-End Example
52+
53+
Here's a step-by-step guide on how to create a snapshot, upload it, and configure Warnet to use this snapshot when deploying. This particular example is for creating a premined signet chain:
54+
55+
1. Create a snapshot of the mining node:
56+
```bash
57+
warnet snapshot miner --output /tmp/snapshots --filter "blocks,chainstate,wallets"
58+
```
59+
60+
2. The snapshot will be created as a tar.gz file in the specified output directory. The filename will be in the format `{node_name}_{chain}_bitcoin_data.tar.gz`, i.e., `miner_bitcoin_data.tar.gz`.
61+
62+
3. Upload the snapshot to a location accessible by your Kubernetes cluster. This could be a cloud storage service like AWS S3, Google Cloud Storage, or a GitHub repository. If working in a warnet project directory, you can commit your snapshot in a `snapshots/` folder.
63+
64+
4. Note the URL of the uploaded snapshot, e.g., `https://github.com/your-username/your-repo/raw/main/my-warnet-project/snapshots/miner_bitcoin_data.tar.gz`
65+
66+
5. Update your Warnet configuration to use this snapshot. This involves modifying your `network.yaml` configuration file. Here's an example of how to configure the mining node to use the snapshot:
67+
68+
```yaml
69+
nodes:
70+
- name: miner
71+
image:
72+
tag: "27.0"
73+
loadSnapshot:
74+
enabled: true
75+
url: "https://github.com/your-username/your-repo/raw/main/snapshots/miner_bitcoin_data.tar.gz"
76+
# ... other nodes ...
77+
```
78+
79+
6. Deploy Warnet with the updated configuration:
80+
```bash
81+
warnet deploy networks/your_cool_network/network.yaml
82+
```
83+
84+
7. Warnet will now use the uploaded snapshot to initialize the Bitcoin data directory when creating the "miner" node. In this particular example, the blocks will then be distibuted to the other nodes via IBD and the mining node can resume signet mining off the chaintip by loading the wallet from the snapshot:
85+
```bash
86+
warnet bitcoin rpc miner loadwallet mining_wallet
87+
```
88+
89+
## Notes
90+
91+
- Snapshots are specific to the chain (signet, regtest) of the bitcoin node they were created from. Ensure you're using snapshots with the correct network when deploying.
92+
- Large snapshots may take considerable time to upload and download. Consider using filters to reduce snapshot size if you don't need the entire data directory.
93+
- Ensure that your Kubernetes cluster has the necessary permissions to access the location where you've uploaded the snapshot.
94+
- When using GitHub to host snapshots, make sure to use the "raw" URL of the file for direct download.

resources/charts/bitcoincore/templates/pod.yaml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ spec:
2323
{{- end }}
2424
securityContext:
2525
{{- toYaml .Values.podSecurityContext | nindent 4 }}
26+
{{- if .Values.loadSnapshot.enabled }}
27+
initContainers:
28+
- name: download-blocks
29+
image: alpine:latest
30+
command: ["/bin/sh", "-c"]
31+
args:
32+
- |
33+
apk add --no-cache curl
34+
mkdir -p /root/.bitcoin/{{ .Values.chain }}
35+
curl -L {{ .Values.loadSnapshot.url }} | tar -xz -C /root/.bitcoin/{{ .Values.chain }}
36+
volumeMounts:
37+
- name: data
38+
mountPath: /root/.bitcoin
39+
{{- end }}
2640
containers:
2741
- name: {{ .Chart.Name }}
2842
securityContext:
@@ -54,6 +68,8 @@ spec:
5468
{{- with .Values.volumeMounts }}
5569
{{- toYaml . | nindent 8 }}
5670
{{- end }}
71+
- mountPath: /root/.bitcoin
72+
name: data
5773
- mountPath: /root/.bitcoin/bitcoin.conf
5874
name: config
5975
subPath: bitcoin.conf
@@ -83,9 +99,11 @@ spec:
8399
{{- with .Values.volumes }}
84100
{{- toYaml . | nindent 4 }}
85101
{{- end }}
86-
- configMap:
102+
- name: data
103+
emptyDir: {}
104+
- name: config
105+
configMap:
87106
name: {{ include "bitcoincore.fullname" . }}
88-
name: config
89107
{{- with .Values.nodeSelector }}
90108
nodeSelector:
91109
{{- toYaml . | nindent 4 }}

resources/charts/bitcoincore/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,6 @@ baseConfig: |
134134
config: ""
135135

136136
connect: []
137+
loadSnapshot:
138+
enabled: false
139+
url: ""

src/warnet/control.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import json
3+
import os
34
import subprocess
45
import time
56
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -18,6 +19,7 @@
1819
get_default_namespace,
1920
get_mission,
2021
get_pods,
22+
snapshot_bitcoin_datadir,
2123
)
2224
from .process import run_command, stream_command
2325

@@ -291,3 +293,104 @@ def logs(pod_name: str, follow: bool):
291293
print(f"Please consider waiting for the pod to become available. Encountered: {e}")
292294
else:
293295
pass # cancelled by user
296+
297+
298+
@click.command()
299+
@click.argument("tank_name", required=False)
300+
@click.option("--all", "-a", "snapshot_all", is_flag=True, help="Snapshot all running tanks")
301+
@click.option(
302+
"--output",
303+
"-o",
304+
type=click.Path(),
305+
default="./warnet-snapshots",
306+
help="Output directory for snapshots",
307+
)
308+
@click.option(
309+
"--filter",
310+
"-f",
311+
type=str,
312+
help="Comma-separated list of directories and/or files to include in the snapshot",
313+
)
314+
def snapshot(tank_name, snapshot_all, output, filter):
315+
"""Create a snapshot of a tank's Bitcoin data or snapshot all tanks"""
316+
tanks = get_mission("tank")
317+
318+
if not tanks:
319+
console.print("[bold red]No active tanks found.[/bold red]")
320+
return
321+
322+
# Create the output directory if it doesn't exist
323+
os.makedirs(output, exist_ok=True)
324+
325+
filter_list = [f.strip() for f in filter.split(",")] if filter else None
326+
if snapshot_all:
327+
snapshot_all_tanks(tanks, output, filter_list)
328+
elif tank_name:
329+
snapshot_single_tank(tank_name, tanks, output, filter_list)
330+
else:
331+
select_and_snapshot_tank(tanks, output, filter_list)
332+
333+
334+
def find_tank_by_name(tanks, tank_name):
335+
for tank in tanks:
336+
if tank.metadata.name == tank_name:
337+
return tank
338+
return None
339+
340+
341+
def snapshot_all_tanks(tanks, output_dir, filter_list):
342+
with console.status("[bold yellow]Snapshotting all tanks...[/bold yellow]"):
343+
for tank in tanks:
344+
tank_name = tank.metadata.name
345+
chain = tank.metadata.labels["chain"]
346+
snapshot_tank(tank_name, chain, output_dir, filter_list)
347+
console.print("[bold green]All tank snapshots completed.[/bold green]")
348+
349+
350+
def snapshot_single_tank(tank_name, tanks, output_dir, filter_list):
351+
tank = find_tank_by_name(tanks, tank_name)
352+
if tank:
353+
chain = tank.metadata.labels["chain"]
354+
snapshot_tank(tank_name, chain, output_dir, filter_list)
355+
else:
356+
console.print(f"[bold red]No active tank found with name: {tank_name}[/bold red]")
357+
358+
359+
def select_and_snapshot_tank(tanks, output_dir, filter_list):
360+
table = Table(title="Active Tanks", show_header=True, header_style="bold magenta")
361+
table.add_column("Number", style="cyan", justify="right")
362+
table.add_column("Tank Name", style="green")
363+
364+
for idx, tank in enumerate(tanks, 1):
365+
table.add_row(str(idx), tank.metadata.name)
366+
367+
console.print(table)
368+
369+
choices = [str(i) for i in range(1, len(tanks) + 1)] + ["q"]
370+
choice = Prompt.ask(
371+
"[bold yellow]Enter the number of the tank to snapshot, or 'q' to quit[/bold yellow]",
372+
choices=choices,
373+
show_choices=False,
374+
)
375+
376+
if choice == "q":
377+
console.print("[bold blue]Operation cancelled.[/bold blue]")
378+
return
379+
380+
selected_tank = tanks[int(choice) - 1]
381+
tank_name = selected_tank.metadata.name
382+
chain = selected_tank.metadata.labels["chain"]
383+
snapshot_tank(tank_name, chain, output_dir, filter_list)
384+
385+
386+
def snapshot_tank(tank_name, chain, output_dir, filter_list):
387+
try:
388+
output_path = Path(output_dir).resolve()
389+
snapshot_bitcoin_datadir(tank_name, chain, str(output_path), filter_list)
390+
console.print(
391+
f"[bold green]Successfully created snapshot for tank: {tank_name}[/bold green]"
392+
)
393+
except Exception as e:
394+
console.print(
395+
f"[bold red]Failed to create snapshot for tank {tank_name}: {str(e)}[/bold red]"
396+
)

src/warnet/k8s.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import json
2+
import os
23
import tempfile
34
from pathlib import Path
45

56
import yaml
67
from kubernetes import client, config
78
from kubernetes.client.models import CoreV1Event, V1PodList
89
from kubernetes.dynamic import DynamicClient
10+
from kubernetes.stream import stream
911

1012
from .constants import DEFAULT_NAMESPACE, KUBECONFIG
1113
from .process import run_command, stream_command
@@ -115,3 +117,113 @@ def get_default_namespace() -> str:
115117
command = "kubectl config view --minify -o jsonpath='{..namespace}'"
116118
kubectl_namespace = run_command(command)
117119
return kubectl_namespace if kubectl_namespace else DEFAULT_NAMESPACE
120+
121+
122+
def snapshot_bitcoin_datadir(
123+
pod_name: str, chain: str, local_path: str = "./", filters: list[str] = None
124+
) -> None:
125+
namespace = get_default_namespace()
126+
sclient = get_static_client()
127+
128+
try:
129+
sclient.read_namespaced_pod(name=pod_name, namespace=namespace)
130+
131+
# Filter down to the specified list of directories and files
132+
# This allows for creating snapshots of only the relevant data, e.g.,
133+
# we may want to snapshot the blocks but not snapshot peers.dat or the node
134+
# wallets.
135+
#
136+
# TODO: never snapshot bitcoin.conf, as this is managed by the helm config
137+
if filters:
138+
find_command = [
139+
"find",
140+
f"/root/.bitcoin/{chain}",
141+
"(",
142+
"-type",
143+
"f",
144+
"-o",
145+
"-type",
146+
"d",
147+
")",
148+
"(",
149+
"-name",
150+
filters[0],
151+
]
152+
for f in filters[1:]:
153+
find_command.extend(["-o", "-name", f])
154+
find_command.append(")")
155+
else:
156+
# If no filters, get everything in the Bitcoin directory (TODO: exclude bitcoin.conf)
157+
find_command = ["find", f"/root/.bitcoin/{chain}"]
158+
159+
resp = stream(
160+
sclient.connect_get_namespaced_pod_exec,
161+
pod_name,
162+
namespace,
163+
command=find_command,
164+
stderr=True,
165+
stdin=False,
166+
stdout=True,
167+
tty=False,
168+
_preload_content=False,
169+
)
170+
171+
file_list = []
172+
while resp.is_open():
173+
resp.update(timeout=1)
174+
if resp.peek_stdout():
175+
file_list.extend(resp.read_stdout().strip().split("\n"))
176+
if resp.peek_stderr():
177+
print(f"Error: {resp.read_stderr()}")
178+
179+
resp.close()
180+
if not file_list:
181+
print("No matching files or directories found.")
182+
return
183+
tar_command = ["tar", "-czf", "/tmp/bitcoin_data.tar.gz", "-C", f"/root/.bitcoin/{chain}"]
184+
tar_command.extend(
185+
[os.path.relpath(f, f"/root/.bitcoin/{chain}") for f in file_list if f.strip()]
186+
)
187+
resp = stream(
188+
sclient.connect_get_namespaced_pod_exec,
189+
pod_name,
190+
namespace,
191+
command=tar_command,
192+
stderr=True,
193+
stdin=False,
194+
stdout=True,
195+
tty=False,
196+
_preload_content=False,
197+
)
198+
while resp.is_open():
199+
resp.update(timeout=1)
200+
if resp.peek_stdout():
201+
print(f"Tar output: {resp.read_stdout()}")
202+
if resp.peek_stderr():
203+
print(f"Error: {resp.read_stderr()}")
204+
resp.close()
205+
local_file_path = Path(local_path) / f"{pod_name}_bitcoin_data.tar.gz"
206+
copy_command = (
207+
f"kubectl cp {namespace}/{pod_name}:/tmp/bitcoin_data.tar.gz {local_file_path}"
208+
)
209+
if not stream_command(copy_command):
210+
raise Exception("Failed to copy tar file from pod to local machine")
211+
212+
print(f"Bitcoin data exported successfully to {local_file_path}")
213+
cleanup_command = ["rm", "/tmp/bitcoin_data.tar.gz"]
214+
stream(
215+
sclient.connect_get_namespaced_pod_exec,
216+
pod_name,
217+
namespace,
218+
command=cleanup_command,
219+
stderr=True,
220+
stdin=False,
221+
stdout=True,
222+
tty=False,
223+
)
224+
225+
print("To untar and repopulate the directory, use the following command:")
226+
print(f"tar -xzf {local_file_path} -C /path/to/destination/.bitcoin/{chain}")
227+
228+
except Exception as e:
229+
print(f"An error occurred: {str(e)}")

0 commit comments

Comments
 (0)