archive

package
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package archive is the Hubtask archive format of backup-restore.md §3: what a backup writes and what a restore reads, as a reader and a writer over the backup target port.

It lives in the application layer rather than in the domain because an archive is a wire format. A domain object does not serialise itself (project-structure.md §3), and the thing this package is about is exactly the serialisation - which field is called what, in which order the members land, and what a reader of version 2 is allowed to assume about a file written by version 1.

The format is a commitment rather than an implementation detail, which is why it is its own task and comes before the job that writes one on a timer. An archive written this month has to open in a version nobody has written yet; a change here that a reader cannot see coming is a restore that fails on the day it is needed.

Index

Constants

View Source
const (
	// CodeChecksumsUnreadable is a `checksums.txt` that is not one.
	CodeChecksumsUnreadable = "backup.archive_checksums_unreadable"
	// CodeChecksumMismatch is a member whose bytes are not the bytes that were written, or one
	// the file does not list at all.
	CodeChecksumMismatch = "backup.archive_checksum_mismatch"
	// CodeArchiveIncomplete is an archive with no `checksums.txt`: a run that died before it
	// finished, or one still in progress. Distinct from a corrupt archive, because the answer is
	// different - this one is not damaged, it is simply not there yet.
	CodeArchiveIncomplete = "backup.archive_incomplete"
)

The refusals of the checksum file.

View Source
const (
	// ManifestName is what an archive says about itself. It is written second to last, and it is
	// what `GET /backup-targets/{id}/backups` reads at the target when the database is gone
	// (backup-restore.md §8.1).
	ManifestName = "manifest.json"
	// ChecksumsName is the commit point of an archive. See Checksums.go for why it is this file
	// and not the manifest.
	ChecksumsName = "checksums.txt"
	// DataPrefix holds one JSON Lines file per aggregate.
	DataPrefix = "data/"
	// MediaPrefix holds the media, addressed by the SHA-256 of their content.
	MediaPrefix = "media/"
)

The names of the members. Fixed strings rather than a computed layout: a restore in a later version looks for these exact keys, and a reader that had to derive them would be a reader that could derive them differently.

View Source
const (
	EncryptionAES256GCM = "AES256_GCM"
	EncryptionNone      = "NONE"
)

The encryption modes as the manifest spells them. They are the values of backup.EncryptionMode; repeated as strings here because the manifest is a wire format and a wire format that changed when a domain constant was renamed would be a wire format nobody controls.

View Source
const (
	// CodeFormatUnsupported is an archive written in a format version this build does not know.
	// It is refused before anything is read, which is the acceptance criterion: a partial import
	// of a file whose shape is guessed is worse than no import at all.
	CodeFormatUnsupported = "backup.archive_format_unsupported"
	// CodeManifestUnreadable is a manifest that is not JSON, or larger than any manifest has
	// business being.
	CodeManifestUnreadable = "backup.archive_manifest_unreadable"
	// CodeManifestInvalid is a manifest this build can parse and cannot use - a missing scope, an
	// unknown mode, an incremental with no parent.
	CodeManifestInvalid = "backup.archive_manifest_invalid"
)

The refusals of the format, as codes rather than as prose.

View Source
const (
	// CodeArchiveNotFound is a path at which the target holds no archive: nothing to restore, as
	// opposed to a chain that cannot be completed. A caller that sent the manifest's archive_id
	// where the path belongs meets this rather than a broken chain.
	CodeArchiveNotFound = "backup.archive_not_found"
	// CodeChainBroken is an incremental whose parent is missing, or a chain that does not end at
	// a full archive. Restoring the part that happens to be present would restore a tenant to a
	// state it was never in.
	CodeChainBroken = "backup.archive_chain_broken"
	// CodeArchiveKeyRequired is an encrypted archive read without its key: a defect in the
	// caller, which derives every key before it reads.
	CodeArchiveKeyRequired = "backup.archive_key_required"
	// CodeArchiveKeyUnknown is an encrypted archive this installation cannot open: the master key
	// its manifest names is not in the ring, or a key of that name is and does not open it (another
	// installation's key under the same name, or a damaged archive). It names the key id, which is
	// what the operator has to bring back (backup-restore.md §4).
	CodeArchiveKeyUnknown = "backup.archive_key_unknown"
	// CodeArchiveKeyUnexpected is a key offered for an archive that has none - a caller confusing
	// two archives, which is worth stopping because the next thing they do is trust the result.
	CodeArchiveKeyUnexpected = "backup.archive_key_unexpected"
)

The refusals of a read.

View Source
const (
	// CodeRecordInvalid is a line this build will not write or will not use.
	CodeRecordInvalid = "backup.archive_record_invalid"
	// CodeRecordUnreadable is a line that is not JSON, or one longer than any record has business
	// being.
	CodeRecordUnreadable = "backup.archive_record_unreadable"
)

The refusals of a data file.

View Source
const (
	// CodeArchiveRequestInvalid is a run that would produce an archive nothing could restore.
	CodeArchiveRequestInvalid = "backup.archive_request_invalid"
	// CodeMediaMissing is a record referencing a medium the object store no longer has. It is a
	// refusal rather than a warning: an archive silently missing an attachment is one whose
	// restore is a surprise.
	CodeMediaMissing = "backup.archive_media_missing"
)

The refusals of a run.

View Source
const FormatVersion = 1

FormatVersion is the version of the archive format this build writes.

It is not the schema version and not the product version, and keeping the three apart is the point: the schema version says which migrations the data has taken, the product version says which build produced the file, and this says how the file is put together. A patch release changes the second, a migration changes the first, and this one changes when a reader that did not know about the change would misread the file.

View Source
const MinimumReadableFormatVersion = 1

MinimumReadableFormatVersion is the oldest format this build still opens. It is 1 and will stay 1 for as long as there is any archive of version 1 anywhere - raising it is a decision about somebody else's data, taken in a release note rather than in a constant.

Variables

This section is empty.

Functions

func DataName

func DataName(entity string) string

DataName is the member one entity's records are written to.

func Digest

func Digest(r io.Reader) (string, error)

Digest is the SHA-256 of a stream, hexadecimal and lower case - the one spelling used for a checksum line, a media address and a manifest entry alike.

crypto/sha256 in the application layer rather than behind a port, deliberately, and it is worth saying why because the seam next door is strict about exactly this. TestCryptographyStaysInItsAdapter keeps ciphers in infrastructure/crypto so that there is one nonce discipline and one place where "where does the master key live" changes. None of that applies to a hash with no key: there is no key to place, no nonce to get wrong, and no second implementation to diverge from - and SHA-256 here is an address and a checksum rather than a secret. Putting it behind a port would buy an interface with one implementation and cost the streaming shape everything above depends on.

func ExcludedTables

func ExcludedTables() map[string]string

ExcludedTables answers what an archive leaves out and why. The reasons are read by the test that keeps the two lists total, and by anybody wondering where their table went.

func MediaName

func MediaName(digest string) string

MediaName is where a medium lives, addressed by the SHA-256 of its content.

The two-character prefix directory is not decoration. Several of the targets behind the port are ordinary file systems, and a hundred thousand entries in one directory is where `ext4` starts answering a listing slowly and where a WebDAV `PROPFIND` starts timing out.

func MemberPurpose

func MemberPurpose(archiveID, path string) crypto.Purpose

MemberPurpose binds a member to its archive and its path, so that one lifted out of an archive and dropped into another stops opening (core/port/crypto, Purpose).

A function shared by the writer and the reader rather than a string built twice: two spellings of this would be an archive that writes correctly and never opens, and the failure would look exactly like a wrong key.

func Name

func Name(scopeID shared.ID, at time.Time, mode Mode) string

Name is the directory an archive occupies at a target, as §3 spells it:

hubtask-backup-<tenant>-<utc-timestamp>-<full|incremental>

The timestamp is UTC and basic ISO 8601, because a target's key namespace is flat and sorting it as text has to sort it by time. A colon is not spelled out for the same reason a slash is not: several of the protocols behind the port cannot carry one in a name.

func NotRestored

func NotRestored() map[string]string

NotRestored answers what a restore reads and does not write back, and why.

func Prefix

func Prefix(scopeID shared.ID) string

Prefix is the beginning every archive name of one scope shares, and nothing else at a target does.

It is a filter over names rather than a key to ask a target for. The storage port's prefix is a place - the local adapter walks it as a directory - and an archive's name is a directory under the target's root rather than a directory containing them. So a caller lists the target and keeps what starts with this, which is the reading that works on every adapter and leaves everything else at the target unlisted.

func ReadRecords

func ReadRecords(r io.Reader, yield func(Record) error) error

ReadRecords hands every line of a data file to yield, in the order it was written.

The order matters and is part of the format: within one entity a later line supersedes an earlier one, which is what lets a restore apply a file in one pass without sorting it first.

func WholeEntities

func WholeEntities() []string

WholeEntities are the entities written complete in every archive, by name.

func WriteRecords

func WriteRecords(w io.Writer, records func(yield func(Record) error) error) (int64, error)

WriteRecords writes lines to w and answers how many it wrote.

JSON Lines rather than a JSON array, and that is the property the whole format rests on: a writer appends one line at a time and never holds the file, and a reader takes one line at a time and never holds it either. An array would need its closing bracket, which means a writer that has to come back to the end - over a protocol where coming back means a second request.

Types

type Blob

type Blob struct {
	// Digest is the lower-case hexadecimal SHA-256 of the content, as the media object recorded
	// it - never recomputed by the archive writer, which would mean reading every attachment in
	// order to decide whether to read it.
	Digest string `json:"sha256"`
	Bytes  int64  `json:"bytes"`
}

Blob is a reference from a record to a file that lives under media/ rather than inside the line.

The digest is the address: a medium is stored at MediaName(digest), so two records referencing the same bytes reference the same object and an incremental run can tell, without asking the object store, that a file it already transferred has not changed.

type Checksums

type Checksums struct {
	// contains filtered or unexported fields
}

Checksums is `checksums.txt`: one SHA-256 per member, and one over the manifest.

The file is written **last**, and that is the decision this type carries. An archive is a directory of objects rather than one stream, so there is no single moment at which it becomes complete unless one is chosen - and a run that died between two members has to be distinguishable from one that finished. The last thing written is that marker: an archive without `checksums.txt` is an unfinished archive, whatever else is lying next to it, and the listing in §8.1 reports exactly that as its checksum status.

The manifest is inside this file rather than the other way round for the same reason. The manifest names the members and their checksums, so it cannot name its own; something has to close over it, and closing over it last is what makes the closure meaningful.

What this catches is corruption, not an attacker. Somebody who can rewrite a member at the target can rewrite this file too. Against them the defence is elsewhere and stronger: the members are encrypted with an authenticated cipher, so an altered byte fails to open at all. These checksums are for the bit that rotted, the transfer that truncated, and the disk that lied - which is what actually happens to backups.

func NewChecksums

func NewChecksums() *Checksums

NewChecksums starts an empty set.

func ParseChecksums

func ParseChecksums(r io.Reader) (*Checksums, error)

ParseChecksums reads the file back.

func (*Checksums) Add

func (c *Checksums) Add(path, digest string) error

Add records one member's digest. A path recorded twice with different digests is a defect in the writer, and it is refused rather than silently kept.

func (*Checksums) Digest

func (c *Checksums) Digest(path string) (string, bool)

Digest answers what was recorded for a path.

func (*Checksums) Encode

func (c *Checksums) Encode(w io.Writer) error

Encode writes the file in the shape `sha256sum` reads: the digest, two spaces, the path, sorted by path so that the same archive produces the same bytes twice.

Reusing that shape is deliberate. An operator holding a directory at a target and no Hubtask can check it with a tool that has been on every machine for thirty years, which is worth more on the day the database is gone than a format of our own would be.

func (*Checksums) Paths

func (c *Checksums) Paths() []string

Paths answers every recorded path, sorted.

func (*Checksums) Verify

func (c *Checksums) Verify(path string, content io.Reader) error

Verify checks one member against what was recorded, and says which member failed rather than only that something did.

A path that is not in the file is a failure too. An archive that grew a member after the checksums were written is not an archive with an extra file in it - it is an archive somebody has been editing.

type Counter

type Counter struct {
	// contains filtered or unexported fields
}

Counter wraps a writer and answers, afterwards, how many bytes went through it and what their SHA-256 is.

It exists because both numbers are needed for every member and neither may cost a second pass. A member is written once, to a target that is somebody else's machine; reading it back to hash it would double the traffic on the one operation that is already the slowest thing the system does.

func NewCounter

func NewCounter() *Counter

NewCounter starts counting.

func (*Counter) Bytes

func (c *Counter) Bytes() int64

Bytes is how much went through.

func (*Counter) Digest

func (c *Counter) Digest() string

Digest is the SHA-256 of everything that went through.

func (*Counter) Write

func (c *Counter) Write(p []byte) (int, error)

Write implements io.Writer. It hashes rather than stores: the member itself is going to the target, and nothing here holds a copy of it (T-17).

type Description

type Description struct {
	// Prefix is the archive's directory at the target, and what every other call here takes.
	Prefix   string
	Manifest Manifest
	// Complete is whether checksums.txt is there. An archive without it is a run that died or one
	// still going; it is not damaged, and the difference matters to whoever is deciding what to
	// restore from.
	Complete bool
	// Bytes is what the archive occupies at the target, members and media together.
	Bytes int64
}

Description is one archive as the target knows it: what its manifest says, whether the run that wrote it finished, and how much room it takes.

It is what `GET /backup-targets/{id}/backups` answers, and it is assembled from the target alone - no row in any database is consulted. That is the property §8.1 promises and the one that makes a restore possible after a total loss.

type Encryption

type Encryption struct {
	// Mode is AES256_GCM or NONE, matching backup.EncryptionMode on the target that wrote it.
	Mode string `json:"mode"`
	// KeyID names the key. It is what makes a rotation cheap and BK-2's second half true: a new
	// archive is written under the new key, an old one keeps naming the old one, and neither is
	// rewritten. Empty on an unencrypted archive.
	KeyID string `json:"key_id,omitempty"`
	// KDF names the derivation, when the key came from a passphrase. Empty when the key was
	// supplied directly, and then the four numbers below are empty too.
	KDF         string `json:"kdf,omitempty"`
	Salt        string `json:"salt,omitempty"`
	Passes      uint32 `json:"passes,omitempty"`
	MemoryKiB   uint32 `json:"memory_kib,omitempty"`
	Parallelism uint8  `json:"parallelism,omitempty"`
	KeyLength   uint32 `json:"key_length,omitempty"`
	// ArchiveSalt is the salt this archive's key was derived with from the master key, base64:
	// drawn when the archive is written, so that every archive has a key of its own and the key
	// can be derived again from the manifest alone, after the database is lost
	// (backup-restore.md §4). Not secret. Empty on an archive written before archives carried
	// one, whose key is bound to the target that wrote it instead.
	ArchiveSalt string `json:"archive_salt,omitempty"`
}

Encryption describes how the members were protected, and - deliberately - nothing that would help anybody open them.

The salt and the cost parameters are here rather than derived from a constant for the reason core/port/crypto gives: the parameters this system derives with will be raised as machines get faster, and an archive written under the old ones has to keep opening. A salt and three numbers are not secret. The passphrase is not here and is not anywhere.

func (Encryption) IsEncrypted

func (e Encryption) IsEncrypted() bool

IsEncrypted reports whether the members have to be opened before they can be read.

type Entity

type Entity struct {
	// Name is the file under data/, without the extension.
	Name string
	// Table is where the rows come from.
	Table string
	// Optional marks an entity a run may leave out. Only the audit trail is: `include_audit` is a
	// schedule setting, because including it gives better evidence and keeps personal metadata
	// longer (backup-restore.md §7).
	Optional bool
	// Later marks an entity format 1 gained after archives were already written (tenant-export.md
	// §11, additive). It is written into every archive like any other; an archive from before it
	// carries no such file, and reads as holding none of its rows rather than as an archive
	// somebody tampered with.
	Later bool
	// Whole marks an entity that cannot say when one of its rows changed, and is therefore
	// written complete in every archive - including an incremental one - with a restore replacing
	// its set rather than merging into it.
	//
	// It is a property of the schema rather than a choice. A join table has no timestamp at all,
	// and a configuration row that carries a creation stamp and no change stamp cannot tell an
	// edit from silence. Deriving "unchanged" from a column that does not move is how an
	// incremental chain quietly loses an edit, and there is no column to add here that would not
	// be a lie until every writer maintained it.
	//
	// All of them are small by nature - the join tables and the configuration - so writing them
	// whole costs little, and it is the reading that has to be right.
	Whole bool
	// Keys are the columns that make up a record's identity, in the order Record.ID joins them
	// with "/" - the primary key without `tenant_id`, because the tenant comes from the scope a
	// restore runs in rather than from the archive.
	//
	// Declared rather than derived. A restore has to be able to say what a line identifies
	// without a database to ask, which is the whole premise of §8.1, and a build that read the
	// key from its own schema would read a schema the archive was not written under.
	Keys []string
	// References are the fields that point at another entity's rows. Only the ones inside the
	// archive: a reference to something a restore does not import is not a reference it can do
	// anything about.
	//
	// They exist for one purpose - DUPLICATE, which has to give the copy new identities and keep
	// the copies pointing at each other rather than at the originals - and they are kept honest
	// by a test that compares them against the foreign keys the database actually has.
	References []Reference
	// Duplicable says whether a collision in this entity may be settled by making a copy.
	//
	// Not everything may. An account is who somebody is, a media object is the same bytes under a
	// content address, and a webhook subscription copied is a subscription that fires twice. For those a DUPLICATE restore falls back to SKIP and says so
	// in the report - the alternative is a merge that quietly doubles the tenant's identities and
	// its outbound integrations.
	Duplicable bool
	// Unique are the columns, other than the identity, that carry a uniqueness a copy would meet.
	//
	// A DUPLICATE gives the copy an identity of its own, and changing nothing else would make a
	// duplicated collection arrive under the living one's name and land nothing.
	// The identity is not the only thing the schema insists is unique, and what is left is not a
	// special case each: it is a property of the entity, declared here beside Keys and References
	// and kept honest by a test that compares it against the unique indexes the database has.
	//
	// Only DUPLICATE consults them. Every one of these indexes is per tenant, and a NEW_TENANT
	// copy lands in a tenant that did not exist a moment ago.
	Unique []UniqueField
}

Entity is one aggregate's data file: what it is called in the archive, and which table it came from.

The two names are kept apart because they answer to different masters. The table name follows the schema and changes with a migration; the archive name is a wire format and may not change at all. Today every pair matches except in number, and that is a coincidence worth being able to break without a format version.

func Entities

func Entities() []Entity

Entities is what an archive holds, in restore order.

func FindEntity

func FindEntity(name string) (Entity, bool)

FindEntity answers the entity of that name.

func FindEntityByTable

func FindEntityByTable(table string) (Entity, bool)

FindEntityByTable answers the entity whose rows come from that table.

It exists because the deletion markers are written in the schema's vocabulary - the lifecycle records a tombstone against `work_item`, not against `work_items` - and an exporter turning those into archive lines has to cross from one naming to the other exactly once.

func RestoredEntities

func RestoredEntities() []Entity

RestoredEntities are the entities a restore writes, in the order it writes them: a row's parents before the row, so that the files are applied in sequence without deferring a foreign key.

The format's order with one change. `work_items` names `media_objects` in `cover_media_id` and `accounts` in `avatar_media_id`, references the format writes forward (tenant-export.md §6) and the schema enforces immediately, so the media are applied first, after the tenant - they reference nothing else, which is what makes that free. The same order, reversed, is what a replace empties the tenant in, and there it takes the covered entries out before the media their `ON DELETE RESTRICT` would hold.

func (Entity) HasOwnIdentity

func (e Entity) HasOwnIdentity() bool

HasOwnIdentity reports an entity whose identity is a single `id` column of its own rather than a key made of references to other entities.

It is the question DUPLICATE asks: an entity with an identity of its own needs a new one minted for the copy, and a join table's identity follows from the rows it joins - remap those and the key has changed by itself.

func (Entity) String

func (e Entity) String() string

String makes an entity printable in a test failure without spelling out both names by hand.

type File

type File struct {
	Path   string `json:"path"`
	Bytes  int64  `json:"bytes"`
	SHA256 string `json:"sha256"`
	// Records is how many JSON Lines the file holds before encryption. Zero for a member that is
	// not a data file.
	Records int64 `json:"records,omitempty"`
}

File is one member of the archive, as stored.

SHA256 is over the bytes at the target, which on an encrypted archive is the ciphertext. That is the one checksum `POST /backups/{id}:verify` can check without the key, and checking an archive at the target without restoring it is exactly what that endpoint promises (backup-restore.md §3).

type Keys

type Keys map[string]secret.Bytes

Keys holds the key of every archive of a chain, by archive identifier. Each archive is encrypted under a key of its own (backup-restore.md §4), and a medium may live in any archive of the chain, so reading a chain takes one key per archive rather than one for all of them.

func (Keys) Of

func (k Keys) Of(description Description) secret.Bytes

Of answers the key of the archive the description names, or an empty key where none is held.

type Manifest

type Manifest struct {
	FormatVersion int `json:"format_version"`
	// ArchiveID identifies this archive across targets and is what a later incremental names as
	// its parent. It is supplied by the caller from the ID port, never derived from the clock:
	// two runs in the same second at two targets must not collide.
	ArchiveID string `json:"archive_id"`
	// SchemaVersion is the migration the source database stood at. A restore compares it against
	// its own and runs the upward migrations in between - which is the whole reason the data is
	// JSON Lines rather than a pg_dump (§3).
	SchemaVersion string `json:"schema_version"`
	// ProductVersion is the build that wrote the archive. Diagnostic rather than load-bearing:
	// the restore path reads SchemaVersion, and this is what an operator quotes in a bug report.
	ProductVersion string `json:"product_version"`
	Mode           Mode   `json:"mode"`
	Scope          Scope  `json:"scope"`
	Period         Period `json:"period"`
	// SnapshotAt is the instant the export's REPEATABLE READ snapshot was taken, and therefore
	// the instant the archive represents (§5). It equals Period.To and is repeated under its own
	// name because that is what `backup_run.snapshot_at` records and what an operator reads.
	SnapshotAt time.Time `json:"snapshot_at"`
	// ParentID is the archive this one continues, empty on a full archive.
	ParentID string `json:"parent_id,omitempty"`
	// ParentPrefix is where that parent lies at the same target, so that a restore can walk the
	// chain by reading manifests rather than by listing and matching identifiers.
	ParentPrefix string     `json:"parent_prefix,omitempty"`
	Encryption   Encryption `json:"encryption"`
	// Counts is how many records each entity contributed, by entity name. It is what a dry run
	// reports before anything is written (§8.3) and what BK-3 compares.
	Counts map[string]int64 `json:"counts"`
	// MediaCount and MediaBytes describe the media without listing them. A content-addressed file
	// is named after the SHA-256 of its content, so a list of media checksums would be a list of
	// the file names - and on a holding with a hundred thousand attachments it would also be the
	// largest thing in the archive.
	MediaCount int64 `json:"media_count"`
	MediaBytes int64 `json:"media_bytes"`
	// Whole names the entities this archive carried complete rather than as a delta, even when it
	// is an incremental one - the join tables and the configuration rows whose schema cannot say
	// when they changed (Entity.Whole).
	//
	// It is in the manifest rather than only in the registry because it is a restore instruction:
	// for these entities the newest archive of a chain holds the whole truth, and the copies in
	// older archives are superseded rather than merged. A reader that had to consult its own
	// build's registry would be a reader deciding from its own version what an older archive
	// meant.
	Whole []string `json:"whole,omitempty"`
	// Files are the data members and their checksums. Media are deliberately absent, for the
	// reason above.
	Files []File `json:"files"`
}

Manifest is what an archive says about itself.

It is the one member that is never encrypted, and that is a decision rather than an oversight: §8.1 requires a restore to list the archives at a target "without requiring any state in the database", showing the timestamp, the scope, the size, full or incremental, the chain to the parent and the encryption key ID. An operator who has lost the database and is holding the target credentials has not necessarily got the archive key yet, and a manifest they cannot read makes the listing impossible - which turns the worst day into a guessing game about which of forty directories is the one to try.

What follows from that is a rule with teeth: no user content in the manifest. Not a container name, not an item title, not an account's address. Rule 10 applies to the exporter exactly as it applies to a log line, and here it applies for a second reason on top - this file is readable by whoever holds the storage.

func ReadManifest

func ReadManifest(r io.Reader) (Manifest, error)

ReadManifest parses a manifest, and refuses a format version it does not know before it looks at anything else.

The two-stage decode is the acceptance criterion in code. A single Unmarshal into Manifest would happily fill in the fields it recognised from a version 2 file and leave the rest at their zero values, and the caller would then restore an archive it had understood half of. So the version is read first, from a struct with one field, and a file this build cannot read is a typed error rather than a partial import.

func (Manifest) Encode

func (m Manifest) Encode(w io.Writer) error

Encode writes the manifest as the archive stores it: indented, so that an operator staring at a target with a text editor can read it, and with a trailing newline, so that the file is a well-behaved text file.

func (Manifest) Validate

func (m Manifest) Validate() error

Validate reports what makes a manifest unusable rather than merely unexpected.

It is deliberately about structure and not about content: whether the counts are true is a question for the checksums and for the reader, and a manifest that lied about them would still have to survive being parsed in order to be caught.

type Media

type Media interface {
	// Open answers the medium's content, or ErrNotFound. The caller closes the stream.
	Open(ctx context.Context, digest string) (io.ReadCloser, error)
}

Media opens a medium by its content address. Separate from Source because the two answer to different stores: the records come from the database, and the bytes of an attachment come from the object store.

type Mode

type Mode string

Mode is what an archive holds: everything, or what changed since its parent.

const (
	// ModeFull is a self-contained archive. It needs no parent and restores on its own.
	ModeFull Mode = "FULL"
	// ModeIncremental holds what changed since the parent it names, including the deletions.
	// Without its chain back to a full archive it restores nothing.
	ModeIncremental Mode = "INCREMENTAL"
)

func (Mode) Valid

func (m Mode) Valid() bool

type Op

type Op string

Op is what one line of a data file says happened to a row.

Two values, not the change log's three. ACCESS_REVOKED is a statement about one device's view and means nothing in an archive - an archive is the tenant's data, not a particular actor's sight of it (offline-sync.md §6).

const (
	// OpUpsert is the row as it stood at the snapshot. Creation and change are one operation for
	// the same reason they are one in the change log: the reader does not know which of the two
	// it is looking at, and does not need to.
	OpUpsert Op = "UPSERT"
	// OpDelete is a tombstone: the row was there in the parent archive and is gone now.
	//
	// Carrying these is what makes an incremental chain honest. A chain that only carried upserts
	// would restore every object that ever existed, and the ones deleted three runs ago would come
	// back looking current - the defect BK-3 and BK-6 exist to catch.
	OpDelete Op = "DELETE"
)

func (Op) Valid

func (o Op) Valid() bool

type Period

type Period struct {
	From time.Time `json:"from,omitempty"`
	To   time.Time `json:"to"`
}

Period is the stretch of time an archive accounts for.

From is exclusive and To is inclusive, which is what makes a chain of incrementals join up without a gap and without an overlap: the next run's From is this run's To. On a full archive From is the zero time, because a full archive accounts for everything that ever happened.

type Reader

type Reader struct {
	// contains filtered or unexported fields
}

Reader opens archives at a target.

func NewReader

func NewReader(store backupstorage.Store, cipher crypto.StreamCipher) *Reader

NewReader builds a reader for one target.

func (*Reader) Chain

func (r *Reader) Chain(ctx context.Context, prefix string) ([]Description, error)

Chain answers the archives a restore has to read, newest first, back to the full archive at the root.

It walks the parents by reading manifests rather than by matching identifiers against a listing, because a manifest names where its parent lies. A chain that does not end at a full archive is a refusal: restoring the part of a chain that happens to be present would restore a tenant to a state it was never in.

func (*Reader) Describe

func (r *Reader) Describe(ctx context.Context, prefix string) (Description, error)

Describe reads one archive's manifest and says whether the run that wrote it finished.

func (*Reader) List

func (r *Reader) List(ctx context.Context, prefix string) ([]Description, error)

List answers every archive beneath a prefix, newest first.

One listing, not one per archive. A target is somebody else's machine and a listing is the expensive call; grouping the keys locally is free.

func (*Reader) Medium

func (r *Reader) Medium(
	ctx context.Context, chain []Description, digest string, keys Keys,
) (io.ReadCloser, error)

Medium opens one medium, searching the chain from newest to oldest.

That search is the other half of "an incremental does not re-transfer a file that did not change": the file lives in whichever archive first referenced it, and a restore finds it by looking rather than by every archive carrying a copy.

func (*Reader) Probe

func (r *Reader) Probe(ctx context.Context, description Description, key secret.Bytes) error

Probe opens the first member of an encrypted archive far enough to authenticate it, so that a key that does not open the archive is found before anything is applied. It reads one chunk, not the member.

func (*Reader) Records

func (r *Reader) Records(
	ctx context.Context, description Description, entity Entity, key secret.Bytes, yield func(Record) error,
) error

Records hands out one entity's records, in the order they were written.

The member's checksum is verified as it is read rather than beforehand. A separate pass would read the whole file twice over somebody else's network, and it would also verify a different read from the one that was used - which is exactly the gap a flaky target lives in.

func (*Reader) Verify

func (r *Reader) Verify(ctx context.Context, prefix string) error

Verify checks an archive at the target without restoring it, and without the archive key - which is what `POST /backups/{id}:verify` promises (backup-restore.md §3).

Every member named in checksums.txt is read and hashed, including the manifest. Media are checked for presence rather than for content when the archive is encrypted: their name is the digest of the plaintext, and the bytes at the target are the ciphertext. On an unencrypted archive the name is checkable and is checked - the address is the checksum, which is the second thing content addressing pays for.

type Record

type Record struct {
	// ID is the row's identity within its entity. For a table with a composite primary key it is
	// the key's parts joined by "/" in the order the schema declares them, which is what lets a
	// DELETE line in a later archive name the same row an UPSERT named in an earlier one.
	ID string `json:"id"`
	Op Op     `json:"op"`
	// UpdatedAt is what an incremental run selects on. On a tombstone it is the deletion's time.
	UpdatedAt time.Time `json:"updated_at"`
	// Data is the row. Nil on a tombstone - a deletion carries no content, for the reason
	// sync.Change gives and one more: the fields of a row that is being deleted are exactly the
	// personal data an erasure was meant to remove.
	//
	// Written even when it is empty, which is deliberate: `omitempty` would make a row with no
	// fields beyond its identity - an item_label is exactly that - indistinguishable from a line
	// that lost its payload. An UPSERT always carries an object and a DELETE always carries null,
	// so a reader never has to guess which of the two an absent field meant.
	Data map[string]any `json:"data"`
	// Blobs are the media this row references, if any. Absent on a tombstone.
	Blobs []Blob `json:"blobs,omitempty"`
}

Record is one line of a data file.

It is deliberately flat and deliberately dumb: an identity, what happened, when, and the row's fields as they were. No nesting of children inside parents, because a parent that carried its children could not be written until they were all in memory - and the restore order in Entities puts parents first anyway.

func (Record) Validate

func (r Record) Validate() error

Validate reports what makes a line unusable. It is called on write and on read: a writer that produced a bad line and a target that handed one back are the same problem to whoever is restoring, and finding it on the way out is finding it a month earlier.

type Reference

type Reference struct {
	// Field is the column in this entity's data.
	Field string
	// Table is what it points at, in the schema's vocabulary - the same word the entity's own
	// Table is written in.
	Table string
	// Clearable marks a reference the row can lose and still be itself - a nullable column the
	// row merely uses, like an entry's cover. A restore that withholds the target clears such a
	// reference instead of withholding the row; a reference the row's identity rests on (a join
	// row, an attachment) keeps the row out with what it points at (backup-restore.md §7).
	Clearable bool
}

Reference is one field pointing at another entity's rows.

type Request

type Request struct {
	// ArchiveID identifies the archive. It comes from the ID port rather than from the clock, so
	// that two runs in the same second at two targets cannot collide.
	ArchiveID shared.ID
	// Prefix is the directory at the target, from Name. Everything this run writes lands beneath
	// it and nothing outside it.
	Prefix string
	Scope  Scope
	Mode   Mode
	// SnapshotAt is when the export's snapshot was taken. It is the archive's instant, and on an
	// incremental it is the next run's Since.
	SnapshotAt time.Time
	// Since is where an incremental starts, exclusive. Zero on a full archive.
	Since time.Time
	// ParentID and ParentPrefix name the archive an incremental continues.
	ParentID     string
	ParentPrefix string
	// Ancestors are the prefixes of the whole chain back to the full archive, newest first. They
	// are what makes "do not re-transfer a file that did not change" true: a medium already
	// stored anywhere in the chain is referenced rather than written again, and a restore
	// resolves a digest by searching the chain from newest to oldest.
	Ancestors []string

	SchemaVersion  string
	ProductVersion string

	// Encryption describes what protects the members, and is copied into the manifest verbatim.
	Encryption Encryption
	// Key is the archive key. Empty exactly when Encryption.Mode is NONE - a mismatch either way
	// is refused, because an archive that says it is encrypted and is not is worse than one that
	// says it is not.
	Key secret.Bytes

	// IncludeMedia and IncludeAudit are the two schedule settings that change what an archive
	// holds (backup-restore.md §5, §7).
	IncludeMedia bool
	IncludeAudit bool

	// Sections are files one archive carries beyond the format's entities, written after them
	// from the same source. Only a person's own copy of their data has one: what about them no
	// workspace archive may carry, because an administrator reads those (data-protection.md §4).
	// No restore reads a section - RestoredEntities never names one.
	Sections []Entity
}

Request is one run: what to write, where, and under which key.

type Scope

type Scope struct {
	Kind ScopeKind `json:"kind"`
	ID   string    `json:"id"`
}

Scope is what the archive covers, in the shape §5 writes it in a schedule.

type ScopeKind

type ScopeKind string

ScopeKind is what an archive covers.

const (
	// ScopeTenant is a whole tenant - the only scope a schedule can be configured with today
	// (backup-restore.md §5).
	ScopeTenant ScopeKind = "TENANT"
	// ScopeContainer is one hub or collection and what hangs below it. Not written by anything
	// yet; named here because the manifest field is read by a restore that must not have to guess
	// what an unfamiliar value means.
	ScopeContainer ScopeKind = "CONTAINER"
)

func (ScopeKind) Valid

func (k ScopeKind) Valid() bool

type Source

type Source interface {
	// Records hands every record of one entity to yield, oldest first.
	//
	// since is exclusive and is the zero time on a full archive. An incremental run passes its
	// parent's snapshot instant, and the source answers what changed after it - including the
	// tombstones, which are what stop a chain resurrecting deleted objects.
	Records(ctx context.Context, entity Entity, since time.Time, yield func(Record) error) error
}

Source is where an archive's records come from.

It is a callback rather than a slice, and that is the shape the whole export rests on: the database hands over one row at a time inside its snapshot, and a method returning []Record would be a method that reads a tenant into memory before writing a byte of it.

type UniqueField

type UniqueField struct {
	// Field is the column in this entity's data.
	Field string
	// Rule is what a copy does with the value when Within has not already settled it.
	Rule UniqueRule
	// Within are the reference columns the uniqueness is scoped by - the rest of the index.
	//
	// It is what stops the rule firing where it is not needed, and that is most of the time. A
	// duplicated collection lands under the duplicated hub, because `parent_id` is a reference and
	// a live parent is remapped at the copy; under a hub of its own the collection's name is free,
	// and suffixing it would disfigure a copy for a collision that cannot happen. What is left is
	// the top of the duplicated tree - a hub, whose `parent_id` is null and has nothing to follow.
	Within []string
}

UniqueField is one column a copy may not carry unchanged, and what the copy does with it.

type UniqueRule

type UniqueRule string

UniqueRule is how a copy settles a uniqueness Within did not already settle.

const (
	// UniqueRename gives the copy a name derived from the run, so that it stands beside the
	// original under a name of its own. See backup.DuplicatedName for the shape and for why it is
	// derived rather than counted.
	UniqueRename UniqueRule = "RENAME"
	// UniqueClear leaves the column empty, for a value that addresses the row to somebody outside
	// the product and therefore belongs to the row that was addressed. A work item's calendar UID
	// is the one: a calendar client minted it, keys its todo by it, and a copy carrying it would
	// be a second row claiming one address.
	UniqueClear UniqueRule = "CLEAR"
	// UniqueSkip makes the row one a DUPLICATE leaves alone, and the report says so - the answer
	// Duplicable already gives for an account or a medium, decided per row because Within is.
	//
	// A custom field definition is the one, and only when it is tenant-wide (`collection_id` is
	// null, which is how the schema says "the whole workspace"). Its key cannot be renamed:
	// `work_item.custom_fields` is a jsonb document keyed by that key rather than by the
	// definition's identity, so a renamed copy would be a field none of the copied values are
	// stored under. It cannot be emptied either - the column is NOT NULL under a CHECK. A copy
	// that can be told apart from the original by nothing is not a copy.
	//
	// A definition inside a collection needs none of this: the collection is remapped, so the copy
	// is a field of the copied collection and its key is free there.
	UniqueSkip UniqueRule = "SKIP"
)

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer assembles an archive at a target.

**The archive is streamed to the target as it is produced, not staged and then transferred**, and that is this task's own decision. Both were open. Staging makes a checksum over the whole archive trivial and a resumption after process death cheap, because the finished part is on local disk; streaming keeps memory and disk flat whatever the holding weighs.

Streaming wins on the case that decides it. The installation this system is built for runs in a container whose writable layer is small and whose data volume is the database - staging a hundred gigabytes there fails at the disk rather than at the target, which is the worse of the two failures because it happens on the machine that is still working. What staging bought is bought differently instead: the archive is a directory of members rather than one stream, so a checksum per member replaces a checksum over the whole, and a resumption finds the members already at the target with one List rather than on a scratch disk that a restarted container no longer has.

What that costs is honest to state: a member is written once and cannot be rewound, so a producer that fails half way leaves a member at the target that no manifest names. Nothing reads such a member - the manifest is written after them and checksums.txt after that - and generational retention removes the directory when it removes the run.

func NewWriter

func NewWriter(store backupstorage.Store, cipher crypto.StreamCipher, media Media) *Writer

NewWriter builds a writer for one target.

func (*Writer) Write

func (w *Writer) Write(ctx context.Context, request Request, source Source) (Manifest, error)

Write produces the archive and answers the manifest it wrote.

The order is the format: the data files, then the media they reference, then the manifest that names them, then checksums.txt. Nothing later depends on nothing earlier, and the last file is the one that says the run finished.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL