From e29a99512021687537780e18296780488171aa52 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 26 Jul 2025 14:47:26 +0200 Subject: [PATCH] Refactor: Move Vaultik struct and methods to internal/vaultik package - Created new internal/vaultik package with unified Vaultik struct - Moved all command methods (snapshot, info, prune, verify) from CLI to vaultik package - Implemented single constructor that handles crypto capabilities automatically - Added CanDecrypt() method to check if decryption is available - Updated all CLI commands to use the new vaultik.Vaultik struct - Removed old fragmented App structs and WithCrypto wrapper - Fixed context management - Vaultik now owns its context lifecycle - Cleaned up package imports and dependencies This creates a cleaner separation between CLI/Cobra code and business logic, with all vaultik operations now centralized in the internal/vaultik package. --- TODO-verify.md | 86 +++ go.mod | 114 ++- go.sum | 463 +++++++++++- internal/blob/packer.go | 1 - internal/cli/app.go | 8 + internal/cli/fetch.go | 155 ++-- internal/cli/info.go | 70 ++ internal/cli/prune.go | 246 +------ internal/cli/purge.go | 99 +++ internal/cli/restore.go | 148 ++-- internal/cli/root.go | 1 + internal/cli/snapshot.go | 941 ++++--------------------- internal/cli/vaultik_snapshot_types.go | 10 + internal/cli/verify.go | 136 ++-- internal/config/config.go | 18 +- internal/config/size.go | 9 + internal/crypto/encryption.go | 64 ++ internal/database/chunks.go | 6 +- internal/database/snapshots.go | 30 + internal/snapshot/progress.go | 73 +- internal/snapshot/scanner.go | 76 +- internal/snapshot/snapshot.go | 60 +- 22 files changed, 1494 insertions(+), 1320 deletions(-) create mode 100644 TODO-verify.md create mode 100644 internal/cli/info.go create mode 100644 internal/cli/purge.go create mode 100644 internal/cli/vaultik_snapshot_types.go diff --git a/TODO-verify.md b/TODO-verify.md new file mode 100644 index 0000000..951ce24 --- /dev/null +++ b/TODO-verify.md @@ -0,0 +1,86 @@ +# TODO: Implement Verify Command + +## Overview +Implement the `verify` command to check snapshot integrity. Both shallow and deep verification require the age_secret_key from config to decrypt the database index. + +## Implementation Steps + +### 1. Update Config Structure +- Add `AgeSecretKey string` field to the Config struct in `internal/config/config.go` +- Add corresponding `age_secret_key` YAML tag +- Ensure the field is properly loaded from config file + +### 2. Remove Command Line Flags +- Remove --bucket, --prefix, and --snapshot flags from: + - `internal/cli/verify.go` + - `internal/cli/restore.go` + - `internal/cli/fetch.go` +- Update all commands to use bucket/prefix from config instead of flags +- Update verify command to take snapshot ID as first positional argument + +### 3. Implement Shallow Verification +**Requires age_secret_key from config** + +1. Download from S3: + - `metadata/{snapshot-id}/manifest.json.zst` + - `metadata/{snapshot-id}/db.zst.age` + +2. Process files: + - Decompress manifest (not encrypted) + - Decrypt db.zst.age using age_secret_key + - Decompress decrypted database + - Load SQLite database from dump + +3. Verify integrity: + - Query snapshot_blobs table for all blobs in this snapshot + - Compare DB blob list against manifest blob list + - **FAIL IMMEDIATELY** if lists don't match exactly + +4. For each blob in manifest: + - Use S3 HeadObject to check existence + - **FAIL IMMEDIATELY** if blob is missing + - Verify blob hash matches filename + - **FAIL IMMEDIATELY** if hash mismatch + +5. Only report success if ALL checks pass + +### 4. Implement Deep Verification +**Requires age_secret_key from config** + +1. Run all shallow verification first (fail on any error) + +2. For each blob referenced in snapshot: + - Download blob from S3 + - Decrypt using age_secret_key (streaming) + - Decompress (streaming) + - Parse blob structure to extract chunks + +3. For each chunk in blob: + - Calculate SHA256 of chunk data + - Query database for expected chunk hash + - **FAIL IMMEDIATELY** if calculated != expected + - Verify chunks are ordered correctly by offset + - **FAIL IMMEDIATELY** if chunks out of order + +4. Progress reporting: + - Show blob-by-blob progress + - Show chunk verification within each blob + - But continue only if no errors + +5. Only report success if ALL blobs and ALL chunks verify + +### 5. Error Handling + +- **FAIL IMMEDIATELY** if age_secret_key missing from config +- **FAIL IMMEDIATELY** on decryption failure +- **FAIL IMMEDIATELY** on any verification mismatch +- Use log.Fatal() or return error to ensure non-zero exit code +- Provide clear error messages indicating exactly what failed + +## Success Criteria + +- Verify command exits with code 0 only if ALL checks pass +- Any failure results in non-zero exit code +- Clear error messages for each failure type +- Progress reporting during verification +- Works with remote-only snapshots (not in local DB) \ No newline at end of file diff --git a/go.mod b/go.mod index d2624e9..7231b4d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.4 require ( filippo.io/age v1.2.1 + git.eeqj.de/sneak/smartconfig v1.0.0 github.com/aws/aws-sdk-go-v2 v1.36.6 github.com/aws/aws-sdk-go-v2/config v1.29.18 github.com/aws/aws-sdk-go-v2/credentials v1.17.71 @@ -17,7 +18,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 go.uber.org/fx v1.24.0 golang.org/x/term v0.33.0 gopkg.in/yaml.v3 v3.0.1 @@ -25,6 +26,18 @@ require ( ) require ( + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/secretmanager v1.15.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect + github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go v1.44.256 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect @@ -36,27 +49,114 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/hashicorp/consul/api v1.32.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/serf v0.10.1 // indirect + github.com/hashicorp/vault/api v1.20.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.29 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.etcd.io/etcd/api/v3 v3.6.2 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect + go.etcd.io/etcd/client/v3 v3.6.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect go.uber.org/dig v1.19.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/crypto v0.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.33.0 // indirect + google.golang.org/api v0.237.0 // indirect + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.33.3 // indirect + k8s.io/apimachinery v0.33.3 // indirect + k8s.io/client-go v0.33.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/libc v1.65.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index ce21e9e..f1f5b8b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,48 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/secretmanager v1.15.0 h1:RtkCMgTpaBMbzozcRUGfZe46jb9a3qh5EdEtVRUATF8= +cloud.google.com/go/secretmanager v1.15.0/go.mod h1:1hQSAhKK7FldiYw//wbR/XPfPc08eQ81oBsnRUHEvUc= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +git.eeqj.de/sneak/smartconfig v1.0.0 h1:v3rNOo4oEdQgOR5FuVgetKpv1tTvHIFFpV1fNtmlKmg= +git.eeqj.de/sneak/smartconfig v1.0.0/go.mod h1:h4LZ6yaSBx51tm+VKrcQcq5FgyqzrmflD+loC5npnH8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 h1:xnO4sFyG8UH2fElBkcqLTOZsAajvKfnSlgBBW8dXYjw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0/go.mod h1:XD3DIOOVgBCO03OleB1fHjgktVRFxlT++KwKgIOewdM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.44.256 h1:O8VH+bJqgLDguqkH/xQBFz5o/YheeZqgcOYIgsTVWY4= github.com/aws/aws-sdk-go v1.44.256/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU= @@ -34,6 +75,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.18 h1:OS2e0SKqsU2Li github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.18/go.mod h1:+Yrk+MDGzlNGxCXieljNeWpoZTCQUQVL+Jk9hGGJ8qM= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.1 h1:RkHXU9jP0DptGy7qKI8CBGsUJruWz0v5IgwBa2DwWcU= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.1/go.mod h1:3xAOf7tdKF+qbb+XpU+EPhNXAdun3Lu1RcDrj8KC24I= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 h1:HD6R8K10gPbN9CNqRDOs42QombXlYeLOr4KkIxe2lQs= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8/go.mod h1:x66GdH8qjYTr6Kb4ik38Ewl6moLsg8igbceNsmxVxeA= github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI= github.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc= @@ -42,18 +85,157 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI github.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk= github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc= github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE= +github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3nozU4= +github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -61,22 +243,123 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668 h1:+Mn8Sj5VzjOTuzyBCxfUnEcS+Iky4/5piUraOC3E5qQ= github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668/go.mod h1:t6osVdP++3g4v2awHz4+HFccij23BbdT1rX3W7IijqQ= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jotfs/fastcdc-go v0.2.0 h1:WHYIGk3k9NumGWfp4YMsemEcx/s4JKpGAa6tpCpHJOo= github.com/jotfs/fastcdc-go v0.2.0/go.mod h1:PGFBIloiASFbiKnkCd/hmHXxngxYDYtisyurJ/zyDNM= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= +github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= +github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= @@ -85,51 +368,139 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc= +go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk= +go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU= +go.etcd.io/etcd/client/pkg/v3 v3.6.2/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI= +go.etcd.io/etcd/client/v3 v3.6.2 h1:RgmcLJxkpHqpFvgKNwAQHX3K+wsSARMXKgjmUSpoSKQ= +go.etcd.io/etcd/client/v3 v3.6.2/go.mod h1:PL7e5QMKzjybn0FosgiWvCUDzvdChpo5UgGR4Sk4Gzc= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8= go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -144,29 +515,74 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.237.0 h1:MP7XVsGZesOsx3Q8WVa4sUdbrsTvDSOERd3Vh4xj/wc= +google.golang.org/api v0.237.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= +k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= +k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= +k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= +k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= @@ -191,3 +607,12 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/blob/packer.go b/internal/blob/packer.go index bc462f1..8f55f47 100644 --- a/internal/blob/packer.go +++ b/internal/blob/packer.go @@ -420,7 +420,6 @@ func (p *Packer) finalizeCurrentBlob() error { // Call blob handler if set if p.blobHandler != nil { - log.Debug("Invoking blob handler callback", "blob_hash", blobHash[:8]+"...") // Reset file position for handler if _, err := p.currentBlob.tempFile.Seek(0, io.SeekStart); err != nil { p.cleanupTempFile() diff --git a/internal/cli/app.go b/internal/cli/app.go index b5cd7f0..aa1013a 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -9,9 +9,13 @@ import ( "time" "git.eeqj.de/sneak/vaultik/internal/config" + "git.eeqj.de/sneak/vaultik/internal/crypto" "git.eeqj.de/sneak/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/globals" "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/s3" + "git.eeqj.de/sneak/vaultik/internal/snapshot" + "git.eeqj.de/sneak/vaultik/internal/vaultik" "go.uber.org/fx" ) @@ -48,6 +52,10 @@ func NewApp(opts AppOptions) *fx.App { config.Module, database.Module, log.Module, + s3.Module, + snapshot.Module, + crypto.Module, // This will provide crypto only if age_secret_key is configured + fx.Provide(vaultik.New), fx.Invoke(setupGlobals), fx.NopLogger, } diff --git a/internal/cli/fetch.go b/internal/cli/fetch.go index 94edc7b..8b2634c 100644 --- a/internal/cli/fetch.go +++ b/internal/cli/fetch.go @@ -3,20 +3,29 @@ package cli import ( "context" "fmt" - "os" + "git.eeqj.de/sneak/vaultik/internal/config" + "git.eeqj.de/sneak/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/globals" + "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/s3" + "git.eeqj.de/sneak/vaultik/internal/snapshot" "github.com/spf13/cobra" "go.uber.org/fx" ) // FetchOptions contains options for the fetch command type FetchOptions struct { - Bucket string - Prefix string - SnapshotID string - FilePath string - Target string +} + +// FetchApp contains all dependencies needed for fetch +type FetchApp struct { + Globals *globals.Globals + Config *config.Config + Repositories *database.Repositories + S3Client *s3.Client + DB *database.DB + Shutdowner fx.Shutdowner } // NewFetchCommand creates the fetch command @@ -24,65 +33,107 @@ func NewFetchCommand() *cobra.Command { opts := &FetchOptions{} cmd := &cobra.Command{ - Use: "fetch", + Use: "fetch ", Short: "Extract single file from backup", - Long: `Download and decrypt a single file from a backup snapshot`, - Args: cobra.NoArgs, + Long: `Download and decrypt a single file from a backup snapshot. + +This command extracts a specific file from the snapshot and saves it to the target path. +The age_secret_key must be configured in the config file for decryption.`, + Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { - // Validate required flags - if opts.Bucket == "" { - return fmt.Errorf("--bucket is required") + snapshotID := args[0] + filePath := args[1] + targetPath := args[2] + + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err } - if opts.Prefix == "" { - return fmt.Errorf("--prefix is required") - } - if opts.SnapshotID == "" { - return fmt.Errorf("--snapshot is required") - } - if opts.FilePath == "" { - return fmt.Errorf("--file is required") - } - if opts.Target == "" { - return fmt.Errorf("--target is required") - } - return runFetch(cmd.Context(), opts) + + // Use the app framework like other commands + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{ + snapshot.Module, + s3.Module, + fx.Provide(fx.Annotate( + func(g *globals.Globals, cfg *config.Config, repos *database.Repositories, + s3Client *s3.Client, db *database.DB, shutdowner fx.Shutdowner) *FetchApp { + return &FetchApp{ + Globals: g, + Config: cfg, + Repositories: repos, + S3Client: s3Client, + DB: db, + Shutdowner: shutdowner, + } + }, + )), + }, + Invokes: []fx.Option{ + fx.Invoke(func(app *FetchApp, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Start the fetch operation in a goroutine + go func() { + // Run the fetch operation + if err := app.runFetch(ctx, snapshotID, filePath, targetPath, opts); err != nil { + if err != context.Canceled { + log.Error("Fetch operation failed", "error", err) + } + } + + // Shutdown the app when fetch completes + if err := app.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + log.Debug("Stopping fetch operation") + return nil + }, + }) + }), + }, + }) }, } - cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") - cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") - cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID") - cmd.Flags().StringVar(&opts.FilePath, "file", "", "Path of file to extract from backup") - cmd.Flags().StringVar(&opts.Target, "target", "", "Target path for extracted file") - return cmd } -func runFetch(ctx context.Context, opts *FetchOptions) error { - if os.Getenv("VAULTIK_PRIVATE_KEY") == "" { - return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set") +// runFetch executes the fetch operation +func (app *FetchApp) runFetch(ctx context.Context, snapshotID, filePath, targetPath string, opts *FetchOptions) error { + // Check for age_secret_key + if app.Config.AgeSecretKey == "" { + return fmt.Errorf("age_secret_key missing from config - required for fetch") } - app := fx.New( - fx.Supply(opts), - fx.Provide(globals.New), - // Additional modules will be added here - fx.Invoke(func(g *globals.Globals) error { - // TODO: Implement fetch logic - fmt.Printf("Fetching %s from snapshot %s to %s\n", opts.FilePath, opts.SnapshotID, opts.Target) - return nil - }), - fx.NopLogger, + log.Info("Starting fetch operation", + "snapshot_id", snapshotID, + "file_path", filePath, + "target_path", targetPath, + "bucket", app.Config.S3.Bucket, + "prefix", app.Config.S3.Prefix, ) - if err := app.Start(ctx); err != nil { - return fmt.Errorf("failed to start fetch: %w", err) - } - defer func() { - if err := app.Stop(ctx); err != nil { - fmt.Printf("error stopping app: %v\n", err) - } - }() + // TODO: Implement fetch logic + // 1. Download and decrypt database from S3 + // 2. Find the file metadata and chunk list + // 3. Download and decrypt only the necessary blobs + // 4. Reconstruct the file from chunks + // 5. Write file to target path with proper metadata + + fmt.Printf("Fetching %s from snapshot %s to %s\n", filePath, snapshotID, targetPath) + fmt.Println("TODO: Implement fetch logic") return nil } diff --git a/internal/cli/info.go b/internal/cli/info.go new file mode 100644 index 0000000..d64ce18 --- /dev/null +++ b/internal/cli/info.go @@ -0,0 +1,70 @@ +package cli + +import ( + "context" + "os" + + "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/vaultik" + "github.com/spf13/cobra" + "go.uber.org/fx" +) + +// NewInfoCommand creates the info command +func NewInfoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Display system and configuration information", + Long: `Shows information about the current vaultik configuration, including: +- System details (OS, architecture, version) +- Storage configuration (S3 bucket, endpoint) +- Backup settings (source directories, compression) +- Encryption configuration (recipients) +- Local database statistics`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err + } + + // Use the app framework + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + if err := v.ShowInfo(); err != nil { + if err != context.Canceled { + log.Error("Failed to show info", "error", err) + os.Exit(1) + } + } + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + v.Cancel() + return nil + }, + }) + }), + }, + }) + }, + } + + return cmd +} diff --git a/internal/cli/prune.go b/internal/cli/prune.go index a68d763..3246722 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -2,51 +2,28 @@ package cli import ( "context" - "fmt" - "strings" + "os" - "git.eeqj.de/sneak/vaultik/internal/config" - "git.eeqj.de/sneak/vaultik/internal/database" - "git.eeqj.de/sneak/vaultik/internal/globals" "git.eeqj.de/sneak/vaultik/internal/log" - "git.eeqj.de/sneak/vaultik/internal/s3" - "git.eeqj.de/sneak/vaultik/internal/snapshot" - "github.com/dustin/go-humanize" + "git.eeqj.de/sneak/vaultik/internal/vaultik" "github.com/spf13/cobra" "go.uber.org/fx" ) -// PruneOptions contains options for the prune command -type PruneOptions struct { - DryRun bool -} - -// PruneApp contains all dependencies needed for pruning -type PruneApp struct { - Globals *globals.Globals - Config *config.Config - Repositories *database.Repositories - S3Client *s3.Client - DB *database.DB - Shutdowner fx.Shutdowner -} - // NewPruneCommand creates the prune command func NewPruneCommand() *cobra.Command { - opts := &PruneOptions{} + opts := &vaultik.PruneOptions{} cmd := &cobra.Command{ Use: "prune", Short: "Remove unreferenced blobs", - Long: `Delete blobs that are no longer referenced by any snapshot. + Long: `Removes blobs that are not referenced by any snapshot. -This command will: -1. Download the manifest from the last successful snapshot -2. List all blobs in S3 -3. Delete any blobs not referenced in the manifest +This command scans all snapshots and their manifests to build a list of +referenced blobs, then removes any blobs in storage that are not in this list. -Config is located at /etc/vaultik/config.yml by default, but can be overridden by -specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, +Use this command after deleting snapshots with 'vaultik purge' to reclaim +storage space.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Use unified config resolution @@ -63,38 +40,23 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, }, - Modules: []fx.Option{ - snapshot.Module, - s3.Module, - fx.Provide(fx.Annotate( - func(g *globals.Globals, cfg *config.Config, repos *database.Repositories, - s3Client *s3.Client, db *database.DB, shutdowner fx.Shutdowner) *PruneApp { - return &PruneApp{ - Globals: g, - Config: cfg, - Repositories: repos, - S3Client: s3Client, - DB: db, - Shutdowner: shutdowner, - } - }, - )), - }, + Modules: []fx.Option{}, Invokes: []fx.Option{ - fx.Invoke(func(app *PruneApp, lc fx.Lifecycle) { + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { // Start the prune operation in a goroutine go func() { // Run the prune operation - if err := app.runPrune(ctx, opts); err != nil { + if err := v.PruneBlobs(opts); err != nil { if err != context.Canceled { log.Error("Prune operation failed", "error", err) + os.Exit(1) } } // Shutdown the app when prune completes - if err := app.Shutdowner.Shutdown(); err != nil { + if err := v.Shutdowner.Shutdown(); err != nil { log.Error("Failed to shutdown", "error", err) } }() @@ -102,6 +64,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, }, OnStop: func(ctx context.Context) error { log.Debug("Stopping prune operation") + v.Cancel() return nil }, }) @@ -111,186 +74,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, }, } - cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be deleted without actually deleting") + cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") return cmd } - -// runPrune executes the prune operation -func (app *PruneApp) runPrune(ctx context.Context, opts *PruneOptions) error { - log.Info("Starting prune operation", - "bucket", app.Config.S3.Bucket, - "prefix", app.Config.S3.Prefix, - "dry_run", opts.DryRun, - ) - - // Step 1: Get the latest complete snapshot from the database - log.Info("Getting latest snapshot from database") - snapshots, err := app.Repositories.Snapshots.ListRecent(ctx, 1) - if err != nil { - return fmt.Errorf("listing snapshots: %w", err) - } - - if len(snapshots) == 0 { - return fmt.Errorf("no snapshots found in database") - } - - latestSnapshot := snapshots[0] - if latestSnapshot.CompletedAt == nil { - return fmt.Errorf("latest snapshot %s is incomplete", latestSnapshot.ID) - } - - log.Info("Found latest snapshot", - "id", latestSnapshot.ID, - "completed_at", latestSnapshot.CompletedAt.Format("2006-01-02 15:04:05")) - - // Step 2: Find and download the manifest from the last successful snapshot in S3 - log.Info("Finding last successful snapshot in S3") - metadataPrefix := "metadata/" - - // List all snapshots in S3 - var s3Snapshots []string - objectCh := app.S3Client.ListObjectsStream(ctx, metadataPrefix, false) - for obj := range objectCh { - if obj.Err != nil { - return fmt.Errorf("listing metadata objects: %w", obj.Err) - } - // Extract snapshot ID from path like "metadata/hostname-20240115-143052Z/manifest.json.zst" - parts := strings.Split(obj.Key, "/") - if len(parts) >= 2 && strings.HasSuffix(obj.Key, "/manifest.json.zst") { - s3Snapshots = append(s3Snapshots, parts[1]) - } - } - - if len(s3Snapshots) == 0 { - return fmt.Errorf("no snapshot manifests found in S3") - } - - // Find the most recent snapshot (they're named with timestamps) - var lastS3Snapshot string - for _, snap := range s3Snapshots { - if lastS3Snapshot == "" || snap > lastS3Snapshot { - lastS3Snapshot = snap - } - } - - log.Info("Found last S3 snapshot", "id", lastS3Snapshot) - - // Step 3: Verify the last S3 snapshot matches the latest DB snapshot - if lastS3Snapshot != latestSnapshot.ID { - return fmt.Errorf("latest snapshot in database (%s) does not match last successful snapshot in S3 (%s)", - latestSnapshot.ID, lastS3Snapshot) - } - - // Step 4: Download and parse the manifest - log.Info("Downloading manifest", "snapshot_id", lastS3Snapshot) - manifest, err := app.downloadManifest(ctx, lastS3Snapshot) - if err != nil { - return fmt.Errorf("downloading manifest: %w", err) - } - - log.Info("Manifest loaded", "blob_count", len(manifest.Blobs)) - - // Step 5: Build set of referenced blobs - referencedBlobs := make(map[string]bool) - for _, blob := range manifest.Blobs { - referencedBlobs[blob.Hash] = true - } - - // Step 6: List all blobs in S3 - log.Info("Listing all blobs in S3") - blobPrefix := "blobs/" - var totalBlobs int - var unreferencedBlobs []s3.ObjectInfo - var unreferencedSize int64 - - objectCh = app.S3Client.ListObjectsStream(ctx, blobPrefix, true) - for obj := range objectCh { - if obj.Err != nil { - return fmt.Errorf("listing blobs: %w", obj.Err) - } - - totalBlobs++ - - // Extract blob hash from path like "blobs/ca/fe/cafebabe..." - parts := strings.Split(obj.Key, "/") - if len(parts) == 4 { - blobHash := parts[3] - if !referencedBlobs[blobHash] { - unreferencedBlobs = append(unreferencedBlobs, obj) - unreferencedSize += obj.Size - } - } - } - - log.Info("Blob scan complete", - "total_blobs", totalBlobs, - "referenced_blobs", len(referencedBlobs), - "unreferenced_blobs", len(unreferencedBlobs), - "unreferenced_size", humanize.Bytes(uint64(unreferencedSize))) - - // Step 7: Delete or report unreferenced blobs - if opts.DryRun { - fmt.Printf("\nDry run mode - would delete %d unreferenced blobs\n", len(unreferencedBlobs)) - fmt.Printf("Total size of blobs to delete: %s\n", humanize.Bytes(uint64(unreferencedSize))) - - if len(unreferencedBlobs) > 0 { - log.Debug("Unreferenced blobs found", "count", len(unreferencedBlobs)) - for _, obj := range unreferencedBlobs { - log.Debug("Would delete blob", "key", obj.Key, "size", humanize.Bytes(uint64(obj.Size))) - } - } - } else { - if len(unreferencedBlobs) == 0 { - fmt.Println("No unreferenced blobs to delete") - return nil - } - - fmt.Printf("\nDeleting %d unreferenced blobs (%s)...\n", - len(unreferencedBlobs), humanize.Bytes(uint64(unreferencedSize))) - - deletedCount := 0 - deletedSize := int64(0) - - for _, obj := range unreferencedBlobs { - if err := app.S3Client.RemoveObject(ctx, obj.Key); err != nil { - log.Error("Failed to delete blob", "key", obj.Key, "error", err) - continue - } - deletedCount++ - deletedSize += obj.Size - - // Show progress every 100 blobs - if deletedCount%100 == 0 { - fmt.Printf(" Deleted %d/%d blobs (%s)...\n", - deletedCount, len(unreferencedBlobs), - humanize.Bytes(uint64(deletedSize))) - } - } - - fmt.Printf("\nDeleted %d blobs (%s)\n", deletedCount, humanize.Bytes(uint64(deletedSize))) - } - - log.Info("Prune operation completed successfully") - return nil -} - -// downloadManifest downloads and decompresses a snapshot manifest -func (app *PruneApp) downloadManifest(ctx context.Context, snapshotID string) (*snapshot.Manifest, error) { - manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) - - // Download the compressed manifest - reader, err := app.S3Client.GetObject(ctx, manifestPath) - if err != nil { - return nil, fmt.Errorf("downloading manifest: %w", err) - } - defer func() { _ = reader.Close() }() - - // Decode manifest - manifest, err := snapshot.DecodeManifest(reader) - if err != nil { - return nil, fmt.Errorf("decoding manifest: %w", err) - } - - return manifest, nil -} diff --git a/internal/cli/purge.go b/internal/cli/purge.go new file mode 100644 index 0000000..9840d7a --- /dev/null +++ b/internal/cli/purge.go @@ -0,0 +1,99 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/vaultik" + "github.com/spf13/cobra" + "go.uber.org/fx" +) + +// PurgeOptions contains options for the purge command +type PurgeOptions struct { + KeepLatest bool + OlderThan string + Force bool +} + +// NewPurgeCommand creates the purge command +func NewPurgeCommand() *cobra.Command { + opts := &PurgeOptions{} + + cmd := &cobra.Command{ + Use: "purge", + Short: "Purge old snapshots", + Long: `Removes snapshots based on age or count criteria. + +This command allows you to: +- Keep only the latest snapshot (--keep-latest) +- Remove snapshots older than a specific duration (--older-than) + +Config is located at /etc/vaultik/config.yml by default, but can be overridden by +specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Validate flags + if !opts.KeepLatest && opts.OlderThan == "" { + return fmt.Errorf("must specify either --keep-latest or --older-than") + } + if opts.KeepLatest && opts.OlderThan != "" { + return fmt.Errorf("cannot specify both --keep-latest and --older-than") + } + + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err + } + + // Use the app framework like other commands + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Start the purge operation in a goroutine + go func() { + // Run the purge operation + if err := v.PurgeSnapshots(opts.KeepLatest, opts.OlderThan, opts.Force); err != nil { + if err != context.Canceled { + log.Error("Purge operation failed", "error", err) + os.Exit(1) + } + } + + // Shutdown the app when purge completes + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + log.Debug("Stopping purge operation") + v.Cancel() + return nil + }, + }) + }), + }, + }) + }, + } + + cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot") + cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g. 30d, 6m, 1y)") + cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts") + + return cmd +} diff --git a/internal/cli/restore.go b/internal/cli/restore.go index ab7a556..3f34679 100644 --- a/internal/cli/restore.go +++ b/internal/cli/restore.go @@ -3,19 +3,30 @@ package cli import ( "context" "fmt" - "os" + "git.eeqj.de/sneak/vaultik/internal/config" + "git.eeqj.de/sneak/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/globals" + "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/s3" + "git.eeqj.de/sneak/vaultik/internal/snapshot" "github.com/spf13/cobra" "go.uber.org/fx" ) // RestoreOptions contains options for the restore command type RestoreOptions struct { - Bucket string - Prefix string - SnapshotID string - TargetDir string + TargetDir string +} + +// RestoreApp contains all dependencies needed for restore +type RestoreApp struct { + Globals *globals.Globals + Config *config.Config + Repositories *database.Repositories + S3Client *s3.Client + DB *database.DB + Shutdowner fx.Shutdowner } // NewRestoreCommand creates the restore command @@ -23,61 +34,104 @@ func NewRestoreCommand() *cobra.Command { opts := &RestoreOptions{} cmd := &cobra.Command{ - Use: "restore", + Use: "restore ", Short: "Restore files from backup", - Long: `Download and decrypt files from a backup snapshot`, - Args: cobra.NoArgs, + Long: `Download and decrypt files from a backup snapshot. + +This command will restore all files from the specified snapshot to the target directory. +The age_secret_key must be configured in the config file for decryption.`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - // Validate required flags - if opts.Bucket == "" { - return fmt.Errorf("--bucket is required") + snapshotID := args[0] + opts.TargetDir = args[1] + + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err } - if opts.Prefix == "" { - return fmt.Errorf("--prefix is required") - } - if opts.SnapshotID == "" { - return fmt.Errorf("--snapshot is required") - } - if opts.TargetDir == "" { - return fmt.Errorf("--target is required") - } - return runRestore(cmd.Context(), opts) + + // Use the app framework like other commands + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{ + snapshot.Module, + s3.Module, + fx.Provide(fx.Annotate( + func(g *globals.Globals, cfg *config.Config, repos *database.Repositories, + s3Client *s3.Client, db *database.DB, shutdowner fx.Shutdowner) *RestoreApp { + return &RestoreApp{ + Globals: g, + Config: cfg, + Repositories: repos, + S3Client: s3Client, + DB: db, + Shutdowner: shutdowner, + } + }, + )), + }, + Invokes: []fx.Option{ + fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Start the restore operation in a goroutine + go func() { + // Run the restore operation + if err := app.runRestore(ctx, snapshotID, opts); err != nil { + if err != context.Canceled { + log.Error("Restore operation failed", "error", err) + } + } + + // Shutdown the app when restore completes + if err := app.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + log.Debug("Stopping restore operation") + return nil + }, + }) + }), + }, + }) }, } - cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") - cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") - cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to restore") - cmd.Flags().StringVar(&opts.TargetDir, "target", "", "Target directory for restore") - return cmd } -func runRestore(ctx context.Context, opts *RestoreOptions) error { - if os.Getenv("VAULTIK_PRIVATE_KEY") == "" { - return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set") +// runRestore executes the restore operation +func (app *RestoreApp) runRestore(ctx context.Context, snapshotID string, opts *RestoreOptions) error { + // Check for age_secret_key + if app.Config.AgeSecretKey == "" { + return fmt.Errorf("age_secret_key missing from config - required for restore") } - app := fx.New( - fx.Supply(opts), - fx.Provide(globals.New), - // Additional modules will be added here - fx.Invoke(func(g *globals.Globals) error { - // TODO: Implement restore logic - fmt.Printf("Restoring snapshot %s to %s\n", opts.SnapshotID, opts.TargetDir) - return nil - }), - fx.NopLogger, + log.Info("Starting restore operation", + "snapshot_id", snapshotID, + "target_dir", opts.TargetDir, + "bucket", app.Config.S3.Bucket, + "prefix", app.Config.S3.Prefix, ) - if err := app.Start(ctx); err != nil { - return fmt.Errorf("failed to start restore: %w", err) - } - defer func() { - if err := app.Stop(ctx); err != nil { - fmt.Printf("error stopping app: %v\n", err) - } - }() + // TODO: Implement restore logic + // 1. Download and decrypt database from S3 + // 2. Download and decrypt blobs + // 3. Reconstruct files from chunks + // 4. Write files to target directory with proper metadata + + fmt.Printf("Restoring snapshot %s to %s\n", snapshotID, opts.TargetDir) + fmt.Println("TODO: Implement restore logic") return nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index 6dc500a..4a8ef2f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -43,6 +43,7 @@ on the source system.`, NewFetchCommand(), NewStoreCommand(), NewSnapshotCommand(), + NewInfoCommand(), ) return cmd diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 26ea8b0..7cdd255 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -2,63 +2,15 @@ package cli import ( "context" - "encoding/json" "fmt" - "io" "os" - "path/filepath" - "sort" - "strings" - "text/tabwriter" - "time" - "git.eeqj.de/sneak/vaultik/internal/config" - "git.eeqj.de/sneak/vaultik/internal/database" - "git.eeqj.de/sneak/vaultik/internal/globals" "git.eeqj.de/sneak/vaultik/internal/log" - "git.eeqj.de/sneak/vaultik/internal/s3" - "git.eeqj.de/sneak/vaultik/internal/snapshot" - "github.com/dustin/go-humanize" + "git.eeqj.de/sneak/vaultik/internal/vaultik" "github.com/spf13/cobra" "go.uber.org/fx" ) -// SnapshotCreateOptions contains options for the snapshot create command -type SnapshotCreateOptions struct { - Daemon bool - Cron bool - Prune bool -} - -// SnapshotCreateApp contains all dependencies needed for creating snapshots -type SnapshotCreateApp struct { - Globals *globals.Globals - Config *config.Config - Repositories *database.Repositories - ScannerFactory snapshot.ScannerFactory - SnapshotManager *snapshot.SnapshotManager - S3Client *s3.Client - DB *database.DB - Lifecycle fx.Lifecycle - Shutdowner fx.Shutdowner - Stdout io.Writer - Stderr io.Writer - Stdin io.Reader -} - -// SnapshotApp contains dependencies for snapshot commands -type SnapshotApp struct { - *SnapshotCreateApp // Reuse snapshot creation functionality - S3Client *s3.Client -} - -// SnapshotInfo represents snapshot information for listing -type SnapshotInfo struct { - ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` - CompressedSize int64 `json:"compressed_size"` -} - // NewSnapshotCommand creates the snapshot command and subcommands func NewSnapshotCommand() *cobra.Command { cmd := &cobra.Command{ @@ -78,7 +30,7 @@ func NewSnapshotCommand() *cobra.Command { // newSnapshotCreateCommand creates the 'snapshot create' subcommand func newSnapshotCreateCommand() *cobra.Command { - opts := &SnapshotCreateOptions{} + opts := &vaultik.SnapshotCreateOptions{} cmd := &cobra.Command{ Use: "create", @@ -104,49 +56,22 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, Debug: rootFlags.Debug, Cron: opts.Cron, }, - Modules: []fx.Option{ - snapshot.Module, - s3.Module, - fx.Provide(fx.Annotate( - func(g *globals.Globals, cfg *config.Config, repos *database.Repositories, - scannerFactory snapshot.ScannerFactory, snapshotManager *snapshot.SnapshotManager, - s3Client *s3.Client, db *database.DB, - lc fx.Lifecycle, shutdowner fx.Shutdowner) *SnapshotCreateApp { - return &SnapshotCreateApp{ - Globals: g, - Config: cfg, - Repositories: repos, - ScannerFactory: scannerFactory, - SnapshotManager: snapshotManager, - S3Client: s3Client, - DB: db, - Lifecycle: lc, - Shutdowner: shutdowner, - Stdout: os.Stdout, - Stderr: os.Stderr, - Stdin: os.Stdin, - } - }, - )), - }, + Modules: []fx.Option{}, Invokes: []fx.Option{ - fx.Invoke(func(app *SnapshotCreateApp, lc fx.Lifecycle) { - // Create a cancellable context for the snapshot - snapshotCtx, snapshotCancel := context.WithCancel(context.Background()) - + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { // Start the snapshot creation in a goroutine go func() { // Run the snapshot creation - if err := app.runSnapshot(snapshotCtx, opts); err != nil { + if err := v.CreateSnapshot(opts); err != nil { if err != context.Canceled { log.Error("Snapshot creation failed", "error", err) } } // Shutdown the app when snapshot completes - if err := app.Shutdowner.Shutdown(); err != nil { + if err := v.Shutdowner.Shutdown(); err != nil { log.Error("Failed to shutdown", "error", err) } }() @@ -154,8 +79,8 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, }, OnStop: func(ctx context.Context) error { log.Debug("Stopping snapshot creation") - // Cancel the snapshot context - snapshotCancel() + // Cancel the Vaultik context + v.Cancel() return nil }, }) @@ -172,245 +97,6 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, return cmd } -// runSnapshot executes the snapshot creation operation -func (app *SnapshotCreateApp) runSnapshot(ctx context.Context, opts *SnapshotCreateOptions) error { - snapshotStartTime := time.Now() - - log.Info("Starting snapshot creation", - "version", app.Globals.Version, - "commit", app.Globals.Commit, - "index_path", app.Config.IndexPath, - ) - - // Clean up incomplete snapshots FIRST, before any scanning - // This is critical for data safety - see CleanupIncompleteSnapshots for details - hostname := app.Config.Hostname - if hostname == "" { - hostname, _ = os.Hostname() - } - - // CRITICAL: This MUST succeed. If we fail to clean up incomplete snapshots, - // the deduplication logic will think files from the incomplete snapshot were - // already backed up and skip them, resulting in data loss. - if err := app.SnapshotManager.CleanupIncompleteSnapshots(ctx, hostname); err != nil { - return fmt.Errorf("cleanup incomplete snapshots: %w", err) - } - - if opts.Daemon { - log.Info("Running in daemon mode") - // TODO: Implement daemon mode with inotify - return fmt.Errorf("daemon mode not yet implemented") - } - - // Resolve source directories to absolute paths - resolvedDirs := make([]string, 0, len(app.Config.SourceDirs)) - for _, dir := range app.Config.SourceDirs { - absPath, err := filepath.Abs(dir) - if err != nil { - return fmt.Errorf("failed to resolve absolute path for %s: %w", dir, err) - } - - // Resolve symlinks - resolvedPath, err := filepath.EvalSymlinks(absPath) - if err != nil { - // If the path doesn't exist yet, use the absolute path - if os.IsNotExist(err) { - resolvedPath = absPath - } else { - return fmt.Errorf("failed to resolve symlinks for %s: %w", absPath, err) - } - } - - resolvedDirs = append(resolvedDirs, resolvedPath) - } - - // Create scanner with progress enabled (unless in cron mode) - scanner := app.ScannerFactory(snapshot.ScannerParams{ - EnableProgress: !opts.Cron, - }) - - // Perform a single snapshot run - log.Notice("Starting snapshot", "source_dirs", len(resolvedDirs)) - _, _ = fmt.Fprintf(app.Stdout, "Starting snapshot with %d source directories\n", len(resolvedDirs)) - for i, dir := range resolvedDirs { - log.Info("Source directory", "index", i+1, "path", dir) - _, _ = fmt.Fprintf(app.Stdout, "Source directory %d: %s\n", i+1, dir) - } - - // Statistics tracking - totalFiles := 0 - totalBytes := int64(0) - totalChunks := 0 - totalBlobs := 0 - totalBytesSkipped := int64(0) - totalFilesSkipped := 0 - totalBytesUploaded := int64(0) - totalBlobsUploaded := 0 - uploadDuration := time.Duration(0) - - // Create a new snapshot at the beginning - snapshotID, err := app.SnapshotManager.CreateSnapshot(ctx, hostname, app.Globals.Version, app.Globals.Commit) - if err != nil { - return fmt.Errorf("creating snapshot: %w", err) - } - log.Info("Created snapshot", "snapshot_id", snapshotID) - _, _ = fmt.Fprintf(app.Stdout, "\nCreated snapshot: %s\n", snapshotID) - - for _, dir := range resolvedDirs { - // Check if context is cancelled - select { - case <-ctx.Done(): - log.Info("Snapshot creation cancelled") - return ctx.Err() - default: - } - - log.Info("Scanning directory", "path", dir) - result, err := scanner.Scan(ctx, dir, snapshotID) - if err != nil { - return fmt.Errorf("failed to scan %s: %w", dir, err) - } - - totalFiles += result.FilesScanned - totalBytes += result.BytesScanned - totalChunks += result.ChunksCreated - totalBlobs += result.BlobsCreated - totalFilesSkipped += result.FilesSkipped - totalBytesSkipped += result.BytesSkipped - - log.Info("Directory scan complete", - "path", dir, - "files", result.FilesScanned, - "files_skipped", result.FilesSkipped, - "bytes", result.BytesScanned, - "bytes_skipped", result.BytesSkipped, - "chunks", result.ChunksCreated, - "blobs", result.BlobsCreated, - "duration", result.EndTime.Sub(result.StartTime)) - - // Human-friendly output - _, _ = fmt.Fprintf(app.Stdout, "\nDirectory: %s\n", dir) - _, _ = fmt.Fprintf(app.Stdout, " Scanned: %d files (%s)\n", result.FilesScanned, humanize.Bytes(uint64(result.BytesScanned))) - _, _ = fmt.Fprintf(app.Stdout, " Skipped: %d files (%s) - already backed up\n", result.FilesSkipped, humanize.Bytes(uint64(result.BytesSkipped))) - _, _ = fmt.Fprintf(app.Stdout, " Created: %d chunks, %d blobs\n", result.ChunksCreated, result.BlobsCreated) - _, _ = fmt.Fprintf(app.Stdout, " Duration: %s\n", result.EndTime.Sub(result.StartTime).Round(time.Millisecond)) - } - - // Get upload statistics from scanner progress if available - if s := scanner.GetProgress(); s != nil { - stats := s.GetStats() - totalBytesUploaded = stats.BytesUploaded.Load() - totalBlobsUploaded = int(stats.BlobsUploaded.Load()) - uploadDuration = time.Duration(stats.UploadDurationMs.Load()) * time.Millisecond - } - - // Update snapshot statistics with extended fields - extStats := snapshot.ExtendedBackupStats{ - BackupStats: snapshot.BackupStats{ - FilesScanned: totalFiles, - BytesScanned: totalBytes, - ChunksCreated: totalChunks, - BlobsCreated: totalBlobs, - BytesUploaded: totalBytesUploaded, - }, - BlobUncompressedSize: 0, // Will be set from database query below - CompressionLevel: app.Config.CompressionLevel, - UploadDurationMs: uploadDuration.Milliseconds(), - } - - if err := app.SnapshotManager.UpdateSnapshotStatsExtended(ctx, snapshotID, extStats); err != nil { - return fmt.Errorf("updating snapshot stats: %w", err) - } - - // Mark snapshot as complete - if err := app.SnapshotManager.CompleteSnapshot(ctx, snapshotID); err != nil { - return fmt.Errorf("completing snapshot: %w", err) - } - - // Export snapshot metadata - // Export snapshot metadata without closing the database - // The export function should handle its own database connection - if err := app.SnapshotManager.ExportSnapshotMetadata(ctx, app.Config.IndexPath, snapshotID); err != nil { - return fmt.Errorf("exporting snapshot metadata: %w", err) - } - - // Calculate final statistics - snapshotDuration := time.Since(snapshotStartTime) - totalFilesChanged := totalFiles - totalFilesSkipped - totalBytesChanged := totalBytes - totalBytesAll := totalBytes + totalBytesSkipped - - // Calculate upload speed - var avgUploadSpeed string - if totalBytesUploaded > 0 && uploadDuration > 0 { - bytesPerSec := float64(totalBytesUploaded) / uploadDuration.Seconds() - bitsPerSec := bytesPerSec * 8 - if bitsPerSec >= 1e9 { - avgUploadSpeed = fmt.Sprintf("%.1f Gbit/s", bitsPerSec/1e9) - } else if bitsPerSec >= 1e6 { - avgUploadSpeed = fmt.Sprintf("%.0f Mbit/s", bitsPerSec/1e6) - } else if bitsPerSec >= 1e3 { - avgUploadSpeed = fmt.Sprintf("%.0f Kbit/s", bitsPerSec/1e3) - } else { - avgUploadSpeed = fmt.Sprintf("%.0f bit/s", bitsPerSec) - } - } else { - avgUploadSpeed = "N/A" - } - - // Get total blob sizes from database - totalBlobSizeCompressed := int64(0) - totalBlobSizeUncompressed := int64(0) - if blobHashes, err := app.Repositories.Snapshots.GetBlobHashes(ctx, snapshotID); err == nil { - for _, hash := range blobHashes { - if blob, err := app.Repositories.Blobs.GetByHash(ctx, hash); err == nil && blob != nil { - totalBlobSizeCompressed += blob.CompressedSize - totalBlobSizeUncompressed += blob.UncompressedSize - } - } - } - - // Calculate compression ratio - var compressionRatio float64 - if totalBlobSizeUncompressed > 0 { - compressionRatio = float64(totalBlobSizeCompressed) / float64(totalBlobSizeUncompressed) - } else { - compressionRatio = 1.0 - } - - // Print comprehensive summary - _, _ = fmt.Fprintln(app.Stdout, "\n=== Snapshot Summary ===") - _, _ = fmt.Fprintf(app.Stdout, "Snapshot ID: %s\n", snapshotID) - _, _ = fmt.Fprintf(app.Stdout, "Source files: %s files, %s total\n", - formatNumber(totalFiles), - humanize.Bytes(uint64(totalBytesAll))) - _, _ = fmt.Fprintf(app.Stdout, "Changed files: %s files, %s\n", - formatNumber(totalFilesChanged), - humanize.Bytes(uint64(totalBytesChanged))) - _, _ = fmt.Fprintf(app.Stdout, "Unchanged files: %s files, %s\n", - formatNumber(totalFilesSkipped), - humanize.Bytes(uint64(totalBytesSkipped))) - _, _ = fmt.Fprintf(app.Stdout, "Blob storage: %s uncompressed, %s compressed (%.2fx ratio, level %d)\n", - humanize.Bytes(uint64(totalBlobSizeUncompressed)), - humanize.Bytes(uint64(totalBlobSizeCompressed)), - compressionRatio, - app.Config.CompressionLevel) - _, _ = fmt.Fprintf(app.Stdout, "Upload activity: %s uploaded, %d blobs, %s duration, %s avg speed\n", - humanize.Bytes(uint64(totalBytesUploaded)), - totalBlobsUploaded, - formatDuration(uploadDuration), - avgUploadSpeed) - _, _ = fmt.Fprintf(app.Stdout, "Total time: %s\n", formatDuration(snapshotDuration)) - _, _ = fmt.Fprintln(app.Stdout, "==========================") - - if opts.Prune { - log.Info("Pruning enabled - will delete old snapshots after snapshot") - // TODO: Implement pruning - } - - return nil -} - // newSnapshotListCommand creates the 'snapshot list' subcommand func newSnapshotListCommand() *cobra.Command { var jsonOutput bool @@ -419,9 +105,46 @@ func newSnapshotListCommand() *cobra.Command { Use: "list", Short: "List all snapshots", Long: "Lists all snapshots with their ID, timestamp, and compressed size", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runSnapshotCommand(cmd.Context(), func(app *SnapshotApp) error { - return app.List(cmd.Context(), jsonOutput) + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err + } + + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + if err := v.ListSnapshots(jsonOutput); err != nil { + if err != context.Canceled { + log.Error("Failed to list snapshots", "error", err) + os.Exit(1) + } + } + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + v.Cancel() + return nil + }, + }) + }), + }, }) }, } @@ -441,6 +164,7 @@ func newSnapshotPurgeCommand() *cobra.Command { Use: "purge", Short: "Purge old snapshots", Long: "Removes snapshots based on age or count criteria", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Validate flags if !keepLatest && olderThan == "" { @@ -450,8 +174,44 @@ func newSnapshotPurgeCommand() *cobra.Command { return fmt.Errorf("cannot specify both --keep-latest and --older-than") } - return runSnapshotCommand(cmd.Context(), func(app *SnapshotApp) error { - return app.Purge(cmd.Context(), keepLatest, olderThan, force) + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err + } + + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + if err := v.PurgeSnapshots(keepLatest, olderThan, force); err != nil { + if err != context.Canceled { + log.Error("Failed to purge snapshots", "error", err) + os.Exit(1) + } + } + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + v.Cancel() + return nil + }, + }) + }), + }, }) }, } @@ -473,8 +233,46 @@ func newSnapshotVerifyCommand() *cobra.Command { Long: "Verifies that all blobs referenced in a snapshot exist", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runSnapshotCommand(cmd.Context(), func(app *SnapshotApp) error { - return app.Verify(cmd.Context(), args[0], deep) + snapshotID := args[0] + + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err + } + + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + if err := v.VerifySnapshot(snapshotID, deep); err != nil { + if err != context.Canceled { + log.Error("Verification failed", "error", err) + os.Exit(1) + } + } + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + v.Cancel() + return nil + }, + }) + }), + }, }) }, } @@ -483,490 +281,3 @@ func newSnapshotVerifyCommand() *cobra.Command { return cmd } - -// List lists all snapshots -func (app *SnapshotApp) List(ctx context.Context, jsonOutput bool) error { - // Get all remote snapshots - remoteSnapshots := make(map[string]bool) - objectCh := app.S3Client.ListObjectsStream(ctx, "metadata/", false) - - for object := range objectCh { - if object.Err != nil { - return fmt.Errorf("listing remote snapshots: %w", object.Err) - } - - // Extract snapshot ID from paths like metadata/hostname-20240115-143052Z/ - parts := strings.Split(object.Key, "/") - if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" { - remoteSnapshots[parts[1]] = true - } - } - - // Get all local snapshots - localSnapshots, err := app.Repositories.Snapshots.ListRecent(ctx, 10000) - if err != nil { - return fmt.Errorf("listing local snapshots: %w", err) - } - - // Build a map of local snapshots for quick lookup - localSnapshotMap := make(map[string]*database.Snapshot) - for _, s := range localSnapshots { - localSnapshotMap[s.ID] = s - } - - // Remove local snapshots that don't exist remotely - for _, snapshot := range localSnapshots { - if !remoteSnapshots[snapshot.ID] { - log.Info("Removing local snapshot not found in remote", "snapshot_id", snapshot.ID) - if err := app.Repositories.Snapshots.Delete(ctx, snapshot.ID); err != nil { - log.Error("Failed to delete local snapshot", "snapshot_id", snapshot.ID, "error", err) - } - delete(localSnapshotMap, snapshot.ID) - } - } - - // Build final snapshot list - snapshots := make([]SnapshotInfo, 0, len(remoteSnapshots)) - - for snapshotID := range remoteSnapshots { - // Check if we have this snapshot locally - if localSnap, exists := localSnapshotMap[snapshotID]; exists && localSnap.CompletedAt != nil { - // Use local data - snapshots = append(snapshots, SnapshotInfo{ - ID: localSnap.ID, - Timestamp: localSnap.StartedAt, - CompressedSize: localSnap.BlobSize, - }) - } else { - // Remote snapshot not in local DB - fetch manifest to get size - timestamp, err := parseSnapshotTimestamp(snapshotID) - if err != nil { - log.Warn("Failed to parse snapshot timestamp", "id", snapshotID, "error", err) - continue - } - - // Try to download manifest to get size - totalSize, err := app.getManifestSize(ctx, snapshotID) - if err != nil { - return fmt.Errorf("failed to get manifest size for %s: %w", snapshotID, err) - } - - snapshots = append(snapshots, SnapshotInfo{ - ID: snapshotID, - Timestamp: timestamp, - CompressedSize: totalSize, - }) - } - } - - // Sort by timestamp (newest first) - sort.Slice(snapshots, func(i, j int) bool { - return snapshots[i].Timestamp.After(snapshots[j].Timestamp) - }) - - if jsonOutput { - // JSON output - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(snapshots) - } - - // Table output - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - if _, err := fmt.Fprintln(w, "SNAPSHOT ID\tTIMESTAMP\tCOMPRESSED SIZE"); err != nil { - return err - } - if _, err := fmt.Fprintln(w, "───────────\t─────────\t───────────────"); err != nil { - return err - } - - for _, snap := range snapshots { - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", - snap.ID, - snap.Timestamp.Format("2006-01-02 15:04:05"), - formatBytes(snap.CompressedSize)); err != nil { - return err - } - } - - return w.Flush() -} - -// Purge removes old snapshots based on criteria -func (app *SnapshotApp) Purge(ctx context.Context, keepLatest bool, olderThan string, force bool) error { - // Sync with remote first - if err := app.syncWithRemote(ctx); err != nil { - return fmt.Errorf("syncing with remote: %w", err) - } - - // Get snapshots from local database - dbSnapshots, err := app.Repositories.Snapshots.ListRecent(ctx, 10000) - if err != nil { - return fmt.Errorf("listing snapshots: %w", err) - } - - // Convert to SnapshotInfo format, only including completed snapshots - snapshots := make([]SnapshotInfo, 0, len(dbSnapshots)) - for _, s := range dbSnapshots { - if s.CompletedAt != nil { - snapshots = append(snapshots, SnapshotInfo{ - ID: s.ID, - Timestamp: s.StartedAt, - CompressedSize: s.BlobSize, - }) - } - } - - // Sort by timestamp (newest first) - sort.Slice(snapshots, func(i, j int) bool { - return snapshots[i].Timestamp.After(snapshots[j].Timestamp) - }) - - var toDelete []SnapshotInfo - - if keepLatest { - // Keep only the most recent snapshot - if len(snapshots) > 1 { - toDelete = snapshots[1:] - } - } else if olderThan != "" { - // Parse duration - duration, err := parseDuration(olderThan) - if err != nil { - return fmt.Errorf("invalid duration: %w", err) - } - - cutoff := time.Now().UTC().Add(-duration) - for _, snap := range snapshots { - if snap.Timestamp.Before(cutoff) { - toDelete = append(toDelete, snap) - } - } - } - - if len(toDelete) == 0 { - fmt.Println("No snapshots to delete") - return nil - } - - // Show what will be deleted - fmt.Printf("The following snapshots will be deleted:\n\n") - for _, snap := range toDelete { - fmt.Printf(" %s (%s, %s)\n", - snap.ID, - snap.Timestamp.Format("2006-01-02 15:04:05"), - formatBytes(snap.CompressedSize)) - } - - // Confirm unless --force is used - if !force { - fmt.Printf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete)) - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - // Treat EOF or error as "no" - fmt.Println("Cancelled") - return nil - } - if strings.ToLower(confirm) != "y" { - fmt.Println("Cancelled") - return nil - } - } else { - fmt.Printf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete)) - } - - // Delete snapshots - for _, snap := range toDelete { - log.Info("Deleting snapshot", "id", snap.ID) - if err := app.deleteSnapshot(ctx, snap.ID); err != nil { - return fmt.Errorf("deleting snapshot %s: %w", snap.ID, err) - } - } - - fmt.Printf("Deleted %d snapshot(s)\n", len(toDelete)) - - // TODO: Run blob pruning to clean up unreferenced blobs - - return nil -} - -// Verify checks snapshot integrity -func (app *SnapshotApp) Verify(ctx context.Context, snapshotID string, deep bool) error { - fmt.Printf("Verifying snapshot %s...\n", snapshotID) - - // Download and parse manifest - manifest, err := app.downloadManifest(ctx, snapshotID) - if err != nil { - return fmt.Errorf("downloading manifest: %w", err) - } - - fmt.Printf("Manifest contains %d blobs\n", len(manifest)) - - // Check each blob exists - missing := 0 - verified := 0 - - for _, blobHash := range manifest { - blobPath := fmt.Sprintf("blobs/%s/%s/%s", blobHash[:2], blobHash[2:4], blobHash) - - if deep { - // Download and verify hash - // TODO: Implement deep verification - fmt.Printf("Deep verification not yet implemented\n") - return nil - } else { - // Just check existence - _, err := app.S3Client.StatObject(ctx, blobPath) - if err != nil { - fmt.Printf(" Missing: %s\n", blobHash) - missing++ - } else { - verified++ - } - } - } - - fmt.Printf("\nVerification complete:\n") - fmt.Printf(" Verified: %d\n", verified) - fmt.Printf(" Missing: %d\n", missing) - - if missing > 0 { - return fmt.Errorf("%d blobs are missing", missing) - } - - return nil -} - -// getManifestSize downloads a manifest and returns the total compressed size -func (app *SnapshotApp) getManifestSize(ctx context.Context, snapshotID string) (int64, error) { - manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) - - reader, err := app.S3Client.GetObject(ctx, manifestPath) - if err != nil { - return 0, fmt.Errorf("downloading manifest: %w", err) - } - defer func() { _ = reader.Close() }() - - manifest, err := snapshot.DecodeManifest(reader) - if err != nil { - return 0, fmt.Errorf("decoding manifest: %w", err) - } - - return manifest.TotalCompressedSize, nil -} - -// downloadManifest downloads and parses a snapshot manifest (for verify command) -func (app *SnapshotApp) downloadManifest(ctx context.Context, snapshotID string) ([]string, error) { - manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) - - reader, err := app.S3Client.GetObject(ctx, manifestPath) - if err != nil { - return nil, err - } - defer func() { _ = reader.Close() }() - - manifest, err := snapshot.DecodeManifest(reader) - if err != nil { - return nil, fmt.Errorf("decoding manifest: %w", err) - } - - // Extract blob hashes - hashes := make([]string, len(manifest.Blobs)) - for i, blob := range manifest.Blobs { - hashes[i] = blob.Hash - } - return hashes, nil -} - -// deleteSnapshot removes a snapshot and its metadata -func (app *SnapshotApp) deleteSnapshot(ctx context.Context, snapshotID string) error { - // List all objects under metadata/{snapshotID}/ - prefix := fmt.Sprintf("metadata/%s/", snapshotID) - objectCh := app.S3Client.ListObjectsStream(ctx, prefix, true) - - var objectsToDelete []string - for object := range objectCh { - if object.Err != nil { - return fmt.Errorf("listing objects: %w", object.Err) - } - objectsToDelete = append(objectsToDelete, object.Key) - } - - // Delete all objects - for _, key := range objectsToDelete { - if err := app.S3Client.RemoveObject(ctx, key); err != nil { - return fmt.Errorf("removing %s: %w", key, err) - } - } - - return nil -} - -// syncWithRemote syncs local database with remote snapshots -func (app *SnapshotApp) syncWithRemote(ctx context.Context) error { - log.Info("Syncing with remote snapshots") - - // Get all remote snapshot IDs - remoteSnapshots := make(map[string]bool) - objectCh := app.S3Client.ListObjectsStream(ctx, "metadata/", false) - - for object := range objectCh { - if object.Err != nil { - return fmt.Errorf("listing remote snapshots: %w", object.Err) - } - - // Extract snapshot ID from paths like metadata/hostname-20240115-143052Z/ - parts := strings.Split(object.Key, "/") - if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" { - remoteSnapshots[parts[1]] = true - } - } - - log.Debug("Found remote snapshots", "count", len(remoteSnapshots)) - - // Get all local snapshots (use a high limit to get all) - localSnapshots, err := app.Repositories.Snapshots.ListRecent(ctx, 10000) - if err != nil { - return fmt.Errorf("listing local snapshots: %w", err) - } - - // Remove local snapshots that don't exist remotely - removedCount := 0 - for _, snapshot := range localSnapshots { - if !remoteSnapshots[snapshot.ID] { - log.Info("Removing local snapshot not found in remote", "snapshot_id", snapshot.ID) - if err := app.Repositories.Snapshots.Delete(ctx, snapshot.ID); err != nil { - log.Error("Failed to delete local snapshot", "snapshot_id", snapshot.ID, "error", err) - } else { - removedCount++ - } - } - } - - if removedCount > 0 { - log.Info("Removed local snapshots not found in remote", "count", removedCount) - } - - return nil -} - -// parseSnapshotTimestamp extracts timestamp from snapshot ID -// Format: hostname-20240115-143052Z -func parseSnapshotTimestamp(snapshotID string) (time.Time, error) { - // The snapshot ID format is: hostname-YYYYMMDD-HHMMSSZ - // We need to find the timestamp part which starts after the hostname - - // Split by hyphen - parts := strings.Split(snapshotID, "-") - if len(parts) < 3 { - return time.Time{}, fmt.Errorf("invalid snapshot ID format: expected hostname-YYYYMMDD-HHMMSSZ") - } - - // The last two parts should be the date and time with Z suffix - dateStr := parts[len(parts)-2] - timeStr := parts[len(parts)-1] - - // Reconstruct the full timestamp - fullTimestamp := dateStr + "-" + timeStr - - // Parse the timestamp with Z suffix - return time.Parse("20060102-150405Z", fullTimestamp) -} - -// parseDuration is now in duration.go - -// runSnapshotCommand creates the FX app and runs the given function -func runSnapshotCommand(ctx context.Context, fn func(*SnapshotApp) error) error { - var result error - rootFlags := GetRootFlags() - - // Use unified config resolution - configPath, err := ResolveConfigPath() - if err != nil { - return err - } - - err = RunWithApp(ctx, AppOptions{ - ConfigPath: configPath, - LogOptions: log.LogOptions{ - Verbose: rootFlags.Verbose, - Debug: rootFlags.Debug, - }, - Modules: []fx.Option{ - s3.Module, - fx.Provide(func( - g *globals.Globals, - cfg *config.Config, - db *database.DB, - repos *database.Repositories, - s3Client *s3.Client, - lc fx.Lifecycle, - shutdowner fx.Shutdowner, - ) *SnapshotApp { - snapshotCreateApp := &SnapshotCreateApp{ - Globals: g, - Config: cfg, - Repositories: repos, - ScannerFactory: nil, // Not needed for snapshot commands - S3Client: s3Client, - DB: db, - Lifecycle: lc, - Shutdowner: shutdowner, - } - return &SnapshotApp{ - SnapshotCreateApp: snapshotCreateApp, - S3Client: s3Client, - } - }), - }, - Invokes: []fx.Option{ - fx.Invoke(func(app *SnapshotApp, shutdowner fx.Shutdowner) { - result = fn(app) - // Shutdown after command completes - go func() { - time.Sleep(100 * time.Millisecond) // Brief delay to ensure clean shutdown - if err := shutdowner.Shutdown(); err != nil { - log.Error("Failed to shutdown", "error", err) - } - }() - }), - }, - }) - - if err != nil { - return err - } - return result -} - -// formatNumber formats a number with comma separators -func formatNumber(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - return humanize.Comma(int64(n)) -} - -// formatDuration formats a duration in a human-readable way -func formatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } - if d < time.Hour { - mins := int(d.Minutes()) - secs := int(d.Seconds()) % 60 - if secs > 0 { - return fmt.Sprintf("%dm%ds", mins, secs) - } - return fmt.Sprintf("%dm", mins) - } - hours := int(d.Hours()) - mins := int(d.Minutes()) % 60 - if mins > 0 { - return fmt.Sprintf("%dh%dm", hours, mins) - } - return fmt.Sprintf("%dh", hours) -} diff --git a/internal/cli/vaultik_snapshot_types.go b/internal/cli/vaultik_snapshot_types.go new file mode 100644 index 0000000..9d6b7c6 --- /dev/null +++ b/internal/cli/vaultik_snapshot_types.go @@ -0,0 +1,10 @@ +package cli + +import "time" + +// SnapshotInfo represents snapshot information for listing +type SnapshotInfo struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + CompressedSize int64 `json:"compressed_size"` +} diff --git a/internal/cli/verify.go b/internal/cli/verify.go index 4fd360b..66c964f 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -2,85 +2,93 @@ package cli import ( "context" - "fmt" "os" - "git.eeqj.de/sneak/vaultik/internal/globals" + "git.eeqj.de/sneak/vaultik/internal/log" + "git.eeqj.de/sneak/vaultik/internal/vaultik" "github.com/spf13/cobra" "go.uber.org/fx" ) -// VerifyOptions contains options for the verify command -type VerifyOptions struct { - Bucket string - Prefix string - SnapshotID string - Quick bool -} - // NewVerifyCommand creates the verify command func NewVerifyCommand() *cobra.Command { - opts := &VerifyOptions{} + opts := &vaultik.VerifyOptions{} cmd := &cobra.Command{ - Use: "verify", - Short: "Verify backup integrity", - Long: `Check that all referenced blobs exist and verify metadata integrity`, - Args: cobra.NoArgs, + Use: "verify ", + Short: "Verify snapshot integrity", + Long: `Verifies that all blobs referenced in a snapshot exist and optionally verifies their contents. + +Shallow verification (default): +- Downloads and decompresses manifest +- Checks existence of all blobs in S3 +- Reports missing blobs + +Deep verification (--deep): +- Downloads and decrypts database +- Verifies blob lists match between manifest and database +- Downloads, decrypts, and decompresses each blob +- Verifies SHA256 hash of each chunk matches database +- Ensures chunks are ordered correctly + +The command will fail immediately on any verification error and exit with non-zero status.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Validate required flags - if opts.Bucket == "" { - return fmt.Errorf("--bucket is required") + snapshotID := args[0] + + // Use unified config resolution + configPath, err := ResolveConfigPath() + if err != nil { + return err } - if opts.Prefix == "" { - return fmt.Errorf("--prefix is required") - } - return runVerify(cmd.Context(), opts) + + // Use the app framework for all verification + rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ + ConfigPath: configPath, + LogOptions: log.LogOptions{ + Verbose: rootFlags.Verbose, + Debug: rootFlags.Debug, + }, + Modules: []fx.Option{}, + Invokes: []fx.Option{ + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Run the verify operation directly + go func() { + var err error + if opts.Deep { + err = v.RunDeepVerify(snapshotID, opts) + } else { + err = v.VerifySnapshot(snapshotID, false) + } + + if err != nil { + if err != context.Canceled { + log.Error("Verification failed", "error", err) + os.Exit(1) + } + } + if err := v.Shutdowner.Shutdown(); err != nil { + log.Error("Failed to shutdown", "error", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + log.Debug("Stopping verify operation") + v.Cancel() + return nil + }, + }) + }), + }, + }) }, } - cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") - cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") - cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to verify (optional, defaults to latest)") - cmd.Flags().BoolVar(&opts.Quick, "quick", false, "Perform quick verification by checking blob existence and S3 content hashes without downloading") + cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Perform deep verification by downloading and verifying all blob contents") return cmd } - -func runVerify(ctx context.Context, opts *VerifyOptions) error { - if os.Getenv("VAULTIK_PRIVATE_KEY") == "" { - return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set") - } - - app := fx.New( - fx.Supply(opts), - fx.Provide(globals.New), - // Additional modules will be added here - fx.Invoke(func(g *globals.Globals) error { - // TODO: Implement verify logic - if opts.SnapshotID == "" { - fmt.Printf("Verifying latest snapshot in bucket %s with prefix %s\n", opts.Bucket, opts.Prefix) - } else { - fmt.Printf("Verifying snapshot %s in bucket %s with prefix %s\n", opts.SnapshotID, opts.Bucket, opts.Prefix) - } - if opts.Quick { - fmt.Println("Performing quick verification") - } else { - fmt.Println("Performing deep verification") - } - return nil - }), - fx.NopLogger, - ) - - if err := app.Start(ctx); err != nil { - return fmt.Errorf("failed to start verify: %w", err) - } - defer func() { - if err := app.Stop(ctx); err != nil { - fmt.Printf("error stopping app: %v\n", err) - } - }() - - return nil -} diff --git a/internal/config/config.go b/internal/config/config.go index 638417a..6d5e53c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "time" + "git.eeqj.de/sneak/smartconfig" "go.uber.org/fx" "gopkg.in/yaml.v3" ) @@ -15,6 +16,7 @@ import ( // Configuration is typically loaded from a YAML file. type Config struct { AgeRecipients []string `yaml:"age_recipients"` + AgeSecretKey string `yaml:"age_secret_key"` BackupInterval time.Duration `yaml:"backup_interval"` BlobSizeLimit Size `yaml:"blob_size_limit"` ChunkSize Size `yaml:"chunk_size"` @@ -65,13 +67,14 @@ func New(path ConfigPath) (*Config, error) { // Load reads and parses the configuration file from the specified path. // It applies default values for optional fields, performs environment variable -// substitution for certain fields (like IndexPath), and validates the configuration. +// substitution using smartconfig, and validates the configuration. // The configuration file should be in YAML format. Returns an error if the file // cannot be read, parsed, or if validation fails. func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) + // Load config using smartconfig for interpolation + sc, err := smartconfig.NewFromConfigPath(path) if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + return nil, fmt.Errorf("failed to load config file: %w", err) } cfg := &Config{ @@ -85,7 +88,14 @@ func Load(path string) (*Config, error) { CompressionLevel: 3, } - if err := yaml.Unmarshal(data, cfg); err != nil { + // Convert smartconfig data to YAML then unmarshal + configData := sc.Data() + yamlBytes, err := yaml.Marshal(configData) + if err != nil { + return nil, fmt.Errorf("failed to marshal config data: %w", err) + } + + if err := yaml.Unmarshal(yamlBytes, cfg); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } diff --git a/internal/config/size.go b/internal/config/size.go index 3e24370..d66049d 100644 --- a/internal/config/size.go +++ b/internal/config/size.go @@ -51,3 +51,12 @@ func (s Size) Int64() int64 { func (s Size) String() string { return humanize.Bytes(uint64(s)) } + +// ParseSize parses a size string into a Size value +func ParseSize(s string) (Size, error) { + bytes, err := humanize.ParseBytes(s) + if err != nil { + return 0, fmt.Errorf("invalid size format: %w", err) + } + return Size(bytes), nil +} diff --git a/internal/crypto/encryption.go b/internal/crypto/encryption.go index 25611ea..1d7b3cb 100644 --- a/internal/crypto/encryption.go +++ b/internal/crypto/encryption.go @@ -7,6 +7,7 @@ import ( "sync" "filippo.io/age" + "go.uber.org/fx" ) // Encryptor provides thread-safe encryption using the age encryption library. @@ -143,3 +144,66 @@ func (e *Encryptor) UpdateRecipients(publicKeys []string) error { return nil } + +// Decryptor provides thread-safe decryption using the age encryption library. +// It uses a private key to decrypt data that was encrypted for the corresponding +// public key. +type Decryptor struct { + identity age.Identity + mu sync.RWMutex +} + +// NewDecryptor creates a new decryptor with the given age private key. +// The private key should be a valid age X25519 identity string. +// Returns an error if the private key is invalid. +func NewDecryptor(privateKey string) (*Decryptor, error) { + identity, err := age.ParseX25519Identity(privateKey) + if err != nil { + return nil, fmt.Errorf("parsing age identity: %w", err) + } + + return &Decryptor{ + identity: identity, + }, nil +} + +// Decrypt decrypts data using age decryption. +// This method is suitable for small to medium amounts of data that fit in memory. +// For large data streams, use DecryptStream instead. +func (d *Decryptor) Decrypt(data []byte) ([]byte, error) { + d.mu.RLock() + identity := d.identity + d.mu.RUnlock() + + r, err := age.Decrypt(bytes.NewReader(data), identity) + if err != nil { + return nil, fmt.Errorf("creating decrypted reader: %w", err) + } + + decrypted, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading decrypted data: %w", err) + } + + return decrypted, nil +} + +// DecryptStream returns a reader that decrypts data from the provided reader. +// This method is suitable for decrypting large files or streams as it processes +// data in a streaming fashion without loading everything into memory. +// The caller should close the input reader when done. +func (d *Decryptor) DecryptStream(src io.Reader) (io.Reader, error) { + d.mu.RLock() + identity := d.identity + d.mu.RUnlock() + + r, err := age.Decrypt(src, identity) + if err != nil { + return nil, fmt.Errorf("creating decrypted reader: %w", err) + } + + return r, nil +} + +// Module exports the crypto module for fx dependency injection. +var Module = fx.Module("crypto") diff --git a/internal/database/chunks.go b/internal/database/chunks.go index ed9e25e..0a4f508 100644 --- a/internal/database/chunks.go +++ b/internal/database/chunks.go @@ -139,7 +139,7 @@ func (r *ChunkRepository) ListUnpacked(ctx context.Context, limit int) ([]*Chunk return chunks, rows.Err() } -// DeleteOrphaned deletes chunks that are not referenced by any file +// DeleteOrphaned deletes chunks that are not referenced by any file or blob func (r *ChunkRepository) DeleteOrphaned(ctx context.Context) error { query := ` DELETE FROM chunks @@ -147,6 +147,10 @@ func (r *ChunkRepository) DeleteOrphaned(ctx context.Context) error { SELECT 1 FROM file_chunks WHERE file_chunks.chunk_hash = chunks.chunk_hash ) + AND NOT EXISTS ( + SELECT 1 FROM blob_chunks + WHERE blob_chunks.chunk_hash = chunks.chunk_hash + ) ` result, err := r.db.ExecWithLog(ctx, query) diff --git a/internal/database/snapshots.go b/internal/database/snapshots.go index e307658..c6e721b 100644 --- a/internal/database/snapshots.go +++ b/internal/database/snapshots.go @@ -337,6 +337,24 @@ func (r *SnapshotRepository) GetBlobHashes(ctx context.Context, snapshotID strin return blobs, rows.Err() } +// GetSnapshotTotalCompressedSize returns the total compressed size of all blobs referenced by a snapshot +func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(ctx context.Context, snapshotID string) (int64, error) { + query := ` + SELECT COALESCE(SUM(b.compressed_size), 0) + FROM snapshot_blobs sb + JOIN blobs b ON sb.blob_hash = b.blob_hash + WHERE sb.snapshot_id = ? + ` + + var totalSize int64 + err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize) + if err != nil { + return 0, fmt.Errorf("querying total compressed size: %w", err) + } + + return totalSize, nil +} + // GetIncompleteSnapshots returns all snapshots that haven't been completed func (r *SnapshotRepository) GetIncompleteSnapshots(ctx context.Context) ([]*Snapshot, error) { query := ` @@ -474,3 +492,15 @@ func (r *SnapshotRepository) DeleteSnapshotBlobs(ctx context.Context, snapshotID return nil } + +// DeleteSnapshotUploads removes all uploads entries for a snapshot +func (r *SnapshotRepository) DeleteSnapshotUploads(ctx context.Context, snapshotID string) error { + query := `DELETE FROM uploads WHERE snapshot_id = ?` + + _, err := r.db.ExecWithLog(ctx, query, snapshotID) + if err != nil { + return fmt.Errorf("deleting snapshot uploads: %w", err) + } + + return nil +} diff --git a/internal/snapshot/progress.go b/internal/snapshot/progress.go index 039632f..0653cec 100644 --- a/internal/snapshot/progress.go +++ b/internal/snapshot/progress.go @@ -22,6 +22,9 @@ const ( // DetailInterval defines how often multi-line detailed status reports are printed. // These reports include comprehensive statistics about files, chunks, blobs, and uploads. DetailInterval = 60 * time.Second + + // UploadProgressInterval defines how often upload progress messages are logged. + UploadProgressInterval = 15 * time.Second ) // ProgressStats holds atomic counters for progress tracking @@ -52,9 +55,10 @@ type ProgressStats struct { // UploadInfo tracks current upload progress type UploadInfo struct { - BlobHash string - Size int64 - StartTime time.Time + BlobHash string + Size int64 + StartTime time.Time + LastLogTime time.Time } // ProgressReporter handles periodic progress reporting @@ -330,6 +334,11 @@ func (pr *ProgressReporter) ReportUploadStart(blobHash string, size int64) { StartTime: time.Now().UTC(), } pr.stats.CurrentUpload.Store(info) + + // Log the start of upload + log.Info("Starting blob upload to S3", + "hash", blobHash[:8]+"...", + "size", humanize.Bytes(uint64(size))) } // ReportUploadComplete marks the completion of a blob upload @@ -377,36 +386,34 @@ func (pr *ProgressReporter) UpdateChunkingActivity() { func (pr *ProgressReporter) ReportUploadProgress(blobHash string, bytesUploaded, totalSize int64, instantSpeed float64) { // Update the current upload info with progress if uploadInfo, ok := pr.stats.CurrentUpload.Load().(*UploadInfo); ok && uploadInfo != nil { - // Format speed in bits/second - bitsPerSec := instantSpeed * 8 - var speedStr string - if bitsPerSec >= 1e9 { - speedStr = fmt.Sprintf("%.1fGbit/sec", bitsPerSec/1e9) - } else if bitsPerSec >= 1e6 { - speedStr = fmt.Sprintf("%.0fMbit/sec", bitsPerSec/1e6) - } else if bitsPerSec >= 1e3 { - speedStr = fmt.Sprintf("%.0fKbit/sec", bitsPerSec/1e3) - } else { - speedStr = fmt.Sprintf("%.0fbit/sec", bitsPerSec) + now := time.Now() + + // Only log at the configured interval + if now.Sub(uploadInfo.LastLogTime) >= UploadProgressInterval { + // Format speed in bits/second using humanize + bitsPerSec := instantSpeed * 8 + speedStr := humanize.SI(bitsPerSec, "bit/sec") + + percent := float64(bytesUploaded) / float64(totalSize) * 100 + + // Calculate ETA based on current speed + etaStr := "unknown" + if instantSpeed > 0 && bytesUploaded < totalSize { + remainingBytes := totalSize - bytesUploaded + remainingSeconds := float64(remainingBytes) / instantSpeed + eta := time.Duration(remainingSeconds * float64(time.Second)) + etaStr = formatDuration(eta) + } + + log.Info("Blob upload progress", + "hash", blobHash[:8]+"...", + "progress", fmt.Sprintf("%.1f%%", percent), + "uploaded", humanize.Bytes(uint64(bytesUploaded)), + "total", humanize.Bytes(uint64(totalSize)), + "speed", speedStr, + "eta", etaStr) + + uploadInfo.LastLogTime = now } - - percent := float64(bytesUploaded) / float64(totalSize) * 100 - - // Calculate ETA based on current speed - etaStr := "unknown" - if instantSpeed > 0 && bytesUploaded < totalSize { - remainingBytes := totalSize - bytesUploaded - remainingSeconds := float64(remainingBytes) / instantSpeed - eta := time.Duration(remainingSeconds * float64(time.Second)) - etaStr = formatDuration(eta) - } - - log.Info("Blob upload progress", - "hash", blobHash[:8]+"...", - "progress", fmt.Sprintf("%.1f%%", percent), - "uploaded", humanize.Bytes(uint64(bytesUploaded)), - "total", humanize.Bytes(uint64(totalSize)), - "speed", speedStr, - "eta", etaStr) } } diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 2a59bc5..0c1d4e6 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -69,8 +69,10 @@ type ScannerConfig struct { type ScanResult struct { FilesScanned int FilesSkipped int + FilesDeleted int BytesScanned int64 BytesSkipped int64 + BytesDeleted int64 ChunksCreated int BlobsCreated int StartTime time.Time @@ -138,6 +140,11 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc defer s.progress.Stop() } + // Phase 0: Check for deleted files from previous snapshots + if err := s.detectDeletedFiles(ctx, path, result); err != nil { + return nil, fmt.Errorf("detecting deleted files: %w", err) + } + // Phase 1: Scan directory and collect files to process log.Info("Phase 1/3: Scanning directory structure") filesToProcess, err := s.scanPhase(ctx, path, result) @@ -163,28 +170,29 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc "files_skipped", result.FilesSkipped, "bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped))) - // Print detailed scan summary - fmt.Printf("\n=== Scan Summary ===\n") - fmt.Printf("Total files examined: %d\n", result.FilesScanned) - fmt.Printf("Files with content changes: %d\n", len(filesToProcess)) - fmt.Printf("Files with unchanged content: %d\n", result.FilesSkipped) - fmt.Printf("Total size of changed files: %s\n", humanize.Bytes(uint64(totalSizeToProcess))) - fmt.Printf("Total size of unchanged files: %s\n", humanize.Bytes(uint64(result.BytesSkipped))) - if len(filesToProcess) > 0 { - fmt.Printf("\nStarting snapshot of %d changed files...\n\n", len(filesToProcess)) - } else { - fmt.Printf("\nNo file contents have changed.\n") - fmt.Printf("Creating metadata-only snapshot to capture current state...\n\n") + // Print scan summary + fmt.Printf("Scan complete: %s examined (%s), %s to process (%s)", + formatNumber(result.FilesScanned), + humanize.Bytes(uint64(totalSizeToProcess+result.BytesSkipped)), + formatNumber(len(filesToProcess)), + humanize.Bytes(uint64(totalSizeToProcess))) + if result.FilesDeleted > 0 { + fmt.Printf(", %s deleted (%s)", + formatNumber(result.FilesDeleted), + humanize.Bytes(uint64(result.BytesDeleted))) } + fmt.Println() // Phase 2: Process files and create chunks if len(filesToProcess) > 0 { + fmt.Printf("Processing %s files...\n", formatNumber(len(filesToProcess))) log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)") if err := s.processPhase(ctx, filesToProcess, result); err != nil { return nil, fmt.Errorf("process phase failed: %w", err) } } else { - log.Info("Phase 2/3: Skipping (no file contents changed, metadata-only snapshot)") + fmt.Printf("No files need processing. Creating metadata-only snapshot.\n") + log.Info("Phase 2/3: Skipping (no files need processing, metadata-only snapshot)") } // Get final stats from packer @@ -266,10 +274,9 @@ func (s *Scanner) scanPhase(ctx context.Context, path string, result *ScanResult changedCount := len(filesToProcess) mu.Unlock() - fmt.Printf("Scan progress: %d files examined, %s total size, %d files changed\n", - filesScanned, - humanize.Bytes(uint64(bytesScanned)), - changedCount) + fmt.Printf("Scan progress: %s files examined, %s changed\n", + formatNumber(int(filesScanned)), + formatNumber(changedCount)) lastStatusTime = time.Now() } @@ -320,8 +327,7 @@ func (s *Scanner) processPhase(ctx context.Context, filesToProcess []*FileToProc eta = elapsed / time.Duration(filesProcessed) * time.Duration(remaining) } - fmt.Printf("Snapshot progress: %d/%d files processed, %d chunks created, %d blobs uploaded", - filesProcessed, totalFiles, result.ChunksCreated, result.BlobsCreated) + fmt.Printf("Progress: %s/%s files", formatNumber(filesProcessed), formatNumber(totalFiles)) if remaining > 0 && eta > 0 { fmt.Printf(", ETA: %s", eta.Round(time.Second)) } @@ -558,8 +564,6 @@ func (s *Scanner) associateExistingChunks(ctx context.Context, path string) erro // handleBlobReady is called by the packer when a blob is finalized func (s *Scanner) handleBlobReady(blobWithReader *blob.BlobWithReader) error { - log.Debug("Invoking blob upload handler", "blob_hash", blobWithReader.Hash[:8]+"...") - startTime := time.Now().UTC() finishedBlob := blobWithReader.FinishedBlob @@ -854,3 +858,33 @@ func (s *Scanner) processFileStreaming(ctx context.Context, fileToProcess *FileT func (s *Scanner) GetProgress() *ProgressReporter { return s.progress } + +// detectDeletedFiles finds files that existed in previous snapshots but no longer exist +func (s *Scanner) detectDeletedFiles(ctx context.Context, path string, result *ScanResult) error { + // Get all files with this path prefix from the database + files, err := s.repos.Files.ListByPrefix(ctx, path) + if err != nil { + return fmt.Errorf("listing files by prefix: %w", err) + } + + for _, file := range files { + // Check if the file still exists on disk + _, err := s.fs.Stat(file.Path) + if os.IsNotExist(err) { + // File has been deleted + result.FilesDeleted++ + result.BytesDeleted += file.Size + log.Debug("Detected deleted file", "path", file.Path, "size", file.Size) + } + } + + return nil +} + +// formatNumber formats a number with comma separators +func formatNumber(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + return humanize.Comma(int64(n)) +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index dff801f..69cb048 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -214,7 +214,7 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(ctx context.Context, dbPath st log.Debug("Database copy complete", "size", getFileSize(tempDBPath)) // Step 2: Clean the temp database to only contain current snapshot data - log.Debug("Cleaning temporary database to contain only current snapshot data", "snapshot_id", snapshotID, "db_path", tempDBPath) + log.Debug("Cleaning temporary database", "snapshot_id", snapshotID) stats, err := sm.cleanSnapshotDB(ctx, tempDBPath, snapshotID) if err != nil { return fmt.Errorf("cleaning snapshot database: %w", err) @@ -231,29 +231,27 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(ctx context.Context, dbPath st // Step 3: Dump the cleaned database to SQL dumpPath := filepath.Join(tempDir, "snapshot.sql") - log.Debug("Dumping database to SQL", "source", tempDBPath, "destination", dumpPath) if err := sm.dumpDatabase(tempDBPath, dumpPath); err != nil { return fmt.Errorf("dumping database: %w", err) } - log.Debug("SQL dump complete", "size", getFileSize(dumpPath)) + log.Debug("SQL dump complete", "size", humanize.Bytes(uint64(getFileSize(dumpPath)))) // Step 4: Compress and encrypt the SQL dump compressedPath := filepath.Join(tempDir, "snapshot.sql.zst.age") - log.Debug("Compressing and encrypting SQL dump", "source", dumpPath, "destination", compressedPath) if err := sm.compressDump(dumpPath, compressedPath); err != nil { return fmt.Errorf("compressing dump: %w", err) } - log.Debug("Compression complete", "original_size", getFileSize(dumpPath), "compressed_size", getFileSize(compressedPath)) + log.Debug("Compression complete", + "original_size", humanize.Bytes(uint64(getFileSize(dumpPath))), + "compressed_size", humanize.Bytes(uint64(getFileSize(compressedPath)))) // Step 5: Read compressed and encrypted data for upload - log.Debug("Reading compressed and encrypted data for upload", "path", compressedPath) finalData, err := os.ReadFile(compressedPath) if err != nil { return fmt.Errorf("reading compressed dump: %w", err) } // Step 6: Generate blob manifest (before closing temp DB) - log.Debug("Generating blob manifest from temporary database", "db_path", tempDBPath) blobManifest, err := sm.generateBlobManifest(ctx, tempDBPath, snapshotID) if err != nil { return fmt.Errorf("generating blob manifest: %w", err) @@ -263,7 +261,6 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(ctx context.Context, dbPath st // Upload database backup (compressed and encrypted) dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID) - log.Debug("Uploading snapshot database to S3", "key", dbKey, "size", len(finalData)) dbUploadStart := time.Now() if err := sm.s3Client.PutObject(ctx, dbKey, bytes.NewReader(finalData)); err != nil { return fmt.Errorf("uploading snapshot database: %w", err) @@ -278,7 +275,6 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(ctx context.Context, dbPath st // Upload blob manifest (compressed only, not encrypted) manifestKey := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) - log.Debug("Uploading blob manifest to S3", "key", manifestKey, "size", len(blobManifest)) manifestUploadStart := time.Now() if err := sm.s3Client.PutObject(ctx, manifestKey, bytes.NewReader(blobManifest)); err != nil { return fmt.Errorf("uploading blob manifest: %w", err) @@ -411,7 +407,6 @@ func (sm *SnapshotManager) cleanSnapshotDB(ctx context.Context, dbPath string, s stats.CompressedSize = compressedSize.Int64 stats.UncompressedSize = uncompressedSize.Int64 - log.Debug("[Temp DB Cleanup] Database cleanup complete", "stats", stats) return stats, nil } @@ -425,7 +420,7 @@ func (sm *SnapshotManager) dumpDatabase(dbPath, dumpPath string) error { return fmt.Errorf("running sqlite3 dump: %w", err) } - log.Debug("SQL dump generated", "size", len(output)) + log.Debug("SQL dump generated", "size", humanize.Bytes(uint64(len(output)))) if err := os.WriteFile(dumpPath, output, 0644); err != nil { return fmt.Errorf("writing dump file: %w", err) } @@ -435,43 +430,43 @@ func (sm *SnapshotManager) dumpDatabase(dbPath, dumpPath string) error { // compressDump compresses the SQL dump using zstd func (sm *SnapshotManager) compressDump(inputPath, outputPath string) error { - log.Debug("Opening SQL dump for compression", "path", inputPath) input, err := os.Open(inputPath) if err != nil { return fmt.Errorf("opening input file: %w", err) } defer func() { - log.Debug("Closing input file", "path", inputPath) if err := input.Close(); err != nil { log.Debug("Failed to close input file", "path", inputPath, "error", err) } }() - log.Debug("Creating output file for compressed and encrypted data", "path", outputPath) output, err := os.Create(outputPath) if err != nil { return fmt.Errorf("creating output file: %w", err) } defer func() { - log.Debug("Closing output file", "path", outputPath) if err := output.Close(); err != nil { log.Debug("Failed to close output file", "path", outputPath, "error", err) } }() // Use blobgen for compression and encryption - log.Debug("Creating compressor/encryptor", "level", sm.config.CompressionLevel) + log.Debug("Compressing and encrypting data") writer, err := blobgen.NewWriter(output, sm.config.CompressionLevel, sm.config.AgeRecipients) if err != nil { return fmt.Errorf("creating blobgen writer: %w", err) } + + // Track if writer has been closed to avoid double-close + writerClosed := false defer func() { - if err := writer.Close(); err != nil { - log.Debug("Failed to close writer", "error", err) + if !writerClosed { + if err := writer.Close(); err != nil { + log.Debug("Failed to close writer", "error", err) + } } }() - log.Debug("Compressing and encrypting data") if _, err := io.Copy(writer, input); err != nil { return fmt.Errorf("compressing data: %w", err) } @@ -480,6 +475,7 @@ func (sm *SnapshotManager) compressDump(inputPath, outputPath string) error { if err := writer.Close(); err != nil { return fmt.Errorf("closing writer: %w", err) } + writerClosed = true log.Debug("Compression complete", "hash", fmt.Sprintf("%x", writer.Sum256())) @@ -524,7 +520,6 @@ func copyFile(src, dst string) error { // generateBlobManifest creates a compressed JSON list of all blobs in the snapshot func (sm *SnapshotManager) generateBlobManifest(ctx context.Context, dbPath string, snapshotID string) ([]byte, error) { - log.Debug("Generating blob manifest", "db_path", dbPath, "snapshot_id", snapshotID) // Open the cleaned database using the database package db, err := database.New(ctx, dbPath) @@ -573,7 +568,6 @@ func (sm *SnapshotManager) generateBlobManifest(ctx context.Context, dbPath stri } // Encode manifest - log.Debug("Encoding manifest") compressedData, err := EncodeManifest(manifest, sm.config.CompressionLevel) if err != nil { return nil, fmt.Errorf("encoding manifest: %w", err) @@ -731,6 +725,17 @@ func (sm *SnapshotManager) cleanupOrphanedData(ctx context.Context) error { // deleteOtherSnapshots deletes all snapshots except the current one func (sm *SnapshotManager) deleteOtherSnapshots(ctx context.Context, tx *sql.Tx, currentSnapshotID string) error { log.Debug("[Temp DB Cleanup] Deleting all snapshot records except current", "keeping", currentSnapshotID) + + // First delete uploads that reference other snapshots (no CASCADE DELETE on this FK) + database.LogSQL("Execute", "DELETE FROM uploads WHERE snapshot_id != ?", currentSnapshotID) + uploadResult, err := tx.ExecContext(ctx, "DELETE FROM uploads WHERE snapshot_id != ?", currentSnapshotID) + if err != nil { + return fmt.Errorf("deleting uploads for other snapshots: %w", err) + } + uploadsDeleted, _ := uploadResult.RowsAffected() + log.Debug("[Temp DB Cleanup] Deleted upload records", "count", uploadsDeleted) + + // Now we can safely delete the snapshots database.LogSQL("Execute", "DELETE FROM snapshots WHERE id != ?", currentSnapshotID) result, err := tx.ExecContext(ctx, "DELETE FROM snapshots WHERE id != ?", currentSnapshotID) if err != nil { @@ -842,16 +847,21 @@ func (sm *SnapshotManager) deleteOrphanedBlobToChunkMappings(ctx context.Context return nil } -// deleteOrphanedChunks deletes chunks not referenced by any file +// deleteOrphanedChunks deletes chunks not referenced by any file or blob func (sm *SnapshotManager) deleteOrphanedChunks(ctx context.Context, tx *sql.Tx) error { log.Debug("[Temp DB Cleanup] Deleting orphaned chunk records") - database.LogSQL("Execute", `DELETE FROM chunks WHERE NOT EXISTS (SELECT 1 FROM file_chunks WHERE file_chunks.chunk_hash = chunks.chunk_hash)`) - result, err := tx.ExecContext(ctx, ` + query := ` DELETE FROM chunks WHERE NOT EXISTS ( SELECT 1 FROM file_chunks WHERE file_chunks.chunk_hash = chunks.chunk_hash - )`) + ) + AND NOT EXISTS ( + SELECT 1 FROM blob_chunks + WHERE blob_chunks.chunk_hash = chunks.chunk_hash + )` + database.LogSQL("Execute", query) + result, err := tx.ExecContext(ctx, query) if err != nil { return fmt.Errorf("deleting orphaned chunks: %w", err) }