flova 0.1.0__py3-none-any.whl
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.
- flova/__init__.py +0 -0
- flova/__main__.py +3 -0
- flova/application/__init__.py +0 -0
- flova/application/deploy.py +46 -0
- flova/application/finance.py +206 -0
- flova/application/ledger.py +24 -0
- flova/application/services.py +15 -0
- flova/bootstrap.py +35 -0
- flova/config.py +108 -0
- flova/core/__init__.py +0 -0
- flova/core/errors.py +8 -0
- flova/core/ports.py +27 -0
- flova/entrypoints/__init__.py +0 -0
- flova/entrypoints/cli.py +147 -0
- flova/entrypoints/download.py +24 -0
- flova/entrypoints/http.py +84 -0
- flova/infrastructure/__init__.py +0 -0
- flova/infrastructure/beancount.py +349 -0
- flova/infrastructure/download.py +22 -0
- flova/infrastructure/git.py +19 -0
- flova/infrastructure/hook.py +18 -0
- flova/infrastructure/process.py +11 -0
- flova/infrastructure/runtime.py +348 -0
- flova-0.1.0.dist-info/METADATA +54 -0
- flova-0.1.0.dist-info/RECORD +28 -0
- flova-0.1.0.dist-info/WHEEL +5 -0
- flova-0.1.0.dist-info/entry_points.txt +3 -0
- flova-0.1.0.dist-info/top_level.txt +1 -0
flova/__init__.py
ADDED
|
File without changes
|
flova/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from flova.config import Webhook
|
|
8
|
+
from flova.core.ports import Repository, SyncHook
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DeploymentService:
|
|
12
|
+
def __init__(self, config: Webhook, repository: Repository, hook: SyncHook, lock=None):
|
|
13
|
+
self.config, self.repository, self.hook = config, repository, hook
|
|
14
|
+
self.lock = lock if lock is not None else threading.Lock()
|
|
15
|
+
|
|
16
|
+
def handle(self, payload: bytes, signature: str, event: str):
|
|
17
|
+
expected = (
|
|
18
|
+
"sha256=" + hmac.new(self.config.secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
19
|
+
)
|
|
20
|
+
if not signature or not hmac.compare_digest(expected, signature):
|
|
21
|
+
return 401, "Invalid signature"
|
|
22
|
+
if event != "push":
|
|
23
|
+
return 200, "Ignored non-push event"
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(payload)
|
|
26
|
+
except (ValueError, UnicodeDecodeError):
|
|
27
|
+
return 400, "Invalid JSON"
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
return 400, "Invalid JSON"
|
|
30
|
+
if data.get("ref") != "refs/heads/" + self.config.branch:
|
|
31
|
+
return 200, "Ignored branch"
|
|
32
|
+
if not self.lock.acquire(blocking=False):
|
|
33
|
+
return 409, "Deployment in progress"
|
|
34
|
+
try:
|
|
35
|
+
if not self.repository.sync(self.config.repository, self.config.branch):
|
|
36
|
+
return 500, "Deploy failed"
|
|
37
|
+
try:
|
|
38
|
+
self.hook.trigger()
|
|
39
|
+
except Exception as error:
|
|
40
|
+
logging.warning("Post-sync hook failed: %s", type(error).__name__)
|
|
41
|
+
return 200, "Deploy success"
|
|
42
|
+
except Exception as error:
|
|
43
|
+
logging.error("Deployment failed: %s", type(error).__name__)
|
|
44
|
+
return 500, "Deploy failed"
|
|
45
|
+
finally:
|
|
46
|
+
self.lock.release()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
from flova.core.errors import StageError
|
|
10
|
+
from flova.core.ports import CommandRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def as_json(text, stage):
|
|
14
|
+
try:
|
|
15
|
+
return json.loads(text)
|
|
16
|
+
except (ValueError, TypeError):
|
|
17
|
+
raise StageError(stage, "invalid-json") from None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def prepare(args, run):
|
|
21
|
+
day = args.date or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
|
|
22
|
+
datetime.strptime(day, "%Y-%m-%d")
|
|
23
|
+
config = json.loads((args.flow / "config/download.json").read_text())
|
|
24
|
+
errors = []
|
|
25
|
+
ready = 0
|
|
26
|
+
for provider, settings in config.items():
|
|
27
|
+
stage = "download"
|
|
28
|
+
try:
|
|
29
|
+
if provider not in {"alipay", "wechat"}:
|
|
30
|
+
raise StageError("download", "unsupported-provider")
|
|
31
|
+
if not args.offline:
|
|
32
|
+
result = as_json(
|
|
33
|
+
run([args.dflow, "download", "-p", provider], stage, args.flow, timeout=300),
|
|
34
|
+
stage,
|
|
35
|
+
)
|
|
36
|
+
if result.get("status") != "success":
|
|
37
|
+
raise StageError(stage, result.get("status", "unknown"))
|
|
38
|
+
stage = "unzip"
|
|
39
|
+
target = Path(settings["target"])
|
|
40
|
+
output = target / f"{day}.{settings['suffix']}"
|
|
41
|
+
# A stale output must not make a failed unzip look successful.
|
|
42
|
+
import tempfile
|
|
43
|
+
|
|
44
|
+
with tempfile.TemporaryDirectory(prefix="n8n-unzip-", dir=target) as directory:
|
|
45
|
+
run(
|
|
46
|
+
[
|
|
47
|
+
args.oub,
|
|
48
|
+
"unzip",
|
|
49
|
+
"--path",
|
|
50
|
+
str(target / f"{day}.zip"),
|
|
51
|
+
"--output",
|
|
52
|
+
directory,
|
|
53
|
+
"--name",
|
|
54
|
+
output.name,
|
|
55
|
+
],
|
|
56
|
+
stage,
|
|
57
|
+
args.flow,
|
|
58
|
+
)
|
|
59
|
+
extracted = Path(directory) / output.name
|
|
60
|
+
if not extracted.is_file() or extracted.stat().st_size == 0:
|
|
61
|
+
raise StageError(stage, "missing-output")
|
|
62
|
+
os.replace(extracted, output)
|
|
63
|
+
ready += 1
|
|
64
|
+
except (StageError, OSError, subprocess.TimeoutExpired) as error:
|
|
65
|
+
errors.append(
|
|
66
|
+
{
|
|
67
|
+
"ok": False,
|
|
68
|
+
"stage": stage,
|
|
69
|
+
"provider": provider,
|
|
70
|
+
"message": str(error)
|
|
71
|
+
if isinstance(error, StageError)
|
|
72
|
+
else f"{stage} failed; check server configuration",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if not ready:
|
|
76
|
+
return errors
|
|
77
|
+
try:
|
|
78
|
+
flags = [] if args.offline else ["--write"]
|
|
79
|
+
report = as_json(
|
|
80
|
+
run(
|
|
81
|
+
[
|
|
82
|
+
args.fa,
|
|
83
|
+
"--config",
|
|
84
|
+
str(args.config),
|
|
85
|
+
"bill",
|
|
86
|
+
"sync",
|
|
87
|
+
"daily",
|
|
88
|
+
"--date",
|
|
89
|
+
day,
|
|
90
|
+
"--json",
|
|
91
|
+
*flags,
|
|
92
|
+
],
|
|
93
|
+
"import",
|
|
94
|
+
args.flow,
|
|
95
|
+
),
|
|
96
|
+
"import",
|
|
97
|
+
)
|
|
98
|
+
if not args.offline:
|
|
99
|
+
# Preserve old markers so restoring the old workflow cannot immediately
|
|
100
|
+
# append the same successfully imported files again.
|
|
101
|
+
for source in report.get("sources", []):
|
|
102
|
+
if source.get("status") not in {"changed", "unchanged"} or not source.get("path"):
|
|
103
|
+
continue
|
|
104
|
+
path = Path(source["path"])
|
|
105
|
+
raw = path.read_bytes()
|
|
106
|
+
if hashlib.sha256(raw).hexdigest() != source.get("sha256"):
|
|
107
|
+
raise StageError("import", "source-changed-after-sync")
|
|
108
|
+
marker = Path(str(path) + ".md5")
|
|
109
|
+
temporary = Path(str(marker) + ".n8n-tmp")
|
|
110
|
+
temporary.write_text(hashlib.md5(raw).hexdigest() + "\n")
|
|
111
|
+
os.replace(temporary, marker)
|
|
112
|
+
payload = as_json(
|
|
113
|
+
run(
|
|
114
|
+
[
|
|
115
|
+
args.fa,
|
|
116
|
+
"--config",
|
|
117
|
+
str(args.config),
|
|
118
|
+
"classify",
|
|
119
|
+
"extract",
|
|
120
|
+
"--root",
|
|
121
|
+
str(args.ledger.parent),
|
|
122
|
+
],
|
|
123
|
+
"extract",
|
|
124
|
+
args.flow,
|
|
125
|
+
),
|
|
126
|
+
"extract",
|
|
127
|
+
)
|
|
128
|
+
if payload.get("schema_version") != 1 or not isinstance(payload.get("transactions"), list):
|
|
129
|
+
raise StageError("extract", "invalid-payload")
|
|
130
|
+
return errors + [
|
|
131
|
+
{
|
|
132
|
+
"ok": True,
|
|
133
|
+
"payload": payload,
|
|
134
|
+
"import": report,
|
|
135
|
+
"has_fixme": bool(payload["transactions"]),
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
except StageError as error:
|
|
139
|
+
return errors + [{"ok": False, "stage": error.stage, "message": str(error)}]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def finish(args, run):
|
|
143
|
+
results = {}
|
|
144
|
+
flags = ["--write"] if args.write else []
|
|
145
|
+
if args.decisions_base64:
|
|
146
|
+
results["classification"] = as_json(
|
|
147
|
+
run(
|
|
148
|
+
[
|
|
149
|
+
args.fa,
|
|
150
|
+
"--config",
|
|
151
|
+
str(args.config),
|
|
152
|
+
"classify",
|
|
153
|
+
"apply",
|
|
154
|
+
"--root",
|
|
155
|
+
str(args.ledger.parent),
|
|
156
|
+
"--input-base64",
|
|
157
|
+
args.decisions_base64,
|
|
158
|
+
"--validator",
|
|
159
|
+
"docker exec fava bean-check /bean/main.bean",
|
|
160
|
+
*flags,
|
|
161
|
+
],
|
|
162
|
+
"classify",
|
|
163
|
+
args.flow,
|
|
164
|
+
),
|
|
165
|
+
"classify",
|
|
166
|
+
)
|
|
167
|
+
if args.subscriptions:
|
|
168
|
+
results["subscriptions"] = as_json(
|
|
169
|
+
run(
|
|
170
|
+
[
|
|
171
|
+
args.fa,
|
|
172
|
+
"--config",
|
|
173
|
+
str(args.config),
|
|
174
|
+
"subscriptions",
|
|
175
|
+
"generate",
|
|
176
|
+
"--ledger",
|
|
177
|
+
str(args.ledger),
|
|
178
|
+
"--subscriptions",
|
|
179
|
+
str(args.subscriptions),
|
|
180
|
+
"--json",
|
|
181
|
+
*flags,
|
|
182
|
+
],
|
|
183
|
+
"subscriptions",
|
|
184
|
+
args.flow,
|
|
185
|
+
),
|
|
186
|
+
"subscriptions",
|
|
187
|
+
)
|
|
188
|
+
elif args.write:
|
|
189
|
+
# Keep the existing subscription operation until its actual plan is known.
|
|
190
|
+
# A missing Makefile target is an error, never an implicit empty plan.
|
|
191
|
+
run(["make", "subscriptions"], "subscriptions", args.ledger.parent)
|
|
192
|
+
results["subscriptions"] = {"legacy_make": True}
|
|
193
|
+
else:
|
|
194
|
+
results["subscriptions"] = {"status": "plan-path-required"}
|
|
195
|
+
return {"ok": True, **results}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class FinanceService:
|
|
199
|
+
def __init__(self, runner: CommandRunner):
|
|
200
|
+
self.runner = runner
|
|
201
|
+
|
|
202
|
+
def prepare(self, args):
|
|
203
|
+
return prepare(args, self.runner.run)
|
|
204
|
+
|
|
205
|
+
def finish(self, args):
|
|
206
|
+
return finish(args, self.runner.run)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
from flova.core.ports import QueryGateway
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LedgerService:
|
|
7
|
+
def __init__(self, gateway: QueryGateway):
|
|
8
|
+
self.gateway = gateway
|
|
9
|
+
|
|
10
|
+
def execute(self, payload):
|
|
11
|
+
if not isinstance(payload, dict):
|
|
12
|
+
raise ValueError("request body must be a JSON object")
|
|
13
|
+
start, end = payload.get("start_date"), payload.get("end_date")
|
|
14
|
+
if not isinstance(start, str) or not isinstance(end, str):
|
|
15
|
+
raise ValueError("start_date and end_date must be strings")
|
|
16
|
+
start, end = date.fromisoformat(start), date.fromisoformat(end)
|
|
17
|
+
if end < start:
|
|
18
|
+
raise ValueError("end_date must not be earlier than start_date")
|
|
19
|
+
if (end - start).days >= 366:
|
|
20
|
+
raise ValueError("date range must not exceed 366 days")
|
|
21
|
+
limit = payload.get("max_transactions", 500)
|
|
22
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
|
|
23
|
+
raise ValueError("max_transactions must be between 1 and 500")
|
|
24
|
+
return self.gateway.query(start, end, limit)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from flova.core.ports import Runtime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ServiceManager:
|
|
5
|
+
def __init__(self, runtime: Runtime):
|
|
6
|
+
self.runtime = runtime
|
|
7
|
+
|
|
8
|
+
def execute(self, action, output=None, *, keep_stopped=False):
|
|
9
|
+
if keep_stopped and action != "pack":
|
|
10
|
+
raise ValueError("--keep-stopped is only valid for service pack")
|
|
11
|
+
if action == "pack":
|
|
12
|
+
return self.runtime.pack(output, keep_stopped=keep_stopped)
|
|
13
|
+
if action in {"start", "restore", "stop", "check", "status"}:
|
|
14
|
+
return getattr(self.runtime, action)()
|
|
15
|
+
raise ValueError("Unknown service action")
|
flova/bootstrap.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from flova.application.deploy import DeploymentService
|
|
2
|
+
from flova.application.finance import FinanceService
|
|
3
|
+
from flova.application.services import ServiceManager
|
|
4
|
+
from flova.infrastructure.git import GitRepository
|
|
5
|
+
from flova.infrastructure.hook import HttpSyncHook
|
|
6
|
+
from flova.infrastructure.process import SubprocessRunner
|
|
7
|
+
from flova.infrastructure.runtime import DockerSystemRuntime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def finance():
|
|
11
|
+
return FinanceService(SubprocessRunner())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def services(config):
|
|
15
|
+
return ServiceManager(DockerSystemRuntime(config.root))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def deployments(config):
|
|
19
|
+
import threading
|
|
20
|
+
|
|
21
|
+
locks = {}
|
|
22
|
+
result = {}
|
|
23
|
+
for hook in config.webhooks.values():
|
|
24
|
+
lock = locks.setdefault(hook.repository.resolve(), threading.Lock())
|
|
25
|
+
result[hook.path] = DeploymentService(
|
|
26
|
+
hook, GitRepository(), HttpSyncHook(hook.after_sync_url, hook.after_sync_token), lock
|
|
27
|
+
)
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ledger(config):
|
|
32
|
+
from flova.application.ledger import LedgerService
|
|
33
|
+
from flova.infrastructure.beancount import BeancountQuery
|
|
34
|
+
|
|
35
|
+
return LedgerService(BeancountQuery(config.ledger))
|
flova/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flova.core.errors import ConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Webhook:
|
|
12
|
+
path: str
|
|
13
|
+
repository: Path
|
|
14
|
+
branch: str
|
|
15
|
+
secret: str = field(repr=False)
|
|
16
|
+
after_sync_url: str = ""
|
|
17
|
+
after_sync_token: str = field(default="", repr=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
root: Path
|
|
23
|
+
webhook_host: str = "0.0.0.0"
|
|
24
|
+
webhook_port: int = 58129
|
|
25
|
+
ledger_host: str = "0.0.0.0"
|
|
26
|
+
ledger_port: int = 8080
|
|
27
|
+
ledger: Path = Path("/bills/main.bean")
|
|
28
|
+
webhooks: dict[str, Webhook] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _expand(value):
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
|
|
34
|
+
def replace(match):
|
|
35
|
+
key = match.group(1)
|
|
36
|
+
if key not in os.environ:
|
|
37
|
+
raise ConfigError("Missing environment variable: " + key)
|
|
38
|
+
return os.environ[key]
|
|
39
|
+
|
|
40
|
+
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {k: _expand(v) for k, v in value.items()}
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_expand(v) for v in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config(path: Path, root: Path | None = None) -> Config:
|
|
49
|
+
try:
|
|
50
|
+
with path.open("rb") as f:
|
|
51
|
+
raw = _expand(tomllib.load(f))
|
|
52
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
53
|
+
raise ConfigError("Unable to read configuration: " + type(error).__name__) from None
|
|
54
|
+
allowed = {"root", "webhook", "ledger", "webhooks"}
|
|
55
|
+
if set(raw) - allowed:
|
|
56
|
+
raise ConfigError("Unknown configuration sections")
|
|
57
|
+
root = (root or Path(raw.get("root", "/root/.flow"))).absolute()
|
|
58
|
+
|
|
59
|
+
def resolve(value):
|
|
60
|
+
p = Path(value)
|
|
61
|
+
return p if p.is_absolute() else root / p
|
|
62
|
+
|
|
63
|
+
webhook, ledger = raw.get("webhook", {}), raw.get("ledger", {})
|
|
64
|
+
for section, fields in [(webhook, {"host", "port"}), (ledger, {"file", "host", "port"})]:
|
|
65
|
+
if set(section) - fields:
|
|
66
|
+
raise ConfigError("Unknown server configuration fields")
|
|
67
|
+
hooks = {}
|
|
68
|
+
for name, data in raw.get("webhooks", {}).items():
|
|
69
|
+
if set(data) - {
|
|
70
|
+
"path",
|
|
71
|
+
"repository",
|
|
72
|
+
"branch",
|
|
73
|
+
"secret",
|
|
74
|
+
"after_sync_url",
|
|
75
|
+
"after_sync_token",
|
|
76
|
+
}:
|
|
77
|
+
raise ConfigError("Unknown webhook fields: " + name)
|
|
78
|
+
for key in ["path", "repository", "branch", "secret"]:
|
|
79
|
+
if not isinstance(data.get(key), str) or not data[key]:
|
|
80
|
+
raise ConfigError("Missing webhook field: " + name + "." + key)
|
|
81
|
+
if not data["path"].startswith("/"):
|
|
82
|
+
raise ConfigError("Webhook path must start with /")
|
|
83
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", data["branch"]):
|
|
84
|
+
raise ConfigError("Invalid Git branch")
|
|
85
|
+
hook = Webhook(
|
|
86
|
+
data["path"],
|
|
87
|
+
resolve(data["repository"]),
|
|
88
|
+
data["branch"],
|
|
89
|
+
data["secret"],
|
|
90
|
+
data.get("after_sync_url", ""),
|
|
91
|
+
data.get("after_sync_token", ""),
|
|
92
|
+
)
|
|
93
|
+
if hook.path in [h.path for h in hooks.values()]:
|
|
94
|
+
raise ConfigError("Duplicate webhook path")
|
|
95
|
+
hooks[name] = hook
|
|
96
|
+
for section in [webhook, ledger]:
|
|
97
|
+
port = section.get("port", 58129 if section is webhook else 8080)
|
|
98
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
|
99
|
+
raise ConfigError("Invalid server port")
|
|
100
|
+
return Config(
|
|
101
|
+
root,
|
|
102
|
+
webhook.get("host", "0.0.0.0"),
|
|
103
|
+
webhook.get("port", 58129),
|
|
104
|
+
ledger.get("host", "0.0.0.0"),
|
|
105
|
+
ledger.get("port", 8080),
|
|
106
|
+
resolve(ledger.get("file", "data/account/main.bean")),
|
|
107
|
+
hooks,
|
|
108
|
+
)
|
flova/core/__init__.py
ADDED
|
File without changes
|
flova/core/errors.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class ConfigError(ValueError):
|
|
2
|
+
"""Invalid local configuration; messages never contain credential values."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StageError(RuntimeError):
|
|
6
|
+
def __init__(self, stage, code):
|
|
7
|
+
self.stage, self.code = stage, code
|
|
8
|
+
super().__init__(f"{stage} failed (exit {code}); inspect server logs")
|
flova/core/ports.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CommandRunner(Protocol):
|
|
6
|
+
def run(self, command: list[str], stage: str, cwd: Path, timeout: int = 180) -> str: ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Repository(Protocol):
|
|
10
|
+
def sync(self, path: Path, branch: str) -> bool: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueryGateway(Protocol):
|
|
14
|
+
def query(self, start, end, limit: int) -> dict[str, Any]: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SyncHook(Protocol):
|
|
18
|
+
def trigger(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Runtime(Protocol):
|
|
22
|
+
def start(self) -> None: ...
|
|
23
|
+
def restore(self) -> None: ...
|
|
24
|
+
def stop(self) -> None: ...
|
|
25
|
+
def status(self) -> None: ...
|
|
26
|
+
def check(self) -> None: ...
|
|
27
|
+
def pack(self, output: Path, *, keep_stopped: bool = False) -> None: ...
|
|
File without changes
|
flova/entrypoints/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import fcntl
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from flova import bootstrap
|
|
13
|
+
from flova.config import load_config
|
|
14
|
+
from flova.core.errors import ConfigError, StageError
|
|
15
|
+
from flova.entrypoints.http import create_server, serve
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parser():
|
|
19
|
+
p = argparse.ArgumentParser(prog="flova")
|
|
20
|
+
p.add_argument("--version", action="version", version="flova " + version("flova"))
|
|
21
|
+
p.add_argument("--root", type=Path, default=None)
|
|
22
|
+
p.add_argument("--config", type=Path)
|
|
23
|
+
commands = p.add_subparsers(dest="group", required=True)
|
|
24
|
+
commands.add_parser("config").add_argument("action", choices=["check"])
|
|
25
|
+
service = commands.add_parser("service")
|
|
26
|
+
service.add_argument("action", choices=["start", "restore", "stop", "check", "status", "pack"])
|
|
27
|
+
service.add_argument("--output", type=Path)
|
|
28
|
+
service.add_argument(
|
|
29
|
+
"--keep-stopped",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="leave source stopped after a successful migration pack",
|
|
32
|
+
)
|
|
33
|
+
download = commands.add_parser("download")
|
|
34
|
+
download.add_argument("--provider", "-p", default="alipay", choices=["alipay", "wechat"])
|
|
35
|
+
download.add_argument("--download-config", type=Path)
|
|
36
|
+
commands.add_parser("webhook").add_argument("action", choices=["serve"])
|
|
37
|
+
ledger = commands.add_parser("ledger")
|
|
38
|
+
ledger.add_argument("action", choices=["serve", "query"])
|
|
39
|
+
ledger.add_argument("--start")
|
|
40
|
+
ledger.add_argument("--end")
|
|
41
|
+
ledger.add_argument("--max-transactions", type=int, default=500)
|
|
42
|
+
f = commands.add_parser("finance")
|
|
43
|
+
f.add_argument("action", choices=["prepare", "finish"])
|
|
44
|
+
f.add_argument("--flow", type=Path)
|
|
45
|
+
f.add_argument("--date")
|
|
46
|
+
f.add_argument("--offline", action="store_true")
|
|
47
|
+
f.add_argument("--write", action="store_true")
|
|
48
|
+
f.add_argument("--decisions-base64", default="")
|
|
49
|
+
f.add_argument("--subscriptions", type=Path)
|
|
50
|
+
f.add_argument("--ledger", type=Path)
|
|
51
|
+
f.add_argument("--fa")
|
|
52
|
+
f.add_argument("--dflow")
|
|
53
|
+
f.add_argument("--oub")
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None):
|
|
58
|
+
os.umask(0o077)
|
|
59
|
+
args = parser().parse_args(argv)
|
|
60
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
61
|
+
root = args.root or Path(os.environ.get("FLOVA_ROOT", "/root/.flow"))
|
|
62
|
+
path = args.config or Path(os.environ.get("FLOVA_CONFIG", str(root / "config/flova.toml")))
|
|
63
|
+
try:
|
|
64
|
+
config = load_config(path, args.root)
|
|
65
|
+
if args.group == "config":
|
|
66
|
+
print("configuration is valid")
|
|
67
|
+
return 0
|
|
68
|
+
if args.group == "service":
|
|
69
|
+
if os.geteuid() != 0:
|
|
70
|
+
raise ConfigError("Service management requires root")
|
|
71
|
+
output = args.output or config.root / "backups" / (
|
|
72
|
+
"flow-transfer-" + time.strftime("%Y%m%d-%H%M%S") + ".tar.gz"
|
|
73
|
+
)
|
|
74
|
+
bootstrap.services(config).execute(args.action, output, keep_stopped=args.keep_stopped)
|
|
75
|
+
return 0
|
|
76
|
+
if args.group == "download":
|
|
77
|
+
from flova.infrastructure.download import BillDownloader
|
|
78
|
+
|
|
79
|
+
result = BillDownloader().download(
|
|
80
|
+
args.provider, args.download_config or config.root / "config/download.json"
|
|
81
|
+
)
|
|
82
|
+
print(json.dumps(result))
|
|
83
|
+
return 0 if result["status"] == "success" else 1
|
|
84
|
+
if args.group == "webhook":
|
|
85
|
+
if not config.webhooks:
|
|
86
|
+
raise ConfigError("No webhooks configured")
|
|
87
|
+
serve(
|
|
88
|
+
create_server(
|
|
89
|
+
config.webhook_host,
|
|
90
|
+
config.webhook_port,
|
|
91
|
+
deployments=bootstrap.deployments(config),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return 0
|
|
95
|
+
if args.group == "ledger":
|
|
96
|
+
query = bootstrap.ledger(config)
|
|
97
|
+
if args.action == "serve":
|
|
98
|
+
serve(create_server(config.ledger_host, config.ledger_port, query=query))
|
|
99
|
+
return 0
|
|
100
|
+
print(
|
|
101
|
+
json.dumps(
|
|
102
|
+
query.execute(
|
|
103
|
+
{
|
|
104
|
+
"start_date": args.start,
|
|
105
|
+
"end_date": args.end,
|
|
106
|
+
"max_transactions": args.max_transactions,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return 0
|
|
112
|
+
args.flow = args.flow or config.root
|
|
113
|
+
args.config = args.flow / "config/fane.yaml"
|
|
114
|
+
args.ledger = args.ledger or args.flow / "data/account/main.bean"
|
|
115
|
+
for name in ["fa", "dflow", "oub"]:
|
|
116
|
+
executable = "flova-download" if name == "dflow" else name
|
|
117
|
+
setattr(args, name, getattr(args, name) or str(args.flow / "runtime/bin" / executable))
|
|
118
|
+
lock = args.flow / "state/locks/n8n-finance.lock"
|
|
119
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
with lock.open("a") as handle:
|
|
121
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
122
|
+
job = bootstrap.finance()
|
|
123
|
+
result = job.prepare(args) if args.action == "prepare" else job.finish(args)
|
|
124
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
125
|
+
return 0
|
|
126
|
+
except (
|
|
127
|
+
ConfigError,
|
|
128
|
+
StageError,
|
|
129
|
+
RuntimeError,
|
|
130
|
+
OSError,
|
|
131
|
+
ValueError,
|
|
132
|
+
KeyError,
|
|
133
|
+
subprocess.SubprocessError,
|
|
134
|
+
) as error:
|
|
135
|
+
payload = {
|
|
136
|
+
"ok": False,
|
|
137
|
+
"stage": getattr(error, "stage", args.group),
|
|
138
|
+
"message": str(error)
|
|
139
|
+
if isinstance(error, (ConfigError, StageError, RuntimeError))
|
|
140
|
+
else type(error).__name__,
|
|
141
|
+
}
|
|
142
|
+
print(json.dumps(payload))
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from flova.infrastructure.download import BillDownloader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv=None):
|
|
10
|
+
p = argparse.ArgumentParser(prog="dflow")
|
|
11
|
+
p.add_argument("action", choices=["download"])
|
|
12
|
+
p.add_argument("--provider", "-p", default="alipay", choices=["alipay", "wechat"])
|
|
13
|
+
p.add_argument("--config", type=Path, default=Path("/root/.flow/config/download.json"))
|
|
14
|
+
args = p.parse_args(argv)
|
|
15
|
+
try:
|
|
16
|
+
result = BillDownloader().download(args.provider, args.config)
|
|
17
|
+
except Exception as error:
|
|
18
|
+
result = {"status": "error", "provider": args.provider, "message": type(error).__name__}
|
|
19
|
+
print(json.dumps(result))
|
|
20
|
+
return 0 if result["status"] == "success" else 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
sys.exit(main())
|