kumaconf 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kumaconf-0.1.0/LICENSE +21 -0
- kumaconf-0.1.0/PKG-INFO +109 -0
- kumaconf-0.1.0/README.md +87 -0
- kumaconf-0.1.0/pyproject.toml +44 -0
- kumaconf-0.1.0/setup.cfg +4 -0
- kumaconf-0.1.0/src/kumaconf/__init__.py +3 -0
- kumaconf-0.1.0/src/kumaconf/cli.py +206 -0
- kumaconf-0.1.0/src/kumaconf/db.py +52 -0
- kumaconf-0.1.0/src/kumaconf/exporter.py +129 -0
- kumaconf-0.1.0/src/kumaconf/importer.py +135 -0
- kumaconf-0.1.0/src/kumaconf/schema.py +89 -0
- kumaconf-0.1.0/src/kumaconf.egg-info/PKG-INFO +109 -0
- kumaconf-0.1.0/src/kumaconf.egg-info/SOURCES.txt +15 -0
- kumaconf-0.1.0/src/kumaconf.egg-info/dependency_links.txt +1 -0
- kumaconf-0.1.0/src/kumaconf.egg-info/entry_points.txt +2 -0
- kumaconf-0.1.0/src/kumaconf.egg-info/top_level.txt +1 -0
- kumaconf-0.1.0/tests/test_kumaconf.py +386 -0
kumaconf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Younes Z.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kumaconf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kumaconf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export the configuration of an Uptime Kuma instance from its SQLite file as JSON, and write it back into an empty instance. History is never read.
|
|
5
|
+
Author: Younes Z.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Rezarys/kumaconf
|
|
8
|
+
Project-URL: Issues, https://github.com/Rezarys/kumaconf/issues
|
|
9
|
+
Keywords: uptime-kuma,uptime kuma,uptime,monitoring,backup,restore,export,sqlite,self hosted
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# kumaconf
|
|
24
|
+
|
|
25
|
+
Export the configuration of an Uptime Kuma instance from its SQLite file as JSON, and restore it into an empty instance. Nothing else is read.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install kumaconf==0.1.0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Read the configuration of an Uptime Kuma instance out of its SQLite file, as readable JSON, and write it back into an empty instance. Monitors, notifications, tags, status pages and settings come out. Heartbeats and statistics never do.
|
|
32
|
+
|
|
33
|
+
Everything is done on the database file. There is no API call, no token, no account, no network traffic of any kind, and nothing is sent anywhere.
|
|
34
|
+
|
|
35
|
+
## Look before you touch
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
kumaconf check /path/to/kuma.db
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`check` only reads. It prints the tables it found, how many rows each one holds, the history tables it will never read, and the columns it will leave out because they hold secrets. Run it first.
|
|
42
|
+
|
|
43
|
+
An instance runs its database in WAL mode, and SQLite writes a `-shm` file next to a WAL database even when it is only reading it. So the file has to sit in a directory you can write to, and a copy of `kuma.db` alone loses whatever is still in its `-wal` file. Take the copy like this, or stop the instance and copy the three files together:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
sqlite3 kuma.db ".backup copy.db"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Export
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
kumaconf export /path/to/kuma.db -o kuma-config.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The output is plain JSON, one entry per table, with the column names written next to the rows. It holds configuration rows only, never history, so it stays small enough for a git repository, unlike a copy of the data directory.
|
|
56
|
+
|
|
57
|
+
Passwords, tokens and notification settings are left out by default, and the command says on standard error exactly which ones it left out. If you want them in the file:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
kumaconf export /path/to/kuma.db -o kuma-config.json --include-secrets
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That file then holds live credentials. Treat it as a secret.
|
|
64
|
+
|
|
65
|
+
A file exported without `--include-secrets` comes back with empty notification settings and empty passwords. A notification whose settings are empty will not send anything, and a status page that was password protected comes back without its password, so it comes back public. Export with `--include-secrets` if you want a restore that stands on its own, and keep that file out of git.
|
|
66
|
+
|
|
67
|
+
## Import into an empty instance
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
kumaconf import /path/to/new/kuma.db kuma-config.json --dry-run
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The dry run says what it would write and writes nothing. When it looks right, drop the flag:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
kumaconf import /path/to/new/kuma.db kuma-config.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Stop the target instance first, so that it is not writing to the file at the same time.
|
|
80
|
+
|
|
81
|
+
For every table the file carries, the import refuses to run if that table already holds rows in the target, and it refuses before writing anything at all. It is a restore into a fresh install, not a merge of two instances. The one exception is the `setting` table, where a key that already exists is left exactly as it is, and only missing keys are added. Keys that look like secrets, and the keys that identify the instance itself, are never written back, even from a file exported with `--include-secrets`.
|
|
82
|
+
|
|
83
|
+
Identifiers are kept, so the links between monitors, tags, notifications and status pages survive. Columns that exist in the file but not in the target database are dropped, and the command says so.
|
|
84
|
+
|
|
85
|
+
## What it never touches
|
|
86
|
+
|
|
87
|
+
- `heartbeat`, `stat_minutely`, `stat_hourly`, `stat_daily`, `monitor_tls_info` and `notification_sent_history`: history, never read, never written.
|
|
88
|
+
- `user`, `api_key` and the `better_auth_*` tables: accounts, password hashes, two factor seeds and sessions. They belong to the instance, not to its configuration.
|
|
89
|
+
- Secret columns, unless you ask for them: `basic_auth_pass`, `push_token`, `radius_password`, `radius_secret`, `oauth_client_secret`, `database_connection_string`, `headers`, `tls_key`, `kafka_producer_sasl_options`, `grpc_metadata`, the whole `config` column of `notification`, the status page password, and any setting key that looks like a secret. A column is treated as a secret when its name matches password, pass, secret, token, api key, credential, connection string, private key or two factor, plus the few above that carry a secret without saying so in their name.
|
|
90
|
+
|
|
91
|
+
Note that the `body` column of a monitor is exported as it is. If you put credentials in a request body, they are in the file.
|
|
92
|
+
|
|
93
|
+
## What is not verified
|
|
94
|
+
|
|
95
|
+
This was written against the upstream database schema, read on 2026-09-28 from `db/knex_init_db.js` and `db/knex_migrations` of the version 2 branch. **It has not been run against a live version 2 instance**, because I do not have one. The tool reads the real tables and columns of the file you give it rather than assuming a fixed schema, and it reports anything it did not expect, but the shape of the data in a running installation is not something I have checked.
|
|
96
|
+
|
|
97
|
+
This is why `check` exists, and why reading is done on a read only connection that cannot write to the database. The database file itself is never modified. Run `check` first, work on a copy of your database file, and please open an issue if what you see does not match what you expected.
|
|
98
|
+
|
|
99
|
+
## Why this exists
|
|
100
|
+
|
|
101
|
+
Request thread: https://github.com/louislam/uptime-kuma/issues/6045
|
|
102
|
+
|
|
103
|
+
People there dump the whole database while excluding four history tables by hand, or stop the service and archive the entire data directory. This tool knows which tables to exclude, so you do not have to.
|
|
104
|
+
|
|
105
|
+
## License and honesty
|
|
106
|
+
|
|
107
|
+
MIT. Not affiliated with the Uptime Kuma project. Built with AI assistance, reviewed and tested by me.
|
|
108
|
+
|
|
109
|
+
Tests: `python -m unittest discover -s tests -t tests` with `src` on `PYTHONPATH`.
|
kumaconf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# kumaconf
|
|
2
|
+
|
|
3
|
+
Export the configuration of an Uptime Kuma instance from its SQLite file as JSON, and restore it into an empty instance. Nothing else is read.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install kumaconf==0.1.0
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Read the configuration of an Uptime Kuma instance out of its SQLite file, as readable JSON, and write it back into an empty instance. Monitors, notifications, tags, status pages and settings come out. Heartbeats and statistics never do.
|
|
10
|
+
|
|
11
|
+
Everything is done on the database file. There is no API call, no token, no account, no network traffic of any kind, and nothing is sent anywhere.
|
|
12
|
+
|
|
13
|
+
## Look before you touch
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
kumaconf check /path/to/kuma.db
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`check` only reads. It prints the tables it found, how many rows each one holds, the history tables it will never read, and the columns it will leave out because they hold secrets. Run it first.
|
|
20
|
+
|
|
21
|
+
An instance runs its database in WAL mode, and SQLite writes a `-shm` file next to a WAL database even when it is only reading it. So the file has to sit in a directory you can write to, and a copy of `kuma.db` alone loses whatever is still in its `-wal` file. Take the copy like this, or stop the instance and copy the three files together:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
sqlite3 kuma.db ".backup copy.db"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Export
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
kumaconf export /path/to/kuma.db -o kuma-config.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The output is plain JSON, one entry per table, with the column names written next to the rows. It holds configuration rows only, never history, so it stays small enough for a git repository, unlike a copy of the data directory.
|
|
34
|
+
|
|
35
|
+
Passwords, tokens and notification settings are left out by default, and the command says on standard error exactly which ones it left out. If you want them in the file:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
kumaconf export /path/to/kuma.db -o kuma-config.json --include-secrets
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That file then holds live credentials. Treat it as a secret.
|
|
42
|
+
|
|
43
|
+
A file exported without `--include-secrets` comes back with empty notification settings and empty passwords. A notification whose settings are empty will not send anything, and a status page that was password protected comes back without its password, so it comes back public. Export with `--include-secrets` if you want a restore that stands on its own, and keep that file out of git.
|
|
44
|
+
|
|
45
|
+
## Import into an empty instance
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
kumaconf import /path/to/new/kuma.db kuma-config.json --dry-run
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The dry run says what it would write and writes nothing. When it looks right, drop the flag:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
kumaconf import /path/to/new/kuma.db kuma-config.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Stop the target instance first, so that it is not writing to the file at the same time.
|
|
58
|
+
|
|
59
|
+
For every table the file carries, the import refuses to run if that table already holds rows in the target, and it refuses before writing anything at all. It is a restore into a fresh install, not a merge of two instances. The one exception is the `setting` table, where a key that already exists is left exactly as it is, and only missing keys are added. Keys that look like secrets, and the keys that identify the instance itself, are never written back, even from a file exported with `--include-secrets`.
|
|
60
|
+
|
|
61
|
+
Identifiers are kept, so the links between monitors, tags, notifications and status pages survive. Columns that exist in the file but not in the target database are dropped, and the command says so.
|
|
62
|
+
|
|
63
|
+
## What it never touches
|
|
64
|
+
|
|
65
|
+
- `heartbeat`, `stat_minutely`, `stat_hourly`, `stat_daily`, `monitor_tls_info` and `notification_sent_history`: history, never read, never written.
|
|
66
|
+
- `user`, `api_key` and the `better_auth_*` tables: accounts, password hashes, two factor seeds and sessions. They belong to the instance, not to its configuration.
|
|
67
|
+
- Secret columns, unless you ask for them: `basic_auth_pass`, `push_token`, `radius_password`, `radius_secret`, `oauth_client_secret`, `database_connection_string`, `headers`, `tls_key`, `kafka_producer_sasl_options`, `grpc_metadata`, the whole `config` column of `notification`, the status page password, and any setting key that looks like a secret. A column is treated as a secret when its name matches password, pass, secret, token, api key, credential, connection string, private key or two factor, plus the few above that carry a secret without saying so in their name.
|
|
68
|
+
|
|
69
|
+
Note that the `body` column of a monitor is exported as it is. If you put credentials in a request body, they are in the file.
|
|
70
|
+
|
|
71
|
+
## What is not verified
|
|
72
|
+
|
|
73
|
+
This was written against the upstream database schema, read on 2026-09-28 from `db/knex_init_db.js` and `db/knex_migrations` of the version 2 branch. **It has not been run against a live version 2 instance**, because I do not have one. The tool reads the real tables and columns of the file you give it rather than assuming a fixed schema, and it reports anything it did not expect, but the shape of the data in a running installation is not something I have checked.
|
|
74
|
+
|
|
75
|
+
This is why `check` exists, and why reading is done on a read only connection that cannot write to the database. The database file itself is never modified. Run `check` first, work on a copy of your database file, and please open an issue if what you see does not match what you expected.
|
|
76
|
+
|
|
77
|
+
## Why this exists
|
|
78
|
+
|
|
79
|
+
Request thread: https://github.com/louislam/uptime-kuma/issues/6045
|
|
80
|
+
|
|
81
|
+
People there dump the whole database while excluding four history tables by hand, or stop the service and archive the entire data directory. This tool knows which tables to exclude, so you do not have to.
|
|
82
|
+
|
|
83
|
+
## License and honesty
|
|
84
|
+
|
|
85
|
+
MIT. Not affiliated with the Uptime Kuma project. Built with AI assistance, reviewed and tested by me.
|
|
86
|
+
|
|
87
|
+
Tests: `python -m unittest discover -s tests -t tests` with `src` on `PYTHONPATH`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kumaconf"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Export the configuration of an Uptime Kuma instance from its SQLite file as JSON, and write it back into an empty instance. History is never read."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Younes Z." }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"uptime-kuma",
|
|
15
|
+
"uptime kuma",
|
|
16
|
+
"uptime",
|
|
17
|
+
"monitoring",
|
|
18
|
+
"backup",
|
|
19
|
+
"restore",
|
|
20
|
+
"export",
|
|
21
|
+
"sqlite",
|
|
22
|
+
"self hosted",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Environment :: Console",
|
|
27
|
+
"Intended Audience :: System Administrators",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Topic :: System :: Monitoring",
|
|
31
|
+
"Topic :: System :: Archiving :: Backup",
|
|
32
|
+
"Topic :: Utilities",
|
|
33
|
+
]
|
|
34
|
+
dependencies = []
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/Rezarys/kumaconf"
|
|
38
|
+
Issues = "https://github.com/Rezarys/kumaconf/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
kumaconf = "kumaconf.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
kumaconf-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .db import DatabaseError, looks_like_uptime_database, open_readonly, table_names
|
|
10
|
+
from .exporter import export_config, inspect
|
|
11
|
+
from .importer import ImportRefused, import_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _warn_if_unexpected(path):
|
|
15
|
+
con = open_readonly(path)
|
|
16
|
+
try:
|
|
17
|
+
present = set(table_names(con))
|
|
18
|
+
finally:
|
|
19
|
+
con.close()
|
|
20
|
+
if not looks_like_uptime_database(present):
|
|
21
|
+
print(
|
|
22
|
+
"warning: this file does not carry the tables this tool expects "
|
|
23
|
+
"(monitor, setting). Reading it anyway, nothing is written.",
|
|
24
|
+
file=sys.stderr,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_check(args):
|
|
29
|
+
_warn_if_unexpected(args.database)
|
|
30
|
+
report = inspect(args.database)
|
|
31
|
+
if args.json:
|
|
32
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
33
|
+
return 0
|
|
34
|
+
print("database: %s" % report["database"])
|
|
35
|
+
print("")
|
|
36
|
+
print("configuration tables read:")
|
|
37
|
+
for table in report["tables_found"]:
|
|
38
|
+
print(" %-24s %6d row(s)" % (table, report["counts"][table]))
|
|
39
|
+
if report["tables_missing"]:
|
|
40
|
+
print("")
|
|
41
|
+
print("not in this file: %s" % ", ".join(report["tables_missing"]))
|
|
42
|
+
if report["history_tables_present"]:
|
|
43
|
+
print("")
|
|
44
|
+
print(
|
|
45
|
+
"history tables found and never read: %s"
|
|
46
|
+
% ", ".join(report["history_tables_present"])
|
|
47
|
+
)
|
|
48
|
+
if report["account_tables_present"]:
|
|
49
|
+
print(
|
|
50
|
+
"account tables found and never read: %s"
|
|
51
|
+
% ", ".join(report["account_tables_present"])
|
|
52
|
+
)
|
|
53
|
+
if report["unknown_tables"]:
|
|
54
|
+
print("")
|
|
55
|
+
print(
|
|
56
|
+
"tables this version does not know about, left alone: %s"
|
|
57
|
+
% ", ".join(report["unknown_tables"])
|
|
58
|
+
)
|
|
59
|
+
if report["secret_columns"]:
|
|
60
|
+
print("")
|
|
61
|
+
print("columns left out unless you pass --include-secrets:")
|
|
62
|
+
for table in sorted(report["secret_columns"]):
|
|
63
|
+
print(" %-24s %s" % (table, ", ".join(report["secret_columns"][table])))
|
|
64
|
+
print("")
|
|
65
|
+
print("nothing was written. This command only reads.")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_export(args):
|
|
70
|
+
_warn_if_unexpected(args.database)
|
|
71
|
+
document = export_config(args.database, include_secrets=args.include_secrets)
|
|
72
|
+
text = json.dumps(document, indent=2, sort_keys=False)
|
|
73
|
+
if args.output in (None, "-"):
|
|
74
|
+
print(text)
|
|
75
|
+
else:
|
|
76
|
+
with open(args.output, "w", encoding="utf-8") as handle:
|
|
77
|
+
handle.write(text + "\n")
|
|
78
|
+
counts = {t: len(v["rows"]) for t, v in document["tables"].items()}
|
|
79
|
+
total = sum(counts.values())
|
|
80
|
+
print(
|
|
81
|
+
"wrote %s: %d row(s) over %d table(s)" % (args.output, total, len(counts)),
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
if document["omitted_secrets"] and not args.include_secrets:
|
|
85
|
+
print(
|
|
86
|
+
"secrets left out: %s" % ", ".join(document["omitted_secrets"]),
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_import(args):
|
|
93
|
+
with open(args.file, "r", encoding="utf-8") as handle:
|
|
94
|
+
document = json.load(handle)
|
|
95
|
+
result = import_config(args.database, document, dry_run=args.dry_run)
|
|
96
|
+
head = "would insert" if args.dry_run else "inserted"
|
|
97
|
+
for table in sorted(result["inserted"]):
|
|
98
|
+
print("%s %-24s %6d row(s)" % (head, table, result["inserted"][table]))
|
|
99
|
+
for table in sorted(result["columns_dropped"]):
|
|
100
|
+
print(
|
|
101
|
+
"column(s) not in this database, dropped from %s: %s"
|
|
102
|
+
% (table, ", ".join(result["columns_dropped"][table])),
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
for table in sorted(result["columns_left_to_default"]):
|
|
106
|
+
print(
|
|
107
|
+
"column(s) absent from the file, left to their default in %s: %s"
|
|
108
|
+
% (table, ", ".join(result["columns_left_to_default"][table])),
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
)
|
|
111
|
+
if result["tables_not_in_database"]:
|
|
112
|
+
print(
|
|
113
|
+
"table(s) in the file but not in this database: %s"
|
|
114
|
+
% ", ".join(result["tables_not_in_database"]),
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
if result["settings_skipped"]:
|
|
118
|
+
print(
|
|
119
|
+
"setting key(s) not written: %s" % ", ".join(result["settings_skipped"]),
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
if result["secrets_absent_from_the_file"]:
|
|
123
|
+
print(
|
|
124
|
+
"these were left out when the file was exported, so they are not restored and have "
|
|
125
|
+
"to be typed in again: %s"
|
|
126
|
+
% ", ".join(result["secrets_absent_from_the_file"]),
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
print(
|
|
130
|
+
"a notification whose settings are empty will not send anything, and a status page "
|
|
131
|
+
"that was password protected comes back without its password.",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
if args.dry_run:
|
|
135
|
+
print("dry run: nothing was written.")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_parser():
|
|
140
|
+
parser = argparse.ArgumentParser(
|
|
141
|
+
prog="kumaconf",
|
|
142
|
+
description=(
|
|
143
|
+
"Read the configuration of a self hosted uptime monitor out of its SQLite file, "
|
|
144
|
+
"as readable JSON, and write it back into an empty instance. History is never read."
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument("--version", action="version", version="kumaconf %s" % __version__)
|
|
148
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
149
|
+
|
|
150
|
+
check = subparsers.add_parser(
|
|
151
|
+
"check", help="show what is in the database file. Writes nothing."
|
|
152
|
+
)
|
|
153
|
+
check.add_argument("database", help="path to the SQLite file of the instance")
|
|
154
|
+
check.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
155
|
+
check.set_defaults(func=cmd_check)
|
|
156
|
+
|
|
157
|
+
export = subparsers.add_parser("export", help="write the configuration as JSON")
|
|
158
|
+
export.add_argument("database", help="path to the SQLite file of the instance")
|
|
159
|
+
export.add_argument(
|
|
160
|
+
"-o", "--output", default="-", help="file to write, or - for standard output"
|
|
161
|
+
)
|
|
162
|
+
export.add_argument(
|
|
163
|
+
"--include-secrets",
|
|
164
|
+
action="store_true",
|
|
165
|
+
help="keep passwords, tokens and notification settings in the output",
|
|
166
|
+
)
|
|
167
|
+
export.set_defaults(func=cmd_export)
|
|
168
|
+
|
|
169
|
+
restore = subparsers.add_parser(
|
|
170
|
+
"import", help="write a JSON configuration into an empty instance"
|
|
171
|
+
)
|
|
172
|
+
restore.add_argument("database", help="path to the SQLite file of the empty instance")
|
|
173
|
+
restore.add_argument("file", help="the JSON file written by export")
|
|
174
|
+
restore.add_argument(
|
|
175
|
+
"--dry-run", action="store_true", help="say what would be written, write nothing"
|
|
176
|
+
)
|
|
177
|
+
restore.set_defaults(func=cmd_import)
|
|
178
|
+
return parser
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main(argv=None):
|
|
182
|
+
parser = build_parser()
|
|
183
|
+
args = parser.parse_args(argv)
|
|
184
|
+
if getattr(args, "func", None) is None:
|
|
185
|
+
parser.print_help()
|
|
186
|
+
return 2
|
|
187
|
+
try:
|
|
188
|
+
return args.func(args)
|
|
189
|
+
except (DatabaseError, ImportRefused) as exc:
|
|
190
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
except sqlite3.Error as exc:
|
|
193
|
+
print(
|
|
194
|
+
"error: sqlite could not use this file: %s. A database left in WAL mode needs to "
|
|
195
|
+
"write a -shm file next to itself even to be read, so copy it to a directory you "
|
|
196
|
+
"can write to, or take the copy with: sqlite3 kuma.db \".backup copy.db\"" % exc,
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
return 1
|
|
200
|
+
except (OSError, ValueError) as exc:
|
|
201
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
sys.exit(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Small SQLite helpers. Reading is always done on a read only connection."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
from urllib.request import pathname2url
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def open_readonly(path):
|
|
13
|
+
"""Open the database file without any possibility of writing to it."""
|
|
14
|
+
if not os.path.isfile(path):
|
|
15
|
+
raise DatabaseError("no such database file: %s" % path)
|
|
16
|
+
uri = "file:%s?mode=ro" % pathname2url(os.path.abspath(path))
|
|
17
|
+
try:
|
|
18
|
+
return sqlite3.connect(uri, uri=True)
|
|
19
|
+
except sqlite3.Error as exc:
|
|
20
|
+
raise DatabaseError("cannot open %s for reading: %s" % (path, exc))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def open_readwrite(path):
|
|
24
|
+
if not os.path.isfile(path):
|
|
25
|
+
raise DatabaseError("no such database file: %s" % path)
|
|
26
|
+
try:
|
|
27
|
+
con = sqlite3.connect(path)
|
|
28
|
+
except sqlite3.Error as exc:
|
|
29
|
+
raise DatabaseError("cannot open %s: %s" % (path, exc))
|
|
30
|
+
con.execute("PRAGMA foreign_keys = ON")
|
|
31
|
+
return con
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def table_names(con):
|
|
35
|
+
rows = con.execute(
|
|
36
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
|
|
37
|
+
).fetchall()
|
|
38
|
+
return [r[0] for r in rows]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def column_names(con, table):
|
|
42
|
+
rows = con.execute('PRAGMA table_info("%s")' % table.replace('"', '""')).fetchall()
|
|
43
|
+
return [r[1] for r in rows]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def row_count(con, table):
|
|
47
|
+
return con.execute('SELECT COUNT(*) FROM "%s"' % table.replace('"', '""')).fetchone()[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def looks_like_uptime_database(present):
|
|
51
|
+
"""True when the file carries the tables this tool was written for."""
|
|
52
|
+
return "monitor" in present and "setting" in present
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Read a configuration out of the database file. Nothing here ever writes."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .db import DatabaseError, column_names, open_readonly, row_count, table_names
|
|
8
|
+
from .schema import (
|
|
9
|
+
ACCOUNT_TABLES,
|
|
10
|
+
CONFIG_TABLES,
|
|
11
|
+
FORMAT_VERSION,
|
|
12
|
+
HISTORY_TABLES,
|
|
13
|
+
is_secret_setting_key,
|
|
14
|
+
secret_columns,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _now():
|
|
19
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def inspect(path):
|
|
23
|
+
"""Read the file and describe what is in it. Used by both `check` and `export`."""
|
|
24
|
+
con = open_readonly(path)
|
|
25
|
+
try:
|
|
26
|
+
present = set(table_names(con))
|
|
27
|
+
report = {
|
|
28
|
+
"database": os.path.basename(path),
|
|
29
|
+
"tables_found": [],
|
|
30
|
+
"tables_missing": [],
|
|
31
|
+
"history_tables_present": sorted(t for t in HISTORY_TABLES if t in present),
|
|
32
|
+
"account_tables_present": sorted(t for t in ACCOUNT_TABLES if t in present),
|
|
33
|
+
"unknown_tables": sorted(
|
|
34
|
+
t
|
|
35
|
+
for t in present
|
|
36
|
+
if t not in CONFIG_TABLES
|
|
37
|
+
and t not in HISTORY_TABLES
|
|
38
|
+
and t not in ACCOUNT_TABLES
|
|
39
|
+
and not t.startswith("sqlite_")
|
|
40
|
+
and t != "knex_migrations"
|
|
41
|
+
and t != "knex_migrations_lock"
|
|
42
|
+
),
|
|
43
|
+
"counts": {},
|
|
44
|
+
"secret_columns": {},
|
|
45
|
+
}
|
|
46
|
+
for table in CONFIG_TABLES:
|
|
47
|
+
if table not in present:
|
|
48
|
+
report["tables_missing"].append(table)
|
|
49
|
+
continue
|
|
50
|
+
report["tables_found"].append(table)
|
|
51
|
+
report["counts"][table] = row_count(con, table)
|
|
52
|
+
found = secret_columns(table, column_names(con, table))
|
|
53
|
+
if found:
|
|
54
|
+
report["secret_columns"][table] = list(found)
|
|
55
|
+
return report
|
|
56
|
+
finally:
|
|
57
|
+
con.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def export_config(path, include_secrets=False):
|
|
61
|
+
"""Return the configuration of the database as a plain dictionary."""
|
|
62
|
+
con = open_readonly(path)
|
|
63
|
+
try:
|
|
64
|
+
present = set(table_names(con))
|
|
65
|
+
document = {
|
|
66
|
+
"kumaconf": {
|
|
67
|
+
"format_version": FORMAT_VERSION,
|
|
68
|
+
"tool_version": __version__,
|
|
69
|
+
"generated_at": _now(),
|
|
70
|
+
"source_database": os.path.basename(path),
|
|
71
|
+
"secrets_included": bool(include_secrets),
|
|
72
|
+
},
|
|
73
|
+
"tables": {},
|
|
74
|
+
"tables_not_found": [],
|
|
75
|
+
"omitted_secrets": [],
|
|
76
|
+
"history_tables_skipped": sorted(t for t in HISTORY_TABLES if t in present),
|
|
77
|
+
}
|
|
78
|
+
omitted = set()
|
|
79
|
+
for table in CONFIG_TABLES:
|
|
80
|
+
if table not in present:
|
|
81
|
+
document["tables_not_found"].append(table)
|
|
82
|
+
continue
|
|
83
|
+
columns = column_names(con, table)
|
|
84
|
+
if not columns:
|
|
85
|
+
raise DatabaseError("table %s has no columns" % table)
|
|
86
|
+
hidden = () if include_secrets else secret_columns(table, columns)
|
|
87
|
+
quoted = ", ".join('"%s"' % c for c in columns)
|
|
88
|
+
rows = []
|
|
89
|
+
for raw in con.execute(
|
|
90
|
+
'SELECT %s FROM "%s" ORDER BY rowid' % (quoted, table)
|
|
91
|
+
):
|
|
92
|
+
row = list(raw)
|
|
93
|
+
for name in hidden:
|
|
94
|
+
index = columns.index(name)
|
|
95
|
+
if row[index] is not None:
|
|
96
|
+
row[index] = None
|
|
97
|
+
omitted.add("%s.%s" % (table, name))
|
|
98
|
+
if table == "setting" and not include_secrets:
|
|
99
|
+
row = _mask_setting(columns, row, omitted)
|
|
100
|
+
rows.append(_encode_row(row))
|
|
101
|
+
document["tables"][table] = {"columns": columns, "rows": rows}
|
|
102
|
+
document["omitted_secrets"] = sorted(omitted)
|
|
103
|
+
return document
|
|
104
|
+
finally:
|
|
105
|
+
con.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mask_setting(columns, row, omitted):
|
|
109
|
+
if "key" not in columns or "value" not in columns:
|
|
110
|
+
return row
|
|
111
|
+
key = row[columns.index("key")]
|
|
112
|
+
if is_secret_setting_key(key):
|
|
113
|
+
value_index = columns.index("value")
|
|
114
|
+
if row[value_index] is not None:
|
|
115
|
+
row[value_index] = None
|
|
116
|
+
omitted.add("setting.value (key %s)" % key)
|
|
117
|
+
return row
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _encode_row(row):
|
|
121
|
+
"""Make the row safe for JSON. Binary values are refused rather than mangled."""
|
|
122
|
+
out = []
|
|
123
|
+
for value in row:
|
|
124
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
125
|
+
raise DatabaseError(
|
|
126
|
+
"a binary value was found in the configuration, which this version does not export"
|
|
127
|
+
)
|
|
128
|
+
out.append(value)
|
|
129
|
+
return out
|