1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
|
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
)
// =============================================================================
// hosty export
// =============================================================================
func cmdExport(args []string) error {
fs := flag.NewFlagSet("export", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`Usage: hosty export -name NAME [-out FILE.hosty] [-include-data] [-include-secrets] [-state-dir DIR] [-system]
Export a .hosty file from the installed app state.
Use the same -system as 'hosty start'; it selects the state directory the app
was installed into.
By default only the _hosty_* tables are exported (image, config schema,
metadata) — app data tables are stripped. The result is a clean
redistributable artifact identical in content to the original packed file,
minus any runtime state the app has written.
With -include-data: all tables are exported, producing a full backup
including the app's own data (visits, users, etc.).
With -include-secrets: this app's sensitive config values from the environment
database are written into the export. The file must be kept confidential.
With -backup: alias for -include-data -include-secrets. Produces a
complete backup suitable for migrating a running instance. Keep
confidential.
Note: the live data.hosty is never modified; export always works on a copy.
Options:
`)
fs.PrintDefaults()
}
name := fs.String("name", "", "App name (required)")
out := fs.String("out", "", "Output .hosty file path (default: ./<name>.hosty)")
stateDir := fs.String("state-dir", "", "State directory (default: $XDG_STATE_HOME/hosty, or /var/lib/hosty with -system)")
system := fs.Bool("system", false, "Export a root portable service instead of a --user unit")
includeData := fs.Bool("include-data", false, "Include app data tables in the export")
includeSecrets := fs.Bool("include-secrets", false, "Include sensitive config values (keep the output file confidential)")
backup := fs.Bool("backup", false, "Alias for -include-data -include-secrets (full backup including secrets)")
if err := fs.Parse(args); err != nil {
return err
}
if *backup {
*includeData = true
*includeSecrets = true
}
if *name == "" {
fs.Usage()
return fmt.Errorf("-name is required")
}
sd, err := resolveStateDir(*stateDir, *system)
if err != nil {
return err
}
appDir := filepath.Join(sd, *name)
dataPath := filepath.Join(appDir, "data.hosty")
if !fileExists(dataPath) {
return fmt.Errorf("app %q is not installed", *name)
}
if *out == "" {
*out = "./" + *name + ".hosty"
}
if fileExists(*out) {
return fmt.Errorf("output file %q already exists", *out)
}
// Use VACUUM INTO to produce a complete, WAL-checkpointed copy of
// data.hosty. A raw file copy would miss unflushed WAL pages.
tmp := *out + ".tmp"
srcDB, err := openDB(dataPath)
if err != nil {
return fmt.Errorf("opening data.hosty: %w", err)
}
if _, err := srcDB.Exec(`VACUUM INTO ?`, tmp); err != nil {
srcDB.Close()
os.Remove(tmp)
return fmt.Errorf("vacuum into %s: %w", tmp, err)
}
srcDB.Close()
// Unless -include-data is set, strip all non-_hosty_ tables from the export.
if !*includeData {
tmpDB, err := openDB(tmp)
if err != nil {
os.Remove(tmp)
return fmt.Errorf("opening temp export for stripping: %w", err)
}
// Find all user tables (those not starting with _hosty_).
tableRows, err := tmpDB.Query(`
SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE '_hosty_%'
ORDER BY name
`)
if err != nil {
tmpDB.Close()
os.Remove(tmp)
return fmt.Errorf("listing tables: %w", err)
}
var userTables []string
for tableRows.Next() {
var t string
if err := tableRows.Scan(&t); err != nil {
continue
}
userTables = append(userTables, t)
}
tableRows.Close()
for _, t := range userTables {
if _, err := tmpDB.Exec(`DROP TABLE IF EXISTS "` + t + `"`); err != nil {
tmpDB.Close()
os.Remove(tmp)
return fmt.Errorf("dropping table %q: %w", t, err)
}
fmt.Fprintf(os.Stderr, "hosty export: stripped app table %q\n", t)
}
// VACUUM to reclaim the freed space.
if _, err := tmpDB.Exec(`VACUUM`); err != nil {
tmpDB.Close()
os.Remove(tmp)
return fmt.Errorf("vacuum after strip: %w", err)
}
tmpDB.Close()
}
if *includeSecrets {
fmt.Fprintf(os.Stderr, "hosty export: WARNING — including sensitive config values in export\n")
fmt.Fprintf(os.Stderr, "hosty export: keep %s confidential\n", *out)
// Open the vacuumed copy for writing + the environment database for
// reading this app's secrets.
tmpDB, err := openDB(tmp)
if err != nil {
os.Remove(tmp)
return fmt.Errorf("opening temp export: %w", err)
}
envDB, err := openEnvDB(sd)
if err != nil {
tmpDB.Close()
os.Remove(tmp)
return err
}
// Copy this app's secrets → _hosty_config.value in the temp copy.
//
// Scoped by app name: the environment database holds every app's
// secrets, so an unscoped read here would write other apps' values into
// this app's export.
secrets, err := secretsAll(envDB, *name)
if err != nil {
tmpDB.Close()
envDB.Close()
os.Remove(tmp)
return fmt.Errorf("reading secrets for %q: %w", *name, err)
}
for k, v := range secrets {
if _, err := tmpDB.Exec(`UPDATE _hosty_config SET value = ? WHERE key = ?`, v, k); err != nil {
tmpDB.Close()
envDB.Close()
os.Remove(tmp)
return fmt.Errorf("writing secret %q to export: %w", k, err)
}
fmt.Fprintf(os.Stderr, "hosty export: included sensitive config %q\n", k)
}
tmpDB.Close()
envDB.Close()
// chmod 600 the output since it contains secrets.
if err := os.Chmod(tmp, 0600); err != nil {
os.Remove(tmp)
return fmt.Errorf("chmod export: %w", err)
}
}
// Atomic rename.
if err := os.Rename(tmp, *out); err != nil {
os.Remove(tmp)
return fmt.Errorf("writing output: %w", err)
}
fi, _ := os.Stat(*out)
fmt.Fprintf(os.Stderr, "hosty export: wrote %s (%s)\n", *out, humanSize(fi.Size()))
return nil
}
|