mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
* feat: add the org_export_jobs and org_import_jobs tables plus the models behind them, so a whole organization can be written to a portable archive and read back on another instance, keeping the option columns typed (a text[] of data groups, an include_secrets boolean, a conflict_strategy check constraint) rather than a settings blob because the option set is small and fixed, and reserving jsonb only for the genuinely free-form parts that are read back for display alone (the source archive's manifest, per-table row counts, the import warning list), with partial indexes on the in-flight and expiring rows so the maintenance sweep stays cheap however much transfer history accumulates, an OrgDataGroup catalog that names the twelve slices of a workspace and carries the dependencies between them, and an org_archive audit entity so an export or import rides the existing audit spine into every teammate's dashboard * feat: add the schema-generic repository behind workspace archives, which reads and writes tables by name rather than through typed structs because that is the only way an archive stays correct as the schema grows, moving rows as jsonb in both directions via to_jsonb on the way out and jsonb_populate_recordset on the way in so Postgres performs every type conversion and no hand-written Go column mapping can drift from arrays, jsonb, tsvector, inet or enums, lifting the pool's 60s statement_timeout inside the export transaction because a full inbox read legitimately runs longer than that, introspecting generated, identity and not-null columns plus primary keys and foreign keys from the catalog rather than trusting a compiled list, and treating identifier safety as structural: table names come from the compiled registry and column names are always intersected against the destination catalog before reaching a query, so nothing out of an uploaded archive is ever interpolated * feat: add the workspace archive registry and on-disk format, covering all 110 organization-owned relations with their scope SQL, dependency order and per-table policy, plus 10 explicitly excluded ones each carrying the reason it must never travel (the KMS-wrapped org data key, in-flight OAuth handshakes, the websocket outbox, live sessions, a pending deletion that would otherwise schedule the destination workspace for destruction), naming the two key domains separately because Warmbly seals mailbox credentials under the instance CREDENTIALS_ENCRYPTION_KEY and everything else under the per-organization DEK and confusing them produces mailboxes that authenticate against nothing, defining the archive as a plain zip of newline-delimited JSON so an operator can unzip it and read the data in a text editor and so the manifest can be written last yet still be read first, and sealing archive secrets under an argon2id passphrase key with parameters deliberately heavier than the login hash since it is derived once per archive and guards every credential in the workspace against offline grinding * feat: implement the workspace export and import engines, streaming rows straight through untouched for the tables that have neither secrets nor blobs so a million-row inbox export stays cheap and only decoding the rows that must change, opening every sealed value against whichever key domain wrote it and re-sealing it under the archive passphrase on the way out then against the destination's own keys on the way in, blanking a credential rather than sinking the whole export when one mailbox cannot be read and clearing the guard flag alongside it so no row is left claiming ciphertext it no longer holds, applying an import inside a single transaction because a half-applied workspace is far worse than a long-running one, rewriting the organization id and matching members to destination accounts by email with unresolvable people blanked where the column is nullable and redirected to the importer where it is not, and running transfers in the accepting process rather than through a queue for the one reason that matters: the passphrase is then never written down anywhere * feat: make the per-organization DEK cache nil-safe in internal/app/cipher so a process built without Redis falls through to KMS on every call instead of dereferencing a nil cache handle, which is what lets warmblyctl run the workspace export and import commands at all: it deliberately attaches Redis as optional because the whole point of that CLI is working while the rest of the instance is down, and the decrypted-key cache was always an optimisation rather than a requirement * feat: add the hourly workspace-archive maintenance job that deletes finished archives past their seven-day retention window, since each one is a complete copy of a workspace sitting in object storage and must not accumulate, and closes out any export or import whose process died mid-run, which is the necessary counterpart to executing transfers in the accepting process so the passphrase is never persisted: without this sweep a restart would leave a job reporting running forever * feat: expose workspace export and import over the JWT-only organization routes and wire the service into the backend, gating every endpoint on workspace ownership through the existing requireOrgOwner check rather than a permission bit because an export with credentials is the single most sensitive artifact this product can produce and an import rewrites the workspace wholesale, so both belong at the same level as deleting it, spooling uploads to a temporary file since a zip needs random access and a length that a multi-gigabyte archive cannot supply from memory, handing that file's ownership to the background import so it outlives the request and is closed exactly when the job ends, streaming downloads with the archive's sha256 in a response header, and constructing the service with both key domains plus object storage so an archive can be opened, re-keyed and stored * feat: add warmblyctl org list, export and import so a self-hoster can move a workspace from the box without a browser, running the same engine in-process against Postgres and adding no HTTP surface to a CLI whose entire trust model is container or host access, resolving --org from whichever handle the operator has (id, slug, or the owner's email), streaming the archive to a file or to stdout so it can be piped straight into ssh with progress still readable on stderr, prompting for the credential passphrase twice through the existing password prompt so the terminal and pipe rules stay identical across every command, and defaulting the import path to a preflight report that names what already exists here and which members have no account before anything is written, with --dry-run to stop there * feat: add the dashboard API layer for workspace archives, fetching the data-group catalog from the server rather than restating it in the client so a new group appears the moment the backend knows about it, mirroring the server's group-dependency closure in expandGroups so the toggles a user sees always match what the archive actually gets, polling only while a transfer is in flight and dropping to no interval the moment none are active since a running job has no realtime event of its own, and downloading a finished archive as a blob through the authenticated client because the endpoint is bearer-authenticated and a plain anchor href cannot carry the token * feat: build the Settings and Data dashboard page for exporting and importing a workspace, following the settings section conventions and the in-app confirm rather than window.confirm, defaulting the export to every data group because a migration that quietly leaves data behind is worse than one that takes a while, marking the heavy groups so nobody exports a decade of inbox history unaware, requiring the credential passphrase twice behind a confirm that states plainly what the file will contain, and making the import a two-step flow where a preflight reads the archive and reports its origin, row counts, unsealable credentials, existing rows and unknown members before a single byte is written, so confirming is never a leap of faith * feat: register the Data settings section in the dashboard rail, route and realtime spine, placing it under Advanced beside the danger zone and gating it to the workspace owner so the nav matches what the endpoints actually allow, and mapping the new org_archive audit entity to the export and import query keys in useRealtimeEvents so an archive starting or landing refreshes the page for every teammate through the existing audit spine rather than a bespoke event * feat: document workspace export and import as a customer guide registered under Account and team, covering what each of the twelve data groups contains and which four dominate archive size, why credentials need a passphrase to travel at all and what happens to mailboxes when they do not, how members are matched to destination accounts by email and what becomes of anyone without one, the difference between keeping existing rows and replacing them, and a table of what deliberately does not import with the reason for each, because billing, plan overrides, worker placement, sync checkpoints and warmup pool membership belong to an instance rather than to a workspace * feat: document org list, export and import in the warmblyctl reference and point the deployment guide at them as the supported route between a self-hosted install and the hosted service in either direction, adding every flag with what it does, the two extra environment variables those commands read and the difference between them (a missing KMS provider stops the command because sealed values cannot be opened, while a missing CREDENTIALS_ENCRYPTION_KEY is only a warning that mailbox credentials will not move), the behaviour when Redis is down, and the warning that an archive carrying credentials is the most sensitive file this product produces * feat: record in AGENTS.md that a migration adding an organization-scoped table is not finished until that table is registered in internal/app/orgtransfer/spec.go, either in Tables with its group and scope or in ExcludedTables with the reason it must not travel, because data left out of the registry is silently absent from every archive and nobody discovers it until a customer's migration lands on the other side missing a feature's data, and spelling out the four things that are easy to get wrong when adding one: dependency order, the group boundary that needs a Requires entry only when a NOT NULL foreign key crosses it, which of the two key domains seals a ciphertext column, and which columns name something only the source instance knows
496 lines
14 KiB
Go
496 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/orgtransfer"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
func runOrg(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
orgUsage(os.Stderr)
|
|
return errors.New("`org` needs a subcommand. Pick one from the list above.")
|
|
}
|
|
|
|
switch args[0] {
|
|
case "help", "-h", "--help":
|
|
orgUsage(os.Stdout)
|
|
return nil
|
|
case "list":
|
|
return runOrgList(ctx, args[1:])
|
|
case "export":
|
|
return runOrgExport(ctx, args[1:])
|
|
case "import":
|
|
return runOrgImport(ctx, args[1:])
|
|
}
|
|
|
|
orgUsage(os.Stderr)
|
|
return fmt.Errorf("unknown subcommand `org %s`. Pick one from the list above.", args[0])
|
|
}
|
|
|
|
func orgUsage(w *os.File) {
|
|
fmt.Fprint(w, "Move a workspace between instances.\n\nUsage:\n warmblyctl org <subcommand> [flags]\n\nSubcommands:\n")
|
|
for _, c := range commands {
|
|
if !strings.HasPrefix(c.name, "org ") {
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, " %-16s %s\n", strings.TrimPrefix(c.name, "org "), c.summary)
|
|
}
|
|
fmt.Fprint(w, "\nExamples:\n")
|
|
for _, c := range commands {
|
|
if strings.HasPrefix(c.name, "org ") {
|
|
fmt.Fprintf(w, " %s\n", c.example)
|
|
}
|
|
}
|
|
fmt.Fprint(w, `
|
|
Data groups (--groups), comma separated. Omit to carry everything:
|
|
`)
|
|
for _, g := range models.OrgDataGroupCatalog {
|
|
note := ""
|
|
if g.Required {
|
|
note = " (always included)"
|
|
} else if g.Heavy {
|
|
note = " (large)"
|
|
}
|
|
fmt.Fprintf(w, " %-12s %s%s\n", g.Key, g.Label, note)
|
|
}
|
|
fmt.Fprint(w, `
|
|
Credentials only travel when you pass --with-credentials, which seals them into
|
|
the archive under a passphrase you supply. Import the archive with the same
|
|
passphrase and mailboxes come up connected; without it they arrive needing a
|
|
reconnect. The passphrase is never stored on either instance, so losing it means
|
|
re-exporting.
|
|
`)
|
|
}
|
|
|
|
// ---------- org list ----------
|
|
|
|
func runOrgList(ctx context.Context, args []string) error {
|
|
fs := newFlagSet("org list")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if err := noExtraArgs(fs); err != nil {
|
|
return err
|
|
}
|
|
|
|
c, err := connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.close()
|
|
|
|
rows, err := c.db.Query(ctx, `
|
|
SELECT o.id, o.name, u.email,
|
|
(SELECT count(*) FROM organization_members m WHERE m.organization_id = o.id),
|
|
(SELECT count(*) FROM email_accounts e WHERE e.organization_id = o.id),
|
|
(SELECT count(*) FROM contacts ct WHERE ct.organization_id = o.id)
|
|
FROM organizations o
|
|
LEFT JOIN users u ON u.id = o.owner_user_id
|
|
ORDER BY o.created_at
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("listing organizations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "ID\tNAME\tOWNER\tMEMBERS\tMAILBOXES\tCONTACTS")
|
|
var found int
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var name string
|
|
var owner *string
|
|
var members, mailboxes, contacts int64
|
|
if err := rows.Scan(&id, &name, &owner, &members, &mailboxes, &contacts); err != nil {
|
|
return err
|
|
}
|
|
ownerEmail := "(none)"
|
|
if owner != nil {
|
|
ownerEmail = *owner
|
|
}
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\n", id, name, ownerEmail, members, mailboxes, contacts)
|
|
found++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := tw.Flush(); err != nil {
|
|
return err
|
|
}
|
|
if found == 0 {
|
|
fmt.Println("\nThis instance has no organizations yet.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- org export ----------
|
|
|
|
func runOrgExport(ctx context.Context, args []string) error {
|
|
fs := newFlagSet("org export")
|
|
orgRef := fs.String("org", "", "organization id, slug, or owner email (required)")
|
|
out := fs.String("out", "", "file to write the archive to (required; - writes to stdout)")
|
|
groupList := fs.String("groups", "", "comma-separated data groups to include (default: all)")
|
|
withCreds := fs.Bool("with-credentials", false, "seal mailbox and integration credentials into the archive")
|
|
passphraseStdin := fs.Bool("passphrase-stdin", false, "read the passphrase from stdin instead of prompting")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if err := noExtraArgs(fs); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*orgRef) == "" {
|
|
return errors.New("--org is required. Run `warmblyctl org list` to see what is on this instance.")
|
|
}
|
|
if strings.TrimSpace(*out) == "" {
|
|
return errors.New("--out is required, for example --out ./workspace.warmbly.zip")
|
|
}
|
|
|
|
groups, err := parseGroups(*groupList)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var passphrase string
|
|
if *withCreds {
|
|
passphrase, err = readPassphrase(ctx, *passphraseStdin, "Passphrase for the archive's credentials")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := orgtransfer.ValidatePassphrase(passphrase); err != nil {
|
|
return fmt.Errorf("%w. Nothing was written.", err)
|
|
}
|
|
}
|
|
|
|
c, err := connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.close()
|
|
|
|
orgID, orgName, err := c.resolveOrg(ctx, *orgRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
svc, err := c.orgTransferService(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// stdout is supported so the archive can be piped straight into ssh or a
|
|
// bucket without ever touching this box's disk.
|
|
var w *os.File
|
|
if *out == "-" {
|
|
w = os.Stdout
|
|
} else {
|
|
w, err = os.Create(*out)
|
|
if err != nil {
|
|
return fmt.Errorf("creating %s: %w", *out, err)
|
|
}
|
|
defer w.Close()
|
|
}
|
|
|
|
progress := newProgressPrinter(*out == "-")
|
|
manifest, err := svc.ExportTo(ctx, orgID, orgtransfer.ExportOptions{
|
|
Groups: groups,
|
|
Passphrase: passphrase,
|
|
}, w, progress.report)
|
|
if err != nil {
|
|
return fmt.Errorf("exporting %s: %w", orgName, err)
|
|
}
|
|
progress.done()
|
|
|
|
if *out == "-" {
|
|
return nil
|
|
}
|
|
|
|
var total int64
|
|
for _, t := range manifest.Tables {
|
|
total += t.Rows
|
|
}
|
|
info, _ := w.Stat()
|
|
|
|
fmt.Printf("\nExported %s\n", orgName)
|
|
fmt.Printf(" File %s", *out)
|
|
if info != nil {
|
|
fmt.Printf(" (%s)", humanBytes(info.Size()))
|
|
}
|
|
fmt.Println()
|
|
fmt.Printf(" Rows %d across %d tables\n", total, len(manifest.Tables))
|
|
fmt.Printf(" Attachments %d\n", len(manifest.Blobs))
|
|
fmt.Printf(" Members %d\n", len(manifest.Members))
|
|
if manifest.Secrets != nil {
|
|
fmt.Printf(" Credentials sealed with your passphrase\n")
|
|
} else {
|
|
fmt.Printf(" Credentials not included; mailboxes will need reconnecting after import\n")
|
|
}
|
|
|
|
printSteps("Import it on the other instance with:", []string{
|
|
"warmblyctl org import --org <destination-org> --file " + *out +
|
|
map[bool]string{true: " --passphrase-stdin", false: ""}[manifest.Secrets != nil],
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ---------- org import ----------
|
|
|
|
func runOrgImport(ctx context.Context, args []string) error {
|
|
fs := newFlagSet("org import")
|
|
orgRef := fs.String("org", "", "destination organization id, slug, or owner email (required)")
|
|
file := fs.String("file", "", "archive to import (required)")
|
|
groupList := fs.String("groups", "", "comma-separated data groups to apply (default: everything in the archive)")
|
|
overwrite := fs.Bool("overwrite", false, "replace rows that already exist here instead of keeping them")
|
|
passphraseStdin := fs.Bool("passphrase-stdin", false, "read the passphrase from stdin instead of prompting")
|
|
withCreds := fs.Bool("with-credentials", false, "unseal the archive's credentials (prompts for the export passphrase)")
|
|
dryRun := fs.Bool("dry-run", false, "report what would be applied and write nothing")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if err := noExtraArgs(fs); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*orgRef) == "" {
|
|
return errors.New("--org is required. Run `warmblyctl org list` to see what is on this instance.")
|
|
}
|
|
if strings.TrimSpace(*file) == "" {
|
|
return errors.New("--file is required, for example --file ./workspace.warmbly.zip")
|
|
}
|
|
|
|
groups, err := parseGroups(*groupList)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Open(*file)
|
|
if err != nil {
|
|
return fmt.Errorf("opening %s: %w", *file, err)
|
|
}
|
|
defer f.Close()
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
archive := &fileArchive{f: f, size: stat.Size()}
|
|
|
|
var passphrase string
|
|
if *withCreds || *passphraseStdin {
|
|
passphrase, err = readPassphrase(ctx, *passphraseStdin, "Passphrase the archive was exported with")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
c, err := connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.close()
|
|
|
|
orgID, orgName, err := c.resolveOrg(ctx, *orgRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
svc, err := c.orgTransferService(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
report, xerr := svc.Preflight(ctx, orgID, archive, passphrase)
|
|
if xerr != nil {
|
|
return errors.New(xerr.Message)
|
|
}
|
|
|
|
fmt.Printf("Archive\n")
|
|
fmt.Printf(" Workspace %s\n", report.Archive.OrganizationName)
|
|
fmt.Printf(" Exported %s from %s\n",
|
|
report.Archive.ExportedAt.Format("2006-01-02 15:04 MST"), orNone(report.Archive.SourceInstance))
|
|
fmt.Printf(" Rows %d across %d tables\n", report.Archive.TotalRows(), len(report.Archive.RowCounts))
|
|
fmt.Printf(" Attachments %d\n", report.Archive.BlobCount)
|
|
fmt.Printf(" Credentials %s\n", credentialState(report))
|
|
fmt.Printf("\nDestination %s\n", orgName)
|
|
|
|
if len(report.UnknownMembers) > 0 {
|
|
lines := make([]string, 0, len(report.UnknownMembers))
|
|
for _, m := range report.UnknownMembers {
|
|
lines = append(lines, m.Email)
|
|
}
|
|
sort.Strings(lines)
|
|
printSteps("These members have no account here, so their rows are reassigned to the workspace owner:", lines)
|
|
}
|
|
if len(report.Conflicts) > 0 {
|
|
lines := make([]string, 0, len(report.Conflicts))
|
|
for table, n := range report.Conflicts {
|
|
lines = append(lines, fmt.Sprintf("%-32s %d", table, n))
|
|
}
|
|
sort.Strings(lines)
|
|
verb := "kept as they are"
|
|
if *overwrite {
|
|
verb = "REPLACED, because --overwrite is set"
|
|
}
|
|
printSteps("Rows that already exist here will be "+verb+":", lines)
|
|
}
|
|
for _, w := range report.Warnings {
|
|
warn("%s", w)
|
|
}
|
|
|
|
if *dryRun {
|
|
fmt.Println("\nDry run: nothing was written.")
|
|
return nil
|
|
}
|
|
|
|
conflict := models.OrgImportConflictSkip
|
|
if *overwrite {
|
|
conflict = models.OrgImportConflictOverwrite
|
|
}
|
|
|
|
owner, err := c.orgOwnerID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println()
|
|
progress := newProgressPrinter(false)
|
|
result, err := svc.ImportFrom(ctx, orgID, archive, orgtransfer.ImportOptions{
|
|
Groups: groups,
|
|
Conflict: conflict,
|
|
Passphrase: passphrase,
|
|
ActorUserID: owner,
|
|
}, progress.report)
|
|
if err != nil {
|
|
return fmt.Errorf("importing into %s: %w\nNothing was written: the import runs in one transaction.", orgName, err)
|
|
}
|
|
progress.done()
|
|
|
|
var applied int64
|
|
for _, n := range result.RowCounts {
|
|
applied += n
|
|
}
|
|
fmt.Printf("\nImported %d rows into %s\n", applied, orgName)
|
|
if result.SecretsApplied {
|
|
fmt.Println(" Credentials were unsealed and re-keyed for this instance.")
|
|
}
|
|
for _, w := range result.Warnings {
|
|
warn("%s", w)
|
|
}
|
|
|
|
printSteps("Next:", []string{
|
|
"Check the mailboxes in the dashboard; any that arrived without credentials show as needing a reconnect.",
|
|
"Workers are assigned by this instance, so a migrated mailbox is placed on its next scheduling pass.",
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
// fileArchive adapts an open file to what the zip reader needs.
|
|
type fileArchive struct {
|
|
f *os.File
|
|
size int64
|
|
}
|
|
|
|
func (a *fileArchive) ReadAt(p []byte, off int64) (int, error) { return a.f.ReadAt(p, off) }
|
|
func (a *fileArchive) Size() int64 { return a.size }
|
|
|
|
// parseGroups turns the --groups flag into a validated group list.
|
|
func parseGroups(raw string) ([]models.OrgDataGroup, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, nil
|
|
}
|
|
known := make(map[string]models.OrgDataGroup, len(models.AllOrgDataGroups))
|
|
for _, g := range models.AllOrgDataGroups {
|
|
known[string(g)] = g
|
|
}
|
|
|
|
var out []models.OrgDataGroup
|
|
for _, part := range strings.Split(raw, ",") {
|
|
name := strings.ToLower(strings.TrimSpace(part))
|
|
if name == "" {
|
|
continue
|
|
}
|
|
g, ok := known[name]
|
|
if !ok {
|
|
valid := make([]string, 0, len(models.AllOrgDataGroups))
|
|
for _, k := range models.AllOrgDataGroups {
|
|
valid = append(valid, string(k))
|
|
}
|
|
return nil, fmt.Errorf("unknown data group %q. Valid groups are: %s", name, strings.Join(valid, ", "))
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readPassphrase prompts twice on a terminal, or reads stdin when told to. It
|
|
// reuses the password prompt so the terminal rules are identical everywhere.
|
|
func readPassphrase(ctx context.Context, fromStdin bool, what string) (string, error) {
|
|
return readPassword(ctx, fromStdin, what)
|
|
}
|
|
|
|
func credentialState(r *models.OrgImportPreflight) string {
|
|
switch {
|
|
case !r.Archive.HasSecrets:
|
|
return "not in this archive; mailboxes will need reconnecting"
|
|
case r.SecretsUnsealed:
|
|
return "sealed, and your passphrase opens them"
|
|
default:
|
|
return "sealed, but no passphrase was given; mailboxes will need reconnecting"
|
|
}
|
|
}
|
|
|
|
func orNone(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "an unnamed instance"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for v := n / unit; v >= unit; v /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// progressPrinter keeps one line updated on stderr. It writes to stderr so
|
|
// `--out -` can pipe the archive on stdout with the progress still visible.
|
|
type progressPrinter struct {
|
|
quiet bool
|
|
last string
|
|
}
|
|
|
|
func newProgressPrinter(quiet bool) *progressPrinter {
|
|
return &progressPrinter{quiet: quiet}
|
|
}
|
|
|
|
func (p *progressPrinter) report(percent int, stage string) {
|
|
if p.quiet || stage == p.last {
|
|
return
|
|
}
|
|
p.last = stage
|
|
fmt.Fprintf(os.Stderr, "\r\033[K %3d%% %s", percent, stage)
|
|
}
|
|
|
|
func (p *progressPrinter) done() {
|
|
if p.quiet || p.last == "" {
|
|
return
|
|
}
|
|
fmt.Fprint(os.Stderr, "\r\033[K")
|
|
}
|