Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create and load tank snapshots #555

Merged
merged 4 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Creating and loading warnet snapshots

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.

## Usage Examples

### Snapshot a Specific bitcoin node

To create a snapshot of a specific bitcoin node:

```bash
warnet snapshot my-node-name -o <snapshots_dir>
```

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.

### Snapshot all nodes

To snapshot all running bitcoin nodes:

```bash
warnet snapshot --all -o `<snapshots_dir>`
```

### Use Filters

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:

```bash
warnet snapshot my-node-name --filter "chainstate,blocks"
```

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:

```bash
# snapshot the logs from all nodes
warnet snapshot --all -f debug.log -o ./node-logs

# snapshot the chainstate and wallet from a mining node
# this is particularly userful for premining a signet chain that
# can be used later for starting a signet network
warnet snapshot mining-node -f "chainstate,blocks,wallets"

# snapshot only the wallets from a node
warnet snapshot my-node -f wallets

# snapshot a specific wallet
warnet snapshot my-node -f mining_wallet
```

## End-to-End Example

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:

1. Create a snapshot of the mining node:
```bash
warnet snapshot miner --output /tmp/snapshots --filter "blocks,chainstate,wallets"
```

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`.

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.

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`

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:
```yaml
nodes:
- name: miner
image:
tag: "27.0"
loadSnapshot:
enabled: true
url: "https://github.com/your-username/your-repo/raw/main/snapshots/miner_bitcoin_data.tar.gz"
# ... other nodes ...
```
6. Deploy Warnet with the updated configuration:
```bash
warnet deploy networks/your_cool_network/network.yaml
```
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:
```bash
warnet bitcoin rpc miner loadwallet mining_wallet
```
## Notes
- 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.
- 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.
- Ensure that your Kubernetes cluster has the necessary permissions to access the location where you've uploaded the snapshot.
- When using GitHub to host snapshots, make sure to use the "raw" URL of the file for direct download.
22 changes: 20 additions & 2 deletions resources/charts/bitcoincore/templates/pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ spec:
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 4 }}
{{- if .Values.loadSnapshot.enabled }}
initContainers:
- name: download-blocks
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
apk add --no-cache curl
mkdir -p /root/.bitcoin/{{ .Values.chain }}
curl -L {{ .Values.loadSnapshot.url }} | tar -xz -C /root/.bitcoin/{{ .Values.chain }}
volumeMounts:
- name: data
mountPath: /root/.bitcoin
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
Expand Down Expand Up @@ -54,6 +68,8 @@ spec:
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
- mountPath: /root/.bitcoin
name: data
- mountPath: /root/.bitcoin/bitcoin.conf
name: config
subPath: bitcoin.conf
Expand Down Expand Up @@ -83,9 +99,11 @@ spec:
{{- with .Values.volumes }}
{{- toYaml . | nindent 4 }}
{{- end }}
- configMap:
- name: data
emptyDir: {}
- name: config
configMap:
name: {{ include "bitcoincore.fullname" . }}
name: config
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 4 }}
Expand Down
3 changes: 3 additions & 0 deletions resources/charts/bitcoincore/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,6 @@ baseConfig: |
config: ""

connect: []
loadSnapshot:
enabled: false
url: ""
103 changes: 103 additions & 0 deletions src/warnet/control.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import json
import os
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
Expand All @@ -18,6 +19,7 @@
get_default_namespace,
get_mission,
get_pods,
snapshot_bitcoin_datadir,
)
from .process import run_command, stream_command

Expand Down Expand Up @@ -291,3 +293,104 @@ def logs(pod_name: str, follow: bool):
print(f"Please consider waiting for the pod to become available. Encountered: {e}")
else:
pass # cancelled by user


@click.command()
@click.argument("tank_name", required=False)
@click.option("--all", "-a", "snapshot_all", is_flag=True, help="Snapshot all running tanks")
@click.option(
"--output",
"-o",
type=click.Path(),
default="./warnet-snapshots",
help="Output directory for snapshots",
)
@click.option(
"--filter",
"-f",
type=str,
help="Comma-separated list of directories and/or files to include in the snapshot",
)
def snapshot(tank_name, snapshot_all, output, filter):
"""Create a snapshot of a tank's Bitcoin data or snapshot all tanks"""
tanks = get_mission("tank")

if not tanks:
console.print("[bold red]No active tanks found.[/bold red]")
return

# Create the output directory if it doesn't exist
os.makedirs(output, exist_ok=True)

filter_list = [f.strip() for f in filter.split(",")] if filter else None
if snapshot_all:
snapshot_all_tanks(tanks, output, filter_list)
elif tank_name:
snapshot_single_tank(tank_name, tanks, output, filter_list)
else:
select_and_snapshot_tank(tanks, output, filter_list)


def find_tank_by_name(tanks, tank_name):
for tank in tanks:
if tank.metadata.name == tank_name:
return tank
return None


def snapshot_all_tanks(tanks, output_dir, filter_list):
with console.status("[bold yellow]Snapshotting all tanks...[/bold yellow]"):
for tank in tanks:
tank_name = tank.metadata.name
chain = tank.metadata.labels["chain"]
snapshot_tank(tank_name, chain, output_dir, filter_list)
console.print("[bold green]All tank snapshots completed.[/bold green]")


def snapshot_single_tank(tank_name, tanks, output_dir, filter_list):
tank = find_tank_by_name(tanks, tank_name)
if tank:
chain = tank.metadata.labels["chain"]
snapshot_tank(tank_name, chain, output_dir, filter_list)
else:
console.print(f"[bold red]No active tank found with name: {tank_name}[/bold red]")


def select_and_snapshot_tank(tanks, output_dir, filter_list):
table = Table(title="Active Tanks", show_header=True, header_style="bold magenta")
table.add_column("Number", style="cyan", justify="right")
table.add_column("Tank Name", style="green")

for idx, tank in enumerate(tanks, 1):
table.add_row(str(idx), tank.metadata.name)

console.print(table)

choices = [str(i) for i in range(1, len(tanks) + 1)] + ["q"]
choice = Prompt.ask(
"[bold yellow]Enter the number of the tank to snapshot, or 'q' to quit[/bold yellow]",
choices=choices,
show_choices=False,
)

if choice == "q":
console.print("[bold blue]Operation cancelled.[/bold blue]")
return

selected_tank = tanks[int(choice) - 1]
tank_name = selected_tank.metadata.name
chain = selected_tank.metadata.labels["chain"]
snapshot_tank(tank_name, chain, output_dir, filter_list)


def snapshot_tank(tank_name, chain, output_dir, filter_list):
try:
output_path = Path(output_dir).resolve()
snapshot_bitcoin_datadir(tank_name, chain, str(output_path), filter_list)
console.print(
f"[bold green]Successfully created snapshot for tank: {tank_name}[/bold green]"
)
except Exception as e:
console.print(
f"[bold red]Failed to create snapshot for tank {tank_name}: {str(e)}[/bold red]"
)
112 changes: 112 additions & 0 deletions src/warnet/k8s.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import json
import os
import tempfile
from pathlib import Path

import yaml
from kubernetes import client, config
from kubernetes.client.models import CoreV1Event, V1PodList
from kubernetes.dynamic import DynamicClient
from kubernetes.stream import stream

from .constants import DEFAULT_NAMESPACE, KUBECONFIG
from .process import run_command, stream_command
Expand Down Expand Up @@ -115,3 +117,113 @@ def get_default_namespace() -> str:
command = "kubectl config view --minify -o jsonpath='{..namespace}'"
kubectl_namespace = run_command(command)
return kubectl_namespace if kubectl_namespace else DEFAULT_NAMESPACE


def snapshot_bitcoin_datadir(
pod_name: str, chain: str, local_path: str = "./", filters: list[str] = None
) -> None:
namespace = get_default_namespace()
sclient = get_static_client()

try:
sclient.read_namespaced_pod(name=pod_name, namespace=namespace)

# Filter down to the specified list of directories and files
# This allows for creating snapshots of only the relevant data, e.g.,
# we may want to snapshot the blocks but not snapshot peers.dat or the node
# wallets.
#
# TODO: never snapshot bitcoin.conf, as this is managed by the helm config
if filters:
find_command = [
"find",
f"/root/.bitcoin/{chain}",
"(",
"-type",
"f",
"-o",
"-type",
"d",
")",
"(",
"-name",
filters[0],
]
for f in filters[1:]:
find_command.extend(["-o", "-name", f])
find_command.append(")")
else:
# If no filters, get everything in the Bitcoin directory (TODO: exclude bitcoin.conf)
find_command = ["find", f"/root/.bitcoin/{chain}"]

resp = stream(
sclient.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=find_command,
stderr=True,
stdin=False,
stdout=True,
tty=False,
_preload_content=False,
)

file_list = []
while resp.is_open():
resp.update(timeout=1)
if resp.peek_stdout():
file_list.extend(resp.read_stdout().strip().split("\n"))
if resp.peek_stderr():
print(f"Error: {resp.read_stderr()}")

resp.close()
if not file_list:
print("No matching files or directories found.")
return
tar_command = ["tar", "-czf", "/tmp/bitcoin_data.tar.gz", "-C", f"/root/.bitcoin/{chain}"]
tar_command.extend(
[os.path.relpath(f, f"/root/.bitcoin/{chain}") for f in file_list if f.strip()]
)
resp = stream(
sclient.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=tar_command,
stderr=True,
stdin=False,
stdout=True,
tty=False,
_preload_content=False,
)
while resp.is_open():
resp.update(timeout=1)
if resp.peek_stdout():
print(f"Tar output: {resp.read_stdout()}")
if resp.peek_stderr():
print(f"Error: {resp.read_stderr()}")
resp.close()
local_file_path = Path(local_path) / f"{pod_name}_bitcoin_data.tar.gz"
copy_command = (
f"kubectl cp {namespace}/{pod_name}:/tmp/bitcoin_data.tar.gz {local_file_path}"
)
if not stream_command(copy_command):
raise Exception("Failed to copy tar file from pod to local machine")

print(f"Bitcoin data exported successfully to {local_file_path}")
cleanup_command = ["rm", "/tmp/bitcoin_data.tar.gz"]
stream(
sclient.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=cleanup_command,
stderr=True,
stdin=False,
stdout=True,
tty=False,
)

print("To untar and repopulate the directory, use the following command:")
print(f"tar -xzf {local_file_path} -C /path/to/destination/.bitcoin/{chain}")

except Exception as e:
print(f"An error occurred: {str(e)}")
Loading