Documentation
¶
Overview ¶
Package migrate copies the service's State from any adapter to any other through the ports and the domain stores (ADR 0031), and is the whole of `sluis migrate`.
It copies through the BUSINESS interfaces, never raw bytes: a domain record and its credential are read from the source's own store (the ConfigMaps and Secrets of internal/kube, or the State records of internal/portstore) and written to the destination's, so a credential lands in the destination's Secrets, and a record lands in the layout its adapter keeps. The issuer's logins in progress (sessions, refresh tokens, the keyring's schedule) are not a domain store; they are State records and Index sets under `issuer:`, which are copied with the lifetime each has left, so nobody signs in again.
A run has three phases, and the first writes nothing:
- Plan: read both sides, and say for every item whether it is new on the destination, already there with the same value, or there with another one. Without Overwrite any difference is a conflict and the run stops here, naming the keys, before a single write.
- Copy what is new (and, with Overwrite, what differs), item by item.
- Verify: read the source and the destination again, fresh, and compare every source item with the destination's, secrets read from each side's Secrets.
A re-run after a failure plans again and so copies exactly what is missing: the copy is idempotent, and an item the destination already holds equal is never rewritten.
What is not copied: leases (transient, and owned by whoever is running), the hub's snapshots (a cache the hub rewrites on its first refresh), and the controllers' in-flight hand-offs and caches (`share.`, `cache.`, `dedupe.`), which a tick rebuilds.
Index ¶
- Constants
- Variables
- func BlobID(c store.Config) string
- func MapS3Ref(ref string) (string, error)
- type BlobMode
- type ConcernSummary
- type Domains
- type KeptParam
- type ModulePlan
- type Options
- type PlanCounts
- type PlanItem
- type PlanOptions
- type PlanReport
- type PlanStatus
- type Problem
- type PruneOptions
- type PruneReport
- type Report
- type SecretTarget
- type Side
- type Step
- type Target
- type V5Options
- type VerifyResult
- type Writes
Constants ¶
const ( DomainGoogle = "google" DomainGitHub = "github" DomainSlack = "slack" DomainConsole = "console" DomainIssuer = "issuer" DomainBlobs = "blobs" )
The domains a run can skip by name.
const ( // KeptNotMigrated is a parameter the migration deliberately does not carry. KeptNotMigrated = "not-migrated" // KeptPulumi is a parameter the Pulumi library owns (Compat.DropV4). KeptPulumi = "pulumi-owned" // KeptUnmapped is a name layout v4 never wrote, or that is already a layout // v5 name. KeptUnmapped = "unmapped" // KeptUnverified is a parameter with a layout v5 address whose copy the // plan did not find equal (the plan did not look for it). KeptUnverified = "unverified" // root both sides share: deleting it would delete the copy. KeptShared = "shared-address" )
The reasons a parameter is kept.
const ( // DefaultPrunePause is the wait between two delete requests. DefaultPrunePause = 500 * time.Millisecond // DefaultPruneBackoff is the first wait after a throttled request. DefaultPruneBackoff = time.Second )
Prune pacing: SSM's DeleteParameters is throttled like PutParameter, so a batch of ten every half second, and a doubling wait when it is throttled.
const DomainDirectory = "directory"
DomainDirectory is the name the Google domain had before v1.75. A run still accepts it in Skip for one release and treats it as DomainGoogle; the report names the domain `google`.
Deprecated: use DomainGoogle. Removed in v1.76.
const S3Module = "google"
S3Module is the module that owns the blob prefix a static S3 credential opens: the Google module's cache, `google/`.
Variables ¶
var ( // ErrWritersRunning is a run that was not told the source is quiet. ErrWritersRunning = errors.New("migrate: refusing to copy while the source can still change: " + "freeze the source with `sluis maintenance on`, or stop every writer and pass --i-have-stopped-writers; " + "--dry-run reads and writes nothing and needs neither") // ErrConflict is a destination that already holds a different value. ErrConflict = errors.New("migrate: the destination already holds a different value") // ErrRefused is a source item the destination would refuse: nothing is // written until it is dealt with, and --overwrite does not change that. ErrRefused = errors.New("migrate: the destination would refuse source items") // ErrMismatch is a copy that did not verify. ErrMismatch = errors.New("migrate: the destination does not match the source after the copy") // ErrUnreadable is a source item that cannot be presented through its store. ErrUnreadable = errors.New("migrate: the source holds items its store cannot read") )
Errors a run ends with. The report says the rest.
var ( // ErrNotMigrated is a v4 item that has no v5 address on purpose: the reason // is in the error. ErrNotMigrated = errors.New("migrate: not carried to layout v5") // ErrNoTarget is a v4 item layout v5 has no address for. ErrNoTarget = errors.New("migrate: no address on layout v5") )
var ( // ErrPruneGate is a prune refused because the destination does not hold // every migrated item equal. ErrPruneGate = errors.New("migrate: prune refused: the destination does not hold every migrated item equal; nothing was deleted") // ErrPruneUnconfirmed is a real prune without the plan hash of a dry run, or // with the hash of another plan. ErrPruneUnconfirmed = errors.New("migrate: prune refused: a real run needs the plan hash a --dry-run printed (--confirm-plan)") // ErrPruneNoSecrets is a source that keeps no layout v4 parameters. ErrPruneNoSecrets = errors.New("migrate: the source keeps no layout v4 secrets of its own: there is nothing to prune") )
var AllDomains = []string{DomainGoogle, DomainGitHub, DomainSlack, DomainConsole, DomainIssuer, DomainBlobs}
AllDomains is every name a run accepts to skip.
var ErrDestinationMaintenance = errors.New("migrate: the destination holds a maintenance flag, so it would start frozen: " +
"lift it first with `sluis maintenance off --config <v5 config>`; a copy never carries the source's flag")
ErrDestinationMaintenance is a destination that holds a maintenance flag: it would start frozen once the layout switch serves from it.
var ErrNotStarted = errors.New("migrate: nothing was written")
ErrNotStarted marks a run stopped by its Before hook: nothing was written.
Functions ¶
func BlobID ¶
BlobID says where a configuration keeps its blobs, so that two sides that keep them in one place are known to: the same S3 bucket and prefix, or, with no `ports.blob`, the same release's objects. The memory adapter keeps them nowhere another process reads, so it is never "the same".
func MapS3Ref ¶ added in v1.75.0
MapS3Ref maps the `credentialsRef` of a layout v4 installation, `internal/<kind>/<id>`, to the v5 reference `internal/<module>/<name>`: the credential moves under the module that owns the blob prefix, and the two segments join (`internal/blobs/r2` becomes `internal/google/blobs-r2`). A ref that already begins with a module is unchanged.
Types ¶
type BlobMode ¶
type BlobMode string
BlobMode says whether the controllers' reports are copied.
const ( // BlobsAuto copies them only when the two sides keep them in different // places. When they share one the next pass rewrites them anyway. BlobsAuto BlobMode = "auto" // BlobsCopy always copies them. BlobsCopy BlobMode = "copy" // BlobsSkip never does. BlobsSkip BlobMode = "skip" )
The blob modes.
type ConcernSummary ¶ added in v1.58.0
type ConcernSummary struct {
Concern string `json:"concern"`
Items int `json:"items"`
Bytes int `json:"bytes"`
Refused int `json:"refused,omitempty"`
}
ConcernSummary is what a run moves for one concern of the installation: state records, secrets, blobs. Items and Bytes are what the source holds and the run would write; Refused is what the destination would not take.
type Domains ¶
type Domains struct {
Workspaces hub.Store
Credentials hub.CredentialStore
Orgs orgStore
Links linkStore
Runner runnerStore
Catalogue catalogueStore
SlackApps slackCatalogueStore
Slack slackStore
Channels channelStore
SessionKey sessionKeys
}
Domains is every domain store of one side.
func OpenDomains ¶
OpenDomains builds the domain stores of one side by the switch the service itself makes (internal/app): any adapter but `legacy` keeps them on the State port with every credential in Secrets, and `legacy` keeps today's ConfigMaps and Secrets. A source is opened as it is; a destination (create) also makes the objects a legacy store needs to exist, as the service does at its start.
func OpenDomainsExporting ¶ added in v1.74.0
func OpenDomainsExporting(ctx context.Context, st *store.Stores, create bool, exported func(id string) bool) (*Domains, error)
OpenDomainsExporting is OpenDomains for a side that may keep its secrets in the layout-v4 stores (the ssm source): an installed runner App, and a catalogue App for which exported says true, is the document external/github/<app> there and not an internal credential, and a Slack App's bot token is external/slack/<app> (ADR 0041). exported may be nil (no catalogue App is exported). A side without v4 stores changes nothing.
func OpenDomainsFor ¶ added in v1.75.0
func OpenDomainsFor(ctx context.Context, st *store.Stores, create bool, exported func(id string) bool, declared portstore.DeclaredGitHubApps, ) (*Domains, error)
OpenDomainsFor is OpenDomainsExporting for a side that also declares what the configuration says of the GitHub Apps. On layout v5 an organisation is written naming its App, which the declaration (AppRef) supplies.
type KeptParam ¶ added in v1.75.0
type KeptParam struct {
Name string `json:"name"`
Class string `json:"class"`
Reason string `json:"reason"`
}
KeptParam is a parameter prune leaves.
type ModulePlan ¶ added in v1.75.0
type ModulePlan struct {
Module string `json:"module"`
PlanCounts
Items []PlanItem `json:"items"`
}
ModulePlan is what the plan found for one module.
type Options ¶
type Options struct {
// DryRun plans and reports and writes nothing, the destination's objects
// included.
DryRun bool
// Overwrite replaces what the destination holds with the source's. Without
// it a destination value that differs fails the run, naming the key.
Overwrite bool
// WritersStopped is the operator's statement that nothing writes to the
// source. A copy is a snapshot: a write during it is a copy of neither
// state. A run that writes refuses without it.
WritersStopped bool
// Sessions also copies the issuer's logins in progress (sessions, refresh
// tokens, the codes in flight, the Index sets). An installation's move does
// not: people sign in again, and only the key ring's schedule is copied, so
// that a token issued before the move keeps verifying.
Sessions bool
// Skip names domains left out (see the Domain constants).
Skip []string
// Blobs says whether the controllers' reports are copied.
Blobs BlobMode
// ReportBlob, when set, is a Blob name the report is also written to on
// the destination (not by a dry run).
ReportBlob string
// ExportedGitHubApp says which catalogue GitHub Apps of the destination have
// `export: true`. Nil exports none; a runner App is always
// exported.
ExportedGitHubApp func(id string) bool
// Log receives progress. Nil is silent.
Log *slog.Logger
// Now is the clock the copied lifetimes are measured against; nil is
// time.Now. For tests, which inject the delay between reading and writing.
Now func() time.Time
}
Options is how a run behaves.
type PlanCounts ¶ added in v1.75.0
type PlanCounts struct {
New int `json:"new"`
Same int `json:"same"`
Different int `json:"different"`
Refused int `json:"refused"`
// Copied and Missing are what a copy wrote and a verify did not find.
Copied int `json:"copied,omitempty"`
Missing int `json:"missing,omitempty"`
// Expired is the issuer's records that ran out of lifetime before they were
// written, and so were skipped.
Expired int `json:"expired,omitempty"`
}
PlanCounts are the items of a module by status.
func (PlanCounts) String ¶ added in v1.75.0
func (c PlanCounts) String() string
String is a one-line summary per module, for a terminal.
type PlanItem ¶ added in v1.75.0
type PlanItem struct {
// Concern is `state`, `secret` or `blob`.
Concern string `json:"concern"`
// Kind and ID are the item's kind and id on layout v5.
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
// From is the v4 logical key (State) or the v4 name below the root (secret).
From string `json:"from"`
// To is the v5 address: `<module>/<kind>/<id>` for State, the name below
// the root for a secret. A name ending in `<ref>` has a ref chosen at each
// write.
To string `json:"to,omitempty"`
Status PlanStatus `json:"status"`
// Count is how many records the row stands for; absent is one.
Count int `json:"count,omitempty"`
// Secrets is how many credentials the record carries; their v4 and v5
// families are FromSecret and ToSecret.
Secrets int `json:"secrets,omitempty"`
FromSecret string `json:"fromSecret,omitempty"`
ToSecret string `json:"toSecret,omitempty"`
// External is the document a consumer reads, which keeps its name.
External string `json:"external,omitempty"`
// FromVersion and ToVersion are the versions of a secret on each side.
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
// Reason says why an item is refused, or Note what the operator must know.
Reason string `json:"reason,omitempty"`
Note string `json:"note,omitempty"`
// contains filtered or unexported fields
}
PlanItem is one thing the plan found, or, for the issuer's records whose keys are bearer values, a count of the records of one kind in one status.
type PlanOptions ¶ added in v1.75.0
type PlanOptions struct {
// Skip names domains left out (see the Domain constants).
Skip []string
// Sessions also plans the issuer's sessions, refresh tokens, single
// sign-on records, codes in flight and Index sets. Without it only the key
// ring and the state secret's fingerprint are planned.
Sessions bool
// Blobs says whether the controllers' reports are planned.
Blobs BlobMode
// ExportedGitHubApp says which catalogue GitHub Apps have `export: true`.
ExportedGitHubApp func(id string) bool
// AppRef is the App each GitHub organisation uses on layout v5
// (`controllers.github.appRefs`), an override and optional: an organisation
// without one uses the source's App that installs it, or an App made of the
// organisation's own (v5orgapp.go).
AppRef func(org string) string
// ConfigNames are the secrets the documents declare below internal/config
// beyond the fixed ones: `providers/google/<provider>/client-id`,
// `directory/<id>/key`.
ConfigNames []string
// CloudflareAccounts are the accounts whose minter credential is planned.
CloudflareAccounts []string
// S3Ref and S3RefTo are the `credentialsRef` of the source's and the
// destination's blob, when they have one.
S3Ref, S3RefTo string
// Log receives progress. Nil is silent.
Log *slog.Logger
// Now is the clock the remaining lifetime of an issuer record is counted
// by. Nil is [time.Now].
Now func() time.Time
}
PlanOptions is what a plan is told beyond the two sides.
type PlanReport ¶ added in v1.75.0
type PlanReport struct {
From string `json:"from"`
To string `json:"to"`
Modules []ModulePlan `json:"modules"`
Totals PlanCounts `json:"totals"`
Notes []string `json:"notes,omitempty"`
// Mode is "copy" or "verify" for those commands; a plan leaves it out.
Mode string `json:"mode,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
// Live is a copy that ran without the writers stopped: the first pass, which
// leaves the issuer out. A final pass is still required.
Live bool `json:"live,omitempty"`
// Verify is what a copy found when it read both sides again afterwards.
Verify *VerifyResult `json:"verify,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
// contains filtered or unexported fields
}
PlanReport is the plan: no value, no timestamp, and the same text for the same installations.
func CopyV5 ¶ added in v1.75.0
CopyV5 copies a layout v4 installation to a layout v5 one, idempotently: nothing the destination already holds equal is written. It plans first and writes nothing when anything is refused or, without Overwrite, different. The report is returned with every error that is not a failure to start, so that a caller can still show it.
func Plan ¶ added in v1.75.0
func Plan(ctx context.Context, from, to Side, opt PlanOptions) (*PlanReport, error)
Plan reads a layout v4 installation (from) and a layout v5 one (to) and reports what a copy would do, per module. It makes no write.
The report is returned with ErrRefused or ErrUnreadable when anything is refused, so that a caller can still show it.
func VerifyV5 ¶ added in v1.75.0
func VerifyV5(ctx context.Context, from, to Side, opt PlanOptions) (*PlanReport, error)
VerifyV5 reads both sides and says, per module, whether the destination holds what the source does: secrets compared by hash, records by value, and an organisation by its record and the App it names (the source has no key of its own for it to match). It writes nothing. A source item the destination lacks is missing, and one it holds with another value is different; either ends the run with ErrMismatch.
func (*PlanReport) JSON ¶ added in v1.75.0
func (r *PlanReport) JSON() []byte
JSON renders the plan, indented.
type PlanStatus ¶ added in v1.75.0
type PlanStatus string
PlanStatus is what a copy would find for one item.
const ( // PlanNew is an item the destination lacks. PlanNew PlanStatus = "new" // PlanSame is an item the destination holds with the same value. PlanSame PlanStatus = "same" // PlanDifferent is an item the destination holds with another value. PlanDifferent PlanStatus = "different" // PlanRefused is an item that cannot be carried: layout v5 has no address // for it, the destination would refuse it, or the source cannot read it. PlanRefused PlanStatus = "refused" // PlanCopied is an item a copy wrote (it was new, or different with // --overwrite). PlanCopied PlanStatus = "copied" // PlanMissing is an item a verify did not find on the destination. PlanMissing PlanStatus = "missing" // PlanExpired is an issuer record whose lifetime ran out between the // moment it was read and the moment a copy came to write it. It is skipped: // the source drops it too. PlanExpired PlanStatus = "expired" )
The statuses.
type Problem ¶
type Problem struct {
Domain string `json:"domain"`
Kind string `json:"kind"`
Key string `json:"key"`
Reason string `json:"reason"`
}
Problem names one item that is not as it should be. Key is the item's key within its kind; for the issuer's state, whose keys carry bearer values (a code, a session), it is a short hash of the key so that a report is safe to keep.
type PruneOptions ¶ added in v1.75.0
type PruneOptions struct {
PlanOptions
// DryRun reads, reports the plan hash and deletes nothing.
DryRun bool
// Confirm is the plan hash of a dry run; a real run needs it.
Confirm string
// SameRoot is true when both sides keep their parameters below one root: a
// layout v4 name that is also the layout v5 name is then the copy itself.
SameRoot bool
// Pause is the wait between two requests (zero is [DefaultPrunePause]) and
// Backoff the first wait after a throttled one (zero is [DefaultPruneBackoff]).
Pause, Backoff time.Duration
// Before is called once, after the plan hash is confirmed and before the
// first delete. The command records its intent there; an error stops the run
// with nothing deleted.
Before func(ctx context.Context, r *PruneReport) error
// Sleep waits; nil is a context-aware time.Sleep.
Sleep func(ctx context.Context, d time.Duration) error
}
PruneOptions is how a prune behaves.
type PruneReport ¶ added in v1.75.0
type PruneReport struct {
From string `json:"from"`
To string `json:"to"`
// DryRun is a run that deleted nothing.
DryRun bool `json:"dryRun,omitempty"`
// PlanHash identifies the parameters to delete and the ones kept; a real
// run is given it as --confirm-plan.
PlanHash string `json:"planHash,omitempty"`
// Verify is what the gate found, by status.
Verify PlanCounts `json:"verify"`
// Gate is "passed", or why the run was refused.
Gate string `json:"gate"`
// Delete are the parameters to delete (a dry run) or deleted.
Delete []string `json:"delete"`
// Deleted counts the parameters a real run removed, in Batches requests.
Deleted int `json:"deleted,omitempty"`
Batches int `json:"batches,omitempty"`
// Kept are the parameters left, with the reason.
Kept []KeptParam `json:"kept"`
// LegacyTableItems are the migrated records on the source: the legacy table
// holds them until it retires; prune does not touch it.
LegacyTableItems int `json:"legacyTableItems"`
Notes []string `json:"notes,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
PruneReport is what a prune found and did: names and counts, never a value.
func PruneV5 ¶ added in v1.75.0
func PruneV5(ctx context.Context, from, to Side, opt PruneOptions) (*PruneReport, error)
PruneV5 runs the gate, decides what to delete and keep, and, unless DryRun, deletes it once the plan hash is confirmed. The report is returned with every error that is not a failure to start, so that a caller can still show it.
func (*PruneReport) JSON ¶ added in v1.75.0
func (r *PruneReport) JSON() []byte
JSON renders the report, indented.
type Report ¶
type Report struct {
From string `json:"from"`
To string `json:"to"`
FromAdapter string `json:"fromAdapter"`
ToAdapter string `json:"toAdapter"`
DryRun bool `json:"dryRun"`
Overwrite bool `json:"overwrite"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Steps []Step `json:"steps"`
Totals Step `json:"totals"`
Conflicts []Problem `json:"conflicts,omitempty"`
Unreadable []Problem `json:"unreadable,omitempty"`
Mismatches []Problem `json:"mismatches,omitempty"`
// Refused are the items the destination would refuse, and why.
Refused []Problem `json:"refused,omitempty"`
// Concerns is the summary per concern: state, secrets, blobs.
Concerns []ConcernSummary `json:"concerns,omitempty"`
// Notes say what was left out and why (a domain skipped, a side with
// nothing to read).
Notes []string `json:"notes,omitempty"`
// Error is how the run ended, if it did not end well.
Error string `json:"error,omitempty"`
// OK is true when the plan had no conflict, nothing unreadable, and, unless
// this was a dry run, every item verified.
OK bool `json:"ok"`
}
Report is what a run says it did: counts and the keys of what went wrong, never a value.
type SecretTarget ¶ added in v1.75.0
type SecretTarget struct {
Module string
Kind string
ID string
// V5 is the name below the installation's root, or the family when it
// holds a `<ref>` the store chooses at each write.
V5 string
// Config is true for a name an operator or Pulumi seeds as plain text
// (below internal/config), which the v4 source delivers by name.
Config bool
}
SecretTarget is where a v4 secret lands on layout v5.
func MapSecret ¶ added in v1.75.0
func MapSecret(name string) (SecretTarget, error)
MapSecret maps a v4 secret name, below the installation's root (`internal/...` or `external/...`), to its v5 address. A name with a `<ref>` last segment is a family: its refs are random per write and are not preserved.
type Side ¶
type Side struct {
// Name labels it in the report: the configuration file it came from.
Name string
// Stores is the opened ports.
Stores *store.Stores
// BlobID identifies where this side keeps blobs, so that two sides that
// share a place are known to; empty is "nowhere shared".
BlobID string
}
Side is one end of a run: the ports and stores of a configuration, opened.
type Step ¶
type Step struct {
Domain string `json:"domain"`
Kind string `json:"kind"`
// Source is how many readable items the source holds.
Source int `json:"source"`
// Unreadable is how many it holds that its store cannot present: they are
// listed and not copied.
Unreadable int `json:"unreadable,omitempty"`
// New is how many the destination lacks, Present how many it already holds
// with the same value, and Conflicts how many it holds with another.
New int `json:"new"`
Present int `json:"present"`
Conflicts int `json:"conflicts,omitempty"`
// Copied is how many were written: New, and with Overwrite the Conflicts.
Copied int `json:"copied"`
// Verified is how many matched on the second read; Mismatched how many did
// not.
Verified int `json:"verified"`
Mismatched int `json:"mismatched,omitempty"`
// Refused is how many the destination would refuse (an item over a size
// limit, an invalid key): they are listed under refused and stop the run
// before anything is written.
Refused int `json:"refused,omitempty"`
// Bytes is the size of the readable source items (a record with its
// credentials, a blob, a State value); Secrets and SecretBytes are the
// credentials among them, which go to the Secrets port.
Bytes int `json:"bytes"`
Secrets int `json:"secrets,omitempty"`
SecretBytes int `json:"secretBytes,omitempty"`
}
Step is the outcome of one collection.
type Target ¶ added in v1.75.0
Target is the module, kind and id of an item on layout v5.
func IndexTarget ¶ added in v1.75.0
IndexTarget is where the Index set lands on layout v5.
func StateTarget ¶ added in v1.75.0
StateTarget is where the State record with the logical key lands on layout v5.
type V5Options ¶ added in v1.75.0
type V5Options struct {
PlanOptions
// DryRun reads and reports and writes nothing.
DryRun bool
// Overwrite replaces what the destination holds with the source's. Without
// it a destination value that differs stops the run before any write.
Overwrite bool
// WritersStopped is the operator's statement that nothing writes to the
// source. A run that writes refuses without it.
WritersStopped bool
// Before is called once, after the plan is read and every check passed and
// before the first write, when there is something to write. The command
// records its intent there; an error stops the run before it writes.
Before func(ctx context.Context, w Writes) error
// Wait pauses until the replicas have seen a freeze set a moment ago; nil
// waits on the clock. A test replaces it.
Wait func(ctx context.Context, d time.Duration)
}
V5Options is how a copy behaves: what the plan is told, and what may be written.
type VerifyResult ¶ added in v1.75.0
type VerifyResult struct {
PlanCounts
// Problems are the items that did not match.
Problems []PlanItem `json:"problems,omitempty"`
OK bool `json:"ok"`
}
VerifyResult is what a copy found when it read both sides again after writing.
type Writes ¶ added in v1.75.0
type Writes struct {
// Items is how many items are written, New of them absent on the
// destination and Replaced replacing a value that differs.
Items, New, Replaced int
// ReplacedNames are the replaced items, as module/concern/name.
ReplacedNames []string
}
Writes is what a copy is about to write: counts and names, never a value.