Skip to content

Commit

Permalink
Snapshot & restore (#2477)
Browse files Browse the repository at this point in the history
* feat: backup & restore

* fix: don't import already synced resources

* test: add snapshot tests

* feat: add support for k0s & embedded etcd
  • Loading branch information
FabianKramm authored Feb 21, 2025
1 parent 51dab53 commit b5d79e5
Show file tree
Hide file tree
Showing 1,021 changed files with 222,528 additions and 2,369 deletions.
22 changes: 7 additions & 15 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ project_name: vcluster

before:
hooks:
- go mod tidy
- just embed-chart {{ .Version }}
- just clean-release
- just copy-assets
- just generate-vcluster-images {{ .Version }}
- just generate-matrix-specific-images {{ .Version }}
- '{{ if not .IsSnapshot }}just embed-chart {{ .Version }}{{ else }}echo "Skipping embed-chart"{{ end }}'
- '{{ if not .IsSnapshot }}just clean-release{{ else }}echo "Skipping clean-release"{{ end }}'
- '{{ if not .IsSnapshot }}just copy-assets{{ else }}echo "Skipping copy-assets"{{ end }}'
- '{{ if not .IsSnapshot }}just generate-vcluster-images {{ .Version }}{{ else }}echo "Skipping generate-vcluster-images"{{ end }}'
- '{{ if not .IsSnapshot }}just generate-matrix-specific-images {{ .Version }}{{ else }}echo "Skipping generate-matrix-specific-images"{{ end }}'

source:
format: tar.gz
Expand All @@ -36,7 +35,7 @@ builds:
ldflags:
- -s -w
- -X github.com/loft-sh/vcluster/pkg/telemetry.SyncerVersion={{.Version}}
- -X github.com/loft-sh/vcluster/pkg/telemetry.telemetryPrivateKey={{.Env.TELEMETRY_PRIVATE_KEY}}
- -X github.com/loft-sh/vcluster/pkg/telemetry.telemetryPrivateKey={{ with index .Env "TELEMETRY_PRIVATE_KEY" }}{{ . }}{{ end }}

- id: vcluster-cli
env:
Expand All @@ -49,14 +48,7 @@ builds:
goarch:
- amd64
- arm64
- arm
goarm:
- "6"
ignore:
- goos: darwin
goarch: arm
- goos: windows
goarch: arm
- goos: windows
goarch: arm64
binary: vcluster
Expand All @@ -72,7 +64,7 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X github.com/loft-sh/vcluster/pkg/telemetry.SyncerVersion={{.Version}}
- -X github.com/loft-sh/vcluster/pkg/telemetry.telemetryPrivateKey={{.Env.TELEMETRY_PRIVATE_KEY}}
- -X github.com/loft-sh/vcluster/pkg/telemetry.telemetryPrivateKey={{ with index .Env "TELEMETRY_PRIVATE_KEY" }}{{ . }}{{ end }}

archives:
- id: vcluster_cli_archives
Expand Down
49 changes: 16 additions & 33 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,49 +1,32 @@
set positional-arguments

[private]
alias align := check-structalign

timestamp := `date +%s`

alias c := create
alias d := delete
GOOS := env("GOOS", `go env GOOS`)
GOARCH := env("GOARCH", `go env GOARCH`)
GOBIN := env("GOBIN", `go env GOPATH`+"/bin")

DIST_FOLDER := if GOARCH == "amd64" { "dist/vcluster_linux_amd64_v1" } else if GOARCH == "arm64" { "dist/vcluster_linux_arm64_v8.0" } else { "unknown" }
DIST_FOLDER_CLI := if GOARCH == "amd64" { "dist/vcluster-cli_" + GOOS + "_amd64_v1" } else if GOARCH == "arm64" { "dist/vcluster-cli_" + GOOS + "_arm64_v8.0" } else { "unknown" }

_default:
@just --list

# --- Build ---

# Build the vcluster binary
build-snapshot:
TELEMETRY_PRIVATE_KEY="" goreleaser build --id vcluster --snapshot --clean

# Build the vcluster release binary in snapshot mode
release-snapshot: gen-license-report
TELEMETRY_PRIVATE_KEY="" goreleaser release --snapshot --clean
# Build the vcluster-cli binary
build-cli-snapshot:
goreleaser build --id vcluster-cli --single-target --snapshot --clean
mv {{DIST_FOLDER_CLI}}/vcluster {{GOBIN}}/vcluster

# --- Code quality ---

# Run golangci-lint for all packages
lint *ARGS:
[ -f ./custom-gcl ] || golangci-lint custom
./custom-gcl cache clean
./custom-gcl run {{ARGS}}

# Check struct memory alignment and print potential improvements
[no-exit-message]
check-structalign *ARGS:
go run github.com/dkorunic/betteralign/cmd/betteralign@latest {{ARGS}} ./...
# Build the vcluster binary (we force linux here to allow building on mac os or windows)
build-snapshot:
GOOS=linux goreleaser build --id vcluster --single-target --snapshot --clean
cp Dockerfile.release {{DIST_FOLDER}}/Dockerfile
cd {{DIST_FOLDER}} && docker build . -t ghcr.io/loft-sh/vcluster:dev-next

# --- Kind ---

# Create a kubernetes cluster using the specified distro
create distro="kind":
just create-{{distro}}

# Create a kubernetes cluster for the specified distro
delete distro="kind":
just delete-{{distro}}

# Create a local kind cluster
create-kind:
kind create cluster -n vcluster
Expand Down Expand Up @@ -192,6 +175,6 @@ create-conformance k8s_version="1.31.1":
minikube addons enable metrics-server

delete-conformance:
-minikube delete
minikube delete

recreate-conformance: delete-conformance create-conformance
223 changes: 223 additions & 0 deletions cmd/vcluster/cmd/restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package cmd

import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"

vclusterconfig "github.com/loft-sh/vcluster/config"
"github.com/loft-sh/vcluster/pkg/config"
"github.com/loft-sh/vcluster/pkg/constants"
"github.com/loft-sh/vcluster/pkg/etcd"
"github.com/loft-sh/vcluster/pkg/snapshot"
"github.com/spf13/cobra"
"k8s.io/klog/v2"
)

type RestoreOptions struct {
Snapshot snapshot.Options
}

func NewRestoreCommand() *cobra.Command {
options := &RestoreOptions{}
envOptions, err := parseOptionsFromEnv()
if err != nil {
klog.Warningf("Error parsing environment variables: %v", err)
} else {
options.Snapshot = *envOptions
}

cmd := &cobra.Command{
Use: "restore",
Short: "restore a vCluster",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return options.Run(cmd.Context())
},
}

// add storage flags
snapshot.AddFlags(cmd.Flags(), &options.Snapshot)
return cmd
}

func (o *RestoreOptions) Run(ctx context.Context) error {
// parse vCluster config
vConfig, err := config.ParseConfig(constants.DefaultVClusterConfigLocation, os.Getenv("VCLUSTER_NAME"), nil)
if err != nil {
return err
}

// make sure to validate options
err = validateOptions(vConfig, &o.Snapshot, true)
if err != nil {
return err
}

// create new etcd client
etcdClient, err := newRestoreEtcdClient(ctx, vConfig)
if err != nil {
return fmt.Errorf("failed to create etcd client: %w", err)
}

// create store
objectStore, err := createStore(ctx, &o.Snapshot)
if err != nil {
return fmt.Errorf("failed to create store: %w", err)
}

// now stream objects from object store to etcd
reader, err := objectStore.GetObject(ctx)
if err != nil {
return fmt.Errorf("failed to get backup: %w", err)
}
defer reader.Close()

// print log message that we start restoring
klog.Infof("Start restoring etcd snapshot from %s...", objectStore.Target())

// optionally decompress
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzipReader.Close()

// create a new tar reader
tarReader := tar.NewReader(gzipReader)

// now restore each key value
restoredKeys := 0
for {
// read from archive
key, value, err := readKeyValue(tarReader)
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("read etcd key/value: %w", err)
} else if errors.Is(err, io.EOF) || len(key) == 0 {
break
}

// write the value to etcd
klog.V(1).Infof("Restore key %s", string(key))
err = etcdClient.Put(ctx, string(key), value)
if err != nil {
return fmt.Errorf("restore etcd key %s: %w", string(key), err)
}

// print status update
restoredKeys++
if restoredKeys%100 == 0 {
klog.Infof("Restored %d keys", restoredKeys)
}
}
klog.Infof("Successfully restored %d etcd keys from snapshot", restoredKeys)
klog.Infof("Successfully restored snapshot from %s", objectStore.Target())

return nil
}

func newRestoreEtcdClient(ctx context.Context, vConfig *config.VirtualClusterConfig) (etcd.Client, error) {
// delete existing storage:
// * embedded etcd: just delete the files locally
// * deploy etcd: range delete request
// * embedded database: just delete the files locally
// * external database: we can't so we skip and then check later if there are any already
if vConfig.BackingStoreType() == vclusterconfig.StoreTypeEmbeddedDatabase {
if vConfig.Distro() == vclusterconfig.K8SDistro {
// this is a little bit stupid since we cannot rename /data, so we have to snapshot the
// individual file.
err := backupFile(ctx, constants.K8sSqliteDatabase)
if err != nil {
return nil, err
}
_ = os.RemoveAll(constants.K8sSqliteDatabase + "-wal")
_ = os.RemoveAll(constants.K8sSqliteDatabase + "-shm")
} else if vConfig.Distro() == vclusterconfig.K3SDistro {
err := backupFolder(ctx, filepath.Dir(constants.K3sSqliteDatabase))
if err != nil {
return nil, err
}
}
} else if vConfig.BackingStoreType() == vclusterconfig.StoreTypeEmbeddedEtcd {
embeddedEtcdData := "/data/etcd"
err := backupFolder(ctx, embeddedEtcdData)
if err != nil {
return nil, err
}
}

// now create the etcd client
etcdClient, err := newEtcdClient(ctx, vConfig, true)
if err != nil {
return nil, err
}

// delete contents in external etcd
if vConfig.BackingStoreType() == vclusterconfig.StoreTypeExternalEtcd {
klog.FromContext(ctx).Info("Delete existing etcd data before restore...")
err = etcdClient.DeletePrefix(ctx, "/")
if err != nil {
return nil, err
}
}

return etcdClient, nil
}

func backupFile(ctx context.Context, file string) error {
_, err := os.Stat(file)
if os.IsNotExist(err) {
return nil
}

backupName := file + ".backup"
_, err = os.Stat(backupName)
if err == nil {
_ = os.RemoveAll(backupName)
}

klog.FromContext(ctx).Info(fmt.Sprintf("Renaming existing database from %s to %s, if something goes wrong please restore the old database", file, backupName))
return os.Rename(file, backupName)
}

func backupFolder(ctx context.Context, dir string) error {
_, err := os.Stat(dir)
if os.IsNotExist(err) {
return nil
}

backupName := dir + ".backup"
_, err = os.Stat(backupName)
if err == nil {
_ = os.RemoveAll(backupName)
}

klog.FromContext(ctx).Info(fmt.Sprintf("Renaming existing database from %s to %s, if something goes wrong please restore the old database", dir, backupName))
err = os.Rename(dir, backupName)
if err != nil {
return err
}

return os.MkdirAll(dir, 0777)
}

func readKeyValue(tarReader *tar.Reader) ([]byte, []byte, error) {
header, err := tarReader.Next()
if err != nil {
return nil, nil, err
}

buf := &bytes.Buffer{}
_, err = io.Copy(buf, tarReader)
if err != nil {
return nil, nil, err
}

return []byte(header.Name), buf.Bytes(), nil
}
2 changes: 2 additions & 0 deletions cmd/vcluster/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ func BuildRoot() *cobra.Command {
// add top level commands
rootCmd.AddCommand(NewStartCommand())
rootCmd.AddCommand(NewCpCommand())
rootCmd.AddCommand(NewSnapshotCommand())
rootCmd.AddCommand(NewRestoreCommand())
rootCmd.AddCommand(debug.NewDebugCmd())
return rootCmd
}
Loading

0 comments on commit b5d79e5

Please sign in to comment.