diff --git a/cli/app.go b/cli/app.go index c87f2defa..4fe3d1036 100644 --- a/cli/app.go +++ b/cli/app.go @@ -167,6 +167,10 @@ func maybeRunMaintenance(ctx context.Context, rep repo.Repository) error { return nil } + if rep.IsReadOnly() { + return nil + } + err := snapshotmaintenance.Run(ctx, rep, maintenance.ModeAuto, false) if err == nil { return nil diff --git a/cli/command_index_inspect.go b/cli/command_index_inspect.go new file mode 100644 index 000000000..f58b55445 --- /dev/null +++ b/cli/command_index_inspect.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/repo" + "github.com/kopia/kopia/repo/blob" + "github.com/kopia/kopia/repo/content" +) + +var ( + indexInspectCommand = indexCommands.Command("inspect", "Inpect index blob") + indexInspectBlobIDs = indexInspectCommand.Arg("blobs", "Names of index blobs to inspect").Strings() +) + +func runInspectIndexAction(ctx context.Context, rep *repo.DirectRepository) error { + for _, indexBlobID := range *indexInspectBlobIDs { + if err := inspectSingleIndexBlob(ctx, rep, blob.ID(indexBlobID)); err != nil { + return err + } + } + + return nil +} + +func dumpIndexBlobEntries(bm blob.Metadata, entries []content.Info) { + for _, ci := range entries { + state := "created" + if ci.Deleted { + state = "deleted" + } + + printStdout("%v %v %v %v %v %v %v %v\n", + formatTimestampPrecise(bm.Timestamp), bm.BlobID, + ci.ID, state, formatTimestampPrecise(ci.Timestamp()), ci.PackBlobID, ci.PackOffset, ci.Length) + } +} + +func inspectSingleIndexBlob(ctx context.Context, rep *repo.DirectRepository, blobID blob.ID) error { + bm, err := rep.Blobs.GetMetadata(ctx, blobID) + if err != nil { + return errors.Wrapf(err, "unable to get metadata for %v", blobID) + } + + entries, err := rep.Content.ParseIndexBlob(ctx, blobID) + if err != nil { + return errors.Wrapf(err, "unable to recover index from %v", blobID) + } + + dumpIndexBlobEntries(bm, entries) + + return nil +} + +func init() { + indexInspectCommand.Action(directRepositoryAction(runInspectIndexAction)) +} diff --git a/cli/command_repository_connect.go b/cli/command_repository_connect.go index ab651957c..91246b90c 100644 --- a/cli/command_repository_connect.go +++ b/cli/command_repository_connect.go @@ -22,6 +22,7 @@ connectHostname string connectUsername string connectCheckForUpdates bool + connectReadonly bool ) func setupConnectOptions(cmd *kingpin.CmdClause) { @@ -35,6 +36,7 @@ func setupConnectOptions(cmd *kingpin.CmdClause) { cmd.Flag("override-hostname", "Override hostname used by this repository connection").Hidden().StringVar(&connectHostname) cmd.Flag("override-username", "Override username used by this repository connection").Hidden().StringVar(&connectUsername) cmd.Flag("check-for-updates", "Periodically check for Kopia updates on GitHub").Default("true").Envar(checkForUpdatesEnvar).BoolVar(&connectCheckForUpdates) + cmd.Flag("readonly", "Make repository read-only to avoid accidental changes").BoolVar(&connectReadonly) } func connectOptions() *repo.ConnectOptions { @@ -48,6 +50,7 @@ func connectOptions() *repo.ConnectOptions { }, HostnameOverride: connectHostname, UsernameOverride: connectUsername, + ReadOnly: connectReadonly, } } diff --git a/cli/command_repository_status.go b/cli/command_repository_status.go index dc57c3dc2..a92589d35 100644 --- a/cli/command_repository_status.go +++ b/cli/command_repository_status.go @@ -35,6 +35,8 @@ func runStatusCommand(ctx context.Context, rep *repo.DirectRepository) error { fmt.Printf("Unique ID: %x\n", rep.UniqueID) fmt.Printf("Hostname: %v\n", rep.Hostname()) fmt.Printf("Username: %v\n", rep.Username()) + fmt.Printf("Read-only: %v\n", rep.IsReadOnly()) + fmt.Println() fmt.Printf("Hash: %v\n", rep.Content.Format.Hash) fmt.Printf("Encryption: %v\n", rep.Content.Format.Encryption) diff --git a/repo/api_server_repository.go b/repo/api_server_repository.go index 19f4b8465..5984b8a80 100644 --- a/repo/api_server_repository.go +++ b/repo/api_server_repository.go @@ -53,6 +53,10 @@ func (r *apiServerRepository) VerifyObject(ctx context.Context, id object.ID) ([ return r.omgr.VerifyObject(ctx, id) } +func (r *apiServerRepository) IsReadOnly() bool { + return false +} + func (r *apiServerRepository) GetManifest(ctx context.Context, id manifest.ID, data interface{}) (*manifest.EntryMetadata, error) { var mm remoterepoapi.ManifestWithMetadata diff --git a/repo/blob/readonly/readonly_storage.go b/repo/blob/readonly/readonly_storage.go new file mode 100644 index 000000000..2eda49613 --- /dev/null +++ b/repo/blob/readonly/readonly_storage.go @@ -0,0 +1,55 @@ +// Package readonly implements wrapper around readonlyStorage that prevents all mutations. +package readonly + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/repo/blob" +) + +// ErrReadonly returns an error indicating that storage is read only. +var ErrReadonly = errors.Errorf("storage is read-only") + +// readonlyStorage prevents all mutations on the underlying storage. +type readonlyStorage struct { + base blob.Storage +} + +func (s readonlyStorage) GetBlob(ctx context.Context, id blob.ID, offset, length int64) ([]byte, error) { + return s.base.GetBlob(ctx, id, offset, length) +} + +func (s readonlyStorage) GetMetadata(ctx context.Context, id blob.ID) (blob.Metadata, error) { + return s.base.GetMetadata(ctx, id) +} + +func (s readonlyStorage) PutBlob(ctx context.Context, id blob.ID, data blob.Bytes) error { + return ErrReadonly +} + +func (s readonlyStorage) DeleteBlob(ctx context.Context, id blob.ID) error { + return ErrReadonly +} + +func (s readonlyStorage) ListBlobs(ctx context.Context, prefix blob.ID, callback func(blob.Metadata) error) error { + return s.base.ListBlobs(ctx, prefix, callback) +} + +func (s readonlyStorage) Close(ctx context.Context) error { + return s.base.Close(ctx) +} + +func (s readonlyStorage) ConnectionInfo() blob.ConnectionInfo { + return s.base.ConnectionInfo() +} + +func (s readonlyStorage) DisplayName() string { + return s.base.DisplayName() +} + +// NewWrapper returns a readonly Storage wrapper that prevents any mutations to the underlying storage. +func NewWrapper(wrapped blob.Storage) blob.Storage { + return &readonlyStorage{base: wrapped} +} diff --git a/repo/connect.go b/repo/connect.go index b91f3ce7a..8c3b6670e 100644 --- a/repo/connect.go +++ b/repo/connect.go @@ -20,6 +20,7 @@ type ConnectOptions struct { PersistCredentials bool `json:"persistCredentials"` HostnameOverride string `json:"hostnameOverride"` UsernameOverride string `json:"usernameOverride"` + ReadOnly bool `json:"readOnly"` content.CachingOptions } @@ -53,6 +54,8 @@ func Connect(ctx context.Context, configFile string, st blob.Storage, password s ci := st.ConnectionInfo() lc.Storage = &ci + lc.ReadOnly = opt.ReadOnly + lc.Hostname = opt.HostnameOverride if lc.Hostname == "" { lc.Hostname = getDefaultHostName(ctx) diff --git a/repo/content/content_manager_indexes.go b/repo/content/content_manager_indexes.go index ea8c012a4..fcdddfa16 100644 --- a/repo/content/content_manager_indexes.go +++ b/repo/content/content_manager_indexes.go @@ -157,6 +157,28 @@ func (bm *Manager) addIndexBlobsToBuilder(ctx context.Context, bld packIndexBuil return nil } +// ParseIndexBlob loads entries in a given index blob and returns them. +func (bm *Manager) ParseIndexBlob(ctx context.Context, blobID blob.ID) ([]Info, error) { + data, err := bm.indexBlobManager.getIndexBlob(ctx, blobID) + if err != nil { + return nil, errors.Wrapf(err, "error getting index %q", blobID) + } + + index, err := openPackIndex(bytes.NewReader(data)) + if err != nil { + return nil, errors.Wrapf(err, "unable to open index blob") + } + + var results []Info + + err = index.Iterate(AllIDs, func(i Info) error { + results = append(results, i) + return nil + }) + + return results, err +} + func addBlobsToIndex(ndx map[blob.ID]*IndexBlobInfo, blobs []blob.Metadata) { for _, it := range blobs { if ndx[it.BlobID] == nil { diff --git a/repo/local_config.go b/repo/local_config.go index e8b2cb654..fad097d50 100644 --- a/repo/local_config.go +++ b/repo/local_config.go @@ -22,6 +22,8 @@ type LocalConfig struct { Hostname string `json:"hostname"` Username string `json:"username"` + + ReadOnly bool `json:"readonly,omitempty"` } // repositoryObjectFormat describes the format of objects in a repository. diff --git a/repo/open.go b/repo/open.go index 5161b53be..093687e43 100644 --- a/repo/open.go +++ b/repo/open.go @@ -14,6 +14,7 @@ "github.com/kopia/kopia/repo/blob" loggingwrapper "github.com/kopia/kopia/repo/blob/logging" + "github.com/kopia/kopia/repo/blob/readonly" "github.com/kopia/kopia/repo/content" "github.com/kopia/kopia/repo/logging" "github.com/kopia/kopia/repo/manifest" @@ -83,6 +84,10 @@ func openDirect(ctx context.Context, configFile string, lc *LocalConfig, passwor st = loggingwrapper.NewWrapper(st, options.TraceStorage, "[STORAGE] ") } + if lc.ReadOnly { + st = readonly.NewWrapper(st) + } + r, err := OpenWithConfig(ctx, st, lc, password, options, lc.Caching) if err != nil { st.Close(ctx) //nolint:errcheck @@ -91,6 +96,7 @@ func openDirect(ctx context.Context, configFile string, lc *LocalConfig, passwor r.hostname = lc.Hostname r.username = lc.Username + r.isReadOnly = lc.ReadOnly if r.hostname == "" { r.hostname = getDefaultHostName(ctx) diff --git a/repo/repository.go b/repo/repository.go index 9079d3f41..b59a6c6a9 100644 --- a/repo/repository.go +++ b/repo/repository.go @@ -25,6 +25,7 @@ type Repository interface { Hostname() string Username() string + IsReadOnly() bool Time() time.Time @@ -43,8 +44,9 @@ type DirectRepository struct { ConfigFile string - hostname string // connected (localhost) hostname - username string // connected username + hostname string // connected (localhost) hostname + username string // connected username + isReadOnly bool timeNow func() time.Time formatBlob *formatBlob @@ -64,6 +66,9 @@ func (r *DirectRepository) Hostname() string { return r.hostname } // Username returns the username that's connect to the repository. func (r *DirectRepository) Username() string { return r.username } +// IsReadOnly returns true if repository is read-only. +func (r *DirectRepository) IsReadOnly() bool { return r.isReadOnly } + // BlobStorage returns the blob storage. func (r *DirectRepository) BlobStorage() blob.Storage { return r.Blobs