Compare commits
2
Commits
f35f1e8d98
...
3b192bdd14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b192bdd14 | ||
|
|
fce253fa39 |
No files matched your search
@@ -359,7 +359,8 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output result as JSON
|
* `--json`: Output result as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does.
|
||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
@@ -383,7 +384,8 @@ manifests — network cost scales with the number of snapshots. `snapshot
|
|||||||
create --prune` runs the same cleanup automatically; this is the
|
create --prune` runs the same cleanup automatically; this is the
|
||||||
manual entry point for the same work.
|
manual entry point for the same work.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON
|
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does.
|
||||||
|
|
||||||
**`info`**: Display system configuration, storage settings, encryption
|
**`info`**: Display system configuration, storage settings, encryption
|
||||||
recipients, and local database statistics.
|
recipients, and local database statistics.
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-08: Made a second `snapshot create` of one name succeed when
|
||||||
|
it starts in the same second as the first
|
||||||
|
([issue #270](https://git.eeqj.de/sneak/vaultik/issues/270)). The
|
||||||
|
timestamp in a snapshot ID is in whole seconds, so the second run got
|
||||||
|
the first run's ID and failed with `UNIQUE constraint failed:
|
||||||
|
snapshots.id`. When the local index already has a snapshot with the
|
||||||
|
ID, the create now waits a second and takes a new timestamp.
|
||||||
|
|
||||||
|
- 2026-10-07: Documented that `--json` skips the confirmation prompt of
|
||||||
|
`snapshot remove` and `prune`
|
||||||
|
([issue #268](https://git.eeqj.de/sneak/vaultik/issues/268)). Both
|
||||||
|
commands delete without asking under `--json`, since a prompt on stdout
|
||||||
|
would break the JSON document, but the help and the README described
|
||||||
|
only `--force` as skipping it. The `--json` help of both commands and
|
||||||
|
their README entries now say so.
|
||||||
|
|
||||||
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
||||||
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
||||||
left out of the snapshot with only a debug log line, even without
|
left out of the snapshot with only a debug log line, even without
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||||
|
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||||
|
// prompt. Both delete without asking under --json, since a prompt on
|
||||||
|
// stdout would break the JSON document.
|
||||||
|
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, cmd := range []*cobra.Command{
|
||||||
|
newSnapshotRemoveCommand(),
|
||||||
|
NewPruneCommand(),
|
||||||
|
} {
|
||||||
|
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||||
|
"skips the confirmation prompt", cmd.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ referenced.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,8 @@ nuke --force' — it is the single supported entry point for that.`,
|
|||||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be removed without removing")
|
"Show what would be removed without removing")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||||
"Skip remote cleanup; only touch the local index")
|
"Skip remote cleanup; only touch the local index")
|
||||||
|
|
||||||
|
|||||||
@@ -115,20 +115,37 @@ func ShortHostname(hostname string) string {
|
|||||||
|
|
||||||
// CreateSnapshotWithName creates a new snapshot record with an optional
|
// CreateSnapshotWithName creates a new snapshot record with an optional
|
||||||
// snapshot name. The snapshot ID format is: hostname_name_timestamp or
|
// snapshot name. The snapshot ID format is: hostname_name_timestamp or
|
||||||
// hostname_timestamp if name is empty.
|
// hostname_timestamp if name is empty. The timestamp is in whole seconds.
|
||||||
|
// If the local index already has a snapshot with that ID, from a run of the
|
||||||
|
// same name that started in the same second, it waits a second and takes a
|
||||||
|
// new timestamp.
|
||||||
func (sm *SnapshotManager) CreateSnapshotWithName(
|
func (sm *SnapshotManager) CreateSnapshotWithName(
|
||||||
ctx context.Context, hostname, name, version, gitRevision string,
|
ctx context.Context, hostname, name, version, gitRevision string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
shortHostname := ShortHostname(hostname)
|
shortHostname := ShortHostname(hostname)
|
||||||
|
|
||||||
// Build snapshot ID with optional name
|
|
||||||
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
||||||
|
|
||||||
var snapshotID string
|
var snapshotID string
|
||||||
if name != "" {
|
|
||||||
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
for {
|
||||||
} else {
|
// Build snapshot ID with optional name
|
||||||
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
|
if name != "" {
|
||||||
|
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
||||||
|
} else {
|
||||||
|
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := sm.repos.Snapshots.GetByID(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("looking up snapshot %s: %w", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot := &database.Snapshot{
|
snapshot := &database.Snapshot{
|
||||||
|
|||||||
@@ -386,3 +386,36 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two creates of one snapshot name back to back start within one second,
|
||||||
|
// the resolution of the timestamp in a snapshot ID. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
||||||
|
func TestCreateSnapshotWithNameTwiceBackToBack(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
sm := &SnapshotManager{repos: database.NewRepositories(db)}
|
||||||
|
|
||||||
|
first, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("both creates returned snapshot ID %s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPruneBlobs_JSONDeletesWithoutAsking checks that prune with --json
|
||||||
|
// and without --force deletes an unreferenced blob without the
|
||||||
|
// confirmation prompt. Stdin is empty, so a prompt would read no answer
|
||||||
|
// and cancel, and its text would come before the JSON document.
|
||||||
|
func TestPruneBlobs_JSONDeletesWithoutAsking(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
// The store holds no manifest, so nothing references this blob.
|
||||||
|
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||||
|
|
||||||
|
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
assert.False(t, env.store.hasKey(blobKey),
|
||||||
|
"the unreferenced blob must be deleted")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||||
|
"stdout must hold only the JSON document, got:\n%s",
|
||||||
|
env.stdout.String())
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user