mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
2041
Commits
v0.5.6-fix1
...
v0.7.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f5d3c8c0 | ||
|
|
082407fa51 | ||
|
|
6688ee26db | ||
|
|
beb003b7ad | ||
|
|
6ecfe0f0a1 | ||
|
|
12057db476 | ||
|
|
ff47d8d48a | ||
|
|
ef5f36cc2b | ||
|
|
84022c4d48 | ||
|
|
118f441029 | ||
|
|
20399b004d | ||
|
|
050eb77508 | ||
|
|
1ab4f079c9 | ||
|
|
6c733f7590 | ||
|
|
d7d8db45ba | ||
|
|
8cf9af79da | ||
|
|
e55793c765 | ||
|
|
d8902ea612 | ||
|
|
a04673a90d | ||
|
|
a97acc07fc | ||
|
|
f8000012f7 | ||
|
|
6080f8cc88 | ||
|
|
37df5b93b1 | ||
|
|
e53abdaec2 | ||
|
|
2db32ea97e | ||
|
|
581897ee74 | ||
|
|
9a88f966d8 | ||
|
|
9d9316e434 | ||
|
|
1b697b1111 | ||
|
|
3043982486 | ||
|
|
0bf92ffffc | ||
|
|
f0f87b56a3 | ||
|
|
4148ab1931 | ||
|
|
550cc36760 | ||
|
|
531cf11025 | ||
|
|
79b70f7b5c | ||
|
|
10d369f59c | ||
|
|
2ef7ac79bc | ||
|
|
778cfb1a5c | ||
|
|
764e9fd131 | ||
|
|
387134ca87 | ||
|
|
a0767d957c | ||
|
|
b94ef91d07 | ||
|
|
e7910751d9 | ||
|
|
1d2655432d | ||
|
|
323273ff30 | ||
|
|
fb2009c65b | ||
|
|
e186cc6848 | ||
|
|
615ac99ad7 | ||
|
|
ec36cfbf75 | ||
|
|
7bf228a33c | ||
|
|
3606290ac8 | ||
|
|
e49024d33b | ||
|
|
fdbc2607ec | ||
|
|
c7cc8fd7db | ||
|
|
07efcb5146 | ||
|
|
856605defa | ||
|
|
713010fa0a | ||
|
|
cd2fbeeead | ||
|
|
a4350a482a | ||
|
|
c825375367 | ||
|
|
fc92c4f431 | ||
|
|
b61c590bdb | ||
|
|
7756c0913f | ||
|
|
c34ec7c1ee | ||
|
|
f8778c4a23 | ||
|
|
e0dbb233f7 | ||
|
|
9725f9abae | ||
|
|
5d575f1590 | ||
|
|
d562c594c3 | ||
|
|
ce226a3010 | ||
|
|
644ae9c1bf | ||
|
|
95053f9502 | ||
|
|
8fbda84acb | ||
|
|
03b7d573e0 | ||
|
|
0c3f51bcec | ||
|
|
e3d97b573b | ||
|
|
ac3796af84 | ||
|
|
f9c343eb07 | ||
|
|
e31df5989a | ||
|
|
98fbf029fc | ||
|
|
4d9a648202 | ||
|
|
405ca3e66a | ||
|
|
0355c28683 | ||
|
|
6c33b8d8fb | ||
|
|
a6c6f14b09 | ||
|
|
e558f55cd9 | ||
|
|
88a057b8d9 | ||
|
|
ed27d404ac | ||
|
|
5dda34c66e | ||
|
|
373ebf26d6 | ||
|
|
f65ed2795c | ||
|
|
664c063a06 | ||
|
|
75795c6fbc | ||
|
|
5b332da7d7 | ||
|
|
d9796d502b | ||
|
|
3b0d87b0fd | ||
|
|
3c348dff3a | ||
|
|
ec1783a35c | ||
|
|
427030c5de | ||
|
|
0be380243b | ||
|
|
312583f055 | ||
|
|
33f49ea9b0 | ||
|
|
470cef17cf | ||
|
|
3f5f65eb9a | ||
|
|
6664c2dbb8 | ||
|
|
0099167a6d | ||
|
|
f009fb73c3 | ||
|
|
715a5ed626 | ||
|
|
5cf38d1b35 | ||
|
|
6b707f29a2 | ||
|
|
9ea84f9748 | ||
|
|
c32d043afb | ||
|
|
8fe4d24408 | ||
|
|
e369e4aab1 | ||
|
|
7dc919e8e3 | ||
|
|
1333efdad5 | ||
|
|
cd8de1aa13 | ||
|
|
d6215d9dec | ||
|
|
9a47267545 | ||
|
|
7851503fbc | ||
|
|
c6d373e6aa | ||
|
|
71fcb9c168 | ||
|
|
3976652942 | ||
|
|
7b56546e21 | ||
|
|
85854e4476 | ||
|
|
b50242ab9f | ||
|
|
20b27a13b2 | ||
|
|
598b2fb374 | ||
|
|
ff7988430d | ||
|
|
25da99fac2 | ||
|
|
8616fe6ee2 | ||
|
|
9b8724453b | ||
|
|
01e104d86a | ||
|
|
0acd1de29c | ||
|
|
a25fab371a | ||
|
|
93e2f95c47 | ||
|
|
f10d631a9c | ||
|
|
cfc4894dab | ||
|
|
3f86fdd6bc | ||
|
|
b09d1f1c33 | ||
|
|
2fc604e047 | ||
|
|
e8afa03e45 | ||
|
|
fc2dfb82d2 | ||
|
|
a728c090a9 | ||
|
|
e58621a735 | ||
|
|
b1be370b2e | ||
|
|
cf0d957ac7 | ||
|
|
f127b67e73 | ||
|
|
7f61bb43c7 | ||
|
|
25c49dd804 | ||
|
|
72222d935c | ||
|
|
063d517306 | ||
|
|
02495ce28e | ||
|
|
63936aa110 | ||
|
|
8d4d42a887 | ||
|
|
2316df5c9a | ||
|
|
59d37ae1dd | ||
|
|
3014fd50c6 | ||
|
|
14c4e3a04e | ||
|
|
8f1070a451 | ||
|
|
0b30cc6b0f | ||
|
|
b2f596b8f0 | ||
|
|
01a96fed74 | ||
|
|
46a903aada | ||
|
|
dfa121dd5b | ||
|
|
bc1da3bf3f | ||
|
|
6e0dc3b59e | ||
|
|
4bf5d4c044 | ||
|
|
736fc76345 | ||
|
|
b6b2ca38f4 | ||
|
|
f07eb25cfc | ||
|
|
d2ea437c1c | ||
|
|
7bc7d0f8d8 | ||
|
|
14cf639aba | ||
|
|
55cdab592c | ||
|
|
ee0ec18283 | ||
|
|
f31c9e03e2 | ||
|
|
e50db10439 | ||
|
|
192dc6c20d | ||
|
|
5e1d14f19b | ||
|
|
b8b89d21b7 | ||
|
|
5eddf4f9ee | ||
|
|
c7186e1720 | ||
|
|
4866509938 | ||
|
|
2122660a5c | ||
|
|
5c68ab896a | ||
|
|
f9c8ec41f4 | ||
|
|
b1ed6b24b0 | ||
|
|
c17c78ad4b | ||
|
|
9ec48ab6b9 | ||
|
|
accd250226 | ||
|
|
80a6579766 | ||
|
|
1ca83ca3fb | ||
|
|
a931da0764 | ||
|
|
a61374c595 | ||
|
|
c3136126e5 | ||
|
|
b23d299533 | ||
|
|
b03aae18c3 | ||
|
|
99b6fe468f | ||
|
|
ef77ec04ca | ||
|
|
242081433e | ||
|
|
b86d4e1f0c | ||
|
|
a151f37d63 | ||
|
|
42f7907740 | ||
|
|
e72e25c59c | ||
|
|
1b0440481b | ||
|
|
26d85681f0 | ||
|
|
1dcee77055 | ||
|
|
1ac16005f9 | ||
|
|
2f1cdb6a0b | ||
|
|
ac93851b2a | ||
|
|
400b3125a4 | ||
|
|
2e5ff32e1a | ||
|
|
a0f7074e59 | ||
|
|
7c32be46ca | ||
|
|
6ed2f9bd0a | ||
|
|
778b106023 | ||
|
|
f179ee72f9 | ||
|
|
974def5fef | ||
|
|
e5351b7d9d | ||
|
|
ed83184d55 | ||
|
|
15b6606c82 | ||
|
|
d7411a3104 | ||
|
|
9f138d09e6 | ||
|
|
bf29129a4b | ||
|
|
f6293b6812 | ||
|
|
7e9424008f | ||
|
|
6c5e70ccb1 | ||
|
|
063834e95b | ||
|
|
c76d6b6396 | ||
|
|
6f00e9fc67 | ||
|
|
d336d1a7fa | ||
|
|
cf0af8fa1e | ||
|
|
fd220b6c42 | ||
|
|
3472bb75e7 | ||
|
|
ba65c96c74 | ||
|
|
c54b214657 | ||
|
|
4fcc17114f | ||
|
|
deb5f55786 | ||
|
|
1836c2b652 | ||
|
|
5b7805181b | ||
|
|
f75894acbb | ||
|
|
541cc197c4 | ||
|
|
363d1aba9a | ||
|
|
eb2cf662b7 | ||
|
|
900f8a7163 | ||
|
|
61bdd304b7 | ||
|
|
279735ae7f | ||
|
|
cc2830f6ec | ||
|
|
8dbd730568 | ||
|
|
bb6aa03485 | ||
|
|
6a22488698 | ||
|
|
f1c30439ff | ||
|
|
1123095bb7 | ||
|
|
938f11981d | ||
|
|
6c4e730e60 | ||
|
|
16584067d7 | ||
|
|
6de0fe75a4 | ||
|
|
34f0913ed0 | ||
|
|
5b305c64e1 | ||
|
|
8ad97761e8 | ||
|
|
0f92ef664d | ||
|
|
16a4fd3687 | ||
|
|
3a3fcbe46a | ||
|
|
18d8ea2052 | ||
|
|
6ab08f4014 | ||
|
|
628a3a0d8d | ||
|
|
f52628e00b | ||
|
|
f9d97ececb | ||
|
|
803e555022 | ||
|
|
b1bd727978 | ||
|
|
c2748dc868 | ||
|
|
302620cb94 | ||
|
|
c255f29e98 | ||
|
|
6d1b818414 | ||
|
|
669636d3e4 | ||
|
|
68038c182b | ||
|
|
308cc88ef7 | ||
|
|
30b545785f | ||
|
|
31fade82f6 | ||
|
|
0246ba93dd | ||
|
|
ff7ec8575c | ||
|
|
aa58cb4a05 | ||
|
|
e9b4efc2d4 | ||
|
|
ea76f7bb0b | ||
|
|
8edcbdcb29 | ||
|
|
5249660e07 | ||
|
|
84b99a641a | ||
|
|
4824e4a487 | ||
|
|
ba723ebe48 | ||
|
|
04ba8cbe9e | ||
|
|
84f41dae77 | ||
|
|
82040bfc21 | ||
|
|
6155ffefcc | ||
|
|
bf4279a590 | ||
|
|
77759fac54 | ||
|
|
63a2fd4dcf | ||
|
|
7a19891c60 | ||
|
|
85573d7980 | ||
|
|
ebd59246a8 | ||
|
|
fd27f55fe5 | ||
|
|
69b8b96fb8 | ||
|
|
19d1d36043 | ||
|
|
9f19ca5754 | ||
|
|
2de2a792f6 | ||
|
|
ada690624b | ||
|
|
465476985b | ||
|
|
da5624c98e | ||
|
|
b2f68bbaf7 | ||
|
|
7507af5829 | ||
|
|
5e39801bba | ||
|
|
5ac153a0bb | ||
|
|
c7a5155ce4 | ||
|
|
869c3d3037 | ||
|
|
ef6a11c146 | ||
|
|
21432911de | ||
|
|
eb98340924 | ||
|
|
746af0d93e | ||
|
|
08ac9c5c58 | ||
|
|
bce3bf2b6e | ||
|
|
4ec9ca61cf | ||
|
|
657e6aa672 | ||
|
|
7835840ebd | ||
|
|
6cabcd85aa | ||
|
|
03e436707d | ||
|
|
781bc5ac58 | ||
|
|
86f72da3d9 | ||
|
|
0a2c674ad8 | ||
|
|
0daa8c196b | ||
|
|
98dc5925a5 | ||
|
|
d5d3f09846 | ||
|
|
0e6fc96eb1 | ||
|
|
b052f40ffb | ||
|
|
2aef9d2478 | ||
|
|
8627a18f2e | ||
|
|
21c478be22 | ||
|
|
9d8f7d158b | ||
|
|
c1649fe837 | ||
|
|
d3c8317939 | ||
|
|
7ffe33f867 | ||
|
|
6c2a57f237 | ||
|
|
0f4141ef3f | ||
|
|
37413c0211 | ||
|
|
d1b64b6748 | ||
|
|
c2bcfab7d4 | ||
|
|
392353ffff | ||
|
|
9734be31cf | ||
|
|
905453d62b | ||
|
|
a3b8a99709 | ||
|
|
2e24e5f358 | ||
|
|
40eb3cf6e1 | ||
|
|
549463088c | ||
|
|
f8b5651883 | ||
|
|
de0a880ca6 | ||
|
|
ba4e194cb5 | ||
|
|
1c05a722c1 | ||
|
|
eda94913cf | ||
|
|
3dfafbc379 | ||
|
|
8d1e54eba6 | ||
|
|
6bfd56b54f | ||
|
|
49f952692b | ||
|
|
fde15c9b60 | ||
|
|
06f26cfacf | ||
|
|
5360665432 | ||
|
|
a20ac1d31f | ||
|
|
1bdd300606 | ||
|
|
c56f0198ff | ||
|
|
02fc6bd4ef | ||
|
|
c3a8352d76 | ||
|
|
463576915f | ||
|
|
1db6b9d307 | ||
|
|
b5a02a118f | ||
|
|
ae96d5d61b | ||
|
|
ce1d532e3c | ||
|
|
f27485ec05 | ||
|
|
9616f458de | ||
|
|
3455faf7da | ||
|
|
7ed4b84654 | ||
|
|
0d76a8e478 | ||
|
|
b9612fef9b | ||
|
|
92ae88f1be | ||
|
|
91a5e58cec | ||
|
|
1658925f52 | ||
|
|
bb5a4454a5 | ||
|
|
9fb600df1b | ||
|
|
fff4fe4e20 | ||
|
|
3e4dfd2bac | ||
|
|
8bd82c8c95 | ||
|
|
b59c724455 | ||
|
|
3ee272fd53 | ||
|
|
ab5d1f266f | ||
|
|
906742e3c4 | ||
|
|
0ee45f41e1 | ||
|
|
6d285410c2 | ||
|
|
eaabfb83ed | ||
|
|
ef2953038e | ||
|
|
cc1a63bf01 | ||
|
|
4b2d8cef3c | ||
|
|
df518ad668 | ||
|
|
37b0c00701 | ||
|
|
88f03aaef2 | ||
|
|
ffd8d273c4 | ||
|
|
ef2a96bcc4 | ||
|
|
734717899b | ||
|
|
d2d28c30d9 | ||
|
|
10532e1a55 | ||
|
|
47886abd2b | ||
|
|
d076f64db3 | ||
|
|
60e3ffc402 | ||
|
|
b21be24faa | ||
|
|
3504875922 | ||
|
|
0f6d4b9146 | ||
|
|
6ebd39ed0b | ||
|
|
5c3a1aecbe | ||
|
|
1a45ec9386 | ||
|
|
97133f657f | ||
|
|
b108dc5ea6 | ||
|
|
35cf44b38e | ||
|
|
6412294262 | ||
|
|
01c8592ca6 | ||
|
|
9b5c3ecd23 | ||
|
|
6de684df59 | ||
|
|
8aca1f8b93 | ||
|
|
bb2fc2ec00 | ||
|
|
18566b5837 | ||
|
|
069e1c1e60 | ||
|
|
2c28d9979c | ||
|
|
0efb3d340d | ||
|
|
535039c29e | ||
|
|
93d3de1644 | ||
|
|
4ce056fe45 | ||
|
|
9ad9858ac2 | ||
|
|
adca142d1e | ||
|
|
739e39e1ca | ||
|
|
14ad6e9b75 | ||
|
|
d46d225a90 | ||
|
|
c05d227df2 | ||
|
|
42e723ff7c | ||
|
|
b02d62642a | ||
|
|
8abedecb16 | ||
|
|
d77a572dc7 | ||
|
|
8606455355 | ||
|
|
21e52722e6 | ||
|
|
6673ab6d4a | ||
|
|
d488b1a680 | ||
|
|
b9ac97ebc3 | ||
|
|
e09d3199c1 | ||
|
|
ccfc4cbddc | ||
|
|
41ad422002 | ||
|
|
674cc85005 | ||
|
|
dd2da69361 | ||
|
|
0ee6e393ce | ||
|
|
433a4d3c7d | ||
|
|
049f26c03b | ||
|
|
cf8372c8cb | ||
|
|
f03550415b | ||
|
|
5a710c4f5e | ||
|
|
56901f91ce | ||
|
|
1109c3547c | ||
|
|
d24ead234d | ||
|
|
d816ae5c88 | ||
|
|
8c6e586063 | ||
|
|
c632ec616d | ||
|
|
bd71a46c25 | ||
|
|
e2b5c3acc8 | ||
|
|
e27ca671fd | ||
|
|
614c999871 | ||
|
|
42693c2c52 | ||
|
|
e21cd72181 | ||
|
|
a9e6a7d644 | ||
|
|
ba72770cab | ||
|
|
7530bec7de | ||
|
|
1173a4d9d5 | ||
|
|
aa409a8a9c | ||
|
|
949e251b2e | ||
|
|
4933ae9014 | ||
|
|
1793443b09 | ||
|
|
23a36e37bb | ||
|
|
c9cf1d458a | ||
|
|
d28a389a93 | ||
|
|
7e76c9763d | ||
|
|
9e029462aa | ||
|
|
4523a2c67b | ||
|
|
84c8bc960e | ||
|
|
c4927162b7 | ||
|
|
1ebe0aeadf | ||
|
|
992c58f2bd | ||
|
|
0bf63cc80e | ||
|
|
5fc6dc8019 | ||
|
|
96184caa48 | ||
|
|
12ff87949d | ||
|
|
b75953bf4c | ||
|
|
c733139091 | ||
|
|
331d37be26 | ||
|
|
57ccd44b89 | ||
|
|
d60b6e7454 | ||
|
|
50e4f27276 | ||
|
|
a0f22ae659 | ||
|
|
235f32e10e | ||
|
|
e7b3acdec3 | ||
|
|
f3a367b02d | ||
|
|
c03aebba3f | ||
|
|
4fb8955bc2 | ||
|
|
5dfccdec3e | ||
|
|
8b386b0aac | ||
|
|
9010f0806a | ||
|
|
495795327c | ||
|
|
686311eabf | ||
|
|
e68b843875 | ||
|
|
b46028cb85 | ||
|
|
fa172ecb95 | ||
|
|
431311979a | ||
|
|
d3249485fa | ||
|
|
4c22a819f9 | ||
|
|
f4d66021e4 | ||
|
|
54c5d5803b | ||
|
|
ae138ddb56 | ||
|
|
b72abec2fc | ||
|
|
db4f3fd210 | ||
|
|
230ce5df5f | ||
|
|
ec681335e8 | ||
|
|
aaad113190 | ||
|
|
63681b4be3 | ||
|
|
b347f1816d | ||
|
|
e9efc5c42a | ||
|
|
28c3a5dbe4 | ||
|
|
505d9fd8bc | ||
|
|
932397d1b3 | ||
|
|
1be445423c | ||
|
|
2df9615fb9 | ||
|
|
fe7fb17ff5 | ||
|
|
72d43878ef | ||
|
|
cc3ce8b5d7 | ||
|
|
d4ae6e0e64 | ||
|
|
480579a0d5 | ||
|
|
ba188aea92 | ||
|
|
40b4e52508 | ||
|
|
e2d5fc9dfb | ||
|
|
c92bdfba16 | ||
|
|
83a2609344 | ||
|
|
18d9004f22 | ||
|
|
74c8bfc59f | ||
|
|
3bf7469d30 | ||
|
|
66837b7d7f | ||
|
|
14b182d09b | ||
|
|
9dba6ec1d9 | ||
|
|
a52b513533 | ||
|
|
218ca8e6eb | ||
|
|
2b2754b779 | ||
|
|
952d1c840d | ||
|
|
2207b60834 | ||
|
|
c09bb28d16 | ||
|
|
13e0759d5a | ||
|
|
80054276eb | ||
|
|
517c9e5108 | ||
|
|
576918daa5 | ||
|
|
bbd4338e8e | ||
|
|
e6423a91aa | ||
|
|
78523f122d | ||
|
|
e1df06f06c | ||
|
|
ecfe04f48a | ||
|
|
ff7f27d4f8 | ||
|
|
dd07425d21 | ||
|
|
ba9aa7c1bd | ||
|
|
92fd253cae | ||
|
|
9f6fac418e | ||
|
|
e53a2757eb | ||
|
|
db32b1d982 | ||
|
|
6b1c1e4f50 | ||
|
|
3eb9614e68 | ||
|
|
8087a98c9d | ||
|
|
5643b2c901 | ||
|
|
18eac2dd7a | ||
|
|
0f2a96554f | ||
|
|
0655e868a2 | ||
|
|
4b66cadf15 | ||
|
|
4ee64339ba | ||
|
|
babe328565 | ||
|
|
6447fda852 | ||
|
|
74f7348529 | ||
|
|
bf456450d7 | ||
|
|
91cb2bbbcc | ||
|
|
c0252387b4 | ||
|
|
bd1e155332 | ||
|
|
ab048b8a03 | ||
|
|
e5ce2ac7a4 | ||
|
|
c7641dad0a | ||
|
|
b5e942ca9d | ||
|
|
74c82d9948 | ||
|
|
d18b13a91a | ||
|
|
0d80db8a8d | ||
|
|
8cc6888c5b | ||
|
|
9af1507238 | ||
|
|
c67818ee86 | ||
|
|
6ef6cbade2 | ||
|
|
8df0e1790d | ||
|
|
8b7643e150 | ||
|
|
ef04f4b0fb | ||
|
|
ce02f1ae8c | ||
|
|
c2d0f60784 | ||
|
|
781830a202 | ||
|
|
9dd545353c | ||
|
|
4c7ebf8b8d | ||
|
|
a4a5f70a10 | ||
|
|
9633bce2e1 | ||
|
|
ca341703bc | ||
|
|
cb2ff61bbc | ||
|
|
08b27806a7 | ||
|
|
b59c3a9e3b | ||
|
|
ecf6019ccb | ||
|
|
2a298de971 | ||
|
|
33633637e5 | ||
|
|
8ede01ad4e | ||
|
|
8966fd6aac | ||
|
|
2b8ff8a743 | ||
|
|
97de4ff8a3 | ||
|
|
f6c3ebf7d3 | ||
|
|
56abfdf39d | ||
|
|
9b95fa4d95 | ||
|
|
f341c573eb | ||
|
|
b227669985 | ||
|
|
ce44d35eb6 | ||
|
|
b05f2a270a | ||
|
|
2e701a90c9 | ||
|
|
507f2f8250 | ||
|
|
9533bd7043 | ||
|
|
2b32b9a445 | ||
|
|
3714c211dc | ||
|
|
504c1ccb37 | ||
|
|
d5e64d6ad9 | ||
|
|
9859aec16c | ||
|
|
0e6d7539ad | ||
|
|
d6eb41aa78 | ||
|
|
97997685b5 | ||
|
|
ab0a90de97 | ||
|
|
eeb7995214 | ||
|
|
68d8f86dc6 | ||
|
|
18fe5a4f11 | ||
|
|
26ee1a9958 | ||
|
|
77c2d91eb0 | ||
|
|
b8a65cbdec | ||
|
|
71f9afc526 | ||
|
|
8ca4a10f24 | ||
|
|
66f21de50e | ||
|
|
3e6ce6cf4a | ||
|
|
cadc45c5b8 | ||
|
|
b7b7b4f718 | ||
|
|
4f49dd5943 | ||
|
|
888414c41b | ||
|
|
8f41bc1558 | ||
|
|
a543ca9e07 | ||
|
|
40b9db3545 | ||
|
|
bd4f6b9206 | ||
|
|
776f95b1ab | ||
|
|
12abde2aeb | ||
|
|
965a8c79af | ||
|
|
d33043288d | ||
|
|
a2ad556f2b | ||
|
|
e53d5f07e8 | ||
|
|
40434005c0 | ||
|
|
3330b2ac4c | ||
|
|
be6e49b9c2 | ||
|
|
e59e6c3797 | ||
|
|
6b04a0a3a6 | ||
|
|
4112a8b2ea | ||
|
|
b84e4a96e2 | ||
|
|
e7f8b259ac | ||
|
|
65c361115a | ||
|
|
129c7c90c0 | ||
|
|
82637ad882 | ||
|
|
931c577345 | ||
|
|
9466d92a7a | ||
|
|
0a0a8b31c7 | ||
|
|
208c77a062 | ||
|
|
02d1343436 | ||
|
|
0226e14251 | ||
|
|
923515ab28 | ||
|
|
4d0c654822 | ||
|
|
779877acd0 | ||
|
|
d49b0a8a45 | ||
|
|
5a9f19cbf2 | ||
|
|
9562295d8b | ||
|
|
3c6924238f | ||
|
|
64ad0f694b | ||
|
|
754f672ee2 | ||
|
|
e50ceeba5a | ||
|
|
57910f906d | ||
|
|
8838e9289b | ||
|
|
d3355a8a09 | ||
|
|
c972bbd397 | ||
|
|
fed9bdf01a | ||
|
|
ab2287202d | ||
|
|
b47282fe4c | ||
|
|
a31e237cc3 | ||
|
|
cfa32a2b4d | ||
|
|
2cacf66a37 | ||
|
|
d0981c2fd5 | ||
|
|
3e12c06627 | ||
|
|
74a3df3f1e | ||
|
|
cb894208a1 | ||
|
|
76752beca6 | ||
|
|
e80e7b0cfa | ||
|
|
77051e6245 | ||
|
|
de7be4f15b | ||
|
|
44fb1af287 | ||
|
|
e7a76b0510 | ||
|
|
2881ff097a | ||
|
|
462c3dde79 | ||
|
|
1be703b56e | ||
|
|
5130da9710 | ||
|
|
8440846bae | ||
|
|
831554f11d | ||
|
|
97fb588a4a | ||
|
|
cd994d57d2 | ||
|
|
70f2882a43 | ||
|
|
fa5d26ce38 | ||
|
|
f76bbaab52 | ||
|
|
cde2062618 | ||
|
|
9673fc4c01 | ||
|
|
19ae7c8902 | ||
|
|
eb63838f6a | ||
|
|
232006f71d | ||
|
|
b6bdc08267 | ||
|
|
7e95e769d5 | ||
|
|
f4c79c80ac | ||
|
|
edded777e7 | ||
|
|
7284165f39 | ||
|
|
1604a6d87d | ||
|
|
7d5ad1e70e | ||
|
|
89860bec97 | ||
|
|
ebc1774300 | ||
|
|
122daf0f87 | ||
|
|
149651f831 | ||
|
|
490306c242 | ||
|
|
316b1e3207 | ||
|
|
84c4c2f9c2 | ||
|
|
4d856f3deb | ||
|
|
61bcbe826a | ||
|
|
bdc848b19e | ||
|
|
65e3b6f3da | ||
|
|
97b05e744f | ||
|
|
fbda210b84 | ||
|
|
ed75ae6d56 | ||
|
|
d1ad1815f7 | ||
|
|
b1a3a26815 | ||
|
|
94760dbc14 | ||
|
|
3a318a86b2 | ||
|
|
f4d0d5904a | ||
|
|
25c7bb935e | ||
|
|
f5deed8709 | ||
|
|
fe3a848eb5 | ||
|
|
8f4f4d2d82 | ||
|
|
66a54cc39e | ||
|
|
7ace958710 | ||
|
|
57655bdb25 | ||
|
|
124077a0a1 | ||
|
|
1b570daf72 | ||
|
|
8bcd5b8189 | ||
|
|
63202a63ef | ||
|
|
7e9ca88e00 | ||
|
|
75aa3dc0cc | ||
|
|
6a1da6a5ff | ||
|
|
d88f092dd1 | ||
|
|
b4d17a392a | ||
|
|
c6a408d4e8 | ||
|
|
d19bf71343 | ||
|
|
02f09c2056 | ||
|
|
6a104d5736 | ||
|
|
95af482ffe | ||
|
|
b05264ff74 | ||
|
|
2aab1ea97b | ||
|
|
f1687017e6 | ||
|
|
052de6b96e | ||
|
|
d2b42e91d2 | ||
|
|
d8a9d7eb5e | ||
|
|
d216a9e219 | ||
|
|
21e82abd54 | ||
|
|
18ed9a57c2 | ||
|
|
702dc3ceb4 | ||
|
|
3180ca2bf9 | ||
|
|
69af74b1e0 | ||
|
|
ef9c0ebbd4 | ||
|
|
ca4d0dc819 | ||
|
|
cd1aa92931 | ||
|
|
9fc9c334c9 | ||
|
|
c563c192b7 | ||
|
|
afedd90c80 | ||
|
|
be2e8e594c | ||
|
|
d392681c58 | ||
|
|
5ed8325592 | ||
|
|
ed1d9fdb57 | ||
|
|
c58ce63fc3 | ||
|
|
5eed329916 | ||
|
|
19c8688eb1 | ||
|
|
4317ff78b1 | ||
|
|
6c16f399d4 | ||
|
|
b480f3aaff | ||
|
|
84f312fa4c | ||
|
|
61d5fdb0ec | ||
|
|
995ab302be | ||
|
|
c6ee558180 | ||
|
|
460cd63d3a | ||
|
|
2a3593d9c5 | ||
|
|
bcca8d295a | ||
|
|
d8f68c1d9a | ||
|
|
ab53326865 | ||
|
|
96b857f642 | ||
|
|
fc12cc8a36 | ||
|
|
276d19b63c | ||
|
|
437024cdb1 | ||
|
|
64b41434ce | ||
|
|
24129d7f12 | ||
|
|
d51b44d642 | ||
|
|
e8c55e8f1f | ||
|
|
9179516b19 | ||
|
|
37abfe66f0 | ||
|
|
ae9d4038b1 | ||
|
|
b6f558d10b | ||
|
|
6d994917a0 | ||
|
|
b99f43783c | ||
|
|
7f76eff827 | ||
|
|
be939f7e63 | ||
|
|
a8a87d2b41 | ||
|
|
0a26accca4 | ||
|
|
40e925680c | ||
|
|
f2cdb74ed8 | ||
|
|
7ac8159728 | ||
|
|
3f0fd15395 | ||
|
|
90dbc279fd | ||
|
|
3723f165bc | ||
|
|
3bffecddf2 | ||
|
|
a818af7833 | ||
|
|
187b3a08fb | ||
|
|
2405ccc0f2 | ||
|
|
0fcfcae9c8 | ||
|
|
2de0cbbafc | ||
|
|
a4012ad353 | ||
|
|
e4315fbbf0 | ||
|
|
a10c02ef63 | ||
|
|
66f154a251 | ||
|
|
92813e6122 | ||
|
|
f50f26e599 | ||
|
|
a3094fda53 | ||
|
|
b004a02e4a | ||
|
|
9586f5158e | ||
|
|
f5ace4fd6d | ||
|
|
a05ae94cea | ||
|
|
dff17b6cb1 | ||
|
|
0b2a8fafce | ||
|
|
691ccaaa04 | ||
|
|
26900c8c9e | ||
|
|
226ce45d0d | ||
|
|
ae472d6744 | ||
|
|
89d08d9953 | ||
|
|
c024c782e4 | ||
|
|
84fc1e35e2 | ||
|
|
995be3781a | ||
|
|
ed570155a1 | ||
|
|
680b617b00 | ||
|
|
0a62e4bc77 | ||
|
|
96d40dd21d | ||
|
|
ff83b54c3b | ||
|
|
81ff375bfd | ||
|
|
b0fc6e68ef | ||
|
|
e1a73be2e1 | ||
|
|
cf2c74e8e8 | ||
|
|
2cb01f7a69 | ||
|
|
48deff15c4 | ||
|
|
d6c8c14de7 | ||
|
|
b2266b588e | ||
|
|
fcaafb3939 | ||
|
|
7d569127ae | ||
|
|
981020a5ab | ||
|
|
d9c8119bda | ||
|
|
485d166912 | ||
|
|
5060532c51 | ||
|
|
f29cca72ba | ||
|
|
77640d51a6 | ||
|
|
f0a6fffa87 | ||
|
|
1b24e1c22a | ||
|
|
ab0d766f47 | ||
|
|
41ffb18604 | ||
|
|
b903f8ff7d | ||
|
|
0483d001b4 | ||
|
|
d290a1fdb9 | ||
|
|
2803e9317d | ||
|
|
c5e26a1ed6 | ||
|
|
a2f91b4108 | ||
|
|
664bd98056 | ||
|
|
5bf236957e | ||
|
|
936e1ae37b | ||
|
|
cbfe1d378f | ||
|
|
e5f1f52759 | ||
|
|
edacc5a7d0 | ||
|
|
ed9267562b | ||
|
|
bddae47454 | ||
|
|
3a5922d4ee | ||
|
|
56994d4c29 | ||
|
|
973eb1a614 | ||
|
|
f9f1fa928a | ||
|
|
328ac721ce | ||
|
|
527feb69db | ||
|
|
ba661f1b3c | ||
|
|
4f584a71df | ||
|
|
97cd92a1a2 | ||
|
|
df7b2824a6 | ||
|
|
03ba1f94d7 | ||
|
|
6dd5d2fe14 | ||
|
|
a2649718ea | ||
|
|
9325c2ad9d | ||
|
|
590151f40b | ||
|
|
4b12ec8913 | ||
|
|
74a1e5ad7d | ||
|
|
75b7319465 | ||
|
|
6a608b8e3f | ||
|
|
2ca4b486ec | ||
|
|
c2cdcefdc8 | ||
|
|
893ac18d60 | ||
|
|
74abb50bdc | ||
|
|
f817f856c8 | ||
|
|
3a23eaa572 | ||
|
|
a7fdce493b | ||
|
|
232976c14a | ||
|
|
dc1009798d | ||
|
|
b6d74249a4 | ||
|
|
f72ab383c9 | ||
|
|
f59cf1090d | ||
|
|
1a50c5e112 | ||
|
|
48da062251 | ||
|
|
bbd3c30b0e | ||
|
|
d6c320bf06 | ||
|
|
d53546d56f | ||
|
|
43d891bee1 | ||
|
|
0e0a24862f | ||
|
|
2345df0e38 | ||
|
|
b09fd48eee | ||
|
|
c916e76bd2 | ||
|
|
8eb4c029b2 | ||
|
|
87e44479cc | ||
|
|
9c7757f801 | ||
|
|
1503986d40 | ||
|
|
0bd3e2fa88 | ||
|
|
0f0b9a6118 | ||
|
|
e4f427f921 | ||
|
|
2006a3e678 | ||
|
|
38240f77ff | ||
|
|
6014925e60 | ||
|
|
1dc9b505e4 | ||
|
|
b0ba1f250d | ||
|
|
2e9feaa60a | ||
|
|
8538364930 | ||
|
|
fb2662b877 | ||
|
|
170218bb26 | ||
|
|
582504d11a | ||
|
|
88d8a8b79f | ||
|
|
8e4fc40be5 | ||
|
|
01a982bdf6 | ||
|
|
daf33a82a6 | ||
|
|
cc5a44e373 | ||
|
|
8e0f8003a9 | ||
|
|
54a8312e46 | ||
|
|
85f48123b6 | ||
|
|
fdea51c7b1 | ||
|
|
870bb19798 | ||
|
|
dbec85344d | ||
|
|
4db01244ec | ||
|
|
6021110fb2 | ||
|
|
1216fa940e | ||
|
|
bcf4adf944 | ||
|
|
07ea745f56 | ||
|
|
3e122c84ef | ||
|
|
98a54b4633 | ||
|
|
2db85b1c53 | ||
|
|
91545cf906 | ||
|
|
5a15822ce0 | ||
|
|
eef21b6c34 | ||
|
|
498b3b1226 | ||
|
|
3b77686cee | ||
|
|
bf511f9f8c | ||
|
|
a0eed2cc51 | ||
|
|
e61aa46dad | ||
|
|
d2831ec6f0 | ||
|
|
a7e71624e3 | ||
|
|
bc0017a1b4 | ||
|
|
4cb7b2d494 | ||
|
|
eb7c8a3ad5 | ||
|
|
6e2f90aba8 | ||
|
|
9c59c0e1a2 | ||
|
|
6a9a54cad6 | ||
|
|
e768145961 | ||
|
|
a4e040a7d7 | ||
|
|
e818443841 | ||
|
|
337d0af136 | ||
|
|
4d2667764f | ||
|
|
feb676b66f | ||
|
|
14871c2255 | ||
|
|
48fe52b207 | ||
|
|
509bd30252 | ||
|
|
91955ad1e0 | ||
|
|
b41a4a000f | ||
|
|
d29d1cf63b | ||
|
|
1f8ff7f6d2 | ||
|
|
e251a63cb3 | ||
|
|
eb654c2fe7 | ||
|
|
697b6e0653 | ||
|
|
acd44328d6 | ||
|
|
8b813f645f | ||
|
|
2d354fa294 | ||
|
|
cfb22d3f06 | ||
|
|
1a196c8cf2 | ||
|
|
f847a71747 | ||
|
|
87c0a915b8 | ||
|
|
2958041dc7 | ||
|
|
5d1460e051 | ||
|
|
6a9017bfce | ||
|
|
a1b0db38f5 | ||
|
|
a99546b390 | ||
|
|
1adbf23be4 | ||
|
|
afbb656510 | ||
|
|
1726df1169 | ||
|
|
d69d862034 | ||
|
|
a03cab4a09 | ||
|
|
c90ed14a24 | ||
|
|
e00df5f7a2 | ||
|
|
3c2497f019 | ||
|
|
3fb007a56d | ||
|
|
10285c5eb9 | ||
|
|
15800d7a80 | ||
|
|
4387a9cdd5 | ||
|
|
8714d93d4b | ||
|
|
68256ece2d | ||
|
|
339808d55b | ||
|
|
e7471f44b0 | ||
|
|
d1a47c068e | ||
|
|
43c476d54a | ||
|
|
9f26383de5 | ||
|
|
8f082674d7 | ||
|
|
fca3f24d91 | ||
|
|
38012c62ff | ||
|
|
63149fe281 | ||
|
|
5509f70ad4 | ||
|
|
110cb147d1 | ||
|
|
92e4066977 | ||
|
|
ba5e802174 | ||
|
|
4b2507b155 | ||
|
|
6d2fcf12cd | ||
|
|
f60fc1cd7a | ||
|
|
9fc270fe69 | ||
|
|
a8ff050518 | ||
|
|
a7bab0ebd2 | ||
|
|
8eda0932b0 | ||
|
|
4e563e3385 | ||
|
|
9c05b5f4e0 | ||
|
|
fa73655134 | ||
|
|
81ee27cdea | ||
|
|
fad28eee2c | ||
|
|
c578689356 | ||
|
|
b9e62d1667 | ||
|
|
09146f8cdd | ||
|
|
0fa97595bf | ||
|
|
7ae38b6c43 | ||
|
|
bfbf7a4663 | ||
|
|
cb0ccb9cdb | ||
|
|
ce70780851 | ||
|
|
30b18d3310 | ||
|
|
1d33e2c51b | ||
|
|
fed676f54f | ||
|
|
9bed5e9f83 | ||
|
|
e16a225eb3 | ||
|
|
67ca47afd4 | ||
|
|
9057537ab8 | ||
|
|
247ea9d1bd | ||
|
|
e91c874863 | ||
|
|
40470d8ca5 | ||
|
|
94c0076778 | ||
|
|
b813498e40 | ||
|
|
cc4512fbbb | ||
|
|
ef4cc55c9a | ||
|
|
e3574e1918 | ||
|
|
7b81c77424 | ||
|
|
c9c968c2e9 | ||
|
|
e3f8fef30c | ||
|
|
ceda0635e4 | ||
|
|
bacb14e5f0 | ||
|
|
9e705ff603 | ||
|
|
1a0f1a7b72 | ||
|
|
3201851667 | ||
|
|
75df21932a | ||
|
|
fb96771b56 | ||
|
|
c4b484fb43 | ||
|
|
37fb79fb87 | ||
|
|
f03039d846 | ||
|
|
69476f69b9 | ||
|
|
bb22978574 | ||
|
|
228253c166 | ||
|
|
d26321006b | ||
|
|
377dd52805 | ||
|
|
d246f6b42c | ||
|
|
59d16ebb4b | ||
|
|
948a173f39 | ||
|
|
56857280d3 | ||
|
|
7e804c408f | ||
|
|
5268f62a64 | ||
|
|
7f101431c5 | ||
|
|
a8ac944959 | ||
|
|
e8259a76f0 | ||
|
|
3983b5a5cf | ||
|
|
79b3a76dc1 | ||
|
|
c2bf17b4dd | ||
|
|
be3afbd279 | ||
|
|
18690ceed2 | ||
|
|
0f42a6ed82 | ||
|
|
545299fc62 | ||
|
|
3c1456706a | ||
|
|
a81053e6ff | ||
|
|
391c2fbe5d | ||
|
|
121bdbd614 | ||
|
|
dcfdba0a97 | ||
|
|
a68c690874 | ||
|
|
3a814f3d1f | ||
|
|
209322b499 | ||
|
|
4a64d078f3 | ||
|
|
5f4fa4ce1f | ||
|
|
7e5a08e09d | ||
|
|
8958bf5e08 | ||
|
|
8b67120964 | ||
|
|
79d07ac79a | ||
|
|
757c264e3e | ||
|
|
a72bf19454 | ||
|
|
eda3738d59 | ||
|
|
3b4f27f767 | ||
|
|
4cf0de681a | ||
|
|
2c865ede35 | ||
|
|
46f44f4ea7 | ||
|
|
da46eb4791 | ||
|
|
e21fb58479 | ||
|
|
a703acd1fe | ||
|
|
9a84a6ff6c | ||
|
|
84a84e3f31 | ||
|
|
3f29335fd6 | ||
|
|
141a81d4a3 | ||
|
|
2543676437 | ||
|
|
fa22384f24 | ||
|
|
2a603fa1e4 | ||
|
|
31d142effc | ||
|
|
66d8e90647 | ||
|
|
080784cd9f | ||
|
|
c52ef1993f | ||
|
|
6247ac3edc | ||
|
|
cc5cb3475e | ||
|
|
71742d5ad2 | ||
|
|
1bc0822ce6 | ||
|
|
fcefa4d198 | ||
|
|
1e2ff26e86 | ||
|
|
dd8c2ebec6 | ||
|
|
6f620d92be | ||
|
|
61473bfb77 | ||
|
|
b7172092e8 | ||
|
|
ea014e0d89 | ||
|
|
8fa90e8ecf | ||
|
|
9eb17eca32 | ||
|
|
18b247de4c | ||
|
|
94852ce60e | ||
|
|
59312ebe73 | ||
|
|
738031696b | ||
|
|
a44667d2a9 | ||
|
|
b8fc36033b | ||
|
|
6ef0bd9488 | ||
|
|
f3d9523502 | ||
|
|
2b439094c5 | ||
|
|
6317587a6b | ||
|
|
7fdbd0c808 | ||
|
|
44c2534f46 | ||
|
|
af8f9e9057 | ||
|
|
cb9d9944e3 | ||
|
|
2cfeb14d88 | ||
|
|
d5d93eda11 | ||
|
|
9bbdf889d4 | ||
|
|
96f9be26da | ||
|
|
0c03c188f1 | ||
|
|
64af7b1d8a | ||
|
|
a345bb8c0d | ||
|
|
624733e874 | ||
|
|
fd44906bb7 | ||
|
|
01c9f9be49 | ||
|
|
3d9f189f0e | ||
|
|
a83aa928a0 | ||
|
|
0386e0426b | ||
|
|
10e679bb2f | ||
|
|
6c0ac2e8da | ||
|
|
2a124318b9 | ||
|
|
47a755a585 | ||
|
|
40314aa7e5 | ||
|
|
df9d30340d | ||
|
|
1f5b294bd0 | ||
|
|
012f8bcdf7 | ||
|
|
79cb9b502a | ||
|
|
4e9f063385 | ||
|
|
41f75a3f19 | ||
|
|
e2bffbbaca | ||
|
|
9525a68719 | ||
|
|
a6b45702e8 | ||
|
|
7b11d43466 | ||
|
|
4bd49d0d7a | ||
|
|
54afe35fcc | ||
|
|
77c0474947 | ||
|
|
33ed7c0737 | ||
|
|
bfda9b3e78 | ||
|
|
621eb55ae1 | ||
|
|
7eaf3e7d03 | ||
|
|
68216bf868 | ||
|
|
6fbb867f5f | ||
|
|
94a6f315ca | ||
|
|
4baee436ba | ||
|
|
beee7a76d2 | ||
|
|
887a58d639 | ||
|
|
110b02a213 | ||
|
|
6219491389 | ||
|
|
d7c2232062 | ||
|
|
2eb4afbec4 | ||
|
|
b03509a5f4 | ||
|
|
b8a6feef4a | ||
|
|
4bf86f85b4 | ||
|
|
be580c35bb | ||
|
|
cf27773582 | ||
|
|
6083461822 | ||
|
|
3264857e4a | ||
|
|
f1358dd845 | ||
|
|
8043cca126 | ||
|
|
6dd7464793 | ||
|
|
79c272f0fd | ||
|
|
f959f02d40 | ||
|
|
98421126f2 | ||
|
|
ddf3fb6f63 | ||
|
|
aacab1a90c | ||
|
|
fce7e959e5 | ||
|
|
6b6d32b4a3 | ||
|
|
f0197b685f | ||
|
|
53fa33b0c5 | ||
|
|
2f915b33c7 | ||
|
|
fd8230121c | ||
|
|
825c1b496d | ||
|
|
ccde3d4045 | ||
|
|
f7071967c0 | ||
|
|
657f9f0be1 | ||
|
|
628329e493 | ||
|
|
82e7a0080e | ||
|
|
ff1ed8b57d | ||
|
|
17b06bd4d6 | ||
|
|
5fcb49b08e | ||
|
|
044ef59d81 | ||
|
|
51b191ad2c | ||
|
|
e890a5c2f1 | ||
|
|
591dcf5cf2 | ||
|
|
58d6b2add6 | ||
|
|
bcd542f7ee | ||
|
|
37da4e245a | ||
|
|
9ef7952da5 | ||
|
|
eb8878695a | ||
|
|
c596d82dec | ||
|
|
96dd3fa4ca | ||
|
|
14eeff8f75 | ||
|
|
2cbbd9ca36 | ||
|
|
627eb4f335 | ||
|
|
8dd4135f7c | ||
|
|
6bdd7792eb | ||
|
|
099653f732 | ||
|
|
1d62722d47 | ||
|
|
fb642f7d62 | ||
|
|
cb5b8ee1ee | ||
|
|
b4a8c5dde2 | ||
|
|
53a5e18b20 | ||
|
|
6f00cabe96 | ||
|
|
a24e4a793d | ||
|
|
8ebee9922c | ||
|
|
c3d97b8c16 | ||
|
|
5abe664d65 | ||
|
|
3e2eca4fd0 | ||
|
|
c4ea042eb4 | ||
|
|
fe27fb17fb | ||
|
|
11c5884d4f | ||
|
|
e3ea2d1451 | ||
|
|
3a770306cc | ||
|
|
47ee8b9c13 | ||
|
|
bfd1ea72b3 | ||
|
|
c130d0e2c9 | ||
|
|
4fc7cecf30 | ||
|
|
9570e5c2c1 | ||
|
|
33aa70c22b | ||
|
|
558abfcfa3 | ||
|
|
7fcb9e6292 | ||
|
|
2b5247b9a8 | ||
|
|
fa47e8a3c0 | ||
|
|
4d59d518d1 | ||
|
|
9e2faa7e5b | ||
|
|
37392d8774 | ||
|
|
a16550249b | ||
|
|
d6de917878 | ||
|
|
e751289dfb | ||
|
|
07a319259b | ||
|
|
02ad67fe33 | ||
|
|
655e369f21 | ||
|
|
a9b809a32c | ||
|
|
230e7d6259 | ||
|
|
3d20c05ef7 | ||
|
|
9194f78e56 | ||
|
|
70f747d406 | ||
|
|
712b484bc8 | ||
|
|
0c9b5ddd77 | ||
|
|
3f1abb6906 | ||
|
|
29fc0be121 | ||
|
|
5311eb0da1 | ||
|
|
321f21ad49 | ||
|
|
639f26ed5d | ||
|
|
032dcf40e7 | ||
|
|
9a67497d8c | ||
|
|
6426b5d80e | ||
|
|
83e1f99ccf | ||
|
|
05b8b8a442 | ||
|
|
ed17147281 | ||
|
|
a7a3c9f023 | ||
|
|
4c16b11cb4 | ||
|
|
17f09fc8c1 | ||
|
|
3e0293ac28 | ||
|
|
07fba70a90 | ||
|
|
da0e968975 | ||
|
|
98e4e91a98 | ||
|
|
218e73e324 | ||
|
|
51c3beb614 | ||
|
|
9da47ceb22 | ||
|
|
e9f03d8d29 | ||
|
|
e5e09b49f4 | ||
|
|
dda6f34a07 | ||
|
|
44930532dc | ||
|
|
f10f5f071d | ||
|
|
517e84e5ae | ||
|
|
2101a4ecc7 | ||
|
|
8d2cbf32df | ||
|
|
a23758808d | ||
|
|
91db4eefd0 | ||
|
|
f52220a8ec | ||
|
|
1d23bf4ecb | ||
|
|
3b542434a2 | ||
|
|
9b866a6d17 | ||
|
|
d8f66b14a8 | ||
|
|
6e1eaf8aec | ||
|
|
488bd08f04 | ||
|
|
3991d47166 | ||
|
|
0c73f245ab | ||
|
|
989b27426b | ||
|
|
c10cd8240e | ||
|
|
5cd2244bc2 | ||
|
|
0ec4b4c8a4 | ||
|
|
4ec591fbf2 | ||
|
|
ea3dc3257e | ||
|
|
5b914aa78c | ||
|
|
e36fb8c07a | ||
|
|
1af9d00abd | ||
|
|
e8763b3bbd | ||
|
|
f3a9fd4c82 | ||
|
|
baa0ddd787 | ||
|
|
1d3ea3232d | ||
|
|
d784c540b6 | ||
|
|
429fdb47e6 | ||
|
|
912a92cd1a | ||
|
|
00744c0ce5 | ||
|
|
bc97e383d3 | ||
|
|
b8205b5a09 | ||
|
|
bb7fe9fe37 | ||
|
|
cef7dcac71 | ||
|
|
61e12e2c17 | ||
|
|
65e915fd1d | ||
|
|
1761921670 | ||
|
|
e5bbc797e0 | ||
|
|
f30b1f3f6b | ||
|
|
2c6209277f | ||
|
|
5b3593038d | ||
|
|
67b092253d | ||
|
|
db1059e73b | ||
|
|
e265475c17 | ||
|
|
343345529d | ||
|
|
657fcd595c | ||
|
|
d0d71aa51a | ||
|
|
4618184516 | ||
|
|
3cc54deb9c | ||
|
|
f695238e8a | ||
|
|
fb46fcd80f | ||
|
|
e0c928fbe8 | ||
|
|
780f09c1a2 | ||
|
|
f3c9835759 | ||
|
|
f29649e3a8 | ||
|
|
35400b0c2d | ||
|
|
5d710d9d10 | ||
|
|
a9d10163af | ||
|
|
31e871fe1b | ||
|
|
5148370253 | ||
|
|
581bc03d4e | ||
|
|
0f94f92c37 | ||
|
|
ccec46eddd | ||
|
|
ad58f4e852 | ||
|
|
bb312ff0cf | ||
|
|
342d4a268c | ||
|
|
40282c3447 | ||
|
|
fa328e18a1 | ||
|
|
f55f22d2e8 | ||
|
|
4374f53315 | ||
|
|
c8d7dbd8d6 | ||
|
|
cf6228f525 | ||
|
|
62153d7d36 | ||
|
|
a5e6bd3b62 | ||
|
|
063ef02306 | ||
|
|
25a2b417be | ||
|
|
c5c56ff92f | ||
|
|
cf7d129595 | ||
|
|
226a6e58d5 | ||
|
|
87afe4898e | ||
|
|
d100c934c0 | ||
|
|
ffe5d16094 | ||
|
|
078347b722 | ||
|
|
9b48a008dc | ||
|
|
b2ff7d2c28 | ||
|
|
13bf222b8d | ||
|
|
c2b7b6c231 | ||
|
|
c1b9d94c84 | ||
|
|
1f74e660de | ||
|
|
77aac74590 | ||
|
|
d719a1329c | ||
|
|
c302dfe42d | ||
|
|
41b51f10a9 | ||
|
|
97cd877ce5 | ||
|
|
7007d7a556 | ||
|
|
e6fd95453d | ||
|
|
0329b7b784 | ||
|
|
2f487fda66 | ||
|
|
e82c9c5104 | ||
|
|
c31261789c | ||
|
|
6666992d01 | ||
|
|
ab54881eb0 | ||
|
|
f8545c5141 | ||
|
|
bafb63a665 | ||
|
|
425227509a | ||
|
|
f2a3836877 | ||
|
|
3363592751 | ||
|
|
569242d72f | ||
|
|
3321bb3ccc | ||
|
|
dd4641d618 | ||
|
|
0ce61bc91c | ||
|
|
cb647d95f9 | ||
|
|
7eae1f90f6 | ||
|
|
b8702ae124 | ||
|
|
e5d3722adf | ||
|
|
ff4e853fd3 | ||
|
|
654a41c3b0 | ||
|
|
c6af4791f8 | ||
|
|
85a630cfa9 | ||
|
|
ac1a126756 | ||
|
|
96a25d058b | ||
|
|
6964729cb7 | ||
|
|
54d77598ae | ||
|
|
0bcfc7d352 | ||
|
|
faaaec28e1 | ||
|
|
dde02e6111 | ||
|
|
aadc6b665c | ||
|
|
6730e821b2 | ||
|
|
05ab09c469 | ||
|
|
1e0bc61526 | ||
|
|
6e5af5ef70 | ||
|
|
d312c397e1 | ||
|
|
0dce667019 | ||
|
|
65cd9dc3e5 | ||
|
|
10605d9fb0 | ||
|
|
af9711c2a2 | ||
|
|
9a41b2c0dc | ||
|
|
d805a28c9a | ||
|
|
ffe34120c1 | ||
|
|
47a11ee0b5 | ||
|
|
28a489acbe | ||
|
|
7597caa3a5 | ||
|
|
586d2cc42b | ||
|
|
1fd1f8216d | ||
|
|
0c3f730852 | ||
|
|
b678132176 | ||
|
|
e4be1d6b56 | ||
|
|
5f0fba1807 | ||
|
|
205f78b39c | ||
|
|
5aa8883865 | ||
|
|
1e2c3fc4fc | ||
|
|
b004551fe5 | ||
|
|
704858390d | ||
|
|
c569081340 | ||
|
|
77d413d777 | ||
|
|
43e7ad112f | ||
|
|
fbb8249c0d | ||
|
|
707d9ac274 | ||
|
|
026a77306c | ||
|
|
4087e096f2 | ||
|
|
8827c46c33 | ||
|
|
adde9ff237 | ||
|
|
cfb4f4582b | ||
|
|
8bb637962f | ||
|
|
67a2ca6e31 | ||
|
|
98ad1172b0 | ||
|
|
05fbbac493 | ||
|
|
a4e7ac1df6 | ||
|
|
fb31928e44 | ||
|
|
47bf1d04a1 | ||
|
|
b70f32c6c2 | ||
|
|
21ac1825f3 | ||
|
|
0081622f90 | ||
|
|
d089ed22c7 | ||
|
|
861ae81ff0 | ||
|
|
593640ac19 | ||
|
|
62e0a0338d | ||
|
|
5fd3240fcf | ||
|
|
563dd44957 | ||
|
|
23233a3243 | ||
|
|
1000b706be | ||
|
|
53acfbabf6 | ||
|
|
37bb120d20 | ||
|
|
4fd2b4a014 | ||
|
|
9a376e4223 | ||
|
|
b2d85d70ca | ||
|
|
6aa16ec792 | ||
|
|
e46629d11a | ||
|
|
5bb08e6aa4 | ||
|
|
3698e5a833 | ||
|
|
f84febbf89 | ||
|
|
e029012f73 | ||
|
|
9703840a36 | ||
|
|
c24a29fa65 | ||
|
|
3fcb2b1514 | ||
|
|
ab82841426 | ||
|
|
def8135118 | ||
|
|
335e440cc5 | ||
|
|
7c5bb7f383 | ||
|
|
a9f610fa69 | ||
|
|
801e16c988 | ||
|
|
a570a77cca | ||
|
|
b0f068cc5d | ||
|
|
fff82de933 | ||
|
|
8a4a41fcef | ||
|
|
aa5761954e | ||
|
|
8cd2c4d5bd | ||
|
|
940a28cff4 | ||
|
|
c95feea286 | ||
|
|
1d896467dc | ||
|
|
3a655440b9 | ||
|
|
80bae8bc2a | ||
|
|
f68c67021c | ||
|
|
e37a32c83d | ||
|
|
ec0bde819a | ||
|
|
848f99d3e5 | ||
|
|
34d295c1e0 | ||
|
|
a54ac76688 | ||
|
|
cf02a10050 | ||
|
|
6144473ebe | ||
|
|
174f11604a | ||
|
|
3f057628b7 | ||
|
|
46f1507d44 | ||
|
|
d5b8583d6b | ||
|
|
b1f6fff0a5 | ||
|
|
010ab127e2 | ||
|
|
82c3c33610 | ||
|
|
677b8f5acb | ||
|
|
b8ce02b4f7 | ||
|
|
5014e2f5fd | ||
|
|
87b433e290 | ||
|
|
5581f7a085 | ||
|
|
0b3f619280 | ||
|
|
b901a6ffc7 | ||
|
|
fe81eafe2c | ||
|
|
b0b40c16ff | ||
|
|
4fc95adfb9 | ||
|
|
4fb9882b54 | ||
|
|
29055c575f | ||
|
|
5d96d6673b | ||
|
|
763ff03a7b | ||
|
|
cbc811f6ce | ||
|
|
1d9c77522a | ||
|
|
8f26e1a31f | ||
|
|
ddf18fed9a | ||
|
|
b5a0070023 | ||
|
|
eaf8475f9e | ||
|
|
455234e797 | ||
|
|
53bb23b510 | ||
|
|
d735b6316f | ||
|
|
46737d32f8 | ||
|
|
25d38ae632 | ||
|
|
aa83b4a7a7 | ||
|
|
913ce2dbcb | ||
|
|
cae5e520ac | ||
|
|
772f2ea601 | ||
|
|
28fa03451c | ||
|
|
6984984c22 | ||
|
|
1209c835c7 | ||
|
|
e4ebd5cca1 | ||
|
|
f573110725 | ||
|
|
a8620e133a | ||
|
|
ddd6adbcf7 | ||
|
|
b570aaac48 | ||
|
|
086efe6efe | ||
|
|
56f3c95763 | ||
|
|
8a6a961900 | ||
|
|
6e55968487 | ||
|
|
8d8cddcef6 | ||
|
|
b90d5095f1 | ||
|
|
a4505b1281 | ||
|
|
1d72a8f9c1 | ||
|
|
3d5b6141a5 | ||
|
|
203cd5a9d5 | ||
|
|
696ec65175 | ||
|
|
cbb66a5667 | ||
|
|
53ef35ec80 | ||
|
|
1af3067303 | ||
|
|
d026398bab | ||
|
|
684689a82b | ||
|
|
eeb5f41bad | ||
|
|
7180eaea88 | ||
|
|
7cb204f18a | ||
|
|
0342f609d0 | ||
|
|
59840fa419 | ||
|
|
d390d46ee8 | ||
|
|
37eada9682 | ||
|
|
73a5325a38 | ||
|
|
460eb5434d | ||
|
|
8a21cb9a55 | ||
|
|
d0df52ce35 | ||
|
|
c1ed42fd3a | ||
|
|
c4bb6b8161 | ||
|
|
d480aa11f3 | ||
|
|
d63d5eff85 | ||
|
|
5dae2a4792 | ||
|
|
dcbd7dc219 | ||
|
|
2dcf8b8414 | ||
|
|
438f16094f | ||
|
|
40e0b82fa0 | ||
|
|
b9b0a75fe4 | ||
|
|
b6cc0bc3a7 | ||
|
|
d2f1431269 | ||
|
|
4ecaefbade | ||
|
|
c97c9332eb | ||
|
|
c070e5a9f6 | ||
|
|
8cd3a69803 | ||
|
|
791c9c98dc | ||
|
|
025e979935 | ||
|
|
131471a13f | ||
|
|
7ff63077c3 | ||
|
|
faba0cbd07 | ||
|
|
75f17935f9 | ||
|
|
60842fbbb5 | ||
|
|
d58c27d22d | ||
|
|
65550159bb | ||
|
|
3c7ad81d62 | ||
|
|
6b23c9b3ce | ||
|
|
900e54d740 | ||
|
|
8cc70934da | ||
|
|
f92b0943b5 | ||
|
|
920a383136 | ||
|
|
693e37d2df | ||
|
|
9c6036a103 | ||
|
|
751a4d9111 | ||
|
|
aafd332198 | ||
|
|
337cd0c505 | ||
|
|
b15ce9977a | ||
|
|
e0286aebe3 | ||
|
|
45985f1c04 | ||
|
|
776dd2f8ea | ||
|
|
bdfe4adc98 | ||
|
|
00a0371997 | ||
|
|
ded6b5b081 | ||
|
|
e9678ea899 | ||
|
|
8d69f72e2a | ||
|
|
127b4e11de | ||
|
|
22093bed4d | ||
|
|
ebd53ad679 | ||
|
|
280c604327 | ||
|
|
ad31cdbf85 | ||
|
|
0112ab752b | ||
|
|
66fec80e79 | ||
|
|
fddfaecf5e | ||
|
|
71ae1a2307 | ||
|
|
4d338ebd3d | ||
|
|
dc440f1507 | ||
|
|
5c732f844a | ||
|
|
c4044ba0b1 | ||
|
|
a8159b7bda | ||
|
|
0ab20be667 | ||
|
|
3f048d373f | ||
|
|
8b49a3d264 | ||
|
|
6e51a3f45d | ||
|
|
0d770d1c4d | ||
|
|
b45f021bba | ||
|
|
1a1bce3e8c | ||
|
|
6aeb5d40ab | ||
|
|
31ef2d134e | ||
|
|
85b50e67e1 | ||
|
|
9353f89af0 | ||
|
|
380d69e096 | ||
|
|
02e2f4f500 | ||
|
|
0dbfefa834 | ||
|
|
04ab4bd9f2 | ||
|
|
4955166b85 | ||
|
|
6235c772ac | ||
|
|
e2ec3f7942 | ||
|
|
a816235efb | ||
|
|
1e39ab3c2e | ||
|
|
85aa66c76d | ||
|
|
9a4817faf8 | ||
|
|
7b0c80a0c4 | ||
|
|
6ec8df97e8 | ||
|
|
f82964217e | ||
|
|
cfa5535f6e | ||
|
|
2d846b2c58 | ||
|
|
f40e8037dd | ||
|
|
2b21a75982 | ||
|
|
9ee27308db | ||
|
|
1518223de6 | ||
|
|
3063938a82 | ||
|
|
86449cae52 | ||
|
|
7c580e843f | ||
|
|
c9f0685b40 | ||
|
|
afd0dcf2ff | ||
|
|
68d4df71d8 | ||
|
|
596227659a | ||
|
|
3b0dbadb1e | ||
|
|
8a8bc999d2 | ||
|
|
57c7cca556 | ||
|
|
9e6578a71b | ||
|
|
bf818e3b61 | ||
|
|
9e7f291aaf | ||
|
|
46bab1b97f | ||
|
|
0258d01ee6 | ||
|
|
4999a1a0a8 | ||
|
|
8cb8666456 | ||
|
|
48f3f481db | ||
|
|
e60462e068 | ||
|
|
c4877e3b6a | ||
|
|
afbb1b9a5d | ||
|
|
9516619b92 | ||
|
|
e106a65c1d | ||
|
|
d84c9d4b71 | ||
|
|
0046123e22 | ||
|
|
40736a8334 | ||
|
|
0a256adc94 | ||
|
|
0bddc7965b | ||
|
|
258be3b640 | ||
|
|
4dbfeb87b8 | ||
|
|
fa69287449 | ||
|
|
654ce89541 | ||
|
|
fd32597015 | ||
|
|
84cf07b7a2 | ||
|
|
e4476d0bc6 | ||
|
|
0379f01ce8 | ||
|
|
95e72594ea | ||
|
|
f9ffb1cae5 | ||
|
|
91b6e0a382 | ||
|
|
f5f7a23bb0 | ||
|
|
8be9601963 | ||
|
|
eaad1579e6 | ||
|
|
1d8bf56efd | ||
|
|
73db997e92 | ||
|
|
2c5654d694 | ||
|
|
f490c3a5cd | ||
|
|
2d9158b321 | ||
|
|
48d13762d9 | ||
|
|
bd3f73c2fc | ||
|
|
25c33846be | ||
|
|
124c4ca403 | ||
|
|
ef0f8dd4d0 | ||
|
|
d0eca509d4 | ||
|
|
90663793a2 | ||
|
|
783f654953 | ||
|
|
9cdcce1b5f | ||
|
|
06b483f79d | ||
|
|
a00e137ffc | ||
|
|
239238fe47 | ||
|
|
4cd6e0d10f | ||
|
|
9b29a65c68 | ||
|
|
df4a49a9fb | ||
|
|
bb268310e2 | ||
|
|
7b0908cd87 | ||
|
|
95bc742057 | ||
|
|
e40c890a8e | ||
|
|
357c4fd61f | ||
|
|
d28fea80df | ||
|
|
fb1aeb789a | ||
|
|
1f3693d3a2 | ||
|
|
90760da499 | ||
|
|
7950ba7dc5 | ||
|
|
a7088ee538 | ||
|
|
050cba9563 | ||
|
|
2269617a9f | ||
|
|
bdccfa6e78 | ||
|
|
d97ec3fde2 | ||
|
|
d17472f09e | ||
|
|
f8b7cd2925 | ||
|
|
e9c3ac94c6 | ||
|
|
fa71cddb60 | ||
|
|
228cbc8f87 | ||
|
|
da915208a8 | ||
|
|
694167f78f | ||
|
|
a7697032a4 | ||
|
|
e86c8edd4b | ||
|
|
b1be413dc0 | ||
|
|
fdb50a065b | ||
|
|
1ac59d4894 | ||
|
|
32ccf61baa | ||
|
|
1d04c41ae7 | ||
|
|
b2dcf82ca8 | ||
|
|
57b86034cf | ||
|
|
095e312ab3 | ||
|
|
82a9fb3c39 | ||
|
|
e181329a81 | ||
|
|
cdce817928 | ||
|
|
97b0146ce9 | ||
|
|
0a60492146 | ||
|
|
4ea187cfac | ||
|
|
dcba7c62a2 | ||
|
|
dba99455a7 | ||
|
|
7ebce161e8 | ||
|
|
022aec5720 | ||
|
|
11997c024e | ||
|
|
f787b1b02a | ||
|
|
a03368a3fe | ||
|
|
e26ed8481f | ||
|
|
8e98eed5c8 | ||
|
|
0bff15f964 | ||
|
|
3384c6d666 | ||
|
|
5f1c74aca0 | ||
|
|
310355bcc1 | ||
|
|
ab07d83aaf | ||
|
|
11cdf52f7b | ||
|
|
8a2c3596ce | ||
|
|
a61d16d120 | ||
|
|
01df063cc1 | ||
|
|
68bae686da | ||
|
|
f978888759 | ||
|
|
f3b9f42202 | ||
|
|
0564893c4f | ||
|
|
c9dbe7936b | ||
|
|
3f7a3d7600 | ||
|
|
5b9d9452c9 | ||
|
|
b7ef900181 | ||
|
|
3eef673885 | ||
|
|
039a18c243 | ||
|
|
97d42703da | ||
|
|
4bf3a453e7 | ||
|
|
a137601728 | ||
|
|
d4840df447 | ||
|
|
2c1e51a490 | ||
|
|
2dcccc9820 | ||
|
|
8fa97ab8da | ||
|
|
a66fa9792d | ||
|
|
dc2bc83c17 | ||
|
|
8b0e92e408 | ||
|
|
b61fc4eb6b | ||
|
|
fea3d183bf | ||
|
|
20ed9cd123 | ||
|
|
d7a8a89aa7 | ||
|
|
005cc3e388 | ||
|
|
fbcb54a8a5 | ||
|
|
85a126f48a | ||
|
|
44cd35c10e | ||
|
|
a2d1cff3b0 | ||
|
|
3ff67fec2f | ||
|
|
6a8b5e6c8e | ||
|
|
92e9caf57e | ||
|
|
10ccbf109c | ||
|
|
f2dc51434d | ||
|
|
9d751e3525 | ||
|
|
27047e5880 | ||
|
|
4fdebfc78e | ||
|
|
11832edf49 | ||
|
|
87d50052cc | ||
|
|
08b89b7ef8 | ||
|
|
4b02078b60 | ||
|
|
1d644de500 | ||
|
|
54530faf03 | ||
|
|
ecb16d345a | ||
|
|
daecdb7676 | ||
|
|
423eb95f7a | ||
|
|
82bbed2720 | ||
|
|
90e804f608 | ||
|
|
e748277902 | ||
|
|
2a0c684e88 | ||
|
|
5bfaee47cc | ||
|
|
8de2f41924 | ||
|
|
ee356c5e6e | ||
|
|
a174cf1b02 | ||
|
|
934723f5f5 | ||
|
|
892c4235ec | ||
|
|
ddeb357c0e | ||
|
|
7cb007b445 | ||
|
|
50262a2f02 | ||
|
|
178fe4be4b | ||
|
|
4855e7cbca | ||
|
|
d2450cbdc1 | ||
|
|
bfee1019ed | ||
|
|
76a6d0ce8e | ||
|
|
579b5e4623 | ||
|
|
f2f2a2dbc4 | ||
|
|
3fff147b9b | ||
|
|
a25f491f45 | ||
|
|
8c2928c39d | ||
|
|
7e73e7fab2 | ||
|
|
61535dfb7e | ||
|
|
54332d31bc | ||
|
|
67b8eb5c2f | ||
|
|
27cef96789 | ||
|
|
560345a889 | ||
|
|
f41bfecf0b | ||
|
|
80438e1d61 | ||
|
|
19f1be03fa | ||
|
|
b6ca16e084 | ||
|
|
c0b80c923a | ||
|
|
61e0959a06 | ||
|
|
0ecf8b703e | ||
|
|
1a1bb0a99c | ||
|
|
5415057a5d | ||
|
|
a2493b4bc0 | ||
|
|
fd9040b9aa | ||
|
|
adaf9e4b93 | ||
|
|
39b036abd5 | ||
|
|
837bed3d47 | ||
|
|
7e447fe54e | ||
|
|
cd70c9148e | ||
|
|
cd05ec770c | ||
|
|
a7675606a1 | ||
|
|
bdd9d0a3dd | ||
|
|
e18a7ee435 | ||
|
|
c43d22b0e4 | ||
|
|
1a45d0aa87 | ||
|
|
41835c8afe | ||
|
|
5da9197eee | ||
|
|
cf2eee222e | ||
|
|
98e60e8f74 | ||
|
|
1e4d0006b9 | ||
|
|
b649a69a5e | ||
|
|
066fc01d2e | ||
|
|
19dd297138 | ||
|
|
f470ab6ec8 | ||
|
|
414f4e40c7 | ||
|
|
bb7d393128 | ||
|
|
05177dffb3 | ||
|
|
0fe5346f4d | ||
|
|
54988916e3 | ||
|
|
a1d972419d | ||
|
|
457fe83a4f | ||
|
|
314e4a497d | ||
|
|
4c5dac603f | ||
|
|
d1724caab4 | ||
|
|
37758c2032 | ||
|
|
d7f0a555c0 | ||
|
|
5629edf487 | ||
|
|
7a81e56553 | ||
|
|
0d2cafaec3 | ||
|
|
11f2ddbba2 | ||
|
|
34f4cca1d2 | ||
|
|
18b1dea8cf | ||
|
|
63870931af | ||
|
|
4dc401677d | ||
|
|
25046d7c98 | ||
|
|
a7bc7f25b4 | ||
|
|
1c16b77a92 | ||
|
|
8a670f5524 | ||
|
|
676e918edc | ||
|
|
6ea33c6bb8 | ||
|
|
26ede849e2 | ||
|
|
f464f32e48 | ||
|
|
c171d65d3f | ||
|
|
d01caaa4b1 | ||
|
|
941e599d36 | ||
|
|
d6b39babf5 | ||
|
|
3f68821663 | ||
|
|
7e68882872 | ||
|
|
e06f6c1935 | ||
|
|
599a62d722 | ||
|
|
d690dcadc7 | ||
|
|
e80bcabccf | ||
|
|
4c83780b5e | ||
|
|
be430ebdde | ||
|
|
483d536e2c | ||
|
|
f80deea110 | ||
|
|
6956536830 | ||
|
|
01c725a2ee | ||
|
|
1c2a8119c0 | ||
|
|
943ca79951 | ||
|
|
62c05093a0 | ||
|
|
d71d4e24be | ||
|
|
37e0785775 | ||
|
|
0ddeccf698 | ||
|
|
875ecca527 | ||
|
|
3a5d687f08 | ||
|
|
257077a59b | ||
|
|
9647d95759 | ||
|
|
2436ce45a2 | ||
|
|
2a2a4b817e | ||
|
|
d51234f202 | ||
|
|
7abe7f1a87 | ||
|
|
2849a9bce5 | ||
|
|
2aee8b8c2c | ||
|
|
e77c28b89b | ||
|
|
ed3f208dda | ||
|
|
68f5e7e502 | ||
|
|
11b0026995 | ||
|
|
5cfae4f8f0 | ||
|
|
bda03d187e | ||
|
|
0fa15604dc | ||
|
|
cd0acf1e6b | ||
|
|
9e183a2033 | ||
|
|
76e8e48400 | ||
|
|
3d6d4a48a5 | ||
|
|
5d0240e675 | ||
|
|
cabaa9f1df | ||
|
|
347bd3345c | ||
|
|
262bc6e1f7 | ||
|
|
e06152b58b | ||
|
|
d8addec077 | ||
|
|
5456596b29 | ||
|
|
8949ad6d9e | ||
|
|
0bb17a1502 | ||
|
|
0c52341dec | ||
|
|
4276e7835c | ||
|
|
fc1ad44346 | ||
|
|
91cf93d133 | ||
|
|
457de0919a | ||
|
|
d4fd3518bf | ||
|
|
f9adf5938e | ||
|
|
5d36fd7f99 | ||
|
|
9f5dd6f658 | ||
|
|
de40bd4705 | ||
|
|
6c5c41e8ea | ||
|
|
bd9c264375 | ||
|
|
1d625916ff | ||
|
|
80d65ad2e4 | ||
|
|
b11e9de6a7 | ||
|
|
7b28680cda | ||
|
|
853c489471 | ||
|
|
c6cd7d5ae6 | ||
|
|
8e120e2665 |
+6
-15
@@ -1,23 +1,14 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
.uv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
# Build artifacts
|
||||
build/
|
||||
target/
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
# frontend/dist/ - 注释掉,因为我们需要预构建的dist文件
|
||||
|
||||
# Development
|
||||
.git/
|
||||
@@ -59,4 +50,4 @@ Dockerfile.*
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
scripts/
|
||||
scripts/
|
||||
|
||||
+63
-31
@@ -1,19 +1,48 @@
|
||||
# ==================== 必须配置(启动前) ====================
|
||||
# 以下配置项必须在项目启动前设置
|
||||
|
||||
# 应用端口(默认 8084)
|
||||
APP_PORT=8084
|
||||
|
||||
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
|
||||
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
|
||||
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
|
||||
|
||||
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
|
||||
# 示例:
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:latest
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:rc
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
# Rust 日志过滤(默认 aether_gateway=info)
|
||||
# 示例: aether_gateway=debug,sqlx=warn
|
||||
RUST_LOG=aether_gateway=info
|
||||
|
||||
# CORS 配置(跨域带 Cookie 时不要写 *,必须显式列出前端源)
|
||||
# 示例: http://localhost:5173,https://app.example.com
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
# CORS_ALLOW_CREDENTIALS=true
|
||||
# 如果前后端跨站并依赖登录刷新 Cookie,还要配合:
|
||||
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_NAME=aether
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_PASSWORD=aether
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
REDIS_PASSWORD=aether
|
||||
|
||||
# JWT密钥(使用 python generate_keys.py 生成)
|
||||
# JWT密钥(使用 ./generate_keys.sh 生成)
|
||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||
JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
|
||||
@@ -21,38 +50,41 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# 代理节点 HMAC 密钥(用于 aether-proxy 认证)
|
||||
# 可选:不设置时会从 ENCRYPTION_KEY 自动派生
|
||||
# 显式设置时,aether-proxy.toml 的 hmac_key 配置相同值即可
|
||||
# 可通过 python generate_keys.py 生成
|
||||
# PROXY_HMAC_KEY=change-this-to-a-proxy-hmac-key
|
||||
|
||||
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
|
||||
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
||||
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
ADMIN_USERNAME=admin123456
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
# ==================== 可选配置(有默认值) ====================
|
||||
# 以下配置项有合理的默认值,可按需调整
|
||||
|
||||
# 应用端口(默认 8084)
|
||||
# APP_PORT=8084
|
||||
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
|
||||
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
|
||||
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
|
||||
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
# API_KEY_PREFIX=sk
|
||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
|
||||
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
# PostgreSQL 连接池配置(默认按 CPU 自动计算;正式高并发环境可显式预算)
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=120000
|
||||
# 可选的 Payload 上限(MiB);默认及 0 均表示不限制。
|
||||
# AETHER_MAX_REQUEST_BODY_MB=0
|
||||
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
|
||||
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=0
|
||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=0
|
||||
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
|
||||
|
||||
# CORS 配置(允许跨域的源,多个源用逗号分隔)
|
||||
# 示例: http://localhost:3000,https://example.com
|
||||
# 默认: * (允许所有源)
|
||||
# CORS_ORIGINS=*
|
||||
|
||||
# ==================== 计费系统(可选) ====================
|
||||
# Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警)
|
||||
# BILLING_REQUIRE_RULE=false
|
||||
#
|
||||
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=0 + 标记 incomplete)
|
||||
# BILLING_STRICT_MODE=false
|
||||
#
|
||||
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
|
||||
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
|
||||
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
|
||||
# work_mem=16MB, maintenance_work_mem=256MB。
|
||||
# POSTGRES_SHARED_BUFFERS=8GB
|
||||
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
||||
# POSTGRES_SHM_SIZE=2gb
|
||||
# POSTGRES_WORK_MEM=16MB
|
||||
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
name: Build aether-proxy Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['proxy-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
use_cross: false
|
||||
- name: windows-amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: aether-proxy -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
working-directory: aether-proxy
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd aether-proxy/target/${{ matrix.target }}/release
|
||||
chmod +x aether-proxy
|
||||
tar czf ../../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd aether-proxy/target/${{ matrix.target }}/release
|
||||
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aether-proxy-${{ matrix.name }}
|
||||
path: |
|
||||
aether-proxy-*.tar.gz
|
||||
aether-proxy-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-proxy-* > SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "aether-proxy ${{ github.ref_name }}"
|
||||
body: |
|
||||
## aether-proxy ${{ github.ref_name }}
|
||||
|
||||
### 下载
|
||||
|
||||
| 平台 | 文件 |
|
||||
|------|------|
|
||||
| Linux x86_64 | `aether-proxy-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `aether-proxy-linux-arm64.tar.gz` |
|
||||
| macOS x86_64 (Intel) | `aether-proxy-macos-amd64.tar.gz` |
|
||||
| macOS ARM64 (Apple Silicon) | `aether-proxy-macos-arm64.tar.gz` |
|
||||
| Windows x86_64 | `aether-proxy-windows-amd64.zip` |
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
# Linux/macOS: 解压
|
||||
tar xzf aether-proxy-<platform>.tar.gz
|
||||
|
||||
# Windows: 解压 zip 文件
|
||||
|
||||
# 配置环境变量 (或写入 .env 文件)
|
||||
export AETHER_PROXY_AETHER_URL=https://your-aether.example.com
|
||||
export AETHER_PROXY_MANAGEMENT_TOKEN=ae_xxx
|
||||
export AETHER_PROXY_HMAC_KEY=your-hmac-key
|
||||
|
||||
# 运行
|
||||
./aether-proxy
|
||||
```
|
||||
|
||||
### 校验
|
||||
|
||||
下载 `SHA256SUMS.txt` 后可验证文件完整性:
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
```
|
||||
files: |
|
||||
artifacts/aether-proxy-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
@@ -0,0 +1,239 @@
|
||||
name: Build aether-tunnel
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['tunnel-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: build-tunnel-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Ensure tunnel tag matches Cargo version
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#tunnel-v}"
|
||||
ACTUAL="$(cargo metadata --manifest-path apps/aether-tunnel/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-tunnel") | .version')"
|
||||
|
||||
echo "tag version: ${EXPECTED}"
|
||||
echo "cargo version: ${ACTUAL}"
|
||||
|
||||
if [ -z "${ACTUAL}" ]; then
|
||||
echo "Could not resolve aether-tunnel package version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
|
||||
echo "tunnel tag ${TAG} does not match apps/aether-tunnel/Cargo.toml version ${ACTUAL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
- name: windows-amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Ensure Rust target is installed
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/aether-tunnel -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/aether-tunnel
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --locked --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
chmod +x aether-tunnel
|
||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: aether-tunnel-${{ matrix.name }}
|
||||
path: |
|
||||
aether-tunnel-*.tar.gz
|
||||
aether-tunnel-*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-tunnel-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
update-readme:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update README download links
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#tunnel-v}"
|
||||
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
||||
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
|
||||
TABLE="| Platform | Download |\n|----------|----------|\n"
|
||||
TABLE+="| Linux x86_64 (GNU) | [aether-tunnel-linux-amd64.tar.gz](${BASE}/aether-tunnel-linux-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (GNU) | [aether-tunnel-linux-arm64.tar.gz](${BASE}/aether-tunnel-linux-arm64.tar.gz) |\n"
|
||||
TABLE+="| Linux x86_64 (musl) | [aether-tunnel-linux-musl-amd64.tar.gz](${BASE}/aether-tunnel-linux-musl-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (musl) | [aether-tunnel-linux-musl-arm64.tar.gz](${BASE}/aether-tunnel-linux-musl-arm64.tar.gz) |\n"
|
||||
TABLE+="| macOS x86_64 | [aether-tunnel-macos-amd64.tar.gz](${BASE}/aether-tunnel-macos-amd64.tar.gz) |\n"
|
||||
TABLE+="| macOS ARM64 | [aether-tunnel-macos-arm64.tar.gz](${BASE}/aether-tunnel-macos-arm64.tar.gz) |\n"
|
||||
TABLE+="| Windows x86_64 | [aether-tunnel-windows-amd64.zip](${BASE}/aether-tunnel-windows-amd64.zip) |"
|
||||
|
||||
# Replace content between markers
|
||||
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
||||
awk -v table="$TABLE" '
|
||||
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
||||
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
||||
!skip { print }
|
||||
' README.md > README.tmp && mv README.tmp README.md
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet && exit 0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
git commit -m "chore(tunnel): update download links for ${TAG}"
|
||||
git push
|
||||
@@ -15,15 +15,43 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
deploy_pages: ${{ steps.classify.outputs.deploy_pages }}
|
||||
steps:
|
||||
- name: Ensure stable Pages release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "deploy_pages=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual Pages deployment."
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Pages deploy for non-stable release tag: ${tag}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: needs.preflight.outputs.deploy_pages == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -41,7 +69,7 @@ jobs:
|
||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_base:
|
||||
description: 'Rebuild base image'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BASE_IMAGE_NAME: fawney19/aether-base
|
||||
APP_IMAGE_NAME: fawney19/aether
|
||||
# Base image hash inputs:
|
||||
# - Dockerfile.base
|
||||
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
|
||||
# - frontend/package-lock.json
|
||||
|
||||
jobs:
|
||||
check-base-changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
outputs:
|
||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if base image needs rebuild
|
||||
id: check
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Calculate current hash of base-related inputs (dependency-only fingerprint)
|
||||
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
CURRENT_HASH=$(
|
||||
(
|
||||
cat Dockerfile.base
|
||||
printf '%s\n' "$PY_FINGERPRINT"
|
||||
cat frontend/package-lock.json
|
||||
) | sha256sum | cut -d' ' -f1
|
||||
)
|
||||
echo "Current base hash: $CURRENT_HASH"
|
||||
|
||||
# Try to get hash label from remote image config
|
||||
# Pull the image config and extract labels
|
||||
REMOTE_HASH=""
|
||||
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest; then
|
||||
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||
else
|
||||
echo "WARN: failed to pull remote base image; forcing base rebuild."
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||
# No remote image or no hash label, need to rebuild
|
||||
echo "No remote base image or hash label found, need rebuild"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
|
||||
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Hash matches, no rebuild needed"
|
||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-base:
|
||||
needs: check-base-changes
|
||||
if: needs.check-base-changes.outputs.base_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Calculate base files hash
|
||||
id: hash
|
||||
run: |
|
||||
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
HASH=$(
|
||||
(
|
||||
cat Dockerfile.base
|
||||
printf '%s\n' "$PY_FINGERPRINT"
|
||||
cat frontend/package-lock.json
|
||||
) | sha256sum | cut -d' ' -f1
|
||||
)
|
||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
labels: |
|
||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=base
|
||||
cache-to: type=gha,mode=max,scope=base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
build-app:
|
||||
needs: [check-base-changes, build-base]
|
||||
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for app image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
|
||||
docker.io/fawney19/aether
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=pre,enable=${{ contains(github.ref, '-') }}
|
||||
type=raw,value=fix,enable=${{ contains(github.ref, '-fix') }}
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# 从 tag 提取版本号,如 v0.2.5 -> 0.2.5
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
if [ "$VERSION" = "$GITHUB_REF" ]; then
|
||||
# 不是 tag 触发,使用 git describe
|
||||
VERSION=$(git describe --tags --always | sed 's/^v//')
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
|
||||
- name: Update Dockerfile.app to use registry base image
|
||||
run: |
|
||||
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
||||
|
||||
- name: Generate version file
|
||||
run: |
|
||||
# 生成 _version.py 文件
|
||||
cat > src/_version.py << EOF
|
||||
# Auto-generated by CI
|
||||
__version__ = '${{ steps.version.outputs.version }}'
|
||||
__version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit())
|
||||
version = __version__
|
||||
version_tuple = __version_tuple__
|
||||
EOF
|
||||
|
||||
- name: Build and push app image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache-filters: builder
|
||||
cache-from: type=gha,scope=app
|
||||
cache-to: type=gha,mode=min,scope=app
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,342 @@
|
||||
name: Release Aether
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-aether-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether
|
||||
DOCKERHUB_IMAGE: fawney19/aether
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Release preflight
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
publish: ${{ steps.classify.outputs.publish }}
|
||||
version_tag: ${{ steps.classify.outputs.version_tag }}
|
||||
prerelease: ${{ steps.classify.outputs.prerelease }}
|
||||
make_latest: ${{ steps.classify.outputs.make_latest }}
|
||||
steps:
|
||||
- name: Classify release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "publish=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "version_tag=" >> "${GITHUB_OUTPUT}"
|
||||
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual release build; publish jobs will be skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then
|
||||
echo "Unsupported release tag: ${tag}" >&2
|
||||
echo "Expected vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version_tag=${tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${tag}" == *-* ]]; then
|
||||
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
echo "publish=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "Manual release build for ${tag}; publish jobs will be skipped."
|
||||
fi
|
||||
|
||||
frontend:
|
||||
name: Build frontend
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install & build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: preflight
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: amd64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: arm64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos
|
||||
arch: amd64
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos
|
||||
arch: arm64
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
||||
AETHER_BUILD_TYPE: release
|
||||
CARGO_TERM_COLOR: always
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
||||
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker:
|
||||
name: Docker multi-arch
|
||||
needs: [preflight, frontend, build]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare dist layout
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
||||
cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
||||
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||
cp -r artifacts/frontend-dist dist/frontend
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta.') }}
|
||||
type=raw,value=rc,enable=${{ contains(github.ref_name, '-rc.') }}
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
package:
|
||||
name: Release tarballs
|
||||
needs: [preflight, frontend, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Build release packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
SOURCE_REF="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="snapshot-${GITHUB_SHA::7}"
|
||||
SOURCE_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
mkdir -p package release-assets
|
||||
for platform in linux macos; do
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p \
|
||||
"${root}/bin" \
|
||||
"${root}/frontend"
|
||||
|
||||
install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
||||
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0644 .env.example "${root}/.env.example"
|
||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||
install -m 0644 README.md "${root}/README.md"
|
||||
install -m 0644 LICENSE "${root}/LICENSE"
|
||||
|
||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||
done
|
||||
done
|
||||
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > release-assets/install.sh
|
||||
chmod +x release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
- name: Upload release package artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
github-release:
|
||||
name: GitHub Release assets
|
||||
needs: [preflight, docker, package]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download release package artifact
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Publish GitHub Release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
||||
make_latest: ${{ needs.preflight.outputs.make_latest }}
|
||||
files: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/SHA256SUMS
|
||||
release-assets/install.sh
|
||||
@@ -0,0 +1,681 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
|
||||
concurrency:
|
||||
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
CARGO_PROFILE_TEST_DEBUG: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: rustfmt
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
|
||||
clippy_gateway:
|
||||
name: Clippy (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_data:
|
||||
name: Clippy (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-data --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_rest:
|
||||
name: Clippy (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- clippy_gateway
|
||||
- clippy_data
|
||||
- clippy_rest
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify clippy jobs
|
||||
run: |
|
||||
if [ "${{ needs.clippy_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_rest.result }}" != "success" ]; then
|
||||
echo "Clippy failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test_gateway:
|
||||
name: Test (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@v1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test lib
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --lib
|
||||
|
||||
- name: Test bin
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --bin aether-gateway
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data:
|
||||
name: Test (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run -p aether-data
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_data_features:
|
||||
name: Check (Data Feature - ${{ matrix.feature }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
feature:
|
||||
- postgres
|
||||
- mysql
|
||||
- sqlite
|
||||
- all-drivers
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Check selected data driver
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_rest:
|
||||
name: Test (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data_adapters:
|
||||
name: Test (Data Adapter - ${{ matrix.package }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package:
|
||||
- aether-data-postgres
|
||||
- aether-data-mysql
|
||||
- aether-data-sqlite
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test adapter
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run -p ${{ matrix.package }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_integration_scenarios:
|
||||
name: Test (Integration Scenarios)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Test scenario binaries
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-integration-tests --bins
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test_gateway
|
||||
- test_data
|
||||
- check_data_features
|
||||
- test_rest
|
||||
- test_data_adapters
|
||||
- check_integration_scenarios
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify test jobs
|
||||
run: |
|
||||
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_data_features.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_rest.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
data_db_smoke_sqlite:
|
||||
name: Data DB Smoke (SQLite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run SQLite data smoke tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-data --all-features sqlite --lib
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_postgres:
|
||||
name: Data DB Smoke (Postgres)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: aether_test
|
||||
POSTGRES_USER: aether
|
||||
POSTGRES_PASSWORD: aether
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -h 127.0.0.1 -U aether -d aether_test"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Add PostgreSQL server binaries to PATH
|
||||
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Postgres migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres provider metadata migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres API key lifecycle tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
||||
run: |
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
|
||||
|
||||
- name: Run Postgres core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run SQLite-to-Postgres import smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_mysql:
|
||||
name: Data DB Smoke (MySQL)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_DATABASE: aether_test
|
||||
MYSQL_USER: aether
|
||||
MYSQL_PASSWORD: aether
|
||||
MYSQL_ROOT_PASSWORD: aether_root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uaether -paether --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run MySQL migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage write smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_and_flushes_counters_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL provider catalog smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet daily usage aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL stats aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke:
|
||||
name: Data DB Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- data_db_smoke_sqlite
|
||||
- data_db_smoke_postgres
|
||||
- data_db_smoke_mysql
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify database smoke jobs
|
||||
run: |
|
||||
if [ "${{ needs.data_db_smoke_sqlite.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_postgres.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_mysql.result }}" != "success" ]; then
|
||||
echo "Data DB smoke failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check:
|
||||
name: check
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- fmt
|
||||
- clippy
|
||||
- test
|
||||
- data_db_smoke
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke.result }}" != "success" ]; then
|
||||
echo "Rust CI failed"
|
||||
exit 1
|
||||
fi
|
||||
+15
-3
@@ -1,14 +1,17 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
CLIProxyAPI/
|
||||
sub2api/
|
||||
*.rsa
|
||||
*_rsa
|
||||
|
||||
# AI Assistant Configuration
|
||||
.codex/
|
||||
.claude/
|
||||
.deepseek/
|
||||
.serena/
|
||||
.gemini*/
|
||||
.plans
|
||||
.playwright-mcp/
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
@@ -215,6 +218,10 @@ backups/
|
||||
|
||||
# Runtime lock files
|
||||
.locks/
|
||||
|
||||
# Local Rust/Cargo configuration
|
||||
.cargo/
|
||||
|
||||
# Demo and test files
|
||||
frontend/public/*-demo.html
|
||||
frontend/public/*-measure.html
|
||||
@@ -229,10 +236,15 @@ test.py
|
||||
.deps-hash
|
||||
.code-hash
|
||||
.migration-hash
|
||||
.hub-hash
|
||||
|
||||
# Hub prebuilt binaries
|
||||
aether-hub/dist/
|
||||
|
||||
# Version file (auto-generated by hatch-vcs)
|
||||
src/_version.py
|
||||
|
||||
# Analysis folder (third-party code for reference)
|
||||
analysis/
|
||||
/aether-proxy/target/
|
||||
new-api/
|
||||
apps/aether-tunnel/aether-tunnel.toml
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
rust = "latest"
|
||||
@@ -1 +0,0 @@
|
||||
3.14
|
||||
Generated
+6398
File diff suppressed because it is too large
Load Diff
+158
@@ -0,0 +1,158 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/aether-tunnel",
|
||||
"crates/aether-ai/formats",
|
||||
"crates/aether-admin",
|
||||
"crates/aether-admission-core",
|
||||
"crates/aether-ai/serving",
|
||||
"crates/aether-pool-core",
|
||||
"crates/aether-provider/core",
|
||||
"crates/aether-provider/pool",
|
||||
"crates/aether-routing-core",
|
||||
"crates/aether-data/contracts",
|
||||
"crates/aether-data/adapters/postgres",
|
||||
"crates/aether-data/adapters/mysql",
|
||||
"crates/aether-data/adapters/sqlite",
|
||||
"crates/aether-data/query",
|
||||
"crates/aether-data/schema",
|
||||
"crates/aether-dispatch-core",
|
||||
"crates/aether-cache",
|
||||
"crates/aether-billing",
|
||||
"crates/aether-wallet",
|
||||
"crates/aether-crypto",
|
||||
"crates/aether-contracts",
|
||||
"crates/aether-data/runtime",
|
||||
"crates/aether-model-fetch",
|
||||
"crates/aether-oauth",
|
||||
"crates/aether-provider/transport",
|
||||
"crates/aether-scheduler-core",
|
||||
"crates/aether-runtime/state",
|
||||
"crates/aether-task/runtime",
|
||||
"crates/aether-task/core",
|
||||
"crates/aether-gateway/frontdoor",
|
||||
"crates/aether-gateway/control",
|
||||
"crates/aether-gateway/execution",
|
||||
"crates/aether-gateway/workers",
|
||||
"crates/aether-gateway/tunnel",
|
||||
"crates/aether-testing/loadtools",
|
||||
"crates/aether-testing/integration",
|
||||
"crates/aether-usage/core",
|
||||
"crates/aether-testing/support",
|
||||
"crates/aether-usage/runtime",
|
||||
"crates/aether-video-tasks-core",
|
||||
"apps/aether-gateway",
|
||||
"crates/aether-http",
|
||||
"crates/aether-runtime/base",
|
||||
"crates/aether-testing/testkit",
|
||||
]
|
||||
default-members = [
|
||||
"apps/aether-gateway",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "LicenseRef-Aether-NonCommercial"
|
||||
repository = "https://github.com/fawney19/Aether.git"
|
||||
|
||||
[workspace.dependencies]
|
||||
aether-admin = { path = "crates/aether-admin" }
|
||||
aether-admission-core = { path = "crates/aether-admission-core" }
|
||||
aether-ai-formats = { path = "crates/aether-ai/formats" }
|
||||
aether-ai-serving = { path = "crates/aether-ai/serving" }
|
||||
aether-pool-core = { path = "crates/aether-pool-core" }
|
||||
aether-provider-core = { path = "crates/aether-provider/core" }
|
||||
aether-provider-pool = { path = "crates/aether-provider/pool" }
|
||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
||||
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
||||
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
||||
aether-data-mysql = { path = "crates/aether-data/adapters/mysql" }
|
||||
aether-data-sqlite = { path = "crates/aether-data/adapters/sqlite" }
|
||||
aether-data-query = { path = "crates/aether-data/query" }
|
||||
aether-data-schema = { path = "crates/aether-data/schema" }
|
||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||
aether-cache = { path = "crates/aether-cache" }
|
||||
aether-billing = { path = "crates/aether-billing" }
|
||||
aether-wallet = { path = "crates/aether-wallet" }
|
||||
aether-crypto = { path = "crates/aether-crypto" }
|
||||
aether-contracts = { path = "crates/aether-contracts" }
|
||||
aether-data = { path = "crates/aether-data/runtime" }
|
||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||
aether-oauth = { path = "crates/aether-oauth" }
|
||||
aether-provider-transport = { path = "crates/aether-provider/transport" }
|
||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||
aether-runtime-state = { path = "crates/aether-runtime/state" }
|
||||
aether-task-runtime = { path = "crates/aether-task/runtime" }
|
||||
aether-task-core = { path = "crates/aether-task/core" }
|
||||
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
|
||||
aether-gateway-control = { path = "crates/aether-gateway/control" }
|
||||
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
|
||||
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
|
||||
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
|
||||
aether-loadtools = { path = "crates/aether-testing/loadtools" }
|
||||
aether-integration-tests = { path = "crates/aether-testing/integration" }
|
||||
aether-test-support = { path = "crates/aether-testing/support" }
|
||||
aether-usage-core = { path = "crates/aether-usage/core" }
|
||||
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
|
||||
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
||||
aether-gateway = { path = "apps/aether-gateway" }
|
||||
aether-http = { path = "crates/aether-http" }
|
||||
aether-runtime = { path = "crates/aether-runtime/base" }
|
||||
aether-testkit = { path = "crates/aether-testing/testkit" }
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
async-stream = "0.3"
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.16"
|
||||
bytes = "1"
|
||||
cbc = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
crypto_box = { version = "0.9", features = ["seal"] }
|
||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
http = "1"
|
||||
object_store = { version = "0.12", default-features = false, features = ["aws"] }
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
||||
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
|
||||
regex = "1"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
serde_path_to_error = "0.1"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.6"
|
||||
tar = "0.4"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5"] }
|
||||
webpki-roots = "0.26"
|
||||
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
url = "2"
|
||||
zstd = "0.13"
|
||||
|
||||
[profile.dev]
|
||||
# Keep file/line information for backtraces while avoiding full debug info
|
||||
# generation on very large crates during local development builds.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
# The gateway test target pulls in a very large in-crate test tree, so use the
|
||||
# lighter debug format here as well to reduce rustc peak memory.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
codegen-units = 8
|
||||
+41
-156
@@ -1,158 +1,43 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 运行镜像:从 base 提取产物到精简运行时
|
||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||
# 用于 GitHub Actions CI(官方源)
|
||||
FROM aether-base:latest AS builder
|
||||
WORKDIR /app
|
||||
# 复制前端源码并构建(CI 通过 no-cache-filters=builder 确保每次重建)
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.14-slim
|
||||
WORKDIR /app
|
||||
# 运行时依赖(无 gcc/nodejs/npm,使用 BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
RUN chmod -R 755 /usr/share/nginx/html
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
COPY gunicorn_conf.py ./
|
||||
# Nginx 配置模板
|
||||
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' # 静态资源:长期缓存' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 安全:阻止访问源码目录' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 后端 API 路由(白名单)→ 代理到后端' \
|
||||
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 600s;' \
|
||||
' proxy_send_timeout 600s;' \
|
||||
' proxy_read_timeout 600s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # API 文档路由 → 代理到后端' \
|
||||
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --max-requests 2000 --max-requests-jitter 100 --access-logfile - --error-logfile - --log-level info' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
# 入口脚本(启动前执行迁移)
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084 \
|
||||
GUNICORN_WORKERS=4
|
||||
EXPOSE 80
|
||||
# Aether Gateway runtime image (cross-compilation)
|
||||
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
|
||||
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
||||
#
|
||||
# Build context must contain:
|
||||
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/frontend/ (npm run build output)
|
||||
|
||||
# --- layout stage: create /opt/aether directory structure with symlink ---
|
||||
# distroless has no shell, so we use busybox to set up the symlink.
|
||||
FROM busybox:1.37-musl AS layout
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
||||
|
||||
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
||||
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
||||
|
||||
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
||||
|
||||
# --- final stage: distroless runtime ---
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
COPY --from=layout /opt/aether /opt/aether
|
||||
|
||||
WORKDIR /opt/aether
|
||||
|
||||
ENV RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_UPDATE_STRATEGY=docker \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER root
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
+128
-153
@@ -1,176 +1,151 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||
# 用于本地/国内服务器部署
|
||||
FROM aether-base:latest AS builder
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
||||
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
||||
|
||||
WORKDIR /app
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
# 复制前端源码并构建
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.14-slim
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
WORKDIR /app
|
||||
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段。
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
|
||||
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
|
||||
|
||||
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
lld \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
RUN chmod -R 755 /usr/share/nginx/html
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
COPY gunicorn_conf.py ./
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
set -eux; \
|
||||
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# Nginx 配置模板
|
||||
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' # 静态资源:长期缓存' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 安全:阻止访问源码目录' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 后端 API 路由(白名单)→ 代理到后端' \
|
||||
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 600s;' \
|
||||
' proxy_send_timeout 600s;' \
|
||||
' proxy_read_timeout 600s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # API 文档路由 → 代理到后端' \
|
||||
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/app/logs \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/local/bin; \
|
||||
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --max-requests 2000 --max-requests-jitter 100 --access-logfile - --error-logfile - --log-level info' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
# 复制 gateway 二进制
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
# 入口脚本(启动前执行迁移)
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
|
||||
WORKDIR /app
|
||||
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084 \
|
||||
GUNICORN_WORKERS=4
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_UPDATE_STRATEGY=manual \
|
||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 本地发布版联调镜像
|
||||
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
|
||||
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ARG AETHER_BUILD_TYPE=release
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo build --release --locked -p aether-gateway --features jemalloc && \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/lib \
|
||||
/runtime-root/opt/aether/logs \
|
||||
/runtime-root/opt/aether/releases/image/bin \
|
||||
/runtime-root/opt/aether/releases/image/frontend; \
|
||||
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
|
||||
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_BASE_DIR=/opt/aether \
|
||||
AETHER_UPDATE_STRATEGY=self \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
@@ -1,28 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 构建镜像:编译环境 + 预编译的依赖
|
||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具(使用 BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm
|
||||
|
||||
# Python 依赖(使用 BuildKit 缓存加速)
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
|
||||
|
||||
# 前端依赖(只安装,不构建,使用 BuildKit 缓存加速)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd frontend && npm ci
|
||||
@@ -1,31 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具(使用清华镜像源 + BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm
|
||||
|
||||
# pip 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖(使用 BuildKit 缓存加速)
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
|
||||
|
||||
# 前端依赖(只安装,不构建,使用淘宝镜像源 + BuildKit 缓存加速)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||
@@ -0,0 +1,567 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
DEV_RUST_LOG := info,executor::candidate_loop=debug,stream::execution=debug
|
||||
ifeq ($(origin RUST_LOG), command line)
|
||||
DEV_RUST_LOG := $(RUST_LOG)
|
||||
endif
|
||||
export DEV_RUST_LOG
|
||||
|
||||
.PHONY: dev dev-backend dev-frontend migration backfill
|
||||
|
||||
define DEV_BACKEND_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
dev_uses_sqlite_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
[[ "$${driver}" == "sqlite" || "$${url}" == sqlite:* ]]
|
||||
}
|
||||
|
||||
dev_uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
dev_uses_redis_runtime() {
|
||||
local backend
|
||||
backend="$$(lowercase "$${AETHER_RUNTIME_BACKEND:-}")"
|
||||
|
||||
if [[ "$${backend}" == "memory" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$${backend}" == "redis" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if dev_uses_sqlite_database; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
print_dev_infra_hint() {
|
||||
echo "=> 本地开发依赖未就绪。"
|
||||
echo "=> 可手动启动 Postgres / Redis:"
|
||||
echo "=> docker compose up -d postgres redis"
|
||||
}
|
||||
|
||||
check_postgres_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
|
||||
if command -v pg_isready >/dev/null 2>&1; then
|
||||
pg_isready -h "$${host}" -p "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
check_redis_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
local password="$$3"
|
||||
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
REDISCLI_AUTH="$${password}" redis-cli -h "$${host}" -p "$${port}" ping >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
is_local_host() {
|
||||
case "$$1" in
|
||||
localhost|127.0.0.1|::1)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_dev_infra() {
|
||||
local postgres_host="$${DB_HOST:-localhost}"
|
||||
local postgres_port="$${DB_PORT:-5432}"
|
||||
local redis_host="$${REDIS_HOST:-localhost}"
|
||||
local redis_port="$${REDIS_PORT:-6379}"
|
||||
local redis_password="$${REDIS_PASSWORD:-}"
|
||||
local need_postgres=false
|
||||
local need_redis=false
|
||||
local services=()
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
if ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
if is_local_host "$${postgres_host}"; then
|
||||
need_postgres=true
|
||||
services+=(postgres)
|
||||
else
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
if ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
if is_local_host "$${redis_host}"; then
|
||||
need_redis=true
|
||||
services+=(redis)
|
||||
else
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$${#services[@]}" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "=> 未找到 docker,无法自动启动本地开发依赖。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> 本地开发依赖未就绪,正在启动: docker compose up -d $${services[*]}"
|
||||
if ! docker compose up -d "$${services[@]}"; then
|
||||
echo "=> docker compose 启动本地开发依赖失败。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
for _ in {1..100}; do
|
||||
local ready=true
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${ready}" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
fi
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
print_startup_failure_hint() {
|
||||
local log_file="$$1"
|
||||
|
||||
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
|
||||
if grep -Eq "database schema is behind" "$${log_file}"; then
|
||||
echo "=> 检测到数据库 schema 落后,请执行: make migration"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "database backfills are behind" "$${log_file}"; then
|
||||
echo "=> 检测到待执行 backfills,请执行: make backfill"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=> 未识别到明确的修复动作,请根据上面的日志继续排查。"
|
||||
}
|
||||
|
||||
wait_for_startup() {
|
||||
local pid="$$1"
|
||||
local timeout_seconds="$$2"
|
||||
local service_name="$$3"
|
||||
shift 3
|
||||
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
|
||||
local attempts=$$((timeout_seconds * 10))
|
||||
if [ "$${attempts}" -lt 1 ]; then
|
||||
attempts=1
|
||||
fi
|
||||
|
||||
for ((i = 0; i < attempts; i++)); do
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> $${service_name} 在 $${timeout_seconds}s 内未通过启动检查。"
|
||||
echo "=> 如果这是冷编译或存在并发 cargo 构建,可调大启动超时后重试。"
|
||||
return 1
|
||||
}
|
||||
|
||||
create_gateway_log_file() {
|
||||
local tmp_root="$${TMPDIR:-/tmp}"
|
||||
tmp_root="$${tmp_root%/}"
|
||||
|
||||
GATEWAY_LOG_DIR="$$(mktemp -d "$${tmp_root}/aether-dev-startup.XXXXXX")"
|
||||
GATEWAY_LOG_FILE="$${GATEWAY_LOG_DIR}/gateway.log"
|
||||
: > "$${GATEWAY_LOG_FILE}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${GATEWAY_PID:-}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止 aether-gateway..."
|
||||
kill "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
wait "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_FILE:-}" ] && [ -f "$${GATEWAY_LOG_FILE}" ]; then
|
||||
rm -f "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_DIR:-}" ] && [ -d "$${GATEWAY_LOG_DIR}" ]; then
|
||||
rmdir "$${GATEWAY_LOG_DIR}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
export RUST_LOG="$${DEV_RUST_LOG}"
|
||||
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS="$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-180}"
|
||||
GATEWAY_STARTUP_TIMEOUT_SECONDS="$${GATEWAY_STARTUP_TIMEOUT_SECONDS:-$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}"
|
||||
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE="$${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}"
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
export REDIS_URL="redis://:$${REDIS_PASSWORD:-}@$${REDIS_HOST:-localhost}:$${REDIS_PORT:-6379}/0"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_REDIS_URL"; then
|
||||
export AETHER_GATEWAY_DATA_REDIS_URL="$${REDIS_URL}"
|
||||
fi
|
||||
else
|
||||
unset REDIS_URL
|
||||
unset AETHER_GATEWAY_DATA_REDIS_URL
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
export DB_POOL_SIZE="$${DB_POOL_SIZE:-5}"
|
||||
export DB_MAX_OVERFLOW="$${DB_MAX_OVERFLOW:-5}"
|
||||
export HTTP_MAX_CONNECTIONS="$${HTTP_MAX_CONNECTIONS:-20}"
|
||||
export HTTP_KEEPALIVE_CONNECTIONS="$${HTTP_KEEPALIVE_CONNECTIONS:-5}"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法启动 aether-gateway。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "=> 未找到 curl,无法检查 aether-gateway 健康状态。请先安装 curl。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
|
||||
export RUSTC_WRAPPER="$$(command -v sccache)"
|
||||
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
|
||||
fi
|
||||
|
||||
if ! ensure_dev_infra; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GATEWAY_PID=""
|
||||
GATEWAY_LOG_DIR=""
|
||||
GATEWAY_LOG_FILE=""
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
create_gateway_log_file
|
||||
|
||||
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
|
||||
echo "=> 日志过滤: $${RUST_LOG}"
|
||||
echo "=> 执行命令: cargo run -p aether-gateway -- --app-port $${APP_PORT}"
|
||||
cargo run -p aether-gateway -- --app-port "$${APP_PORT}" > >(
|
||||
tee -a "$${GATEWAY_LOG_FILE}"
|
||||
) 2>&1 &
|
||||
GATEWAY_PID=$$!
|
||||
|
||||
if ! wait_for_startup "$${GATEWAY_PID}" "$${GATEWAY_STARTUP_TIMEOUT_SECONDS}" "aether-gateway" curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health"; then
|
||||
if [ "$${STARTUP_WAIT_EARLY_EXIT}" = "true" ]; then
|
||||
GATEWAY_PID=""
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if wait "$${GATEWAY_PID}"; then
|
||||
gateway_exit_code=0
|
||||
else
|
||||
gateway_exit_code=$$?
|
||||
fi
|
||||
|
||||
GATEWAY_PID=""
|
||||
|
||||
if [ "$${gateway_exit_code}" -ne 130 ] && [ "$${gateway_exit_code}" -ne 143 ]; then
|
||||
echo "=> aether-gateway 运行失败并已退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
exit "$${gateway_exit_code}"
|
||||
endef
|
||||
export DEV_BACKEND_SCRIPT
|
||||
|
||||
define DEV_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
backend_pid=""
|
||||
frontend_pid=""
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${backend_pid}" ] || [ -n "$${frontend_pid}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止本地开发服务..."
|
||||
if [ -n "$${backend_pid}" ]; then
|
||||
kill "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$${frontend_pid}" ]; then
|
||||
kill "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
wait_for_backend_ready() {
|
||||
while :; do
|
||||
if curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
|
||||
echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway -- --app-port $${APP_PORT:-8084}"
|
||||
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
|
||||
backend_pid=$$!
|
||||
|
||||
echo "=> 等待后端健康检查: http://127.0.0.1:$${APP_PORT}/_gateway/health"
|
||||
wait_for_backend_ready
|
||||
|
||||
echo "=> 启动前端: cd frontend && npm run dev"
|
||||
( cd frontend && exec npm run dev ) &
|
||||
frontend_pid=$$!
|
||||
|
||||
while :; do
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${frontend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${frontend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 前端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 前端进程已退出"
|
||||
fi
|
||||
frontend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
endef
|
||||
export DEV_SCRIPT
|
||||
|
||||
define DB_TASK_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$${DB_TASK_FLAG:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
||||
echo "=> 内部错误: DB_TASK_FLAG / DB_TASK_LABEL 未设置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
if uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法执行 $${DB_TASK_LABEL}。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway -- $${DB_TASK_FLAG}"
|
||||
exec cargo run -p aether-gateway -- "$${DB_TASK_FLAG}"
|
||||
endef
|
||||
export DB_TASK_SCRIPT
|
||||
|
||||
dev:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_SCRIPT"
|
||||
|
||||
dev-backend:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_BACKEND_SCRIPT"
|
||||
|
||||
dev-frontend:
|
||||
@cd frontend && npm run dev
|
||||
|
||||
migration:
|
||||
@DB_TASK_FLAG=--migrate DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
backfill:
|
||||
@DB_TASK_FLAG=--apply-backfills DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
@@ -5,12 +5,13 @@
|
||||
<h1 align="center">Aether</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源 AI API 网关</strong><br>
|
||||
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入层
|
||||
<strong>一站式 AI 基础设施平台</strong><br>
|
||||
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入、格式转换、正/反向代理, 致力于成为用户驱动AI服务的底座
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#简介">简介</a> •
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#api-文档">API 文档</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#qa">Q&A</a>
|
||||
</p>
|
||||
@@ -22,27 +23,15 @@
|
||||
|
||||
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
|
||||
|
||||
### 页面预览
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/architecture/architecture-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/architecture/architecture-light.svg">
|
||||
<img src="docs/architecture/architecture-light.svg" width="680" alt="Aether Architecture">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
| 首页 | 仪表盘 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 健康监控 | 用户管理 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 提供商管理 | 使用记录 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 模型详情 | 关联提供商 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 链路追踪 | 系统设置 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
页面预览: https://fawney19.github.io/Aether/
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -55,138 +44,122 @@ cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
# 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 部署 / 更新(自动执行数据库迁移)
|
||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
||||
# Postgres + Redis (适用于企业或多人使用)
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# 4. 升级前备份
|
||||
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
# Single Node (适用于个人用户或朋友分享)
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
./update.sh
|
||||
```
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
|
||||
|
||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||
```bash
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,并额外挂载一个容器用户可写的目录到 `/opt/aether/logs`。
|
||||
|
||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
||||
|
||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||
|
||||
标准 Docker Compose 使用 Docker named volumes 存放 Postgres/Redis/MySQL 数据;Single Node 使用部署目录下的 `./data` 存放 SQLite 数据。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
./dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
docker compose -f docker-compose.release-local.yml up -d --build
|
||||
```
|
||||
|
||||
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
||||
|
||||
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
||||
|
||||
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release-local.yml down -v
|
||||
```
|
||||
|
||||
可选变量:
|
||||
|
||||
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||
|
||||
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
依赖 Docker、Rust toolchain、Node.js 和 make。
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
|
||||
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
|
||||
|
||||
## Aether Tunnel (可选)
|
||||
|
||||
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
- Docker Compose 部署或下载预编译二进制直接运行
|
||||
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `tunnel-v*` 制品并向现有 `aether-tunnel.toml` 追加 `[[servers]]`
|
||||
- 通过 `aether-tunnel setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [apps/aether-tunnel/README.md](apps/aether-tunnel/README.md)
|
||||
|
||||
## API 文档
|
||||
|
||||
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
||||
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 必需配置
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
|
||||
| `REDIS_PASSWORD` | Redis 密码 |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
|
||||
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key) |
|
||||
| `ADMIN_EMAIL` | 初始管理员邮箱 |
|
||||
| `ADMIN_USERNAME` | 初始管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | 初始管理员密码 |
|
||||
|
||||
### 可选配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `APP_PORT` | 8084 | 应用端口 |
|
||||
| `API_KEY_PREFIX` | sk | API Key 前缀 |
|
||||
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
|
||||
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
|
||||
| `DB_PORT` | 5432 | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | 6379 | Redis 端口 |
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 如何开启/关闭请求体记录?
|
||||
|
||||
管理员在 **系统设置** 中配置日志记录的详细程度:
|
||||
|
||||
| 级别 | 记录内容 |
|
||||
|------|----------|
|
||||
| Base | 基本请求信息 |
|
||||
| Headers | Base + 请求头 |
|
||||
| Full | Headers + 请求体 |
|
||||
|
||||
### Q: 管理员如何给模型配置 1M上下文 / 1H缓存 能力支持?
|
||||
|
||||
1. **模型管理**: 给模型设置 1M上下文 / 1H缓存 的能力支持, 并配置好价格
|
||||
2. **提供商管理**: 给端点添加支持该能力的密钥, 并勾选对应的能力标签
|
||||
|
||||
### Q: 用户如何使用 1H缓存?
|
||||
|
||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
在管理后台 **提供商管理** 中切换调度模式:
|
||||
|
||||
| 模式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 |
|
||||
| **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key |
|
||||
|
||||
### Q: 更新出问题如何回滚?
|
||||
|
||||
**有备份的情况(推荐):**
|
||||
|
||||
```bash
|
||||
# 1. 停止应用
|
||||
docker compose stop app
|
||||
|
||||
# 2. 恢复数据库(先清空再导入)
|
||||
docker compose exec -T postgres psql -U postgres -c "DROP DATABASE aether; CREATE DATABASE aether;"
|
||||
gunzip < backup_xxx.sql.gz | docker compose exec -T postgres psql -U postgres -d aether
|
||||
|
||||
# 3. 拉取旧版本镜像并重启
|
||||
# 方式一:使用具体版本 tag(如果有发布版本号)
|
||||
# 将 docker-compose.yml 中 image 从 ghcr.io/fawney19/aether:latest 改为指定版本
|
||||
# 方式二:使用之前记录的镜像 digest
|
||||
# 将 image 改为 ghcr.io/fawney19/aether@sha256:xxxxx
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest,方便回滚时使用。
|
||||
|
||||
**没有备份的情况:**
|
||||
|
||||
```bash
|
||||
# 1. 用当前容器回退数据库迁移(回退 1 步,按需调整数字)
|
||||
docker compose exec app alembic downgrade -1
|
||||
|
||||
# 2. 查看回退后的版本确认正确
|
||||
docker compose exec app alembic current
|
||||
|
||||
# 3. 切回旧镜像并重启(同上方式修改 docker-compose.yml 中的 image)
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
> 注意:没有备份的回滚依赖 alembic downgrade,如果迁移涉及不可逆的数据变更(如删除列),可能无法完全恢复数据。因此强烈建议升级前备份。
|
||||
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
||||
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`,Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时会自动推导,SQLite 固定 `1/1`,Postgres/MySQL 按 CPU 核心数计算并默认封顶 `100`
|
||||
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`
|
||||
- `AETHER_MAX_REQUEST_BODY_MB`:可选的单请求解压后请求体上限;未配置或设为 `0` 时不限制
|
||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:可选的 heartbeat、管理探测等内部整包响应体上限;未配置或设为 `0` 时不限制
|
||||
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
|
||||
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
|
||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制
|
||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启
|
||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
||||
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
||||
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
|
||||
|
||||
---
|
||||
|
||||
@@ -204,5 +177,4 @@ docker compose up -d app
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fawney19/Aether&Date)
|
||||
|
||||
[](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Aether server URL
|
||||
AETHER_PROXY_AETHER_URL=https://aether.example.com
|
||||
|
||||
# Management Token (ae_xxx, must belong to an ADMIN user)
|
||||
AETHER_PROXY_MANAGEMENT_TOKEN=ae_xxxxx
|
||||
|
||||
# HMAC key (must match Aether's PROXY_HMAC_KEY)
|
||||
AETHER_PROXY_HMAC_KEY=
|
||||
|
||||
# Proxy listen port
|
||||
AETHER_PROXY_LISTEN_PORT=18080
|
||||
|
||||
# Public IP (auto-detected if omitted)
|
||||
# AETHER_PROXY_PUBLIC_IP=203.0.113.42
|
||||
|
||||
# Node identification
|
||||
AETHER_PROXY_NODE_NAME=proxy-01
|
||||
# AETHER_PROXY_NODE_REGION=ap-northeast-1
|
||||
|
||||
# Heartbeat interval in seconds
|
||||
AETHER_PROXY_HEARTBEAT_INTERVAL=30
|
||||
|
||||
# Allowed destination ports (comma-separated)
|
||||
AETHER_PROXY_ALLOWED_PORTS=80,443,8080,8443
|
||||
|
||||
# HMAC timestamp tolerance in seconds
|
||||
AETHER_PROXY_TIMESTAMP_TOLERANCE=300
|
||||
|
||||
# Logging
|
||||
AETHER_PROXY_LOG_LEVEL=info
|
||||
AETHER_PROXY_LOG_JSON=false
|
||||
Generated
-3332
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
[package]
|
||||
name = "aether-proxy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Forward proxy for Aether with HMAC authentication"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] }
|
||||
http-body-util = "0.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
bytes = "1"
|
||||
hex = "0.4"
|
||||
anyhow = "1"
|
||||
toml = "0.8"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-pki-types = "1"
|
||||
rustls-pemfile = "2"
|
||||
rcgen = "0.13"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.28"
|
||||
url = "2"
|
||||
sysinfo = "0.32"
|
||||
libc = "0.2"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM rust:1.83-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
# Create dummy main.rs for dependency caching
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
|
||||
COPY src/ src/
|
||||
# Touch main.rs to force rebuild with real source
|
||||
RUN touch src/main.rs
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/aether-proxy /usr/local/bin/aether-proxy
|
||||
|
||||
EXPOSE 18080
|
||||
|
||||
ENTRYPOINT ["aether-proxy"]
|
||||
@@ -1,77 +0,0 @@
|
||||
# aether-proxy
|
||||
|
||||
Aether 正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
## 安装
|
||||
|
||||
### 下载预编译二进制
|
||||
|
||||
在 [GitHub Releases](../../releases) 页面下载对应平台的预编译文件,无需安装 Rust 环境。
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 首次安装配置(TUI 向导,勾选 Install Service 随系统启动服务)
|
||||
sudo ./aether-proxy setup
|
||||
|
||||
# 2. 日常管理 (勾选 Install Service 作为系统服务的情况下)
|
||||
aether-proxy status # 看状态
|
||||
aether-proxy logs # 看日志
|
||||
|
||||
sudo aether-proxy start # 启动服务
|
||||
sudo aether-proxy stop # 停止服务
|
||||
sudo aether-proxy restart # 重启服务
|
||||
|
||||
# 3. 重新配置(改完自动重启服务)
|
||||
sudo aether-proxy setup
|
||||
|
||||
# 4. 彻底卸载
|
||||
sudo aether-proxy uninstall
|
||||
```
|
||||
保存后配置写入 `aether-proxy.toml`,如果启用了 Install Service,将自动注册并启动 systemd 服务。
|
||||
|
||||
### 直接运行
|
||||
|
||||
如果不需要安装为系统服务,可以直接运行。缺少必填参数时会自动进入 setup 向导:
|
||||
|
||||
```bash
|
||||
./aether-proxy
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
配置按以下优先级加载(高优先级覆盖低优先级):
|
||||
|
||||
1. CLI 参数
|
||||
2. 环境变量(`AETHER_PROXY_*`)
|
||||
3. 配置文件(`aether-proxy.toml`,或通过 `AETHER_PROXY_CONFIG` 指定路径)
|
||||
|
||||
### 参数一览
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--aether-url` | `AETHER_PROXY_AETHER_URL` | **必填** | Aether 服务器地址 |
|
||||
| `--management-token` | `AETHER_PROXY_MANAGEMENT_TOKEN` | **必填** | 管理员 Token(`ae_xxx` 格式) |
|
||||
| `--hmac-key` | `AETHER_PROXY_HMAC_KEY` | **必填** | HMAC 密钥,需与 Aether 端一致 |
|
||||
| `--listen-port` | `AETHER_PROXY_LISTEN_PORT` | `18080` | 监听端口 |
|
||||
| `--public-ip` | `AETHER_PROXY_PUBLIC_IP` | 自动检测 | 公网 IP |
|
||||
| `--node-name` | `AETHER_PROXY_NODE_NAME` | `proxy-01` | 节点名称标识 |
|
||||
| `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 |
|
||||
| `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `30` | 心跳间隔(秒) |
|
||||
| `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
|
||||
| `--timestamp-tolerance` | `AETHER_PROXY_TIMESTAMP_TOLERANCE` | `300` | HMAC 时间戳容差(秒) |
|
||||
| `--log-level` | `AETHER_PROXY_LOG_LEVEL` | `info` | 日志级别 |
|
||||
| `--log-json` | `AETHER_PROXY_LOG_JSON` | `false` | JSON 格式日志 |
|
||||
| `--enable-tls` | `AETHER_PROXY_ENABLE_TLS` | `true` | 启用 TLS |
|
||||
| `--tls-cert` | `AETHER_PROXY_TLS_CERT` | `aether-proxy-cert.pem` | TLS 证书路径 |
|
||||
| `--tls-key` | `AETHER_PROXY_TLS_KEY` | `aether-proxy-key.pem` | TLS 私钥路径 |
|
||||
|
||||
## 发布新版本
|
||||
|
||||
推送 `proxy-v*` 格式的 tag,GitHub Actions 会自动编译所有平台并发布到 Releases:
|
||||
|
||||
```bash
|
||||
git tag proxy-v0.1.0
|
||||
git push origin proxy-v0.1.0
|
||||
```
|
||||
@@ -1,200 +0,0 @@
|
||||
//! Application lifecycle: initialization, task orchestration, and shutdown.
|
||||
//!
|
||||
//! Extracted from `main.rs` to keep the entry point minimal and consolidate
|
||||
//! the startup sequence, tracing init, and graceful shutdown logic.
|
||||
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tokio::signal;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::net;
|
||||
use crate::registration::client::AetherClient;
|
||||
use crate::runtime::{self, DynamicConfig};
|
||||
use crate::state::AppState;
|
||||
use crate::{hardware, proxy};
|
||||
|
||||
/// Run the full application lifecycle after config has been parsed.
|
||||
pub async fn run(mut config: Config) -> anyhow::Result<()> {
|
||||
init_tracing(&config);
|
||||
|
||||
info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
port = config.listen_port,
|
||||
node_name = %config.node_name,
|
||||
"aether-proxy starting"
|
||||
);
|
||||
|
||||
// Resolve public IP
|
||||
let public_ip = match &config.public_ip {
|
||||
Some(ip) => ip.clone(),
|
||||
None => net::detect_public_ip().await?,
|
||||
};
|
||||
info!(public_ip = %public_ip, "using public IP");
|
||||
|
||||
// Auto-detect region if not configured
|
||||
if config.node_region.is_none() {
|
||||
if let Some(region) = net::detect_region(&public_ip).await {
|
||||
config.node_region = Some(region);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize TLS if enabled
|
||||
let (tls_acceptor, tls_fingerprint) = if config.enable_tls {
|
||||
let cert_path = std::path::PathBuf::from(&config.tls_cert);
|
||||
let key_path = std::path::PathBuf::from(&config.tls_key);
|
||||
|
||||
proxy::tls::ensure_self_signed_cert(&cert_path, &key_path)?;
|
||||
let acceptor = proxy::tls::build_tls_acceptor(&cert_path, &key_path)?;
|
||||
let fingerprint = proxy::tls::cert_sha256_fingerprint(&cert_path)?;
|
||||
|
||||
info!(fingerprint = %fingerprint, "TLS enabled");
|
||||
(Some(acceptor), Some(fingerprint))
|
||||
} else {
|
||||
info!("TLS disabled");
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Collect hardware info (once at startup)
|
||||
let hw_info = hardware::collect();
|
||||
|
||||
// Register with Aether
|
||||
let aether_client = Arc::new(AetherClient::new(&config));
|
||||
let node_id = aether_client
|
||||
.register(
|
||||
&config,
|
||||
&public_ip,
|
||||
config.enable_tls,
|
||||
tls_fingerprint.as_deref(),
|
||||
Some(&hw_info),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(node_id = %node_id, "node registered");
|
||||
|
||||
// Build DynamicConfig before moving config into Arc
|
||||
let dynamic = Arc::new(RwLock::new(DynamicConfig::from_config(&config)));
|
||||
|
||||
// Build delegate HTTP client (for proxy-initiated upstream requests).
|
||||
// No overall timeout — SSE streams can last indefinitely.
|
||||
// Connect timeout limits connection establishment; Aether controls
|
||||
// first-byte / idle timeouts on its own side.
|
||||
let delegate_client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(20)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||
.build()
|
||||
.expect("failed to create delegate HTTP client");
|
||||
|
||||
// Build shared application state
|
||||
let state = Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
node_id: Arc::new(RwLock::new(node_id)),
|
||||
dynamic,
|
||||
aether_client,
|
||||
hardware_info: Arc::new(hw_info),
|
||||
public_ip,
|
||||
tls_fingerprint,
|
||||
tls_acceptor,
|
||||
delegate_client,
|
||||
active_connections: Arc::new(AtomicU64::new(0)),
|
||||
});
|
||||
|
||||
// Shutdown signal channel
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
// Start heartbeat task
|
||||
let heartbeat_handle = {
|
||||
let state = Arc::clone(&state);
|
||||
let rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::registration::heartbeat::run(&state, rx).await;
|
||||
})
|
||||
};
|
||||
|
||||
// Start proxy server
|
||||
let server_handle = {
|
||||
let state = Arc::clone(&state);
|
||||
let rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = proxy::server::run(&state, rx).await {
|
||||
error!(error = %e, "proxy server error");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Wait for shutdown signal (SIGTERM or SIGINT)
|
||||
wait_for_shutdown().await;
|
||||
|
||||
info!("shutdown signal received, cleaning up...");
|
||||
|
||||
// Signal all tasks to stop
|
||||
let _ = shutdown_tx.send(true);
|
||||
|
||||
// Graceful unregister (best-effort)
|
||||
let current_node_id = state.node_id.read().unwrap().clone();
|
||||
if let Err(e) = state.aether_client.unregister(¤t_node_id).await {
|
||||
error!(error = %e, "unregister failed during shutdown");
|
||||
}
|
||||
|
||||
// Wait for tasks to finish
|
||||
let _ = tokio::join!(heartbeat_handle, server_handle);
|
||||
|
||||
info!("aether-proxy stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing(config: &Config) {
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{reload, EnvFilter};
|
||||
|
||||
let filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
let (filter_layer, reload_handle) = reload::Layer::new(filter);
|
||||
|
||||
// Register log-level hot-reloader
|
||||
runtime::set_log_reloader(Box::new(move |level: &str| {
|
||||
if let Ok(new_filter) = EnvFilter::try_new(level) {
|
||||
let _ = reload_handle.modify(|f| *f = new_filter);
|
||||
}
|
||||
}));
|
||||
|
||||
if config.log_json {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer().json())
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
MissingHeader,
|
||||
InvalidBasicAuth,
|
||||
InvalidUsername,
|
||||
InvalidPasswordFormat,
|
||||
TimestampParseError,
|
||||
TimestampExpired,
|
||||
SignatureMismatch,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingHeader => write!(f, "missing Proxy-Authorization header"),
|
||||
Self::InvalidBasicAuth => write!(f, "invalid Basic auth encoding"),
|
||||
Self::InvalidUsername => write!(f, "username must be 'hmac'"),
|
||||
Self::InvalidPasswordFormat => {
|
||||
write!(f, "password format must be 'timestamp.signature'")
|
||||
}
|
||||
Self::TimestampParseError => write!(f, "invalid timestamp"),
|
||||
Self::TimestampExpired => write!(f, "timestamp outside tolerance window"),
|
||||
Self::SignatureMismatch => write!(f, "HMAC signature mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate Proxy-Authorization header.
|
||||
///
|
||||
/// Expected format: `Basic base64(hmac:{timestamp}.{signature})`
|
||||
/// where signature = hex(HMAC-SHA256(hmac_key, "{timestamp}"))
|
||||
///
|
||||
/// The signature no longer includes `node_id`, eliminating race conditions
|
||||
/// during re-registration where the Aether server's cached `node_id` could
|
||||
/// differ from the proxy's freshly assigned `node_id`.
|
||||
///
|
||||
/// `timestamp_tolerance` is accepted separately so the caller can supply
|
||||
/// the value from [`DynamicConfig`](crate::runtime::DynamicConfig) (which
|
||||
/// may be updated remotely).
|
||||
pub fn validate_proxy_auth(
|
||||
proxy_auth_header: Option<&str>,
|
||||
config: &Config,
|
||||
timestamp_tolerance: u64,
|
||||
) -> Result<(), AuthError> {
|
||||
let header = proxy_auth_header.ok_or(AuthError::MissingHeader)?;
|
||||
|
||||
let encoded = header
|
||||
.strip_prefix("Basic ")
|
||||
.or_else(|| header.strip_prefix("basic "))
|
||||
.ok_or(AuthError::InvalidBasicAuth)?;
|
||||
|
||||
let decoded_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded.trim())
|
||||
.map_err(|_| AuthError::InvalidBasicAuth)?;
|
||||
|
||||
let decoded = String::from_utf8(decoded_bytes).map_err(|_| AuthError::InvalidBasicAuth)?;
|
||||
|
||||
// format: hmac:{timestamp}.{signature}
|
||||
let (username, password) = decoded.split_once(':').ok_or(AuthError::InvalidBasicAuth)?;
|
||||
|
||||
if username != "hmac" {
|
||||
return Err(AuthError::InvalidUsername);
|
||||
}
|
||||
|
||||
let (timestamp_str, signature_hex) = password
|
||||
.split_once('.')
|
||||
.ok_or(AuthError::InvalidPasswordFormat)?;
|
||||
|
||||
// Validate timestamp window
|
||||
let timestamp: u64 = timestamp_str
|
||||
.parse()
|
||||
.map_err(|_| AuthError::TimestampParseError)?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock before epoch")
|
||||
.as_secs();
|
||||
|
||||
let diff = now.abs_diff(timestamp);
|
||||
|
||||
if diff > timestamp_tolerance {
|
||||
return Err(AuthError::TimestampExpired);
|
||||
}
|
||||
|
||||
// Recompute signature: HMAC-SHA256(key, timestamp)
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(config.hmac_key.as_bytes()).expect("HMAC accepts any key size");
|
||||
mac.update(timestamp_str.as_bytes());
|
||||
let expected = mac.finalize().into_bytes();
|
||||
let expected_hex = hex::encode(expected);
|
||||
|
||||
// Constant-time comparison
|
||||
let sig_bytes = signature_hex.as_bytes();
|
||||
let exp_bytes = expected_hex.as_bytes();
|
||||
|
||||
if sig_bytes.len() != exp_bytes.len() || sig_bytes.ct_eq(exp_bytes).unwrap_u8() != 1 {
|
||||
return Err(AuthError::SignatureMismatch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config() -> Config {
|
||||
Config {
|
||||
aether_url: String::new(),
|
||||
management_token: String::new(),
|
||||
hmac_key: "test-hmac-key".to_string(),
|
||||
listen_port: 18080,
|
||||
public_ip: None,
|
||||
node_name: "test".to_string(),
|
||||
node_region: None,
|
||||
heartbeat_interval: 30,
|
||||
allowed_ports: vec![80, 443],
|
||||
timestamp_tolerance: 300,
|
||||
log_level: "info".to_string(),
|
||||
log_json: false,
|
||||
enable_tls: false,
|
||||
tls_cert: String::new(),
|
||||
tls_key: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_valid_auth(config: &Config) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
|
||||
mac.update(now.to_string().as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let cred = format!("hmac:{}.{}", now, sig);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(cred);
|
||||
format!("Basic {}", encoded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_auth() {
|
||||
let config = make_config();
|
||||
let header = make_valid_auth(&config);
|
||||
assert!(validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_header() {
|
||||
let config = make_config();
|
||||
assert!(matches!(
|
||||
validate_proxy_auth(None, &config, config.timestamp_tolerance),
|
||||
Err(AuthError::MissingHeader)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_username() {
|
||||
let cred = "user:12345.abc";
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(cred);
|
||||
let header = format!("Basic {}", encoded);
|
||||
let config = make_config();
|
||||
assert!(matches!(
|
||||
validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance),
|
||||
Err(AuthError::InvalidUsername)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod hmac;
|
||||
|
||||
pub use self::hmac::validate_proxy_auth;
|
||||
@@ -1,189 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Aether forward proxy with HMAC authentication.
|
||||
///
|
||||
/// Deployed on overseas VPS to relay API traffic for Aether instances
|
||||
/// behind the GFW. Registers with Aether, sends heartbeats, and validates
|
||||
/// incoming proxy requests via HMAC-SHA256 signatures in Basic Auth.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(version, about)]
|
||||
pub struct Config {
|
||||
/// Aether server URL (e.g. https://aether.example.com)
|
||||
#[arg(long, env = "AETHER_PROXY_AETHER_URL")]
|
||||
pub aether_url: String,
|
||||
|
||||
/// Management Token for Aether admin API (ae_xxx)
|
||||
#[arg(long, env = "AETHER_PROXY_MANAGEMENT_TOKEN")]
|
||||
pub management_token: String,
|
||||
|
||||
/// HMAC-SHA256 key for proxy authentication
|
||||
#[arg(long, env = "AETHER_PROXY_HMAC_KEY")]
|
||||
pub hmac_key: String,
|
||||
|
||||
/// Port to listen on for proxy connections
|
||||
#[arg(long, env = "AETHER_PROXY_LISTEN_PORT", default_value_t = 18080)]
|
||||
pub listen_port: u16,
|
||||
|
||||
/// Public IP address of this node (auto-detected if omitted)
|
||||
#[arg(long, env = "AETHER_PROXY_PUBLIC_IP")]
|
||||
pub public_ip: Option<String>,
|
||||
|
||||
/// Human-readable node name
|
||||
#[arg(long, env = "AETHER_PROXY_NODE_NAME", default_value = "proxy-01")]
|
||||
pub node_name: String,
|
||||
|
||||
/// Region label (e.g. ap-northeast-1)
|
||||
#[arg(long, env = "AETHER_PROXY_NODE_REGION")]
|
||||
pub node_region: Option<String>,
|
||||
|
||||
/// Heartbeat interval in seconds
|
||||
#[arg(long, env = "AETHER_PROXY_HEARTBEAT_INTERVAL", default_value_t = 30)]
|
||||
pub heartbeat_interval: u64,
|
||||
|
||||
/// Allowed destination ports (default: 80,443,8080,8443)
|
||||
#[arg(long, env = "AETHER_PROXY_ALLOWED_PORTS", value_delimiter = ',', default_values_t = vec![80, 443, 8080, 8443])]
|
||||
pub allowed_ports: Vec<u16>,
|
||||
|
||||
/// Timestamp tolerance window in seconds for HMAC validation
|
||||
#[arg(long, env = "AETHER_PROXY_TIMESTAMP_TOLERANCE", default_value_t = 300)]
|
||||
pub timestamp_tolerance: u64,
|
||||
|
||||
/// Log level (trace, debug, info, warn, error)
|
||||
#[arg(long, env = "AETHER_PROXY_LOG_LEVEL", default_value = "info")]
|
||||
pub log_level: String,
|
||||
|
||||
/// Output logs as JSON
|
||||
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
|
||||
pub log_json: bool,
|
||||
|
||||
/// Enable TLS encryption (dual-stack: accepts both HTTP and TLS on same port)
|
||||
#[arg(long, env = "AETHER_PROXY_ENABLE_TLS", default_value_t = true)]
|
||||
pub enable_tls: bool,
|
||||
|
||||
/// Path to TLS certificate PEM file
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_TLS_CERT",
|
||||
default_value = "aether-proxy-cert.pem"
|
||||
)]
|
||||
pub tls_cert: String,
|
||||
|
||||
/// Path to TLS private key PEM file
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_TLS_KEY",
|
||||
default_value = "aether-proxy-key.pem"
|
||||
)]
|
||||
pub tls_key: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOML config file support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serializable config for TOML file persistence.
|
||||
/// All fields are optional — only populated values are written.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub management_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hmac_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub listen_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_ip: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heartbeat_interval: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_ports: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp_tolerance: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub log_level: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub log_json: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_tls: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls_cert: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
/// Load from a TOML file.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
}
|
||||
|
||||
/// Save to a TOML file.
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inject values as environment variables so clap picks them up.
|
||||
///
|
||||
/// Only sets variables that are **not** already present in the
|
||||
/// environment, preserving the precedence: CLI > env > config file.
|
||||
pub fn inject_env(&self) {
|
||||
self.inject_env_inner(false);
|
||||
}
|
||||
|
||||
/// Inject values as environment variables, **overriding** any existing
|
||||
/// values. Used after setup to ensure the freshly-saved config takes
|
||||
/// effect before re-parsing.
|
||||
pub fn inject_env_override(&self) {
|
||||
self.inject_env_inner(true);
|
||||
}
|
||||
|
||||
fn inject_env_inner(&self, force: bool) {
|
||||
macro_rules! set {
|
||||
($env:expr, $val:expr) => {
|
||||
if let Some(ref v) = $val {
|
||||
if force || std::env::var($env).is_err() {
|
||||
std::env::set_var($env, v.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
set!("AETHER_PROXY_AETHER_URL", self.aether_url);
|
||||
set!("AETHER_PROXY_MANAGEMENT_TOKEN", self.management_token);
|
||||
set!("AETHER_PROXY_HMAC_KEY", self.hmac_key);
|
||||
set!("AETHER_PROXY_LISTEN_PORT", self.listen_port);
|
||||
set!("AETHER_PROXY_PUBLIC_IP", self.public_ip);
|
||||
set!("AETHER_PROXY_NODE_NAME", self.node_name);
|
||||
set!("AETHER_PROXY_NODE_REGION", self.node_region);
|
||||
set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval);
|
||||
set!("AETHER_PROXY_TIMESTAMP_TOLERANCE", self.timestamp_tolerance);
|
||||
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
|
||||
set!("AETHER_PROXY_LOG_JSON", self.log_json);
|
||||
set!("AETHER_PROXY_ENABLE_TLS", self.enable_tls);
|
||||
set!("AETHER_PROXY_TLS_CERT", self.tls_cert);
|
||||
set!("AETHER_PROXY_TLS_KEY", self.tls_key);
|
||||
|
||||
// allowed_ports needs special handling (comma-separated)
|
||||
if let Some(ref ports) = self.allowed_ports {
|
||||
if force || std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
|
||||
let s: String = ports
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
std::env::set_var("AETHER_PROXY_ALLOWED_PORTS", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use sysinfo::System;
|
||||
use tracing::info;
|
||||
|
||||
/// Hardware information collected at startup.
|
||||
///
|
||||
/// The struct is `Serialize`-able so it can be sent directly as the
|
||||
/// `hardware_info` JSON bag in the registration request. New fields
|
||||
/// can be added without database schema migrations.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HardwareInfo {
|
||||
pub cpu_cores: u32,
|
||||
pub total_memory_mb: u64,
|
||||
pub os_info: String,
|
||||
pub fd_limit: u64,
|
||||
#[serde(skip)]
|
||||
pub estimated_max_concurrency: u64,
|
||||
}
|
||||
|
||||
/// Collect hardware information and estimate max concurrency.
|
||||
///
|
||||
/// Should be called once at startup -- hardware does not change at runtime.
|
||||
pub fn collect() -> HardwareInfo {
|
||||
let sys = System::new_all();
|
||||
|
||||
let cpu_cores = sys.cpus().len() as u32;
|
||||
let total_memory_mb = sys.total_memory() / (1024 * 1024);
|
||||
let os_info = format!(
|
||||
"{} {}",
|
||||
System::name().unwrap_or_else(|| "Unknown".into()),
|
||||
System::os_version().unwrap_or_default(),
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Estimate max concurrent connections:
|
||||
// - Each tokio async task uses ~8-16 KB stack + heap buffers
|
||||
// - OS file descriptor limit is often the real bottleneck
|
||||
// - Conservative formula: min(fd_limit - 100, ram_mb * 40, cpu_cores * 2000)
|
||||
let fd_limit = get_fd_limit();
|
||||
let by_fd = fd_limit.saturating_sub(100);
|
||||
let by_ram = total_memory_mb.saturating_mul(40);
|
||||
let by_cpu = (cpu_cores as u64).saturating_mul(2000);
|
||||
let estimated_max_concurrency = by_fd.min(by_ram).min(by_cpu);
|
||||
|
||||
info!(
|
||||
cpu_cores,
|
||||
total_memory_mb,
|
||||
os_info = %os_info,
|
||||
fd_limit,
|
||||
estimated_max_concurrency,
|
||||
"hardware info collected"
|
||||
);
|
||||
|
||||
HardwareInfo {
|
||||
cpu_cores,
|
||||
total_memory_mb,
|
||||
os_info,
|
||||
fd_limit,
|
||||
estimated_max_concurrency,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the soft file-descriptor limit (RLIMIT_NOFILE).
|
||||
fn get_fd_limit() -> u64 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut rlim = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
let ret = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) };
|
||||
if ret == 0 {
|
||||
return rlim.rlim_cur;
|
||||
}
|
||||
}
|
||||
// Fallback for non-unix or error
|
||||
1024
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
mod app;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod hardware;
|
||||
mod net;
|
||||
mod proxy;
|
||||
mod registration;
|
||||
mod runtime;
|
||||
mod setup;
|
||||
mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
|
||||
use config::Config;
|
||||
|
||||
/// Default config file name.
|
||||
const DEFAULT_CONFIG: &str = "aether-proxy.toml";
|
||||
|
||||
/// Build the full clap command: Config args + discoverable subcommands.
|
||||
///
|
||||
/// `subcommand_negates_reqs` lets subcommands bypass the required Config
|
||||
/// flags so that e.g. `aether-proxy setup` doesn't demand `--aether-url`.
|
||||
fn build_command() -> clap::Command {
|
||||
Config::command()
|
||||
.subcommand(
|
||||
clap::Command::new("setup")
|
||||
.about("Interactive setup wizard (TUI)")
|
||||
.arg(
|
||||
clap::Arg::new("config_path")
|
||||
.help("Path to config file")
|
||||
.default_value(DEFAULT_CONFIG),
|
||||
),
|
||||
)
|
||||
.subcommand(clap::Command::new("start").about("Start the systemd service"))
|
||||
.subcommand(clap::Command::new("status").about("Show service status"))
|
||||
.subcommand(clap::Command::new("logs").about("Tail service logs"))
|
||||
.subcommand(clap::Command::new("restart").about("Restart the systemd service"))
|
||||
.subcommand(clap::Command::new("stop").about("Stop the systemd service"))
|
||||
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service"))
|
||||
.subcommand(
|
||||
clap::Command::new("upgrade")
|
||||
.about("Self-upgrade from GitHub releases")
|
||||
.arg(clap::Arg::new("version").help("Target version (e.g. 0.2.0)")),
|
||||
)
|
||||
.subcommand_negates_reqs(true)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to install rustls CryptoProvider"))?;
|
||||
|
||||
// Load config file as env-var defaults (before clap parsing)
|
||||
let config_file_path =
|
||||
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
if std::path::Path::new(&config_file_path).exists() {
|
||||
if let Ok(file_cfg) = config::ConfigFile::load(std::path::Path::new(&config_file_path)) {
|
||||
file_cfg.inject_env();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI (subcommands + config args in one pass)
|
||||
match build_command().try_get_matches() {
|
||||
Ok(matches) => match matches.subcommand() {
|
||||
Some(("setup", sub_m)) => {
|
||||
let path = sub_m
|
||||
.get_one::<String>("config_path")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
|
||||
handle_setup_result(setup::run(path)?).await
|
||||
}
|
||||
Some(("start", _)) => setup::service::cmd_start(),
|
||||
Some(("status", _)) => setup::service::cmd_status(),
|
||||
Some(("logs", _)) => setup::service::cmd_logs(),
|
||||
Some(("restart", _)) => setup::service::cmd_restart(),
|
||||
Some(("stop", _)) => setup::service::cmd_stop(),
|
||||
Some(("uninstall", _)) => setup::service::cmd_uninstall(),
|
||||
Some(("upgrade", sub_m)) => {
|
||||
let version = sub_m.get_one::<String>("version").cloned();
|
||||
setup::upgrade::cmd_upgrade(version).await
|
||||
}
|
||||
Some(_) => unreachable!(),
|
||||
None => {
|
||||
// No subcommand — run the proxy with parsed config.
|
||||
let config = Config::from_arg_matches(&matches)?;
|
||||
run_proxy(config).await
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
|
||||
eprintln!("Missing required config, launching setup wizard...\n");
|
||||
handle_setup_result(setup::run(PathBuf::from(&config_file_path))?).await
|
||||
} else {
|
||||
e.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what to do after the setup wizard completes.
|
||||
async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()> {
|
||||
match outcome {
|
||||
setup::SetupOutcome::ServiceInstalled => Ok(()),
|
||||
setup::SetupOutcome::ReadyToRun(config_path) => {
|
||||
// Reload config from the file that setup just wrote, overriding
|
||||
// any stale env vars from a previous config.
|
||||
match config::ConfigFile::load(&config_path) {
|
||||
Ok(file_cfg) => file_cfg.inject_env_override(),
|
||||
Err(e) => anyhow::bail!("failed to reload config after setup: {}", e),
|
||||
}
|
||||
// Parse from env-only (argv may still contain "setup" etc.)
|
||||
let config = Config::try_parse_from(["aether-proxy"])
|
||||
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
|
||||
eprintln!(" Starting proxy...\n");
|
||||
run_proxy(config).await
|
||||
}
|
||||
setup::SetupOutcome::Cancelled => {
|
||||
eprintln!(" Setup cancelled.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the proxy server, checking for systemd conflicts first.
|
||||
async fn run_proxy(config: Config) -> anyhow::Result<()> {
|
||||
// Warn if systemd service is already running (would cause port conflict).
|
||||
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
|
||||
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {
|
||||
eprintln!("Warning: systemd service is already running.");
|
||||
eprintln!("Use `aether-proxy stop` to stop it first, or manage via subcommands:");
|
||||
eprintln!(" aether-proxy status / logs / restart / stop");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
app::run(config).await
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Request, Response};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::proxy::target_filter;
|
||||
|
||||
/// Handle HTTP CONNECT tunnel requests.
|
||||
///
|
||||
/// Flow: validate auth -> check target filter -> TCP connect -> 200 -> bidirectional copy
|
||||
pub async fn handle_connect(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
timestamp_tolerance: u64,
|
||||
) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
// Extract Proxy-Authorization header
|
||||
let proxy_auth = req
|
||||
.headers()
|
||||
.get("proxy-authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// HMAC authentication
|
||||
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, timestamp_tolerance) {
|
||||
warn!(error = %e, "CONNECT auth failed");
|
||||
return proxy_auth_required(&e.to_string());
|
||||
}
|
||||
|
||||
// Parse target host:port from CONNECT URI
|
||||
let authority = match req.uri().authority() {
|
||||
Some(auth) => auth.clone(),
|
||||
None => {
|
||||
warn!("CONNECT request missing authority");
|
||||
return bad_request("missing target authority");
|
||||
}
|
||||
};
|
||||
|
||||
let host = authority.host().to_string();
|
||||
let port = authority.port_u16().unwrap_or(443);
|
||||
|
||||
// Target filter: private IP + port whitelist
|
||||
let target_addr = match target_filter::validate_target(&host, port, allowed_ports).await {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!(host = %host, port, error = %e, "CONNECT target rejected");
|
||||
return forbidden(&e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
debug!(target = %target_addr, "CONNECT tunnel establishing");
|
||||
|
||||
// Connect to target
|
||||
let target_stream = match TcpStream::connect(target_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(target = %target_addr, error = %e, "CONNECT target connection failed");
|
||||
return bad_gateway(&e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
// Respond 200 and upgrade connection to raw TCP tunnel
|
||||
let target_display = target_addr.to_string();
|
||||
tokio::task::spawn(async move {
|
||||
match hyper::upgrade::on(req).await {
|
||||
Ok(upgraded) => {
|
||||
let mut upgraded = hyper_util::rt::TokioIo::new(upgraded);
|
||||
let mut target = target_stream;
|
||||
|
||||
match tokio::io::copy_bidirectional(&mut upgraded, &mut target).await {
|
||||
Ok((from_client, from_target)) => {
|
||||
debug!(
|
||||
target = %target_display,
|
||||
from_client,
|
||||
from_target,
|
||||
"CONNECT tunnel closed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(target = %target_display, error = %e, "CONNECT tunnel error");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target = %target_display, error = %e, "CONNECT upgrade failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.body(http_body_util::Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn proxy_auth_required(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
Response::builder()
|
||||
.status(407)
|
||||
.header("Proxy-Authenticate", "HMAC-SHA256")
|
||||
.header("Content-Length", "0")
|
||||
.header("X-Error", msg)
|
||||
.body(http_body_util::Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn forbidden(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
Response::builder()
|
||||
.status(403)
|
||||
.header("Content-Length", "0")
|
||||
.header("X-Error", msg)
|
||||
.body(http_body_util::Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
Response::builder()
|
||||
.status(400)
|
||||
.header("Content-Length", "0")
|
||||
.header("X-Error", msg)
|
||||
.body(http_body_util::Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bad_gateway(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
Response::builder()
|
||||
.status(502)
|
||||
.header("Content-Length", "0")
|
||||
.header("X-Error", msg)
|
||||
.body(http_body_util::Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use http_body_util::{BodyExt, Full, Limited, StreamBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::{Request, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
use url::Url;
|
||||
|
||||
use super::BoxBody;
|
||||
use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::proxy::target_filter;
|
||||
|
||||
/// Delegation request payload sent by Aether.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DelegateRequest {
|
||||
method: String,
|
||||
url: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Option<String>,
|
||||
/// Accepted but not used on the proxy side — Aether controls timeouts.
|
||||
#[allow(dead_code)]
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// Handle delegation requests: Aether sends a full request description,
|
||||
/// and the proxy issues the actual upstream HTTP call using its own TLS stack.
|
||||
///
|
||||
/// Endpoint: POST /_aether/delegate
|
||||
pub async fn handle_delegate(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
timestamp_tolerance: u64,
|
||||
http_client: &reqwest::Client,
|
||||
) -> Response<BoxBody> {
|
||||
// Authenticate via Authorization header (same HMAC scheme as Proxy-Authorization)
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
if let Err(e) = auth::validate_proxy_auth(auth_header, &config, timestamp_tolerance) {
|
||||
warn!(error = %e, "delegate auth failed");
|
||||
return error_response(401, "authentication_failed", &e.to_string());
|
||||
}
|
||||
|
||||
// Read and parse request body (limit to 10 MB to prevent OOM)
|
||||
const MAX_BODY: usize = 10 * 1024 * 1024;
|
||||
let body_bytes = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to read delegate request body");
|
||||
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
|
||||
}
|
||||
};
|
||||
|
||||
let delegate_req: DelegateRequest = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "invalid delegate request JSON");
|
||||
return error_response(400, "bad_request", &format!("invalid JSON: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// Target filter: validate the upstream URL against allowed ports and private IP checks
|
||||
let parsed_url = match Url::parse(&delegate_req.url) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!(url = %delegate_req.url, error = %e, "invalid delegate target URL");
|
||||
return error_response(400, "bad_request", &format!("invalid URL: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let host = match parsed_url.host_str() {
|
||||
Some(h) => h.to_string(),
|
||||
None => {
|
||||
warn!(url = %delegate_req.url, "delegate target URL missing host");
|
||||
return error_response(400, "bad_request", "URL missing host");
|
||||
}
|
||||
};
|
||||
|
||||
let port = parsed_url.port_or_known_default().unwrap_or(443);
|
||||
|
||||
if let Err(e) = target_filter::validate_target(&host, port, allowed_ports).await {
|
||||
warn!(host = %host, port, error = %e, "delegate target rejected");
|
||||
return error_response(403, "target_not_allowed", &e.to_string());
|
||||
}
|
||||
|
||||
debug!(
|
||||
method = %delegate_req.method,
|
||||
url = %delegate_req.url,
|
||||
"delegate request"
|
||||
);
|
||||
|
||||
// Build upstream request
|
||||
let method = match delegate_req.method.parse::<reqwest::Method>() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!(error = %e, method = %delegate_req.method, "invalid HTTP method");
|
||||
return error_response(400, "bad_request", &format!("invalid method: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut upstream_req = http_client.request(method, &delegate_req.url);
|
||||
|
||||
// NOTE: We intentionally do NOT set a per-request timeout here.
|
||||
// reqwest's `.timeout()` caps the *entire* request including body streaming,
|
||||
// which would truncate long-lived SSE streams. The delegate_client already
|
||||
// has a 30s connect_timeout for connection establishment, and Aether controls
|
||||
// first-byte / idle timeouts on its own side via asyncio.
|
||||
|
||||
// Set headers (skip `host` — reqwest sets it from the URL automatically,
|
||||
// and a duplicate Host header can confuse certain upstreams)
|
||||
for (name, value) in &delegate_req.headers {
|
||||
if name.eq_ignore_ascii_case("host") {
|
||||
continue;
|
||||
}
|
||||
upstream_req = upstream_req.header(name.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
// Set body
|
||||
if let Some(body) = delegate_req.body {
|
||||
upstream_req = upstream_req.body(body);
|
||||
}
|
||||
|
||||
// Send upstream request
|
||||
let upstream_resp = match upstream_req.send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
warn!(url = %delegate_req.url, error = %e, "delegate upstream request failed");
|
||||
// Sanitize: strip URL details from error message to avoid leaking
|
||||
// API keys or paths that may appear in query strings / paths.
|
||||
let safe_detail = sanitize_upstream_error(&e.to_string());
|
||||
if e.is_timeout() {
|
||||
return error_response(504, "upstream_timeout", &safe_detail);
|
||||
}
|
||||
return error_response(502, "upstream_connection_failed", &safe_detail);
|
||||
}
|
||||
};
|
||||
|
||||
// Build response: pass through upstream status + headers, stream body back
|
||||
let status = upstream_resp.status().as_u16();
|
||||
let upstream_headers = upstream_resp.headers().clone();
|
||||
|
||||
debug!(url = %delegate_req.url, status, "delegate upstream response");
|
||||
|
||||
// Stream the response body
|
||||
let body_stream = upstream_resp
|
||||
.bytes_stream()
|
||||
.map_ok(Frame::data)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
|
||||
|
||||
let stream_body: BoxBody = StreamBody::new(body_stream).boxed();
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
for (name, value) in upstream_headers.iter() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
builder
|
||||
.body(stream_body)
|
||||
.unwrap_or_else(|_| Response::builder().status(500).body(super::empty_box_body()).unwrap())
|
||||
}
|
||||
|
||||
// ── Sanitisation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Strip full URLs from error messages to prevent leaking upstream API keys,
|
||||
/// paths, or query parameters in the delegate error response.
|
||||
///
|
||||
/// Replaces `https://api.example.com/v1/chat?key=xxx` with `api.example.com`.
|
||||
fn sanitize_upstream_error(msg: &str) -> String {
|
||||
// Simple regex-free approach: find "https://..." or "http://..." spans and
|
||||
// replace them with just the host portion.
|
||||
let mut result = msg.to_string();
|
||||
for scheme in &["https://", "http://"] {
|
||||
while let Some(start) = result.find(scheme) {
|
||||
let after_scheme = start + scheme.len();
|
||||
// Host ends at '/', '?', '#', ' ', or end of string
|
||||
let host_end = result[after_scheme..]
|
||||
.find(['/', '?', '#', ' '])
|
||||
.map(|i| after_scheme + i)
|
||||
.unwrap_or(result.len());
|
||||
let host = &result[after_scheme..host_end];
|
||||
result = format!("{}{}{}", &result[..start], host, &result[host_end..]);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── Error response helpers ───────────────────────────────────────────────────
|
||||
|
||||
fn error_response(status: u16, error: &str, detail: &str) -> Response<BoxBody> {
|
||||
let body = serde_json::json!({
|
||||
"error": error,
|
||||
"detail": detail,
|
||||
});
|
||||
let body_bytes = bytes::Bytes::from(body.to_string());
|
||||
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Delegate-Error", "true")
|
||||
.body(
|
||||
Full::new(body_bytes)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
pub mod connect;
|
||||
pub mod delegate;
|
||||
pub mod server;
|
||||
pub mod target_filter;
|
||||
pub mod tls;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
/// Boxed body type used across proxy handlers.
|
||||
pub type BoxBody =
|
||||
http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Create an empty [`BoxBody`] (for error responses, 405, etc.).
|
||||
pub fn empty_box_body() -> BoxBody {
|
||||
http_body_util::Full::new(bytes::Bytes::new())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed()
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Incoming;
|
||||
use hyper::rt::{Read, Write};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::proxy::{connect, delegate, tls, BoxBody};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Start the proxy server.
|
||||
///
|
||||
/// Listens for incoming TCP connections and dispatches:
|
||||
/// - CONNECT requests -> tunnel handler
|
||||
/// - POST /_aether/delegate -> delegate handler
|
||||
/// - Other requests -> 405 Method Not Allowed
|
||||
///
|
||||
/// When TLS is configured, the server operates in dual-stack mode:
|
||||
/// it peeks at the first byte of each connection to distinguish TLS ClientHello
|
||||
/// (0x16) from plain HTTP, and handles both on the same port.
|
||||
pub async fn run(
|
||||
state: &Arc<AppState>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.listen_port));
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
if state.tls_acceptor.is_some() {
|
||||
info!(addr = %addr, "proxy server listening (HTTP+TLS dual-stack)");
|
||||
} else {
|
||||
info!(addr = %addr, "proxy server listening (HTTP only)");
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
let (stream, peer_addr) = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to accept connection");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(peer = %peer_addr, "new connection");
|
||||
|
||||
let state = Arc::clone(state);
|
||||
state.active_connections.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
// Dual-stack: peek first byte to decide TLS vs plain HTTP
|
||||
if let Some(ref acceptor) = state.tls_acceptor {
|
||||
if tls::is_tls_client_hello(&stream).await {
|
||||
match acceptor.clone().accept(stream).await {
|
||||
Ok(tls_stream) => {
|
||||
debug!(peer = %peer_addr, "TLS handshake ok");
|
||||
serve_connection(
|
||||
TokioIo::new(tls_stream),
|
||||
peer_addr,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(peer = %peer_addr, error = %e, "TLS handshake failed");
|
||||
}
|
||||
}
|
||||
state.active_connections.fetch_sub(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain HTTP
|
||||
serve_connection(
|
||||
TokioIo::new(stream),
|
||||
peer_addr,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
state.active_connections.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("proxy server shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serve a single HTTP/1.1 connection (works over both plain TCP and TLS).
|
||||
async fn serve_connection<I>(io: I, peer_addr: SocketAddr, state: &Arc<AppState>)
|
||||
where
|
||||
I: Read + Write + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::clone(&state.config);
|
||||
let dynamic = Arc::clone(&state.dynamic);
|
||||
let delegate_client = state.delegate_client.clone();
|
||||
|
||||
let service = service_fn(move |req: Request<Incoming>| {
|
||||
let config = Arc::clone(&config);
|
||||
let dynamic = Arc::clone(&dynamic);
|
||||
let delegate_client = delegate_client.clone();
|
||||
|
||||
async move {
|
||||
// Snapshot current dynamic values (may be updated by remote config)
|
||||
let (allowed_ports, timestamp_tolerance) = {
|
||||
let d = dynamic.read().unwrap();
|
||||
(d.allowed_ports.clone(), d.timestamp_tolerance)
|
||||
};
|
||||
|
||||
if req.method() == Method::CONNECT {
|
||||
let resp =
|
||||
connect::handle_connect(req, config, &allowed_ports, timestamp_tolerance).await;
|
||||
let resp = resp.map(|_| -> BoxBody {
|
||||
http_body_util::Empty::new()
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed()
|
||||
});
|
||||
Ok::<_, hyper::Error>(resp)
|
||||
} else if req.uri().path() == "/_aether/delegate" && req.method() == hyper::Method::POST
|
||||
{
|
||||
let resp = delegate::handle_delegate(
|
||||
req,
|
||||
config,
|
||||
&allowed_ports,
|
||||
timestamp_tolerance,
|
||||
&delegate_client,
|
||||
)
|
||||
.await;
|
||||
Ok(resp)
|
||||
} else {
|
||||
// Only CONNECT tunnels and /_aether/delegate are supported;
|
||||
// plain HTTP forward proxy was removed (all API traffic is HTTPS).
|
||||
Ok(Response::builder()
|
||||
.status(405)
|
||||
.header("Allow", "CONNECT")
|
||||
.header("Content-Length", "0")
|
||||
.body(crate::proxy::empty_box_body())
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.title_case_headers(false)
|
||||
.serve_connection(io, service)
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
if !e.to_string().contains("connection closed") {
|
||||
debug!(peer = %peer_addr, error = %e, "connection error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Check if an IP address belongs to a private/reserved network.
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => is_private_ipv4(v4),
|
||||
IpAddr::V6(v6) => is_private_ipv6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
|
||||
let octets = ip.octets();
|
||||
// 10.0.0.0/8
|
||||
if octets[0] == 10 {
|
||||
return true;
|
||||
}
|
||||
// 172.16.0.0/12
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 192.168.0.0/16
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true;
|
||||
}
|
||||
// 127.0.0.0/8
|
||||
if octets[0] == 127 {
|
||||
return true;
|
||||
}
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true;
|
||||
}
|
||||
// 0.0.0.0/8
|
||||
if octets[0] == 0 {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
|
||||
// ::1 loopback
|
||||
if ip.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
// :: unspecified
|
||||
if ip.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 (ULA) - first byte is 0xfc or 0xfd
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
return true;
|
||||
}
|
||||
// fe80::/10 (link-local)
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_private_ipv4(&v4);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FilterError {
|
||||
PrivateIp(IpAddr),
|
||||
PortNotAllowed(u16),
|
||||
DnsResolutionFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FilterError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::PrivateIp(ip) => write!(f, "target IP {} is in private/reserved range", ip),
|
||||
Self::PortNotAllowed(port) => write!(f, "port {} not in allowed list", port),
|
||||
Self::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for {}", host),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that the target host:port is allowed.
|
||||
///
|
||||
/// Uses async DNS resolution (via `tokio::net::lookup_host`) to avoid
|
||||
/// blocking the async runtime on potentially slow DNS lookups.
|
||||
///
|
||||
/// Returns the resolved socket address to connect to.
|
||||
pub async fn validate_target(
|
||||
host: &str,
|
||||
port: u16,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
) -> Result<SocketAddr, FilterError> {
|
||||
// Port whitelist check
|
||||
if !allowed_ports.contains(&port) {
|
||||
return Err(FilterError::PortNotAllowed(port));
|
||||
}
|
||||
|
||||
// Try parsing as IP directly (no DNS needed)
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_private_ip(&ip) {
|
||||
return Err(FilterError::PrivateIp(ip));
|
||||
}
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
|
||||
// Async DNS resolution with private IP check (DNS rebinding protection)
|
||||
let addr_str = format!("{}:{}", host, port);
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
|
||||
.await
|
||||
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
|
||||
.collect();
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(FilterError::DnsResolutionFailed(host.to_string()));
|
||||
}
|
||||
|
||||
// All resolved addresses must be non-private
|
||||
for addr in &addrs {
|
||||
if is_private_ip(&addr.ip()) {
|
||||
return Err(FilterError::PrivateIp(addr.ip()));
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first valid address
|
||||
Ok(addrs[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ports() -> HashSet<u16> {
|
||||
[80, 443, 8080, 8443].into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ipv4() {
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
|
||||
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ipv6() {
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
|
||||
// fc00::1 (ULA)
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfc00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
// fe80::1 (link-local)
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_port_not_allowed() {
|
||||
let result = validate_target("8.8.8.8", 22, &ports()).await;
|
||||
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_private_ip_blocked() {
|
||||
let result = validate_target("127.0.0.1", 80, &ports()).await;
|
||||
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_public_ip_allowed() {
|
||||
let result = validate_target("8.8.8.8", 443, &ports()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use std::fs;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rcgen::{CertificateParams, KeyPair};
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Generate a self-signed certificate if the files do not already exist.
|
||||
///
|
||||
/// The certificate includes SANs: `localhost` and `aether-proxy`.
|
||||
/// The private key file is set to mode 0600 on unix.
|
||||
pub fn ensure_self_signed_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<()> {
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
info!(
|
||||
cert = %cert_path.display(),
|
||||
key = %key_path.display(),
|
||||
"using existing TLS certificate"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("generating self-signed TLS certificate");
|
||||
|
||||
let mut params = CertificateParams::new(vec!["localhost".into(), "aether-proxy".into()])?;
|
||||
params.distinguished_name = rcgen::DistinguishedName::new();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::CommonName, "aether-proxy");
|
||||
|
||||
let key_pair = KeyPair::generate()?;
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
|
||||
let cert_pem = cert.pem();
|
||||
let key_pem = key_pair.serialize_pem();
|
||||
|
||||
fs::write(cert_path, &cert_pem)?;
|
||||
fs::write(key_path, &key_pem)?;
|
||||
|
||||
// Set key file permissions to 0600 on unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o600);
|
||||
fs::set_permissions(key_path, perms)?;
|
||||
}
|
||||
|
||||
info!(
|
||||
cert = %cert_path.display(),
|
||||
key = %key_path.display(),
|
||||
"self-signed TLS certificate generated"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a `TlsAcceptor` from PEM certificate and key files.
|
||||
pub fn build_tls_acceptor(cert_path: &Path, key_path: &Path) -> anyhow::Result<TlsAcceptor> {
|
||||
let cert_file = fs::File::open(cert_path)?;
|
||||
let key_file = fs::File::open(key_path)?;
|
||||
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if certs.is_empty() {
|
||||
anyhow::bail!("no certificates found in {}", cert_path.display());
|
||||
}
|
||||
|
||||
let key: PrivateKeyDer<'static> =
|
||||
rustls_pemfile::private_key(&mut BufReader::new(key_file))?
|
||||
.ok_or_else(|| anyhow::anyhow!("no private key found in {}", key_path.display()))?;
|
||||
|
||||
let config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
|
||||
Ok(TlsAcceptor::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
/// Compute the SHA-256 fingerprint of the first certificate in a PEM file.
|
||||
///
|
||||
/// Returns the hex-encoded fingerprint (lowercase, no separators).
|
||||
pub fn cert_sha256_fingerprint(cert_path: &Path) -> anyhow::Result<String> {
|
||||
let cert_file = fs::File::open(cert_path)?;
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let cert = certs
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("no certificates found in {}", cert_path.display()))?;
|
||||
|
||||
let digest = Sha256::digest(cert.as_ref());
|
||||
Ok(hex::encode(digest))
|
||||
}
|
||||
|
||||
/// Peek at the first byte of a TCP stream to determine if it is a TLS ClientHello.
|
||||
///
|
||||
/// Returns `true` if the first byte is 0x16 (TLS record type: Handshake).
|
||||
pub async fn is_tls_client_hello(stream: &tokio::net::TcpStream) -> bool {
|
||||
let mut buf = [0u8; 1];
|
||||
match stream.peek(&mut buf).await {
|
||||
Ok(1) => buf[0] == 0x16,
|
||||
Ok(_) => false,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to peek first byte");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::hardware::HardwareInfo;
|
||||
|
||||
/// Heartbeat-specific error that distinguishes "node not found" (needs
|
||||
/// re-registration) from transient / other failures.
|
||||
#[derive(Debug)]
|
||||
pub enum HeartbeatError {
|
||||
/// HTTP 404 – the node_id is no longer known to Aether.
|
||||
NodeNotFound(String),
|
||||
/// Any other failure (network, 5xx, etc.).
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HeartbeatError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NodeNotFound(msg) => write!(f, "node not found: {}", msg),
|
||||
Self::Other(e) => write!(f, "{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RegisterRequest {
|
||||
name: String,
|
||||
ip: String,
|
||||
port: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
region: Option<String>,
|
||||
heartbeat_interval: u64,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
tls_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tls_cert_fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hardware_info: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
estimated_max_concurrency: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HeartbeatRequest {
|
||||
node_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
active_connections: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_requests: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
avg_latency_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// Remote configuration pushed by the Aether management backend.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RemoteConfig {
|
||||
pub node_name: Option<String>,
|
||||
pub allowed_ports: Option<Vec<u16>>,
|
||||
pub log_level: Option<String>,
|
||||
pub heartbeat_interval: Option<u64>,
|
||||
pub timestamp_tolerance: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parsed heartbeat response from Aether.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HeartbeatResponseBody {
|
||||
#[serde(default)]
|
||||
node: Option<HeartbeatNodeInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HeartbeatNodeInfo {
|
||||
#[serde(default)]
|
||||
remote_config: Option<RemoteConfig>,
|
||||
#[serde(default)]
|
||||
config_version: Option<u64>,
|
||||
}
|
||||
|
||||
/// Heartbeat result returned to the caller.
|
||||
#[derive(Debug)]
|
||||
pub struct HeartbeatResult {
|
||||
pub remote_config: Option<RemoteConfig>,
|
||||
pub config_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UnregisterRequest {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
/// Aether API client for proxy node lifecycle management.
|
||||
pub struct AetherClient {
|
||||
http: Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl AetherClient {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
let http = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
http,
|
||||
base_url: config.aether_url.trim_end_matches('/').to_string(),
|
||||
token: config.management_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node with Aether (idempotent upsert by ip:port).
|
||||
///
|
||||
/// Returns the stable node_id assigned by Aether.
|
||||
pub async fn register(
|
||||
&self,
|
||||
config: &Config,
|
||||
public_ip: &str,
|
||||
tls_enabled: bool,
|
||||
tls_cert_fingerprint: Option<&str>,
|
||||
hw: Option<&HardwareInfo>,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
||||
let body = RegisterRequest {
|
||||
name: config.node_name.clone(),
|
||||
ip: public_ip.to_string(),
|
||||
port: config.listen_port,
|
||||
region: config.node_region.clone(),
|
||||
heartbeat_interval: config.heartbeat_interval,
|
||||
tls_enabled,
|
||||
tls_cert_fingerprint: tls_cert_fingerprint.map(|s| s.to_string()),
|
||||
hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()),
|
||||
estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency),
|
||||
};
|
||||
|
||||
info!(
|
||||
url = %url,
|
||||
name = %body.name,
|
||||
ip = %body.ip,
|
||||
port = body.port,
|
||||
"registering with Aether"
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("register failed (HTTP {}): {}", status, text);
|
||||
}
|
||||
|
||||
let data: RegisterResponse = resp.json().await?;
|
||||
info!(node_id = %data.node_id, "registered successfully");
|
||||
Ok(data.node_id)
|
||||
}
|
||||
|
||||
/// Send heartbeat to Aether.
|
||||
///
|
||||
/// On success, returns any remote config included in the response.
|
||||
/// Returns [`HeartbeatError::NodeNotFound`] on HTTP 404 so the caller
|
||||
/// can trigger re-registration.
|
||||
pub async fn heartbeat(
|
||||
&self,
|
||||
node_id: &str,
|
||||
active_connections: Option<i64>,
|
||||
total_requests: Option<i64>,
|
||||
avg_latency_ms: Option<f64>,
|
||||
) -> Result<HeartbeatResult, HeartbeatError> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/heartbeat", self.base_url);
|
||||
let body = HeartbeatRequest {
|
||||
node_id: node_id.to_string(),
|
||||
active_connections,
|
||||
total_requests,
|
||||
avg_latency_ms,
|
||||
};
|
||||
|
||||
debug!(node_id = %node_id, "sending heartbeat");
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| HeartbeatError::Other(e.into()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(status = %status, body = %text, "heartbeat failed");
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Err(HeartbeatError::NodeNotFound(text));
|
||||
}
|
||||
return Err(HeartbeatError::Other(anyhow::anyhow!(
|
||||
"heartbeat failed (HTTP {}): {}",
|
||||
status,
|
||||
text
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse remote config from response (best-effort)
|
||||
let result = match resp.json::<HeartbeatResponseBody>().await {
|
||||
Ok(body) => {
|
||||
let (remote_config, config_version) = match body.node {
|
||||
Some(node) => (node.remote_config, node.config_version.unwrap_or(0)),
|
||||
None => (None, 0),
|
||||
};
|
||||
HeartbeatResult {
|
||||
remote_config,
|
||||
config_version,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "failed to parse heartbeat response body");
|
||||
HeartbeatResult {
|
||||
remote_config: None,
|
||||
config_version: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(node_id = %node_id, config_version = result.config_version, "heartbeat ok");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Unregister this node from Aether (graceful shutdown).
|
||||
pub async fn unregister(&self, node_id: &str) -> anyhow::Result<()> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/unregister", self.base_url);
|
||||
let body = UnregisterRequest {
|
||||
node_id: node_id.to_string(),
|
||||
};
|
||||
|
||||
info!(node_id = %node_id, "unregistering from Aether");
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
info!(node_id = %node_id, "unregistered successfully");
|
||||
Ok(())
|
||||
}
|
||||
Ok(r) => {
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
error!(body = %text, "unregister failed");
|
||||
anyhow::bail!("unregister failed: {}", text);
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort during shutdown
|
||||
error!(error = %e, "unregister request failed");
|
||||
anyhow::bail!("unregister request failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::registration::client::HeartbeatError;
|
||||
use crate::runtime;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Run periodic heartbeat task until shutdown signal.
|
||||
///
|
||||
/// When Aether responds with 404 (node not found), this task automatically
|
||||
/// re-registers the node and updates the shared `node_id` so the proxy
|
||||
/// server and future heartbeats use the new identity.
|
||||
///
|
||||
/// When the heartbeat response includes a `remote_config`, it is applied
|
||||
/// to the [`DynamicConfig`](crate::runtime::DynamicConfig) so the proxy
|
||||
/// picks up changes without a restart.
|
||||
pub async fn run(state: &Arc<AppState>, mut shutdown_rx: watch::Receiver<bool>) {
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
|
||||
// Skip the first tick (registration already acts as initial heartbeat)
|
||||
let initial_interval = state.dynamic.read().unwrap().heartbeat_interval;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(initial_interval)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("heartbeat task stopping (during initial wait)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let current_node_id = state.node_id.read().unwrap().clone();
|
||||
let active_conns = state.active_connections.load(Ordering::Relaxed) as i64;
|
||||
|
||||
match state
|
||||
.aether_client
|
||||
.heartbeat(¤t_node_id, Some(active_conns), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if consecutive_failures > 0 {
|
||||
info!(
|
||||
previous_failures = consecutive_failures,
|
||||
"heartbeat recovered"
|
||||
);
|
||||
}
|
||||
consecutive_failures = 0;
|
||||
|
||||
// Apply remote config if present and version changed
|
||||
if let Some(ref remote) = result.remote_config {
|
||||
runtime::apply_remote_config(&state.dynamic, remote, result.config_version);
|
||||
}
|
||||
}
|
||||
Err(HeartbeatError::NodeNotFound(_)) => {
|
||||
warn!(
|
||||
old_node_id = %current_node_id,
|
||||
"node not found, re-registering"
|
||||
);
|
||||
match state
|
||||
.aether_client
|
||||
.register(
|
||||
&state.config,
|
||||
&state.public_ip,
|
||||
state.config.enable_tls,
|
||||
state.tls_fingerprint.as_deref(),
|
||||
Some(&state.hardware_info),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(new_id) => {
|
||||
info!(
|
||||
old_node_id = %current_node_id,
|
||||
new_node_id = %new_id,
|
||||
"re-registered successfully"
|
||||
);
|
||||
*state.node_id.write().unwrap() = new_id;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_failures += 1;
|
||||
error!(
|
||||
error = %e,
|
||||
consecutive_failures,
|
||||
"re-registration failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(HeartbeatError::Other(e)) => {
|
||||
consecutive_failures += 1;
|
||||
warn!(
|
||||
error = %e,
|
||||
consecutive_failures,
|
||||
"heartbeat failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read interval from dynamic config (may have been updated remotely)
|
||||
let interval_secs = state.dynamic.read().unwrap().heartbeat_interval;
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("heartbeat task stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod client;
|
||||
pub mod heartbeat;
|
||||
@@ -1,123 +0,0 @@
|
||||
//! Runtime-mutable configuration that can be updated remotely via heartbeat.
|
||||
//!
|
||||
//! Fields in [`DynamicConfig`] are initially populated from the static
|
||||
//! [`Config`](crate::config::Config) and may be overridden by the Aether
|
||||
//! management backend through the heartbeat response.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Configuration that can be changed at runtime without restart.
|
||||
#[derive(Debug)]
|
||||
pub struct DynamicConfig {
|
||||
pub node_name: String,
|
||||
pub allowed_ports: HashSet<u16>,
|
||||
pub timestamp_tolerance: u64,
|
||||
pub log_level: String,
|
||||
pub heartbeat_interval: u64,
|
||||
/// Monotonically increasing version from the backend.
|
||||
/// `0` means no remote config has ever been applied.
|
||||
pub config_version: u64,
|
||||
}
|
||||
|
||||
impl DynamicConfig {
|
||||
/// Initialize from static config (startup defaults).
|
||||
pub fn from_config(config: &Config) -> Self {
|
||||
Self {
|
||||
node_name: config.node_name.clone(),
|
||||
allowed_ports: config.allowed_ports.iter().copied().collect(),
|
||||
timestamp_tolerance: config.timestamp_tolerance,
|
||||
log_level: config.log_level.clone(),
|
||||
heartbeat_interval: config.heartbeat_interval,
|
||||
config_version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared dynamic config handle.
|
||||
pub type SharedDynamicConfig = Arc<RwLock<DynamicConfig>>;
|
||||
|
||||
// ── Log-level hot-reload ─────────────────────────────────────────────────────
|
||||
|
||||
/// Global log-level reloader function, set during tracing init.
|
||||
type LogReloader = Box<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
static LOG_RELOADER: OnceLock<LogReloader> = OnceLock::new();
|
||||
|
||||
/// Register the log-level reload function (called once from `init_tracing`).
|
||||
pub fn set_log_reloader(f: LogReloader) {
|
||||
let _ = LOG_RELOADER.set(f);
|
||||
}
|
||||
|
||||
/// Apply a remote config update to the dynamic config.
|
||||
///
|
||||
/// Returns `true` if the config was actually changed.
|
||||
pub fn apply_remote_config(
|
||||
dynamic: &SharedDynamicConfig,
|
||||
remote: &crate::registration::client::RemoteConfig,
|
||||
version: u64,
|
||||
) -> bool {
|
||||
let mut cfg = dynamic.write().unwrap();
|
||||
|
||||
if version <= cfg.config_version {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = Vec::new();
|
||||
|
||||
if let Some(ref name) = remote.node_name {
|
||||
if *name != cfg.node_name {
|
||||
changed.push(format!("node_name → {}", name));
|
||||
cfg.node_name = name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ports) = remote.allowed_ports {
|
||||
let new_set: HashSet<u16> = ports.iter().copied().collect();
|
||||
if new_set != cfg.allowed_ports {
|
||||
changed.push(format!("allowed_ports → {:?}", ports));
|
||||
cfg.allowed_ports = new_set;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tol) = remote.timestamp_tolerance {
|
||||
if tol != cfg.timestamp_tolerance {
|
||||
changed.push(format!("timestamp_tolerance → {}", tol));
|
||||
cfg.timestamp_tolerance = tol;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(interval) = remote.heartbeat_interval {
|
||||
if interval != cfg.heartbeat_interval {
|
||||
changed.push(format!("heartbeat_interval → {}s", interval));
|
||||
cfg.heartbeat_interval = interval;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref level) = remote.log_level {
|
||||
if *level != cfg.log_level {
|
||||
changed.push(format!("log_level → {}", level));
|
||||
cfg.log_level = level.clone();
|
||||
// Hot-reload tracing filter
|
||||
if let Some(reloader) = LOG_RELOADER.get() {
|
||||
reloader(level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.config_version = version;
|
||||
|
||||
if !changed.is_empty() {
|
||||
info!(
|
||||
version,
|
||||
changes = %changed.join(", "),
|
||||
"remote config applied"
|
||||
);
|
||||
}
|
||||
|
||||
!changed.is_empty()
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
//! Systemd service installation for aether-proxy.
|
||||
//!
|
||||
//! Called from the setup TUI when the user enables "Install Service".
|
||||
//! The unit file points to the binary and config at their current
|
||||
//! absolute paths -- no files are copied.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
|
||||
const SERVICE_NAME: &str = "aether-proxy";
|
||||
|
||||
/// Whether systemd service installation is possible (systemd present + root).
|
||||
pub fn is_available() -> bool {
|
||||
is_systemd_available() && is_root()
|
||||
}
|
||||
|
||||
/// Install aether-proxy as a systemd service. Must be run as root.
|
||||
pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
|
||||
if !is_systemd_available() {
|
||||
anyhow::bail!("systemd not available");
|
||||
}
|
||||
if !is_root() {
|
||||
anyhow::bail!("root required, use: sudo aether-proxy setup");
|
||||
}
|
||||
|
||||
let exe_path = std::env::current_exe()?.canonicalize()?;
|
||||
let exe_str = exe_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("binary path contains invalid UTF-8"))?;
|
||||
|
||||
let config_abs = std::fs::canonicalize(config_path)?;
|
||||
let config_str = config_abs
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("config path contains invalid UTF-8"))?;
|
||||
|
||||
let working_dir = config_abs
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/"))
|
||||
.to_str()
|
||||
.unwrap_or("/");
|
||||
|
||||
// Stop existing service if running (ignore errors)
|
||||
if Path::new(UNIT_PATH).exists() {
|
||||
eprintln!(" Stopping existing service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["stop", SERVICE_NAME])
|
||||
.status();
|
||||
}
|
||||
|
||||
// Write unit file
|
||||
eprintln!(" Generating systemd unit file...");
|
||||
eprintln!(" Binary: {}", exe_str);
|
||||
eprintln!(" Config: {}", config_str);
|
||||
eprintln!(" WorkDir: {}", working_dir);
|
||||
|
||||
let unit_content = format!(
|
||||
"[Unit]\n\
|
||||
Description=Aether Proxy\n\
|
||||
After=network.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
WorkingDirectory={working_dir}\n\
|
||||
Environment=AETHER_PROXY_CONFIG={config_str}\n\
|
||||
ExecStart={exe_str}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
LimitNOFILE=65535\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
);
|
||||
std::fs::write(UNIT_PATH, &unit_content)?;
|
||||
|
||||
// Reload and enable
|
||||
eprintln!(" Enabling and starting service...");
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?;
|
||||
|
||||
// Verify
|
||||
eprintln!();
|
||||
let output = Command::new("systemctl")
|
||||
.args(["is-active", SERVICE_NAME])
|
||||
.output()?;
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
if state == "active" {
|
||||
eprintln!(" Service started successfully!");
|
||||
} else {
|
||||
eprintln!(" Service state: {} (check logs)", state);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Commands:");
|
||||
eprintln!(" aether-proxy status # service status");
|
||||
eprintln!(" aether-proxy logs # tail logs");
|
||||
eprintln!(" sudo aether-proxy restart # restart");
|
||||
eprintln!(" sudo aether-proxy stop # stop");
|
||||
eprintln!(" sudo aether-proxy uninstall # remove service");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_systemd_available() -> bool {
|
||||
Command::new("systemctl")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn is_root() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a systemd unit file is currently installed.
|
||||
pub fn is_installed() -> bool {
|
||||
Path::new(UNIT_PATH).exists()
|
||||
}
|
||||
|
||||
/// Remove the systemd service (called from setup TUI when Install Service is toggled off).
|
||||
pub fn uninstall_service() -> anyhow::Result<()> {
|
||||
if !Path::new(UNIT_PATH).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(" Stopping and removing existing service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
std::fs::remove_file(UNIT_PATH)?;
|
||||
eprintln!(" Removed {}", UNIT_PATH);
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
eprintln!(" Service uninstalled.");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the systemd service is currently active.
|
||||
pub fn is_service_active() -> bool {
|
||||
std::path::Path::new(UNIT_PATH).exists()
|
||||
&& Command::new("systemctl")
|
||||
.args(["is-active", "--quiet", SERVICE_NAME])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── CLI subcommands (systemd wrappers) ──────────────────────────────────────
|
||||
|
||||
fn ensure_service_installed() -> anyhow::Result<()> {
|
||||
if !std::path::Path::new(UNIT_PATH).exists() {
|
||||
anyhow::bail!("service not installed, run `sudo aether-proxy setup` first");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_root_and_service() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
if !is_root() {
|
||||
anyhow::bail!("root required, use: sudo aether-proxy <command>");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy status` -- show service status.
|
||||
pub fn cmd_status() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
let status = Command::new("systemctl")
|
||||
.args(["status", SERVICE_NAME])
|
||||
.status()?;
|
||||
// systemctl status returns non-zero when inactive; that's fine
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
/// `aether-proxy logs` -- tail service logs.
|
||||
pub fn cmd_logs() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
let status = Command::new("journalctl")
|
||||
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
|
||||
.status()?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
/// `aether-proxy start` -- start the service.
|
||||
pub fn cmd_start() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["start", SERVICE_NAME])?;
|
||||
eprintln!(" Service started.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy restart` -- restart the service.
|
||||
pub fn cmd_restart() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["restart", SERVICE_NAME])?;
|
||||
eprintln!(" Service restarted.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy stop` -- stop the service.
|
||||
pub fn cmd_stop() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["stop", SERVICE_NAME])?;
|
||||
eprintln!(" Service stopped.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy uninstall` -- disable and remove the systemd service.
|
||||
pub fn cmd_uninstall() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
|
||||
eprintln!(" Stopping and disabling service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
if std::path::Path::new(UNIT_PATH).exists() {
|
||||
std::fs::remove_file(UNIT_PATH)?;
|
||||
eprintln!(" Removed {}", UNIT_PATH);
|
||||
}
|
||||
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
eprintln!(" Service uninstalled.");
|
||||
eprintln!();
|
||||
eprintln!(" Config file and TLS certs are preserved. Remove manually if needed.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
|
||||
let display = format!("{} {}", program, args.join(" "));
|
||||
eprintln!(" > {}", display);
|
||||
|
||||
let status = Command::new(program).args(args).status()?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("command failed: {}", display);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
//! Interactive TUI for configuring aether-proxy.
|
||||
//!
|
||||
//! Launched via `aether-proxy setup [path]`. Presents a full-screen form
|
||||
//! backed by ratatui where the user can navigate fields, edit values, and
|
||||
//! save to a TOML config file.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use crate::config::ConfigFile;
|
||||
|
||||
/// Outcome of the setup wizard, returned to the caller.
|
||||
pub enum SetupOutcome {
|
||||
/// Config saved; systemd service installed and started.
|
||||
ServiceInstalled,
|
||||
/// Config saved; no service — caller should start the proxy directly.
|
||||
ReadyToRun(PathBuf),
|
||||
/// User quit without saving.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Column width reserved for the field label (chars).
|
||||
const LABEL_WIDTH: usize = 22;
|
||||
|
||||
// ── Field types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FieldKind {
|
||||
Text,
|
||||
Secret,
|
||||
Number,
|
||||
Bool,
|
||||
LogLevel,
|
||||
}
|
||||
|
||||
struct Field {
|
||||
label: &'static str,
|
||||
key: &'static str,
|
||||
value: String,
|
||||
kind: FieldKind,
|
||||
required: bool,
|
||||
help: &'static str,
|
||||
}
|
||||
|
||||
// ── App state ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Mode {
|
||||
Normal,
|
||||
Editing,
|
||||
}
|
||||
|
||||
struct App {
|
||||
fields: Vec<Field>,
|
||||
selected: usize,
|
||||
mode: Mode,
|
||||
edit_buffer: String,
|
||||
edit_cursor: usize, // char index
|
||||
config_path: PathBuf,
|
||||
modified: bool,
|
||||
message: Option<(String, Instant, bool)>, // (text, when, is_error)
|
||||
scroll_offset: usize,
|
||||
saved_once: bool,
|
||||
pending_quit: bool, // true after first q/Esc with unsaved changes
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(config_path: PathBuf) -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
Field {
|
||||
label: "Aether URL",
|
||||
key: "aether_url",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Text,
|
||||
required: true,
|
||||
help: "Aether 服务器 URL (如 https://aether.example.com)",
|
||||
},
|
||||
Field {
|
||||
label: "Management Token",
|
||||
key: "management_token",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Secret,
|
||||
required: true,
|
||||
help: "Aether 管理 API Token (ae_xxx)",
|
||||
},
|
||||
Field {
|
||||
label: "HMAC Key",
|
||||
key: "hmac_key",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Secret,
|
||||
required: true,
|
||||
help: "HMAC-SHA256 签名密钥,用于代理请求认证",
|
||||
},
|
||||
Field {
|
||||
label: "Listen Port",
|
||||
key: "listen_port",
|
||||
value: "18080".into(),
|
||||
kind: FieldKind::Number,
|
||||
required: true,
|
||||
help: "代理服务监听端口",
|
||||
},
|
||||
Field {
|
||||
label: "Node Name",
|
||||
key: "node_name",
|
||||
value: "proxy-01".into(),
|
||||
kind: FieldKind::Text,
|
||||
required: true,
|
||||
help: "节点名称,用于在 Aether 后台识别",
|
||||
},
|
||||
Field {
|
||||
label: "Log Level",
|
||||
key: "log_level",
|
||||
value: "info".into(),
|
||||
kind: FieldKind::LogLevel,
|
||||
required: true,
|
||||
help: "日志级别 -- Enter 切换: trace / debug / info / warn / error",
|
||||
},
|
||||
Field {
|
||||
label: "Log JSON",
|
||||
key: "log_json",
|
||||
value: "false".into(),
|
||||
kind: FieldKind::Bool,
|
||||
required: true,
|
||||
help: "是否以 JSON 格式输出日志 -- Enter 切换",
|
||||
},
|
||||
Field {
|
||||
label: "Install Service",
|
||||
key: "install_service",
|
||||
value: if super::service::is_available() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
.into(),
|
||||
kind: FieldKind::Bool,
|
||||
required: true,
|
||||
help: "注册为 systemd 开机启动服务 (需要 root 权限) -- Enter 切换",
|
||||
},
|
||||
],
|
||||
selected: 0,
|
||||
mode: Mode::Normal,
|
||||
edit_buffer: String::new(),
|
||||
edit_cursor: 0,
|
||||
config_path,
|
||||
modified: false,
|
||||
message: None,
|
||||
scroll_offset: 0,
|
||||
saved_once: false,
|
||||
pending_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config ↔ fields ──────────────────────────────────────────────────
|
||||
|
||||
fn load_from_file(&mut self) {
|
||||
if let Ok(cfg) = ConfigFile::load(&self.config_path) {
|
||||
self.apply_config(&cfg);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_config(&mut self, cfg: &ConfigFile) {
|
||||
for field in &mut self.fields {
|
||||
let val: Option<String> = match field.key {
|
||||
"aether_url" => cfg.aether_url.clone(),
|
||||
"management_token" => cfg.management_token.clone(),
|
||||
"hmac_key" => cfg.hmac_key.clone(),
|
||||
"listen_port" => cfg.listen_port.map(|v| v.to_string()),
|
||||
"node_name" => cfg.node_name.clone(),
|
||||
"log_level" => cfg.log_level.clone(),
|
||||
"log_json" => cfg.log_json.map(|v| v.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = val {
|
||||
field.value = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_config(&self) -> ConfigFile {
|
||||
let get = |key: &str| -> Option<String> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.map(|f| f.value.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
|
||||
ConfigFile {
|
||||
aether_url: get("aether_url"),
|
||||
management_token: get("management_token"),
|
||||
hmac_key: get("hmac_key"),
|
||||
listen_port: get("listen_port").and_then(|v| v.parse().ok()),
|
||||
public_ip: None,
|
||||
node_name: get("node_name"),
|
||||
node_region: None,
|
||||
heartbeat_interval: None,
|
||||
allowed_ports: None,
|
||||
timestamp_tolerance: None,
|
||||
log_level: get("log_level"),
|
||||
log_json: get("log_json").and_then(|v| v.parse().ok()),
|
||||
enable_tls: None,
|
||||
tls_cert: None,
|
||||
tls_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&mut self) -> anyhow::Result<()> {
|
||||
let cfg = self.to_config();
|
||||
cfg.save(&self.config_path)?;
|
||||
self.modified = false;
|
||||
self.saved_once = true;
|
||||
self.message = Some((
|
||||
format!("saved to {}", self.config_path.display()),
|
||||
Instant::now(),
|
||||
false,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Scrolling ────────────────────────────────────────────────────────
|
||||
|
||||
fn ensure_visible(&mut self, visible_rows: usize) {
|
||||
if visible_rows == 0 {
|
||||
return;
|
||||
}
|
||||
if self.selected < self.scroll_offset {
|
||||
self.scroll_offset = self.selected;
|
||||
} else if self.selected >= self.scroll_offset + visible_rows {
|
||||
self.scroll_offset = self.selected - visible_rows + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key handling ─────────────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` when the app should exit.
|
||||
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
// Expire old messages (but keep quit-confirmation messages alive)
|
||||
if let Some((_, when, _)) = &self.message {
|
||||
if !self.pending_quit && when.elapsed() > Duration::from_secs(4) {
|
||||
self.message = None;
|
||||
}
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
Mode::Normal => self.handle_normal(key),
|
||||
Mode::Editing => {
|
||||
self.handle_edit(key);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal(&mut self, key: KeyEvent) -> bool {
|
||||
// ── Quit handling (with unsaved-changes confirmation) ─────────
|
||||
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
|
||||
|
||||
if is_quit_key {
|
||||
if !self.modified || self.pending_quit {
|
||||
return true;
|
||||
}
|
||||
self.pending_quit = true;
|
||||
self.message = Some((
|
||||
"unsaved changes! q again to discard, ^S to save".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other key cancels the pending quit
|
||||
if self.pending_quit {
|
||||
self.pending_quit = false;
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Err(e) = self.save() {
|
||||
self.message = Some((format!("error: {}", e), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.selected + 1 < self.fields.len() {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => self.selected = 0,
|
||||
KeyCode::End => self.selected = self.fields.len() - 1,
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let field = &self.fields[self.selected];
|
||||
match field.kind {
|
||||
FieldKind::Bool => {
|
||||
let toggled = if field.value == "true" {
|
||||
"false"
|
||||
} else {
|
||||
"true"
|
||||
};
|
||||
// Block enabling service install without root/systemd
|
||||
if field.key == "install_service"
|
||||
&& toggled == "true"
|
||||
&& !super::service::is_available()
|
||||
{
|
||||
self.message = Some((
|
||||
"requires root with systemd, use: sudo aether-proxy setup".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
self.fields[self.selected].value = toggled.into();
|
||||
self.modified = true;
|
||||
}
|
||||
}
|
||||
FieldKind::LogLevel => {
|
||||
const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
|
||||
let idx = LEVELS.iter().position(|l| *l == field.value).unwrap_or(2);
|
||||
self.fields[self.selected].value = LEVELS[(idx + 1) % LEVELS.len()].into();
|
||||
self.modified = true;
|
||||
}
|
||||
_ => {
|
||||
self.edit_buffer = field.value.clone();
|
||||
self.edit_cursor = self.edit_buffer.chars().count();
|
||||
self.mode = Mode::Editing;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
// Quick save shortcut
|
||||
if let Err(e) = self.save() {
|
||||
self.message = Some((format!("error: {}", e), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_edit(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Cancel -- discard changes to this field
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.validate_edit() {
|
||||
self.fields[self.selected].value = self.edit_buffer.clone();
|
||||
self.modified = true;
|
||||
self.mode = Mode::Normal;
|
||||
} else {
|
||||
self.message = Some(("invalid format".into(), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.edit_cursor > 0 {
|
||||
self.edit_cursor -= 1;
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.remove(byte);
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.edit_cursor < self.edit_buffer.chars().count() {
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.remove(byte);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.edit_cursor = self.edit_cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
let len = self.edit_buffer.chars().count();
|
||||
if self.edit_cursor < len {
|
||||
self.edit_cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => self.edit_cursor = 0,
|
||||
KeyCode::End => self.edit_cursor = self.edit_buffer.chars().count(),
|
||||
KeyCode::Char(c) => {
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.insert(byte, c);
|
||||
self.edit_cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_edit(&self) -> bool {
|
||||
let kind = self.fields[self.selected].kind;
|
||||
let buf = &self.edit_buffer;
|
||||
match kind {
|
||||
FieldKind::Number => buf.is_empty() || buf.parse::<u64>().is_ok(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of the char at `char_idx`.
|
||||
fn char_byte_pos(&self, char_idx: usize) -> usize {
|
||||
self.edit_buffer
|
||||
.char_indices()
|
||||
.nth(char_idx)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.edit_buffer.len())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn ui(f: &mut Frame, app: &mut App) {
|
||||
let area = f.area();
|
||||
|
||||
// Outer block
|
||||
let title = if app.modified {
|
||||
" Aether Proxy Setup [*] "
|
||||
} else {
|
||||
" Aether Proxy Setup "
|
||||
};
|
||||
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.title_alignment(ratatui::layout::Alignment::Center)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
// Split: fields | footer
|
||||
let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(4)]).split(inner);
|
||||
|
||||
let fields_area = chunks[0];
|
||||
let footer_area = chunks[1];
|
||||
|
||||
render_fields(f, app, fields_area);
|
||||
render_footer(f, app, footer_area);
|
||||
}
|
||||
|
||||
fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
let visible = area.height as usize;
|
||||
app.ensure_visible(visible);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for (i, field) in app.fields.iter().enumerate() {
|
||||
if i < app.scroll_offset || i >= app.scroll_offset + visible {
|
||||
continue;
|
||||
}
|
||||
|
||||
let selected = i == app.selected;
|
||||
let indicator = if selected { " > " } else { " " };
|
||||
|
||||
let label_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let padded_label = format!("{:<width$}", field.label, width = LABEL_WIDTH);
|
||||
|
||||
// Value display
|
||||
let (value_text, value_style) = if app.mode == Mode::Editing && selected {
|
||||
(app.edit_buffer.clone(), Style::default().fg(Color::Yellow))
|
||||
} else {
|
||||
field_display(field)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, label_style),
|
||||
Span::styled(padded_label, label_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(value_text, value_style),
|
||||
]));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
f.render_widget(paragraph, area);
|
||||
|
||||
// Cursor position while editing
|
||||
if app.mode == Mode::Editing {
|
||||
let row_in_view = app.selected - app.scroll_offset;
|
||||
// prefix: 3 (indicator) + LABEL_WIDTH + 2 (gap) = 27
|
||||
let prefix: u16 = 3 + LABEL_WIDTH as u16 + 2;
|
||||
let cx = area.x + prefix + app.edit_cursor as u16;
|
||||
let cy = area.y + row_in_view as u16;
|
||||
if cx < area.x + area.width && cy < area.y + area.height {
|
||||
f.set_cursor_position((cx, cy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (display_text, style) for a field in normal mode.
|
||||
fn field_display(field: &Field) -> (String, Style) {
|
||||
if field.value.is_empty() {
|
||||
let text = if field.required {
|
||||
"(required)".into()
|
||||
} else {
|
||||
"-".into()
|
||||
};
|
||||
let color = if field.required {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
return (text, Style::default().fg(color));
|
||||
}
|
||||
|
||||
match field.kind {
|
||||
FieldKind::Secret => (
|
||||
"*".repeat(field.value.len().min(20)),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
FieldKind::Bool => {
|
||||
if field.value == "true" {
|
||||
("[x] on".into(), Style::default().fg(Color::Green))
|
||||
} else {
|
||||
("[ ] off".into(), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
}
|
||||
FieldKind::LogLevel => {
|
||||
let color = match field.value.as_str() {
|
||||
"trace" => Color::Magenta,
|
||||
"debug" => Color::Blue,
|
||||
"info" => Color::Green,
|
||||
"warn" => Color::Yellow,
|
||||
"error" => Color::Red,
|
||||
_ => Color::White,
|
||||
};
|
||||
(field.value.clone(), Style::default().fg(color))
|
||||
}
|
||||
_ => (field.value.clone(), Style::default().fg(Color::White)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
let help = app.fields[app.selected].help;
|
||||
|
||||
let keybindings = if app.mode == Mode::Editing {
|
||||
"Enter confirm Esc cancel"
|
||||
} else {
|
||||
"Up/Down select Enter edit ^S save q quit"
|
||||
};
|
||||
|
||||
let mut status_spans: Vec<Span> = vec![Span::styled(
|
||||
keybindings,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)];
|
||||
|
||||
if let Some((msg, _, is_err)) = &app.message {
|
||||
let color = if *is_err { Color::Red } else { Color::Green };
|
||||
status_spans.push(Span::raw(" "));
|
||||
status_spans.push(Span::styled(msg.clone(), Style::default().fg(color)));
|
||||
}
|
||||
|
||||
let footer_text = vec![
|
||||
Line::raw(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", help),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(
|
||||
status_spans
|
||||
.into_iter()
|
||||
.map(|mut s| {
|
||||
// add left padding to first span
|
||||
if s.content.as_ref() == keybindings {
|
||||
s.content = format!(" {}", s.content).into();
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
];
|
||||
|
||||
let footer = Paragraph::new(footer_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
);
|
||||
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
|
||||
// Setup terminal
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(config_path.clone());
|
||||
app.load_from_file();
|
||||
|
||||
let result = event_loop(&mut terminal, &mut app);
|
||||
|
||||
// Restore terminal
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result?;
|
||||
|
||||
// ── Post-TUI: decide outcome ─────────────────────────────────────
|
||||
|
||||
if !app.saved_once {
|
||||
return Ok(SetupOutcome::Cancelled);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Config saved to {}", config_path.display());
|
||||
eprintln!();
|
||||
|
||||
let wants_service = app
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "install_service")
|
||||
.map(|f| f.value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if wants_service {
|
||||
match super::service::install_service(&config_path) {
|
||||
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
|
||||
Err(e) => {
|
||||
eprintln!(" Service install failed: {}", e);
|
||||
eprintln!(" Starting proxy directly instead.\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Uninstall service if it was previously installed but toggled off
|
||||
if super::service::is_installed() {
|
||||
if let Err(e) = super::service::uninstall_service() {
|
||||
eprintln!(" Service uninstall failed: {}", e);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SetupOutcome::ReadyToRun(config_path))
|
||||
}
|
||||
|
||||
fn event_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// Only handle Press events (ignore Release on Windows)
|
||||
if key.kind == KeyEventKind::Press && app.handle_key(key) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
//! Self-upgrade for aether-proxy.
|
||||
//!
|
||||
//! Downloads a release from GitHub, verifies SHA256 checksum, and atomically
|
||||
//! replaces the running binary. Restarts the systemd service if active.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
const GITHUB_REPO: &str = "fawney19/Aether";
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// ── GitHub API types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
name: String,
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
fn detect_platform() -> &'static str {
|
||||
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
|
||||
"linux-amd64"
|
||||
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
|
||||
"linux-arm64"
|
||||
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
|
||||
"macos-amd64"
|
||||
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
|
||||
"macos-arm64"
|
||||
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
|
||||
"windows-amd64"
|
||||
} else {
|
||||
// All supported targets are covered above; this is unreachable for
|
||||
// any platform we actually build for.
|
||||
panic!("unsupported platform: compile-time target not in the supported matrix")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GitHub HTTP client ───────────────────────────────────────────────────────
|
||||
|
||||
fn build_github_client() -> anyhow::Result<reqwest::Client> {
|
||||
let token = std::env::var("GITHUB_TOKEN").map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"GITHUB_TOKEN is required (private repo).\n Set it via: export GITHUB_TOKEN=ghp_xxx"
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
|
||||
);
|
||||
|
||||
Ok(reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.user_agent(format!("aether-proxy/{}", CURRENT_VERSION))
|
||||
.default_headers(headers)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
// ── Release fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_release(
|
||||
client: &reqwest::Client,
|
||||
version: Option<&str>,
|
||||
) -> anyhow::Result<GithubRelease> {
|
||||
match version {
|
||||
Some(ver) => {
|
||||
// Accept both "proxy-v0.2.0" and bare "0.2.0"
|
||||
let tag = if ver.starts_with("proxy-v") {
|
||||
ver.to_string()
|
||||
} else {
|
||||
format!("proxy-v{}", ver)
|
||||
};
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases/tags/{}",
|
||||
GITHUB_API_BASE, GITHUB_REPO, tag
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("release '{}' not found (HTTP {}): {}", tag, status, body);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
None => {
|
||||
// List releases and find the latest proxy-v* tag
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases?per_page=20",
|
||||
GITHUB_API_BASE, GITHUB_REPO
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("failed to list releases (HTTP {}): {}", status, body);
|
||||
}
|
||||
let releases: Vec<GithubRelease> = resp.json().await?;
|
||||
releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag_name.starts_with("proxy-v"))
|
||||
.ok_or_else(|| anyhow::anyhow!("no proxy-v* release found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download & verify ────────────────────────────────────────────────────────
|
||||
|
||||
async fn download_asset_bytes(
|
||||
client: &reqwest::Client,
|
||||
asset: &GithubAsset,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
// Use GitHub API asset endpoint for reliable private repo downloads
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases/assets/{}",
|
||||
GITHUB_API_BASE, GITHUB_REPO, asset.id
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(reqwest::header::ACCEPT, "application/octet-stream")
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"download failed for '{}' (HTTP {})",
|
||||
asset.name,
|
||||
resp.status(),
|
||||
);
|
||||
}
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result<String> {
|
||||
for line in sums_text.lines() {
|
||||
// Format: "<hash> <filename>" (GNU coreutils convention)
|
||||
let mut parts = line.split_ascii_whitespace();
|
||||
let (Some(hash), Some(name)) = (parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
if name == filename || name.ends_with(filename) {
|
||||
return Ok(hash.to_lowercase());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("checksum for '{}' not found in SHA256SUMS.txt", filename);
|
||||
}
|
||||
|
||||
async fn download_and_verify(
|
||||
client: &reqwest::Client,
|
||||
release: &GithubRelease,
|
||||
platform: &str,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let archive_name = format!("aether-proxy-{}.tar.gz", platform);
|
||||
|
||||
let archive_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == archive_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("asset '{}' not found in release", archive_name))?;
|
||||
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "SHA256SUMS.txt")
|
||||
.ok_or_else(|| anyhow::anyhow!("SHA256SUMS.txt not found in release"))?;
|
||||
|
||||
eprintln!(" Downloading {}...", archive_name);
|
||||
let (archive_bytes, checksum_bytes) = tokio::try_join!(
|
||||
download_asset_bytes(client, archive_asset),
|
||||
download_asset_bytes(client, checksum_asset),
|
||||
)?;
|
||||
let checksum_text = String::from_utf8(checksum_bytes)?;
|
||||
|
||||
eprintln!(
|
||||
" Downloaded {} ({} bytes)",
|
||||
archive_name,
|
||||
archive_bytes.len()
|
||||
);
|
||||
|
||||
// Verify SHA256
|
||||
let expected_hash = parse_checksum(&checksum_text, &archive_name)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&archive_bytes);
|
||||
let actual_hash = hex::encode(hasher.finalize());
|
||||
|
||||
if actual_hash != expected_hash {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}:\n expected: {}\n actual: {}",
|
||||
archive_name,
|
||||
expected_hash,
|
||||
actual_hash
|
||||
);
|
||||
}
|
||||
eprintln!(" SHA256 verified: {}", &actual_hash[..16]);
|
||||
|
||||
extract_binary(&archive_bytes, dest)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Archive extraction ───────────────────────────────────────────────────────
|
||||
|
||||
fn extract_binary(archive_bytes: &[u8], dest: &Path) -> anyhow::Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
use tar::Archive;
|
||||
|
||||
// Guard against decompression bombs
|
||||
const MAX_BINARY_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
let decoder = GzDecoder::new(archive_bytes);
|
||||
let mut archive = Archive::new(decoder);
|
||||
|
||||
let binary_name = if cfg!(target_os = "windows") {
|
||||
"aether-proxy.exe"
|
||||
} else {
|
||||
"aether-proxy"
|
||||
};
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
// Only accept regular files -- reject symlinks to prevent write-through attacks
|
||||
if entry.header().entry_type() != tar::EntryType::Regular {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path()?;
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(binary_name) {
|
||||
let size = entry.header().size()?;
|
||||
if size > MAX_BINARY_SIZE {
|
||||
anyhow::bail!(
|
||||
"binary too large ({} bytes, max {} bytes)",
|
||||
size,
|
||||
MAX_BINARY_SIZE
|
||||
);
|
||||
}
|
||||
let mut file = std::fs::File::create(dest)?;
|
||||
std::io::copy(&mut entry, &mut file)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("'{}' not found in archive", binary_name);
|
||||
}
|
||||
|
||||
// ── Atomic binary replacement ────────────────────────────────────────────────
|
||||
|
||||
fn atomic_replace(new_binary: &Path) -> anyhow::Result<PathBuf> {
|
||||
let current_exe = std::env::current_exe()?.canonicalize()?;
|
||||
let backup_path = current_exe.with_extension("bak");
|
||||
|
||||
// Remove stale backup
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
// current -> .bak
|
||||
std::fs::rename(¤t_exe, &backup_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to backup current binary '{}' -> '{}': {}",
|
||||
current_exe.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// new -> current
|
||||
if let Err(e) = std::fs::rename(new_binary, ¤t_exe) {
|
||||
eprintln!(" ERROR: failed to place new binary, rolling back...");
|
||||
let _ = std::fs::rename(&backup_path, ¤t_exe);
|
||||
anyhow::bail!(
|
||||
"failed to install new binary '{}' -> '{}': {}",
|
||||
new_binary.display(),
|
||||
current_exe.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(" Binary replaced: {}", current_exe.display());
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────────────
|
||||
|
||||
/// `aether-proxy upgrade [version]` -- self-upgrade from GitHub releases.
|
||||
pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
|
||||
// Resolve exe path once; reuse throughout the function
|
||||
let current_exe = std::env::current_exe()?.canonicalize()?;
|
||||
let exe_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?;
|
||||
let temp_path = exe_dir.join(".aether-proxy.upgrade.tmp");
|
||||
|
||||
// Check write permission to binary directory
|
||||
if !super::service::is_root() {
|
||||
let test_path = exe_dir.join(".aether-proxy.write-test");
|
||||
match std::fs::File::create(&test_path) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&test_path);
|
||||
}
|
||||
Err(_) => {
|
||||
anyhow::bail!(
|
||||
"no write access to {}. Use: sudo aether-proxy upgrade",
|
||||
exe_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let platform = detect_platform();
|
||||
eprintln!(" Platform: {}", platform);
|
||||
eprintln!(" Current version: {}", CURRENT_VERSION);
|
||||
|
||||
let client = build_github_client()?;
|
||||
let release = fetch_release(&client, version.as_deref()).await?;
|
||||
let target_tag = &release.tag_name;
|
||||
let target_semver = target_tag.strip_prefix("proxy-v").unwrap_or(target_tag);
|
||||
|
||||
eprintln!(" Target version: {} ({})", target_tag, release.name);
|
||||
|
||||
if target_semver == CURRENT_VERSION {
|
||||
eprintln!(
|
||||
" Already running version {}, nothing to do.",
|
||||
CURRENT_VERSION
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Upgrading: {} -> {}", CURRENT_VERSION, target_semver);
|
||||
eprintln!();
|
||||
|
||||
if let Err(e) = download_and_verify(&client, &release, platform, &temp_path).await {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
let backup_path = match atomic_replace(&temp_path) {
|
||||
Ok(backup) => backup,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Restart systemd service if running
|
||||
if super::service::is_service_active() {
|
||||
eprintln!(" Restarting systemd service...");
|
||||
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?;
|
||||
eprintln!(" Service restarted.");
|
||||
} else {
|
||||
eprintln!(" No active systemd service detected, skipping restart.");
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Upgrade complete!");
|
||||
eprintln!(
|
||||
" Backup kept at: {} (will be cleaned up on next upgrade)",
|
||||
backup_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Shared application state passed to all subsystems.
|
||||
//!
|
||||
//! Consolidates the multiple `Arc<...>` parameters that were previously
|
||||
//! threaded individually through proxy server, heartbeat, and handlers.
|
||||
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::hardware::HardwareInfo;
|
||||
use crate::registration::client::AetherClient;
|
||||
use crate::runtime::SharedDynamicConfig;
|
||||
|
||||
/// Central application state shared across all tasks.
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub node_id: Arc<RwLock<String>>,
|
||||
pub dynamic: SharedDynamicConfig,
|
||||
pub aether_client: Arc<AetherClient>,
|
||||
pub hardware_info: Arc<HardwareInfo>,
|
||||
pub public_ip: String,
|
||||
pub tls_fingerprint: Option<String>,
|
||||
pub tls_acceptor: Option<TlsAcceptor>,
|
||||
/// Shared reqwest client for delegate mode (proxy issues upstream requests directly).
|
||||
pub delegate_client: reqwest::Client,
|
||||
/// Active connection count for metrics reporting.
|
||||
pub active_connections: Arc<AtomicU64>,
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
# Alembic 配置文件
|
||||
# 用于数据库版本化迁移
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本存放目录
|
||||
script_location = alembic
|
||||
|
||||
# 模板文件
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# 时区(用于生成迁移文件的时间戳)
|
||||
timezone = UTC
|
||||
|
||||
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
|
||||
# Docker 环境中会从 DATABASE_URL 环境变量读取
|
||||
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
|
||||
|
||||
# 日志配置
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
用于数据库迁移的运行时环境设置
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# 加载 .env 文件(本地开发时需要)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
|
||||
from src.models.database import Base
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
db_host = os.getenv("DB_HOST", "localhost")
|
||||
db_port = os.getenv("DB_PORT", "5432")
|
||||
db_name = os.getenv("DB_NAME", "aether")
|
||||
db_user = os.getenv("DB_USER", "postgres")
|
||||
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# 配置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 目标元数据(包含所有表定义)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
|
||||
在离线模式下,不需要连接数据库,
|
||||
只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
|
||||
在线模式下,直接连接数据库执行迁移
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
# 根据模式选择运行方式
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,775 +0,0 @@
|
||||
"""Baseline migration - all tables consolidated
|
||||
|
||||
Revision ID: 20251210_baseline
|
||||
Revises:
|
||||
Create Date: 2024-12-10
|
||||
|
||||
This is the consolidated baseline migration that creates all tables from scratch.
|
||||
Includes all schema changes up to circuit breaker v2.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = "20251210_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create ENUM types (with IF NOT EXISTS for idempotency)
|
||||
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute(
|
||||
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
|
||||
)
|
||||
|
||||
# ==================== users ====================
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
|
||||
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("model_capability_settings", sa.JSON, nullable=True),
|
||||
sa.Column("quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== providers ====================
|
||||
op.create_table(
|
||||
"providers",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"billing_type",
|
||||
postgresql.ENUM(
|
||||
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="pay_as_you_go",
|
||||
),
|
||||
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
|
||||
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rpm_limit", sa.Integer, nullable=True),
|
||||
sa.Column("rpm_used", sa.Integer, server_default="0"),
|
||||
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider_priority", sa.Integer, server_default="100"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_limit", sa.Integer, nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== global_models ====================
|
||||
op.create_table(
|
||||
"global_models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("icon_url", sa.String(500), nullable=True),
|
||||
sa.Column("official_url", sa.String(500), nullable=True),
|
||||
sa.Column("default_price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
|
||||
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
|
||||
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("supported_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== api_keys ====================
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
|
||||
sa.Column("key_encrypted", sa.Text, nullable=True),
|
||||
sa.Column("name", sa.String(100), nullable=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("current_balance_usd", sa.Float, nullable=True),
|
||||
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, server_default="100"),
|
||||
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
|
||||
sa.Column("force_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== provider_endpoints ====================
|
||||
op.create_table(
|
||||
"provider_endpoints",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("base_url", sa.String(500), nullable=False),
|
||||
sa.Column("headers", sa.JSON, nullable=True),
|
||||
sa.Column("timeout", sa.Integer, server_default="300"),
|
||||
sa.Column("max_retries", sa.Integer, server_default="3"),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("custom_path", sa.String(200), nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
|
||||
)
|
||||
|
||||
# ==================== models ====================
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("provider_model_name", sa.String(200), nullable=False),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("tiered_pricing", sa.JSON, nullable=True),
|
||||
sa.Column("supports_vision", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_streaming", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_available", sa.Boolean, server_default="true"),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||
)
|
||||
|
||||
# ==================== model_mappings ====================
|
||||
op.create_table(
|
||||
"model_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("source_model", sa.String(200), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"target_global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
|
||||
),
|
||||
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
|
||||
)
|
||||
|
||||
# ==================== provider_api_keys ====================
|
||||
op.create_table(
|
||||
"provider_api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_key", sa.String(500), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
|
||||
sa.Column("internal_priority", sa.Integer, server_default="50"),
|
||||
sa.Column("global_priority", sa.Integer, nullable=True),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("daily_limit", sa.Integer, nullable=True),
|
||||
sa.Column("monthly_limit", sa.Integer, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_429_type", sa.String(50), nullable=True),
|
||||
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
|
||||
sa.Column("adjustment_history", sa.JSON, nullable=True),
|
||||
# Sliding window fields (replaces high_utilization_start)
|
||||
sa.Column("utilization_samples", sa.JSON, nullable=True),
|
||||
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("health_score", sa.Float, server_default="1.0"),
|
||||
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
|
||||
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
|
||||
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
|
||||
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||
# Circuit breaker v2 fields
|
||||
sa.Column("request_results_window", sa.JSON, nullable=True),
|
||||
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("request_count", sa.Integer, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer, server_default="0"),
|
||||
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_msg", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== usage ====================
|
||||
op.create_table(
|
||||
"usage",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("provider", sa.String(100), nullable=False),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("target_model", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("total_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("input_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("output_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("request_type", sa.String(50), nullable=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=True),
|
||||
sa.Column("is_stream", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer, nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
|
||||
sa.Column("request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("request_body", sa.JSON, nullable=True),
|
||||
sa.Column("provider_request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_body", sa.JSON, nullable=True),
|
||||
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("request_metadata", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
# usage 表复合索引(优化常见查询)
|
||||
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
|
||||
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
|
||||
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
|
||||
|
||||
# ==================== user_quotas ====================
|
||||
op.create_table(
|
||||
"user_quotas",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("quota_type", sa.String(50), nullable=False),
|
||||
sa.Column("quota_usd", sa.Float, nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== system_configs ====================
|
||||
op.create_table(
|
||||
"system_configs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_preferences ====================
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("bio", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
|
||||
),
|
||||
sa.Column("theme", sa.String(20), server_default="light"),
|
||||
sa.Column("language", sa.String(10), server_default="zh-CN"),
|
||||
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
|
||||
sa.Column("email_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
|
||||
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcements ====================
|
||||
op.create_table(
|
||||
"announcements",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("type", sa.String(20), server_default="info"),
|
||||
sa.Column("priority", sa.Integer, server_default="0"),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
|
||||
sa.Column("is_pinned", sa.Boolean, server_default="false"),
|
||||
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcement_reads ====================
|
||||
op.create_table(
|
||||
"announcement_reads",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
|
||||
)
|
||||
|
||||
# ==================== audit_logs ====================
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("api_key_id", sa.String(36), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=True, index=True),
|
||||
sa.Column("event_metadata", sa.JSON, nullable=True),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== request_candidates ====================
|
||||
op.create_table(
|
||||
"request_candidates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("candidate_index", sa.Integer, nullable=False),
|
||||
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("skip_reason", sa.Text, nullable=True),
|
||||
sa.Column("is_cached", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_type", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_requests", sa.Integer, nullable=True),
|
||||
sa.Column("extra_data", sa.JSON, nullable=True),
|
||||
sa.Column("required_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
|
||||
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
|
||||
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
|
||||
|
||||
# ==================== stats_daily ====================
|
||||
op.create_table(
|
||||
"stats_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_summary ====================
|
||||
op.create_table(
|
||||
"stats_summary",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
|
||||
),
|
||||
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_user_daily ====================
|
||||
op.create_table(
|
||||
"stats_user_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||
)
|
||||
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
|
||||
|
||||
# ==================== api_key_provider_mappings ====================
|
||||
op.create_table(
|
||||
"api_key_provider_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
|
||||
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
|
||||
)
|
||||
|
||||
# ==================== provider_usage_tracking ====================
|
||||
op.create_table(
|
||||
"provider_usage_tracking",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("successful_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("failed_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
|
||||
)
|
||||
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||
op.drop_table("provider_usage_tracking")
|
||||
op.drop_table("api_key_provider_mappings")
|
||||
op.drop_table("stats_user_daily")
|
||||
op.drop_table("stats_summary")
|
||||
op.drop_table("stats_daily")
|
||||
op.drop_table("request_candidates")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_table("announcement_reads")
|
||||
op.drop_table("announcements")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("system_configs")
|
||||
op.drop_table("user_quotas")
|
||||
op.drop_table("usage")
|
||||
op.drop_table("provider_api_keys")
|
||||
op.drop_table("model_mappings")
|
||||
op.drop_table("models")
|
||||
op.drop_table("provider_endpoints")
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("global_models")
|
||||
op.drop_table("providers")
|
||||
op.drop_table("users")
|
||||
|
||||
# Drop ENUM types
|
||||
op.execute("DROP TYPE IF EXISTS providerbillingtype")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
@@ -1,315 +0,0 @@
|
||||
"""remove_model_mappings_add_aliases
|
||||
|
||||
合并迁移:
|
||||
1. 添加 provider_model_aliases 字段到 models 表
|
||||
2. 迁移 model_mappings 数据到 provider_model_aliases
|
||||
3. 删除 model_mappings 表
|
||||
4. 添加索引优化别名解析性能
|
||||
|
||||
Revision ID: e9b3d63f0cbf
|
||||
Revises: 20251210_baseline
|
||||
Create Date: 2025-12-14 13:00:22.828183+00:00
|
||||
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e9b3d63f0cbf'
|
||||
down_revision = '20251210_baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def table_exists(bind, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def index_exists(bind, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = :index_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 添加 provider_model_aliases 字段(如果不存在)
|
||||
if not column_exists(bind, "models", "provider_model_aliases"):
|
||||
op.add_column(
|
||||
'models',
|
||||
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移 model_mappings 数据(如果表存在)
|
||||
session = Session(bind=bind)
|
||||
|
||||
model_mappings_table = sa.table(
|
||||
"model_mappings",
|
||||
sa.column("source_model", sa.String),
|
||||
sa.column("target_global_model_id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("mapping_type", sa.String),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
)
|
||||
|
||||
models_table = sa.table(
|
||||
"models",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("global_model_id", sa.String),
|
||||
sa.column("provider_model_aliases", sa.JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
def normalize_alias_list(value) -> list[dict]:
|
||||
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value) if value else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
raw_name = item.get("name")
|
||||
if not isinstance(raw_name, str):
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
raw_priority = item.get("priority", 1)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except Exception:
|
||||
priority = 1
|
||||
if priority < 1:
|
||||
priority = 1
|
||||
|
||||
normalized.append({"name": name, "priority": priority})
|
||||
|
||||
return normalized
|
||||
|
||||
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
|
||||
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
|
||||
# 仅当 model_mappings 表存在时执行迁移
|
||||
if table_exists(bind, "model_mappings"):
|
||||
mappings = session.execute(
|
||||
sa.select(
|
||||
model_mappings_table.c.source_model,
|
||||
model_mappings_table.c.target_global_model_id,
|
||||
model_mappings_table.c.provider_id,
|
||||
)
|
||||
.where(
|
||||
model_mappings_table.c.is_active.is_(True),
|
||||
model_mappings_table.c.provider_id.isnot(None),
|
||||
model_mappings_table.c.mapping_type == "alias",
|
||||
)
|
||||
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
|
||||
).all()
|
||||
|
||||
# 按 (provider_id, target_global_model_id) 分组,收集别名
|
||||
alias_groups: dict = {}
|
||||
for source_model, target_global_model_id, provider_id in mappings:
|
||||
if not isinstance(source_model, str):
|
||||
continue
|
||||
source_model = source_model.strip()
|
||||
if not source_model:
|
||||
continue
|
||||
if not isinstance(provider_id, str) or not provider_id:
|
||||
continue
|
||||
if not isinstance(target_global_model_id, str) or not target_global_model_id:
|
||||
continue
|
||||
|
||||
key = (provider_id, target_global_model_id)
|
||||
if key not in alias_groups:
|
||||
alias_groups[key] = []
|
||||
priority = len(alias_groups[key]) + 1
|
||||
alias_groups[key].append({"name": source_model, "priority": priority})
|
||||
|
||||
# 更新对应的 models 记录
|
||||
for (provider_id, global_model_id), aliases in alias_groups.items():
|
||||
model_row = session.execute(
|
||||
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
|
||||
.where(
|
||||
models_table.c.provider_id == provider_id,
|
||||
models_table.c.global_model_id == global_model_id,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if model_row:
|
||||
model_id = model_row[0]
|
||||
existing_aliases = normalize_alias_list(model_row[1])
|
||||
|
||||
existing_names = {a["name"] for a in existing_aliases}
|
||||
merged_aliases = list(existing_aliases)
|
||||
for alias in aliases:
|
||||
name = alias.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
name = name.strip()
|
||||
if not name or name in existing_names:
|
||||
continue
|
||||
|
||||
merged_aliases.append(
|
||||
{
|
||||
"name": name,
|
||||
"priority": len(merged_aliases) + 1,
|
||||
}
|
||||
)
|
||||
existing_names.add(name)
|
||||
|
||||
session.execute(
|
||||
models_table.update()
|
||||
.where(models_table.c.id == model_id)
|
||||
.values(
|
||||
provider_model_aliases=merged_aliases if merged_aliases else None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
# 3. 删除 model_mappings 表
|
||||
op.drop_table('model_mappings')
|
||||
|
||||
# 4. 添加索引优化别名解析性能
|
||||
# provider_model_name 索引(支持精确匹配,如果不存在)
|
||||
if not index_exists(bind, "idx_model_provider_model_name"):
|
||||
op.create_index(
|
||||
"idx_model_provider_model_name",
|
||||
"models",
|
||||
["provider_model_name"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
|
||||
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
|
||||
if bind.dialect.name == "postgresql":
|
||||
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
|
||||
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'models'
|
||||
AND column_name = 'provider_model_aliases'
|
||||
AND data_type = 'json'
|
||||
) THEN
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE jsonb
|
||||
USING provider_model_aliases::jsonb;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
# 创建 GIN 索引
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
|
||||
ON models USING gin(provider_model_aliases jsonb_path_ops)
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 删除索引
|
||||
op.drop_index("idx_model_provider_model_name", table_name="models")
|
||||
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
|
||||
# 将 jsonb 列还原为 json
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE json
|
||||
USING provider_model_aliases::json
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 恢复 model_mappings 表
|
||||
op.create_table(
|
||||
'model_mappings',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('source_model', sa.String(200), nullable=False),
|
||||
sa.Column(
|
||||
'target_global_model_id',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
|
||||
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
|
||||
)
|
||||
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
|
||||
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
|
||||
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
|
||||
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
|
||||
|
||||
# 3. 移除 provider_model_aliases 字段
|
||||
op.drop_column('models', 'provider_model_aliases')
|
||||
@@ -1,47 +0,0 @@
|
||||
"""add first_byte_time_ms to usage table
|
||||
|
||||
Revision ID: 180e63a9c83a
|
||||
Revises: e9b3d63f0cbf
|
||||
Create Date: 2025-12-15 17:07:44.631032+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '180e63a9c83a'
|
||||
down_revision = 'e9b3d63f0cbf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 添加首字时间字段到 usage 表(如果不存在)
|
||||
if not column_exists(bind, "usage", "first_byte_time_ms"):
|
||||
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 删除首字时间字段
|
||||
op.drop_column('usage', 'first_byte_time_ms')
|
||||
@@ -1,110 +0,0 @@
|
||||
"""refactor global_model to use config json field
|
||||
|
||||
Revision ID: 1cc6942cf06f
|
||||
Revises: 180e63a9c83a
|
||||
Create Date: 2025-12-16 03:11:32.480976+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1cc6942cf06f'
|
||||
down_revision = '180e63a9c83a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本
|
||||
|
||||
1. 添加 config 列
|
||||
2. 把旧数据迁移到 config
|
||||
3. 删除旧列
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 检查是否已经迁移过(config 列存在且旧列不存在)
|
||||
has_config = column_exists(bind, "global_models", "config")
|
||||
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
|
||||
|
||||
if has_config and not has_old_columns:
|
||||
# 已完成迁移,跳过
|
||||
return
|
||||
|
||||
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
|
||||
if not has_config:
|
||||
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
|
||||
|
||||
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
|
||||
if has_old_columns:
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET config = jsonb_strip_nulls(jsonb_build_object(
|
||||
'streaming', COALESCE(default_supports_streaming, true),
|
||||
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
|
||||
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
|
||||
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
|
||||
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
|
||||
'description', description,
|
||||
'icon_url', icon_url,
|
||||
'official_url', official_url
|
||||
))
|
||||
""")
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('global_models', 'default_supports_streaming')
|
||||
op.drop_column('global_models', 'default_supports_vision')
|
||||
op.drop_column('global_models', 'default_supports_function_calling')
|
||||
op.drop_column('global_models', 'default_supports_extended_thinking')
|
||||
op.drop_column('global_models', 'default_supports_image_generation')
|
||||
op.drop_column('global_models', 'description')
|
||||
op.drop_column('global_models', 'icon_url')
|
||||
op.drop_column('global_models', 'official_url')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 1. 添加旧列
|
||||
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
|
||||
|
||||
# 2. 从 config 恢复数据
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET
|
||||
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
|
||||
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
|
||||
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
|
||||
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
|
||||
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
|
||||
description = config->>'description',
|
||||
icon_url = config->>'icon_url',
|
||||
official_url = config->>'official_url'
|
||||
""")
|
||||
|
||||
# 3. 删除 config 列
|
||||
op.drop_column('global_models', 'config')
|
||||
@@ -1,57 +0,0 @@
|
||||
"""add proxy field to provider_endpoints
|
||||
|
||||
Revision ID: f30f9936f6a2
|
||||
Revises: 1cc6942cf06f
|
||||
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f30f9936f6a2'
|
||||
down_revision = '1cc6942cf06f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str:
|
||||
"""获取列的类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col['name'] == column_name:
|
||||
return str(col['type']).upper()
|
||||
return ''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||
if not column_exists('provider_endpoints', 'proxy'):
|
||||
# 字段不存在,直接添加 JSONB 类型
|
||||
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||
else:
|
||||
# 字段已存在,检查是否需要转换类型
|
||||
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||
if 'JSONB' not in col_type:
|
||||
# 如果是 JSON 类型,转换为 JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE provider_endpoints '
|
||||
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 proxy 字段"""
|
||||
if column_exists('provider_endpoints', 'proxy'):
|
||||
op.drop_column('provider_endpoints', 'proxy')
|
||||
@@ -1,86 +0,0 @@
|
||||
"""add stats_daily_model table and rename provider_model_aliases
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f30f9936f6a2
|
||||
Create Date: 2025-12-20 12:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f30f9936f6a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
|
||||
# 1. 创建 stats_daily_model 表
|
||||
if not table_exists('stats_daily_model'):
|
||||
op.create_table(
|
||||
'stats_daily_model',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('model', sa.String(100), nullable=False),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
|
||||
)
|
||||
|
||||
# 创建索引
|
||||
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
|
||||
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
|
||||
|
||||
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
|
||||
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
|
||||
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
|
||||
# 恢复列名
|
||||
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
|
||||
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
|
||||
|
||||
# 删除表
|
||||
if table_exists('stats_daily_model'):
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
|
||||
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
|
||||
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
|
||||
op.drop_table('stats_daily_model')
|
||||
@@ -1,65 +0,0 @@
|
||||
"""add usage table composite indexes for query optimization
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-12-20 15:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b2c3d4e5f6g7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""为 usage 表添加复合索引以优化常见查询
|
||||
|
||||
注意:这些索引已经在 baseline 迁移中创建。
|
||||
此迁移仅用于从旧版本升级的场景,新安装会跳过。
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 检查 usage 表是否存在
|
||||
result = conn.execute(text(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
|
||||
))
|
||||
if not result.scalar():
|
||||
# 表不存在,跳过
|
||||
return
|
||||
|
||||
# 定义需要创建的索引
|
||||
indexes = [
|
||||
("idx_usage_user_created", "ON usage (user_id, created_at)"),
|
||||
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
|
||||
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
|
||||
]
|
||||
|
||||
# 分别检查并创建每个索引
|
||||
for index_name, index_def in indexes:
|
||||
result = conn.execute(text(
|
||||
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
|
||||
))
|
||||
if result.scalar():
|
||||
continue # 索引已存在,跳过
|
||||
|
||||
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除复合索引"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 使用 IF EXISTS 避免索引不存在时报错
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
|
||||
))
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_apikey_created"
|
||||
))
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_user_created"
|
||||
))
|
||||
@@ -1,161 +0,0 @@
|
||||
"""add ldap authentication support
|
||||
|
||||
Revision ID: c3d4e5f6g7h8
|
||||
Revises: b2c3d4e5f6g7
|
||||
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c3d4e5f6g7h8'
|
||||
down_revision = 'b2c3d4e5f6g7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _type_exists(conn, type_name: str) -> bool:
|
||||
"""检查 PostgreSQL 类型是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||
{"name": type_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table_name, "column": column_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _index_exists(conn, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": index_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :name AND table_schema = 'public'
|
||||
"""),
|
||||
{"name": table_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 LDAP 认证支持
|
||||
|
||||
1. 创建 authsource 枚举类型
|
||||
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||
3. 创建 ldap_configs 表
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 创建 authsource 枚举类型(幂等)
|
||||
if not _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||
|
||||
# 2. 在 users 表添加字段(幂等)
|
||||
if not _column_exists(conn, 'users', 'auth_source'):
|
||||
op.add_column('users', sa.Column(
|
||||
'auth_source',
|
||||
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||
nullable=False,
|
||||
server_default='local'
|
||||
))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||
|
||||
# 创建索引(幂等)
|
||||
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||
|
||||
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||
|
||||
# 3. 创建 ldap_configs 表(幂等)
|
||||
if not _table_exists(conn, 'ldap_configs'):
|
||||
op.create_table(
|
||||
'ldap_configs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚 LDAP 认证支持
|
||||
|
||||
警告:回滚前请确保:
|
||||
1. 已备份数据库
|
||||
2. 没有 LDAP 用户需要保留
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||
ldap_user_count = result.scalar()
|
||||
if ldap_user_count and ldap_user_count > 0:
|
||||
raise RuntimeError(
|
||||
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||
)
|
||||
|
||||
# 1. 删除 ldap_configs 表(幂等)
|
||||
if _table_exists(conn, 'ldap_configs'):
|
||||
op.drop_table('ldap_configs')
|
||||
|
||||
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||
|
||||
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.drop_column('users', 'ldap_username')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.drop_column('users', 'ldap_dn')
|
||||
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
op.drop_column('users', 'auth_source')
|
||||
|
||||
# 3. 删除 authsource 枚举类型(幂等)
|
||||
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||
if _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("DROP TYPE authsource"))
|
||||
@@ -1,131 +0,0 @@
|
||||
"""add_management_tokens_table
|
||||
|
||||
Revision ID: ad55f1d008b7
|
||||
Revises: c3d4e5f6g7h8
|
||||
Create Date: 2026-01-06 15:24:10.660394+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad55f1d008b7'
|
||||
down_revision = 'c3d4e5f6g7h8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
"""检查约束是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in constraints):
|
||||
return True
|
||||
# 也检查 check 约束
|
||||
check_constraints = inspector.get_check_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in check_constraints):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:创建 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if table_exists("management_tokens"):
|
||||
# 表已存在,检查是否需要添加约束
|
||||
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
return
|
||||
|
||||
op.create_table('management_tokens',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('token_prefix', sa.String(length=12), nullable=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('allowed_ips', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
|
||||
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
|
||||
# 添加用户名称唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:删除 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if not table_exists("management_tokens"):
|
||||
return
|
||||
|
||||
# 删除约束
|
||||
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
|
||||
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
|
||||
|
||||
# 删除索引
|
||||
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
|
||||
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_user_id"):
|
||||
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_is_active"):
|
||||
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
|
||||
|
||||
# 删除表
|
||||
op.drop_table('management_tokens')
|
||||
@@ -1,73 +0,0 @@
|
||||
"""cleanup ambiguous database fields
|
||||
|
||||
Revision ID: 02a45b66b7c4
|
||||
Revises: ad55f1d008b7
|
||||
Create Date: 2026-01-07 11:20:12.684426+00:00
|
||||
|
||||
变更内容:
|
||||
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
|
||||
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
|
||||
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
|
||||
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '02a45b66b7c4'
|
||||
down_revision = 'ad55f1d008b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
1. users.allowed_endpoints -> allowed_api_formats(重命名)
|
||||
2. api_keys.allowed_endpoints 删除
|
||||
3. providers.rate_limit 删除(与 rpm_limit 重复)
|
||||
4. usage.provider -> provider_name(重命名)
|
||||
"""
|
||||
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
|
||||
if _column_exists('users', 'allowed_endpoints'):
|
||||
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
|
||||
|
||||
# 2. api_keys 表:删除 allowed_endpoints 字段
|
||||
if _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.drop_column('api_keys', 'allowed_endpoints')
|
||||
|
||||
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
|
||||
if _column_exists('providers', 'rate_limit'):
|
||||
op.drop_column('providers', 'rate_limit')
|
||||
|
||||
# 4. usage 表:重命名 provider 为 provider_name
|
||||
if _column_exists('usage', 'provider'):
|
||||
op.alter_column('usage', 'provider', new_column_name='provider_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚:恢复原字段"""
|
||||
# 4. usage 表:将 provider_name 改回 provider
|
||||
if _column_exists('usage', 'provider_name'):
|
||||
op.alter_column('usage', 'provider_name', new_column_name='provider')
|
||||
|
||||
# 3. providers 表:恢复 rate_limit 字段
|
||||
if not _column_exists('providers', 'rate_limit'):
|
||||
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
|
||||
|
||||
# 2. api_keys 表:恢复 allowed_endpoints 字段
|
||||
if not _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
|
||||
|
||||
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
|
||||
if _column_exists('users', 'allowed_api_formats'):
|
||||
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
|
||||
@@ -1,604 +0,0 @@
|
||||
"""consolidated schema updates
|
||||
|
||||
Revision ID: m4n5o6p7q8r9
|
||||
Revises: 02a45b66b7c4
|
||||
Create Date: 2026-01-10 20:00:00.000000
|
||||
|
||||
This migration consolidates all schema changes from 2026-01-08 to 2026-01-10:
|
||||
|
||||
1. provider_api_keys: Key 直接关联 Provider (provider_id, api_formats)
|
||||
2. provider_api_keys: 添加 rate_multipliers JSON 字段(按格式费率)
|
||||
3. models: global_model_id 改为可空(支持独立 ProviderModel)
|
||||
4. providers: 添加 timeout, max_retries, proxy(从 endpoint 迁移)
|
||||
5. providers: display_name 重命名为 name,删除原 name
|
||||
6. provider_api_keys: max_concurrent -> rpm_limit(并发改 RPM)
|
||||
7. provider_api_keys: 健康度改为按格式存储(health_by_format, circuit_breaker_by_format)
|
||||
8. provider_endpoints: 删除废弃的 rate_limit 列
|
||||
9. usage: 添加 client_response_headers 字段
|
||||
10. provider_api_keys: 删除 endpoint_id(Key 不再与 Endpoint 绑定)
|
||||
11. provider_endpoints: 删除废弃的 max_concurrent 列
|
||||
12. providers: 删除废弃的 rpm_limit, rpm_used, rpm_reset_at 列
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from alembic import op
|
||||
|
||||
# 配置日志
|
||||
alembic_logger = logging.getLogger("alembic.runtime.migration")
|
||||
|
||||
revision = "m4n5o6p7q8r9"
|
||||
down_revision = "02a45b66b7c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in the table (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :col"
|
||||
),
|
||||
{"table": table_name, "col": column_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
"""Check if a constraint exists (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE table_name = :table AND constraint_name = :name"
|
||||
),
|
||||
{"table": table_name, "name": constraint_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""Check if an index exists (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": index_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Apply all consolidated schema changes"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ==========
|
||||
if not _column_exists("provider_api_keys", "provider_id"):
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("SAVEPOINT sp_add_provider_id"))
|
||||
try:
|
||||
op.add_column(
|
||||
"provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)
|
||||
)
|
||||
conn.execute(sa.text("RELEASE SAVEPOINT sp_add_provider_id"))
|
||||
except ProgrammingError as exc:
|
||||
if getattr(getattr(exc, "orig", None), "pgcode", None) == "42701":
|
||||
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||
alembic_logger.warning("provider_api_keys.provider_id already exists; skipping add")
|
||||
else:
|
||||
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||
raise
|
||||
|
||||
# 数据迁移:从 endpoint 获取 provider_id(如果 endpoint_id 仍存在)
|
||||
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys k
|
||||
SET provider_id = e.provider_id
|
||||
FROM provider_endpoints e
|
||||
WHERE k.endpoint_id = e.id AND k.provider_id IS NULL
|
||||
""")
|
||||
|
||||
# 检查无法关联的孤儿 Key
|
||||
result = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL")
|
||||
)
|
||||
orphan_count = result.scalar() or 0
|
||||
if orphan_count > 0:
|
||||
# 使用 logger 记录更明显的告警
|
||||
alembic_logger.warning("=" * 60)
|
||||
alembic_logger.warning(
|
||||
f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key"
|
||||
)
|
||||
alembic_logger.warning("=" * 60)
|
||||
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...")
|
||||
|
||||
# 先备份孤儿数据到临时表,避免数据丢失
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS
|
||||
SELECT *, NOW() as backup_at
|
||||
FROM provider_api_keys
|
||||
WHERE provider_id IS NULL
|
||||
""")
|
||||
|
||||
# 记录备份的 Key ID
|
||||
orphan_ids = bind.execute(
|
||||
sa.text("SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL")
|
||||
).fetchall()
|
||||
alembic_logger.info("备份的孤儿 Key 列表:")
|
||||
for key_id, key_name in orphan_ids:
|
||||
alembic_logger.info(f" - Key: {key_name} (ID: {key_id})")
|
||||
|
||||
# 删除孤儿数据
|
||||
op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL")
|
||||
alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key")
|
||||
|
||||
# 提供恢复指南
|
||||
alembic_logger.warning("-" * 60)
|
||||
alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key:")
|
||||
alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;")
|
||||
alembic_logger.warning(" 2. 确定正确的 provider_id")
|
||||
alembic_logger.warning(" 3. 执行恢复:")
|
||||
alembic_logger.warning(" INSERT INTO provider_api_keys (...)")
|
||||
alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;")
|
||||
alembic_logger.warning("-" * 60)
|
||||
|
||||
# 设置 NOT NULL 并创建外键
|
||||
op.alter_column("provider_api_keys", "provider_id", nullable=False)
|
||||
|
||||
if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||
op.create_foreign_key(
|
||||
"fk_provider_api_keys_provider",
|
||||
"provider_api_keys",
|
||||
"providers",
|
||||
["provider_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||
op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"])
|
||||
|
||||
if not _column_exists("provider_api_keys", "api_formats"):
|
||||
op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True))
|
||||
|
||||
# 数据迁移:从 endpoint 获取 api_format
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys k
|
||||
SET api_formats = json_build_array(e.api_format)
|
||||
FROM provider_endpoints e
|
||||
WHERE k.endpoint_id = e.id AND k.api_formats IS NULL
|
||||
""")
|
||||
|
||||
op.alter_column("provider_api_keys", "api_formats", nullable=False, server_default="[]")
|
||||
|
||||
# 修改 endpoint_id 为可空,外键改为 SET NULL
|
||||
if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||
op.drop_constraint(
|
||||
"provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey"
|
||||
)
|
||||
op.alter_column("provider_api_keys", "endpoint_id", nullable=True)
|
||||
# 不再重建外键,因为后面会删除这个字段
|
||||
|
||||
# ========== 2. provider_api_keys: 添加 rate_multipliers ==========
|
||||
if not _column_exists("provider_api_keys", "rate_multipliers"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("rate_multipliers", postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
# 数据迁移:将 rate_multiplier 按 api_formats 转换
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET rate_multipliers = (
|
||||
SELECT jsonb_object_agg(elem, rate_multiplier)
|
||||
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||
)
|
||||
WHERE api_formats IS NOT NULL
|
||||
AND api_formats::text != '[]'
|
||||
AND api_formats::text != 'null'
|
||||
AND rate_multipliers IS NULL
|
||||
""")
|
||||
|
||||
# ========== 3. models: global_model_id 改为可空 ==========
|
||||
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
|
||||
|
||||
# ========== 4. providers: 添加 timeout, max_retries, proxy ==========
|
||||
if not _column_exists("providers", "timeout"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("timeout", sa.Integer(), nullable=True, comment="请求超时(秒)"),
|
||||
)
|
||||
|
||||
if not _column_exists("providers", "max_retries"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("max_retries", sa.Integer(), nullable=True, comment="最大重试次数"),
|
||||
)
|
||||
|
||||
if not _column_exists("providers", "proxy"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("proxy", postgresql.JSONB(), nullable=True, comment="代理配置"),
|
||||
)
|
||||
|
||||
# 从端点迁移数据到 provider(动态构建 SQL,仅引用存在的列)
|
||||
ep_has_timeout = _column_exists("provider_endpoints", "timeout")
|
||||
ep_has_max_retries = _column_exists("provider_endpoints", "max_retries")
|
||||
ep_has_proxy = _column_exists("provider_endpoints", "proxy")
|
||||
|
||||
set_clauses = []
|
||||
if _column_exists("providers", "timeout"):
|
||||
if ep_has_timeout:
|
||||
set_clauses.append("""
|
||||
timeout = COALESCE(
|
||||
p.timeout,
|
||||
(SELECT MAX(e.timeout) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.timeout IS NOT NULL),
|
||||
300
|
||||
)""")
|
||||
else:
|
||||
set_clauses.append("timeout = COALESCE(p.timeout, 300)")
|
||||
|
||||
if _column_exists("providers", "max_retries"):
|
||||
if ep_has_max_retries:
|
||||
set_clauses.append("""
|
||||
max_retries = COALESCE(
|
||||
p.max_retries,
|
||||
(SELECT MAX(e.max_retries) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.max_retries IS NOT NULL),
|
||||
2
|
||||
)""")
|
||||
else:
|
||||
set_clauses.append("max_retries = COALESCE(p.max_retries, 2)")
|
||||
|
||||
if _column_exists("providers", "proxy") and ep_has_proxy:
|
||||
set_clauses.append("""
|
||||
proxy = COALESCE(
|
||||
p.proxy,
|
||||
(SELECT e.proxy FROM provider_endpoints e WHERE e.provider_id = p.id AND e.proxy IS NOT NULL ORDER BY e.created_at LIMIT 1)
|
||||
)""")
|
||||
|
||||
if set_clauses:
|
||||
where_parts = []
|
||||
if _column_exists("providers", "timeout"):
|
||||
where_parts.append("p.timeout IS NULL")
|
||||
if _column_exists("providers", "max_retries"):
|
||||
where_parts.append("p.max_retries IS NULL")
|
||||
where_clause = " OR ".join(where_parts) if where_parts else "TRUE"
|
||||
sql = "UPDATE providers p SET " + ", ".join(set_clauses) + " WHERE " + where_clause
|
||||
op.execute(sql)
|
||||
|
||||
# ========== 5. providers: display_name -> name ==========
|
||||
# 注意:这里假设 display_name 已经被重命名为 name
|
||||
# 如果 display_name 仍然存在,则需要执行重命名
|
||||
if _column_exists("providers", "display_name"):
|
||||
# 删除旧的 name 索引
|
||||
if _index_exists("providers", "ix_providers_name"):
|
||||
op.drop_index("ix_providers_name", table_name="providers")
|
||||
|
||||
# 如果存在旧的 name 列,先删除
|
||||
if _column_exists("providers", "name"):
|
||||
op.drop_column("providers", "name")
|
||||
|
||||
# 重命名 display_name 为 name
|
||||
op.alter_column("providers", "display_name", new_column_name="name")
|
||||
|
||||
# 创建新索引
|
||||
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||
|
||||
# ========== 6. provider_api_keys: max_concurrent -> rpm_limit ==========
|
||||
if _column_exists("provider_api_keys", "max_concurrent"):
|
||||
op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit")
|
||||
|
||||
if _column_exists("provider_api_keys", "learned_max_concurrent"):
|
||||
op.alter_column(
|
||||
"provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit"
|
||||
)
|
||||
|
||||
if _column_exists("provider_api_keys", "last_concurrent_peak"):
|
||||
op.alter_column(
|
||||
"provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak"
|
||||
)
|
||||
|
||||
# 删除废弃字段
|
||||
for col in ["rate_limit", "daily_limit", "monthly_limit"]:
|
||||
if _column_exists("provider_api_keys", col):
|
||||
op.drop_column("provider_api_keys", col)
|
||||
|
||||
# ========== 7. provider_api_keys: 健康度改为按格式存储 ==========
|
||||
if not _column_exists("provider_api_keys", "health_by_format"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column(
|
||||
"health_by_format",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
comment="按API格式存储的健康度数据",
|
||||
),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column(
|
||||
"circuit_breaker_by_format",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
comment="按API格式存储的熔断器状态",
|
||||
),
|
||||
)
|
||||
|
||||
# 数据迁移:如果存在旧字段,迁移数据到新结构
|
||||
if _column_exists("provider_api_keys", "health_score"):
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET health_by_format = (
|
||||
SELECT jsonb_object_agg(
|
||||
elem,
|
||||
jsonb_build_object(
|
||||
'health_score', COALESCE(health_score, 1.0),
|
||||
'consecutive_failures', COALESCE(consecutive_failures, 0),
|
||||
'last_failure_at', last_failure_at,
|
||||
'request_results_window', COALESCE(request_results_window::jsonb, '[]'::jsonb)
|
||||
)
|
||||
)
|
||||
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||
)
|
||||
WHERE api_formats IS NOT NULL
|
||||
AND api_formats::text != '[]'
|
||||
AND health_by_format IS NULL
|
||||
""")
|
||||
|
||||
# Circuit Breaker 迁移策略:
|
||||
# 不复制旧的 circuit_breaker_open 状态到所有 format,而是全部重置为 closed
|
||||
# 原因:旧的单一 circuit breaker 状态可能因某一个 format 失败而打开,
|
||||
# 如果复制到所有 format,会导致其他正常工作的 format 被错误标记为不可用
|
||||
if _column_exists("provider_api_keys", "circuit_breaker_open"):
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET circuit_breaker_by_format = (
|
||||
SELECT jsonb_object_agg(
|
||||
elem,
|
||||
jsonb_build_object(
|
||||
'open', false,
|
||||
'open_at', NULL,
|
||||
'next_probe_at', NULL,
|
||||
'half_open_until', NULL,
|
||||
'half_open_successes', 0,
|
||||
'half_open_failures', 0
|
||||
)
|
||||
)
|
||||
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||
)
|
||||
WHERE api_formats IS NOT NULL
|
||||
AND api_formats::text != '[]'
|
||||
AND circuit_breaker_by_format IS NULL
|
||||
""")
|
||||
|
||||
# 设置默认空对象
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET health_by_format = '{}'::jsonb
|
||||
WHERE health_by_format IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET circuit_breaker_by_format = '{}'::jsonb
|
||||
WHERE circuit_breaker_by_format IS NULL
|
||||
""")
|
||||
|
||||
# 创建 GIN 索引
|
||||
if not _index_exists("provider_api_keys", "ix_provider_api_keys_health_by_format"):
|
||||
op.create_index(
|
||||
"ix_provider_api_keys_health_by_format",
|
||||
"provider_api_keys",
|
||||
["health_by_format"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
if not _index_exists("provider_api_keys", "ix_provider_api_keys_circuit_breaker_by_format"):
|
||||
op.create_index(
|
||||
"ix_provider_api_keys_circuit_breaker_by_format",
|
||||
"provider_api_keys",
|
||||
["circuit_breaker_by_format"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
# 删除旧字段
|
||||
old_health_columns = [
|
||||
"health_score",
|
||||
"consecutive_failures",
|
||||
"last_failure_at",
|
||||
"request_results_window",
|
||||
"circuit_breaker_open",
|
||||
"circuit_breaker_open_at",
|
||||
"next_probe_at",
|
||||
"half_open_until",
|
||||
"half_open_successes",
|
||||
"half_open_failures",
|
||||
]
|
||||
for col in old_health_columns:
|
||||
if _column_exists("provider_api_keys", col):
|
||||
op.drop_column("provider_api_keys", col)
|
||||
|
||||
# ========== 8. provider_endpoints: 删除废弃的 rate_limit 列 ==========
|
||||
if _column_exists("provider_endpoints", "rate_limit"):
|
||||
op.drop_column("provider_endpoints", "rate_limit")
|
||||
|
||||
# ========== 9. usage: 添加 client_response_headers ==========
|
||||
if not _column_exists("usage", "client_response_headers"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("client_response_headers", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# ========== 10. provider_api_keys: 删除 endpoint_id ==========
|
||||
# Key 不再与 Endpoint 绑定,通过 provider_id + api_formats 关联
|
||||
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||
# 查找 endpoint_id 上的外键并删除(用 savepoint 保护,避免事务中止)
|
||||
conn = op.get_bind()
|
||||
fk_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT con.conname FROM pg_constraint con "
|
||||
"JOIN pg_attribute att ON att.attnum = ANY(con.conkey) "
|
||||
" AND att.attrelid = con.conrelid "
|
||||
"WHERE con.conrelid = 'provider_api_keys'::regclass "
|
||||
" AND con.contype = 'f' AND att.attname = 'endpoint_id'"
|
||||
)
|
||||
).fetchall()
|
||||
for (fk_name,) in fk_rows:
|
||||
conn.execute(sa.text(f"SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
try:
|
||||
op.drop_constraint(fk_name, "provider_api_keys", type_="foreignkey")
|
||||
conn.execute(sa.text(f"RELEASE SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
except Exception:
|
||||
conn.execute(sa.text(f"ROLLBACK TO SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
op.drop_column("provider_api_keys", "endpoint_id")
|
||||
|
||||
# ========== 11. provider_endpoints: 删除废弃的 max_concurrent 列 ==========
|
||||
if _column_exists("provider_endpoints", "max_concurrent"):
|
||||
op.drop_column("provider_endpoints", "max_concurrent")
|
||||
|
||||
# ========== 12. providers: 删除废弃的 RPM 相关字段 ==========
|
||||
if _column_exists("providers", "rpm_limit"):
|
||||
op.drop_column("providers", "rpm_limit")
|
||||
if _column_exists("providers", "rpm_used"):
|
||||
op.drop_column("providers", "rpm_used")
|
||||
if _column_exists("providers", "rpm_reset_at"):
|
||||
op.drop_column("providers", "rpm_reset_at")
|
||||
|
||||
alembic_logger.info("[OK] Consolidated migration completed successfully")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Downgrade is complex due to data migrations.
|
||||
For safety, this only removes new columns without restoring old structure.
|
||||
Manual intervention may be required for full rollback.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 12. 恢复 providers RPM 相关字段
|
||||
if not _column_exists("providers", "rpm_limit"):
|
||||
op.add_column("providers", sa.Column("rpm_limit", sa.Integer(), nullable=True))
|
||||
if not _column_exists("providers", "rpm_used"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("rpm_used", sa.Integer(), server_default="0", nullable=True),
|
||||
)
|
||||
if not _column_exists("providers", "rpm_reset_at"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# 11. 恢复 provider_endpoints.max_concurrent
|
||||
if not _column_exists("provider_endpoints", "max_concurrent"):
|
||||
op.add_column(
|
||||
"provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)
|
||||
)
|
||||
|
||||
# 10. 恢复 endpoint_id
|
||||
if not _column_exists("provider_api_keys", "endpoint_id"):
|
||||
op.add_column("provider_api_keys", sa.Column("endpoint_id", sa.String(36), nullable=True))
|
||||
|
||||
# 9. 删除 client_response_headers
|
||||
if _column_exists("usage", "client_response_headers"):
|
||||
op.drop_column("usage", "client_response_headers")
|
||||
|
||||
# 8. 恢复 provider_endpoints.rate_limit(如果需要)
|
||||
if not _column_exists("provider_endpoints", "rate_limit"):
|
||||
op.add_column("provider_endpoints", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||
|
||||
# 7. 删除健康度 JSON 字段
|
||||
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_health_by_format"))
|
||||
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_circuit_breaker_by_format"))
|
||||
if _column_exists("provider_api_keys", "health_by_format"):
|
||||
op.drop_column("provider_api_keys", "health_by_format")
|
||||
if _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||
op.drop_column("provider_api_keys", "circuit_breaker_by_format")
|
||||
|
||||
# 6. rpm_limit -> max_concurrent(简化版:仅重命名)
|
||||
if _column_exists("provider_api_keys", "rpm_limit"):
|
||||
op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent")
|
||||
if _column_exists("provider_api_keys", "learned_rpm_limit"):
|
||||
op.alter_column(
|
||||
"provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent"
|
||||
)
|
||||
if _column_exists("provider_api_keys", "last_rpm_peak"):
|
||||
op.alter_column(
|
||||
"provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak"
|
||||
)
|
||||
|
||||
# 恢复已删除的字段
|
||||
if not _column_exists("provider_api_keys", "rate_limit"):
|
||||
op.add_column("provider_api_keys", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||
if not _column_exists("provider_api_keys", "daily_limit"):
|
||||
op.add_column("provider_api_keys", sa.Column("daily_limit", sa.Integer(), nullable=True))
|
||||
if not _column_exists("provider_api_keys", "monthly_limit"):
|
||||
op.add_column("provider_api_keys", sa.Column("monthly_limit", sa.Integer(), nullable=True))
|
||||
|
||||
# 5. name -> display_name (需要先删除索引)
|
||||
if _column_exists("providers", "name") and not _column_exists("providers", "display_name"):
|
||||
if _index_exists("providers", "ix_providers_name"):
|
||||
op.drop_index("ix_providers_name", table_name="providers")
|
||||
op.alter_column("providers", "name", new_column_name="display_name")
|
||||
|
||||
if not _column_exists("providers", "name"):
|
||||
op.add_column("providers", sa.Column("name", sa.String(100), nullable=True))
|
||||
op.execute("""
|
||||
UPDATE providers
|
||||
SET name = LOWER(REPLACE(REPLACE(display_name, ' ', '_'), '-', '_'))
|
||||
""")
|
||||
op.alter_column("providers", "name", nullable=False)
|
||||
if not _index_exists("providers", "ix_providers_name"):
|
||||
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||
|
||||
# 4. 删除 providers 的 timeout, max_retries, proxy
|
||||
if _column_exists("providers", "proxy"):
|
||||
op.drop_column("providers", "proxy")
|
||||
if _column_exists("providers", "max_retries"):
|
||||
op.drop_column("providers", "max_retries")
|
||||
if _column_exists("providers", "timeout"):
|
||||
op.drop_column("providers", "timeout")
|
||||
|
||||
# 3. models: global_model_id 改回 NOT NULL
|
||||
result = bind.execute(sa.text("SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"))
|
||||
orphan_model_count = result.scalar() or 0
|
||||
if orphan_model_count > 0:
|
||||
alembic_logger.warning(
|
||||
f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除"
|
||||
)
|
||||
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
|
||||
alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型")
|
||||
op.alter_column("models", "global_model_id", nullable=False)
|
||||
|
||||
# 2. 删除 rate_multipliers
|
||||
if _column_exists("provider_api_keys", "rate_multipliers"):
|
||||
op.drop_column("provider_api_keys", "rate_multipliers")
|
||||
|
||||
# 1. 删除 provider_id 和 api_formats
|
||||
if _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||
op.drop_index("idx_provider_api_keys_provider_id", table_name="provider_api_keys")
|
||||
if _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||
op.drop_constraint("fk_provider_api_keys_provider", "provider_api_keys", type_="foreignkey")
|
||||
if _column_exists("provider_api_keys", "api_formats"):
|
||||
op.drop_column("provider_api_keys", "api_formats")
|
||||
if _column_exists("provider_api_keys", "provider_id"):
|
||||
op.drop_column("provider_api_keys", "provider_id")
|
||||
|
||||
# 恢复 endpoint_id 外键(简化版:仅创建外键,不强制 NOT NULL)
|
||||
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||
if not _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||
op.create_foreign_key(
|
||||
"provider_api_keys_endpoint_id_fkey",
|
||||
"provider_api_keys",
|
||||
"provider_endpoints",
|
||||
["endpoint_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
alembic_logger.info("[OK] Downgrade completed (simplified version)")
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
"""add auto_fetch_models and locked_models to provider_api_keys
|
||||
|
||||
Revision ID: e4ebe3233b40
|
||||
Revises: m4n5o6p7q8r9
|
||||
Create Date: 2026-01-13 17:59:53.119479+00:00
|
||||
|
||||
为 provider_api_keys 表添加自动获取模型相关字段:
|
||||
1. auto_fetch_models: 是否启用自动获取模型
|
||||
2. last_models_fetch_at: 最后获取时间
|
||||
3. last_models_fetch_error: 最后获取错误信息
|
||||
4. locked_models: 被锁定的模型列表(刷新时不会被删除)
|
||||
|
||||
注意: downgrade 操作会永久删除 auto_fetch_models 配置和 locked_models 数据
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
"""Check if an index exists"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes("provider_api_keys")
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e4ebe3233b40'
|
||||
down_revision = 'm4n5o6p7q8r9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in the table"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加自动获取模型相关字段"""
|
||||
if not _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auto_fetch_models", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("last_models_fetch_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("last_models_fetch_error", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "locked_models"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("locked_models", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加复合索引以优化调度器查询
|
||||
if not _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||
op.create_index(
|
||||
"ix_provider_api_keys_auto_fetch_active",
|
||||
"provider_api_keys",
|
||||
["auto_fetch_models", "is_active"],
|
||||
postgresql_where=sa.text("auto_fetch_models = true AND is_active = true"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除自动获取模型相关字段"""
|
||||
# 先删除索引
|
||||
if _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||
op.drop_index("ix_provider_api_keys_auto_fetch_active", table_name="provider_api_keys")
|
||||
|
||||
if _column_exists("provider_api_keys", "locked_models"):
|
||||
op.drop_column("provider_api_keys", "locked_models")
|
||||
|
||||
if _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||
op.drop_column("provider_api_keys", "last_models_fetch_error")
|
||||
|
||||
if _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||
op.drop_column("provider_api_keys", "last_models_fetch_at")
|
||||
|
||||
if _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||
op.drop_column("provider_api_keys", "auto_fetch_models")
|
||||
@@ -1,104 +0,0 @@
|
||||
"""add header_rules to provider_endpoints and is_locked to api_keys
|
||||
|
||||
Revision ID: 6d579000e511
|
||||
Revises: e4ebe3233b40
|
||||
Create Date: 2026-01-15 23:00:00.000000+00:00
|
||||
|
||||
变更:
|
||||
1. provider_endpoints 表: 添加 header_rules 字段,迁移 headers 数据
|
||||
2. api_keys 表: 添加 is_locked 字段(管理员锁定标志)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6d579000e511'
|
||||
down_revision = 'e4ebe3233b40'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(connection, table: str, column: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = connection.execute(
|
||||
sa.text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table, "column": column}
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 header_rules 字段并迁移现有 headers 数据;添加 is_locked 字段"""
|
||||
connection = op.get_bind()
|
||||
|
||||
# ========== provider_endpoints.header_rules ==========
|
||||
# 1. 添加 header_rules 列(幂等)
|
||||
if not _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||
op.add_column('provider_endpoints', sa.Column('header_rules', JSON, nullable=True))
|
||||
|
||||
# 2. 批量迁移:headers -> header_rules
|
||||
# 使用纯 SQL 将 {"k1":"v1", "k2":"v2"} 转换为 [{"action":"set","key":"k1","value":"v1"}, ...]
|
||||
if _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET header_rules = (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('action', 'set', 'key', key, 'value', value)
|
||||
)
|
||||
FROM jsonb_each_text(headers::jsonb)
|
||||
)
|
||||
WHERE headers IS NOT NULL
|
||||
AND headers::text != '{}'
|
||||
AND jsonb_typeof(headers::jsonb) = 'object'
|
||||
AND header_rules IS NULL
|
||||
""")
|
||||
)
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('provider_endpoints', 'headers')
|
||||
|
||||
# ========== api_keys.is_locked ==========
|
||||
if not _column_exists(connection, 'api_keys', 'is_locked'):
|
||||
op.add_column(
|
||||
'api_keys',
|
||||
sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 header_rules 字段,恢复 headers 字段;移除 is_locked 字段"""
|
||||
connection = op.get_bind()
|
||||
|
||||
# ========== api_keys.is_locked ==========
|
||||
if _column_exists(connection, 'api_keys', 'is_locked'):
|
||||
op.drop_column('api_keys', 'is_locked')
|
||||
|
||||
# ========== provider_endpoints.header_rules ==========
|
||||
# 1. 添加 headers 列(幂等)
|
||||
if not _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||
op.add_column('provider_endpoints', sa.Column('headers', JSON, nullable=True))
|
||||
|
||||
# 2. 批量迁移:header_rules -> headers(仅提取 set 操作)
|
||||
if _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET headers = (
|
||||
SELECT jsonb_object_agg(rule->>'key', rule->>'value')
|
||||
FROM jsonb_array_elements(header_rules::jsonb) AS rule
|
||||
WHERE rule->>'action' = 'set'
|
||||
AND rule->>'key' IS NOT NULL
|
||||
)
|
||||
WHERE header_rules IS NOT NULL
|
||||
AND jsonb_typeof(header_rules::jsonb) = 'array'
|
||||
AND jsonb_array_length(header_rules::jsonb) > 0
|
||||
""")
|
||||
)
|
||||
|
||||
# 3. 删除 header_rules 列
|
||||
op.drop_column('provider_endpoints', 'header_rules')
|
||||
@@ -1,127 +0,0 @@
|
||||
"""add global_priority_by_format and remove deprecated fields
|
||||
|
||||
Revision ID: ddd59cdf0349
|
||||
Revises: 6d579000e511
|
||||
Create Date: 2026-01-16 12:00:00.000000+00:00
|
||||
|
||||
变更:
|
||||
1. provider_api_keys 表: 添加 global_priority_by_format 字段(按 API 格式的全局优先级)
|
||||
2. 迁移现有 global_priority 数据到新字段
|
||||
3. 删除已废弃的 global_priority 字段
|
||||
4. 删除已废弃的 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||
5. 删除已废弃的 providers.timeout 字段(由环境变量控制)
|
||||
6. 删除已废弃的 provider_endpoints.timeout 字段(由环境变量控制)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ddd59cdf0349'
|
||||
down_revision = '6d579000e511'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(connection, table: str, column: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = connection.execute(
|
||||
sa.text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table, "column": column}
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# 1. 添加 global_priority_by_format 字段
|
||||
if not _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('global_priority_by_format', JSON, nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移现有 global_priority 数据到新字段
|
||||
# 对于有 global_priority 的 Key,将其值应用到所有支持的 api_formats
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||
# 将 JSON 数组转换为 text[] 后使用 unnest
|
||||
connection.execute(sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET global_priority_by_format = (
|
||||
SELECT jsonb_object_agg(format, global_priority)
|
||||
FROM jsonb_array_elements_text(api_formats::jsonb) AS format
|
||||
)
|
||||
WHERE global_priority IS NOT NULL
|
||||
AND api_formats IS NOT NULL
|
||||
AND jsonb_array_length(api_formats::jsonb) > 0
|
||||
AND global_priority_by_format IS NULL
|
||||
"""))
|
||||
|
||||
# 3. 删除 global_priority 字段
|
||||
op.drop_column('provider_api_keys', 'global_priority')
|
||||
|
||||
# 4. 删除 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||
if _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||
op.drop_column('provider_api_keys', 'rate_multiplier')
|
||||
|
||||
# 5. 删除 providers.timeout 字段(由环境变量控制)
|
||||
if _column_exists(connection, 'providers', 'timeout'):
|
||||
op.drop_column('providers', 'timeout')
|
||||
|
||||
# 6. 删除 provider_endpoints.timeout 字段(由环境变量控制)
|
||||
if _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||
op.drop_column('provider_endpoints', 'timeout')
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# 1. 恢复 rate_multiplier 字段
|
||||
if not _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('rate_multiplier', sa.Float, nullable=False, server_default='1.0')
|
||||
)
|
||||
|
||||
# 2. 恢复 global_priority 字段并迁移数据
|
||||
if not _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('global_priority', sa.Integer, nullable=True)
|
||||
)
|
||||
|
||||
# 从 global_priority_by_format 迁移数据(取第一个格式的优先级值)
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
connection.execute(sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET global_priority = (
|
||||
SELECT (value::text)::integer
|
||||
FROM jsonb_each(global_priority_by_format::jsonb)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE global_priority_by_format IS NOT NULL
|
||||
AND jsonb_typeof(global_priority_by_format::jsonb) = 'object'
|
||||
AND global_priority IS NULL
|
||||
"""))
|
||||
|
||||
# 3. 删除 global_priority_by_format 字段
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
op.drop_column('provider_api_keys', 'global_priority_by_format')
|
||||
|
||||
# 4. 恢复 providers.timeout 字段
|
||||
if not _column_exists(connection, 'providers', 'timeout'):
|
||||
op.add_column(
|
||||
'providers',
|
||||
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||
)
|
||||
|
||||
# 5. 恢复 provider_endpoints.timeout 字段
|
||||
if not _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||
op.add_column(
|
||||
'provider_endpoints',
|
||||
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||
)
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
"""make users email/password nullable add email_verified and oauth tables
|
||||
|
||||
Revision ID: 33e347f97c0c
|
||||
Revises: ddd59cdf0349
|
||||
Create Date: 2026-01-18 11:18:15.940559+00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "33e347f97c0c"
|
||||
down_revision = "ddd59cdf0349"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_is_nullable(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否允许 NULL"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col["name"] == column_name:
|
||||
return col["nullable"]
|
||||
return False
|
||||
|
||||
|
||||
def enum_value_exists(enum_name: str, value: str) -> bool:
|
||||
"""检查 PostgreSQL ENUM 是否包含指定值"""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return True # 非 PostgreSQL 跳过检查
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_enum WHERE enumlabel = :value "
|
||||
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = :enum_name)"
|
||||
),
|
||||
{"value": value, "enum_name": enum_name},
|
||||
).first()
|
||||
return result is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# ========== Part 1: users 表修改 ==========
|
||||
|
||||
# 1) 新增 email_verified
|
||||
if not column_exists("users", "email_verified"):
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=True))
|
||||
# 历史数据回填:已有邮箱的用户默认视为已验证
|
||||
op.execute(sa.text("UPDATE users SET email_verified = true WHERE email IS NOT NULL"))
|
||||
op.execute(sa.text("UPDATE users SET email_verified = false WHERE email IS NULL"))
|
||||
# 收紧约束
|
||||
op.alter_column("users", "email_verified", existing_type=sa.Boolean(), nullable=False)
|
||||
|
||||
# 2) email 放宽为可空
|
||||
if not column_is_nullable("users", "email"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 3) password_hash 放宽为可空
|
||||
if not column_is_nullable("users", "password_hash"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ========== Part 2: OAuth 相关 ==========
|
||||
|
||||
# 4) 扩展 authsource enum
|
||||
if bind.dialect.name == "postgresql" and not enum_value_exists("authsource", "oauth"):
|
||||
ctx = op.get_context()
|
||||
with ctx.autocommit_block():
|
||||
op.execute("ALTER TYPE authsource ADD VALUE IF NOT EXISTS 'oauth'")
|
||||
|
||||
# 5) OAuth provider 配置表
|
||||
if not table_exists("oauth_providers"):
|
||||
op.create_table(
|
||||
"oauth_providers",
|
||||
sa.Column("provider_type", sa.String(length=50), primary_key=True),
|
||||
sa.Column("display_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("client_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("authorization_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("token_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("userinfo_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("scopes", sa.JSON(), nullable=True),
|
||||
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
|
||||
sa.Column("frontend_callback_url", sa.String(length=500), nullable=False),
|
||||
sa.Column("attribute_mapping", sa.JSON(), nullable=True),
|
||||
sa.Column("extra_config", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
# 6) 用户 OAuth 绑定关系表
|
||||
if not table_exists("user_oauth_links"):
|
||||
op.create_table(
|
||||
"user_oauth_links",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(length=36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_type",
|
||||
sa.String(length=50),
|
||||
sa.ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("provider_username", sa.String(length=255), nullable=True),
|
||||
sa.Column("provider_email", sa.String(length=255), nullable=True),
|
||||
sa.Column("extra_data", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"linked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"provider_type", "provider_user_id", name="uq_oauth_provider_user"
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
|
||||
)
|
||||
op.create_index("ix_user_oauth_links_user_id", "user_oauth_links", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_user_oauth_links_provider_type", "user_oauth_links", ["provider_type"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# ========== Part 2: OAuth 相关(先删除,因为有外键依赖) ==========
|
||||
|
||||
if table_exists("user_oauth_links"):
|
||||
op.drop_index("ix_user_oauth_links_provider_type", table_name="user_oauth_links")
|
||||
op.drop_index("ix_user_oauth_links_user_id", table_name="user_oauth_links")
|
||||
op.drop_table("user_oauth_links")
|
||||
|
||||
if table_exists("oauth_providers"):
|
||||
op.drop_table("oauth_providers")
|
||||
|
||||
# 注意:Postgres 不支持从 ENUM 删除值,authsource 不回退
|
||||
|
||||
# ========== Part 1: users 表修改 ==========
|
||||
|
||||
# 降级前检查:避免把包含 NULL 的列强制改回 NOT NULL
|
||||
has_null_email = bind.execute(
|
||||
sa.text("SELECT 1 FROM users WHERE email IS NULL LIMIT 1")
|
||||
).first()
|
||||
if has_null_email:
|
||||
raise RuntimeError("Cannot downgrade: users.email contains NULL values")
|
||||
|
||||
has_null_password = bind.execute(
|
||||
sa.text("SELECT 1 FROM users WHERE password_hash IS NULL LIMIT 1")
|
||||
).first()
|
||||
if has_null_password:
|
||||
raise RuntimeError("Cannot downgrade: users.password_hash contains NULL values")
|
||||
|
||||
# 恢复 NOT NULL 约束
|
||||
if column_is_nullable("users", "email"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if column_is_nullable("users", "password_hash"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if column_exists("users", "email_verified"):
|
||||
op.drop_column("users", "email_verified")
|
||||
@@ -1,65 +0,0 @@
|
||||
"""add_stats_daily_provider_table
|
||||
|
||||
Revision ID: c868729753ad
|
||||
Revises: 33e347f97c0c
|
||||
Create Date: 2026-01-19 05:19:49.634662+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c868729753ad'
|
||||
down_revision = '33e347f97c0c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
if not table_exists('stats_daily_provider'):
|
||||
op.create_table(
|
||||
'stats_daily_provider',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('provider_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False),
|
||||
sa.Column('input_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('output_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('date', 'provider_name', name='uq_stats_daily_provider')
|
||||
)
|
||||
op.create_index('idx_stats_daily_provider_date', 'stats_daily_provider', ['date'], unique=False)
|
||||
op.create_index('idx_stats_daily_provider_date_provider', 'stats_daily_provider', ['date', 'provider_name'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
if table_exists('stats_daily_provider'):
|
||||
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date_provider'):
|
||||
op.drop_index('idx_stats_daily_provider_date_provider', table_name='stats_daily_provider')
|
||||
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date'):
|
||||
op.drop_index('idx_stats_daily_provider_date', table_name='stats_daily_provider')
|
||||
op.drop_table('stats_daily_provider')
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
"""add_format_acceptance_config_to_provider_endpoints
|
||||
|
||||
Revision ID: 4b4c7b0df1a2
|
||||
Revises: c868729753ad
|
||||
Create Date: 2026-01-21 18:45:00+00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4b4c7b0df1a2"
|
||||
down_revision = "c868729753ad"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
if column_exists("provider_endpoints", "format_acceptance_config"):
|
||||
return
|
||||
op.add_column(
|
||||
"provider_endpoints",
|
||||
sa.Column("format_acceptance_config", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
if not column_exists("provider_endpoints", "format_acceptance_config"):
|
||||
return
|
||||
op.drop_column("provider_endpoints", "format_acceptance_config")
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"""add_format_conversion_tracking_and_model_filter_patterns_and_provider_timeout
|
||||
|
||||
Revision ID: f7c8d9e0a1b2
|
||||
Revises: 4b4c7b0df1a2
|
||||
Create Date: 2026-01-27 10:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. usage 表: 添加 endpoint_api_format 和 has_format_conversion 字段
|
||||
2. provider_api_keys 表: 添加 model_include_patterns 和 model_exclude_patterns 字段
|
||||
- 支持通配符规则自动过滤从上游获取的模型列表
|
||||
- 包含规则和排除规则(支持 * 和 ? 通配符)
|
||||
3. providers 表: 添加 stream_first_byte_timeout 和 request_timeout 字段
|
||||
- 允许每个提供商单独配置超时时间
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f7c8d9e0a1b2"
|
||||
down_revision = "4b4c7b0df1a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === usage 表: 格式转换追踪 ===
|
||||
if table_exists("usage"):
|
||||
# 添加 endpoint_api_format 字段(端点原生 API 格式)
|
||||
if not column_exists("usage", "endpoint_api_format"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("endpoint_api_format", sa.String(50), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 has_format_conversion 字段(是否发生了格式转换)
|
||||
if not column_exists("usage", "has_format_conversion"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("has_format_conversion", sa.Boolean(), nullable=True, server_default="false"),
|
||||
)
|
||||
|
||||
# === provider_api_keys 表: 模型过滤规则 ===
|
||||
if table_exists("provider_api_keys"):
|
||||
# 添加 model_include_patterns 字段(包含规则,支持 * 和 ? 通配符)
|
||||
if not column_exists("provider_api_keys", "model_include_patterns"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("model_include_patterns", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 model_exclude_patterns 字段(排除规则,支持 * 和 ? 通配符)
|
||||
if not column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("model_exclude_patterns", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# === providers 表: 超时配置 ===
|
||||
if table_exists("providers"):
|
||||
# 添加 stream_first_byte_timeout 字段(流式请求首字节超时)
|
||||
if not column_exists("providers", "stream_first_byte_timeout"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("stream_first_byte_timeout", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 request_timeout 字段(非流式请求整体超时)
|
||||
if not column_exists("providers", "request_timeout"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("request_timeout", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# === providers 表: 移除超时配置 ===
|
||||
if table_exists("providers"):
|
||||
if column_exists("providers", "request_timeout"):
|
||||
op.drop_column("providers", "request_timeout")
|
||||
|
||||
if column_exists("providers", "stream_first_byte_timeout"):
|
||||
op.drop_column("providers", "stream_first_byte_timeout")
|
||||
|
||||
# === provider_api_keys 表: 移除模型过滤规则 ===
|
||||
if table_exists("provider_api_keys"):
|
||||
if column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||
op.drop_column("provider_api_keys", "model_exclude_patterns")
|
||||
|
||||
if column_exists("provider_api_keys", "model_include_patterns"):
|
||||
op.drop_column("provider_api_keys", "model_include_patterns")
|
||||
|
||||
# === usage 表: 移除格式转换追踪 ===
|
||||
if table_exists("usage"):
|
||||
if column_exists("usage", "has_format_conversion"):
|
||||
op.drop_column("usage", "has_format_conversion")
|
||||
|
||||
if column_exists("usage", "endpoint_api_format"):
|
||||
op.drop_column("usage", "endpoint_api_format")
|
||||
@@ -1,58 +0,0 @@
|
||||
"""add_keep_priority_on_conversion_to_providers
|
||||
|
||||
Revision ID: 364680d1bc99
|
||||
Revises: f7c8d9e0a1b2
|
||||
Create Date: 2026-01-28 12:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. providers 表: 添加 keep_priority_on_conversion 字段
|
||||
- 格式转换时是否保持提供商原优先级
|
||||
- 默认 False:需要格式转换时,候选会被降级到不需要转换的候选之后
|
||||
- 设为 True:即使需要格式转换,也保持原优先级排名
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "364680d1bc99"
|
||||
down_revision = "f7c8d9e0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === providers 表: 添加格式转换优先级保持配置 ===
|
||||
if table_exists("providers"):
|
||||
if not column_exists("providers", "keep_priority_on_conversion"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column(
|
||||
"keep_priority_on_conversion",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# === providers 表: 移除格式转换优先级保持配置 ===
|
||||
if table_exists("providers"):
|
||||
if column_exists("providers", "keep_priority_on_conversion"):
|
||||
op.drop_column("providers", "keep_priority_on_conversion")
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Add auth_type and auth_config fields to provider_api_keys table
|
||||
|
||||
Revision ID: 7f6f8065f517
|
||||
Revises: 364680d1bc99
|
||||
Create Date: 2026-01-30 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7f6f8065f517"
|
||||
down_revision: Union[str, None] = "364680d1bc99"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否已存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 auth_type 字段,默认值为 "api_key"
|
||||
if not column_exists("provider_api_keys", "auth_type"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auth_type", sa.String(20), nullable=False, server_default="api_key"),
|
||||
)
|
||||
|
||||
# 添加 auth_config 字段(Text,存储加密后的认证配置)
|
||||
if not column_exists("provider_api_keys", "auth_config"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auth_config", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "auth_config"):
|
||||
op.drop_column("provider_api_keys", "auth_config")
|
||||
|
||||
if column_exists("provider_api_keys", "auth_type"):
|
||||
op.drop_column("provider_api_keys", "auth_type")
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Add video_tasks table
|
||||
|
||||
Revision ID: b6f1a2c5d8e9
|
||||
Revises: 7f6f8065f517
|
||||
Create Date: 2026-01-30 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b6f1a2c5d8e9"
|
||||
down_revision: Union[str, None] = "7f6f8065f517"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"video_tasks",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("external_task_id", sa.String(200), nullable=True, index=False),
|
||||
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("api_key_id", sa.String(36), sa.ForeignKey("api_keys.id"), nullable=True),
|
||||
sa.Column("provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True),
|
||||
sa.Column(
|
||||
"endpoint_id", sa.String(36), sa.ForeignKey("provider_endpoints.id"), nullable=True
|
||||
),
|
||||
sa.Column("key_id", sa.String(36), sa.ForeignKey("provider_api_keys.id"), nullable=True),
|
||||
sa.Column("client_api_format", sa.String(50), nullable=False),
|
||||
sa.Column("provider_api_format", sa.String(50), nullable=False),
|
||||
sa.Column("format_converted", sa.Boolean(), server_default=sa.false()),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("prompt", sa.Text(), nullable=False),
|
||||
sa.Column("original_request_body", sa.JSON(), nullable=True),
|
||||
sa.Column("converted_request_body", sa.JSON(), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), server_default=sa.text("4")),
|
||||
sa.Column("resolution", sa.String(20), server_default=sa.text("'720p'")),
|
||||
sa.Column("aspect_ratio", sa.String(10), server_default=sa.text("'16:9'")),
|
||||
sa.Column("size", sa.String(20), nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default=sa.text("'pending'")),
|
||||
sa.Column("progress_percent", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("progress_message", sa.String(500), nullable=True),
|
||||
sa.Column("video_url", sa.String(2000), nullable=True),
|
||||
sa.Column("video_urls", sa.JSON(), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(2000), nullable=True),
|
||||
sa.Column("video_size_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("video_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("stored_video_path", sa.String(500), nullable=True),
|
||||
sa.Column("storage_provider", sa.String(50), nullable=True),
|
||||
sa.Column("error_code", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("retry_count", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("max_retries", sa.Integer(), server_default=sa.text("3")),
|
||||
sa.Column("poll_interval_seconds", sa.Integer(), server_default=sa.text("10")),
|
||||
sa.Column("next_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("poll_count", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("max_poll_count", sa.Integer(), server_default=sa.text("360")),
|
||||
sa.Column(
|
||||
"remixed_from_task_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("video_tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index("idx_video_tasks_user_status", "video_tasks", ["user_id", "status"])
|
||||
op.create_index("idx_video_tasks_next_poll", "video_tasks", ["next_poll_at"])
|
||||
op.create_index("idx_video_tasks_external_id", "video_tasks", ["external_task_id"])
|
||||
# 唯一约束:同一用户不能有重复的 external_task_id
|
||||
op.create_unique_constraint(
|
||||
"uq_video_tasks_user_external_id",
|
||||
"video_tasks",
|
||||
["user_id", "external_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
op.drop_constraint("uq_video_tasks_user_external_id", "video_tasks", type_="unique")
|
||||
op.drop_index("idx_video_tasks_external_id", table_name="video_tasks")
|
||||
op.drop_index("idx_video_tasks_next_poll", table_name="video_tasks")
|
||||
op.drop_index("idx_video_tasks_user_status", table_name="video_tasks")
|
||||
op.drop_table("video_tasks")
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
"""Add billing system tables and video_tasks.request_metadata
|
||||
|
||||
Revision ID: c8d2e4f6a1b3
|
||||
Revises: b6f1a2c5d8e9
|
||||
Create Date: 2026-01-31 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8d2e4f6a1b3"
|
||||
down_revision: Union[str, None] = "b6f1a2c5d8e9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ==================== video_tasks.request_metadata ====================
|
||||
if not column_exists("video_tasks", "request_metadata"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("request_metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== billing_rules ====================
|
||||
if not table_exists("billing_rules"):
|
||||
op.create_table(
|
||||
"billing_rules",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("models.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("task_type", sa.String(20), nullable=False, server_default="chat"),
|
||||
sa.Column("expression", sa.Text(), nullable=False),
|
||||
sa.Column("variables", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column(
|
||||
"dimension_mappings", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
|
||||
),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(global_model_id IS NOT NULL AND model_id IS NULL) OR "
|
||||
"(global_model_id IS NULL AND model_id IS NOT NULL)",
|
||||
name="chk_billing_rules_model_ref",
|
||||
),
|
||||
)
|
||||
|
||||
# Partial unique indexes for enabled rules
|
||||
if table_exists("billing_rules"):
|
||||
if not index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||
op.create_index(
|
||||
"uq_billing_rules_global_model_task",
|
||||
"billing_rules",
|
||||
["global_model_id", "task_type"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE AND global_model_id IS NOT NULL"),
|
||||
)
|
||||
if not index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||
op.create_index(
|
||||
"uq_billing_rules_model_task",
|
||||
"billing_rules",
|
||||
["model_id", "task_type"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE AND model_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# ==================== dimension_collectors ====================
|
||||
if not table_exists("dimension_collectors"):
|
||||
op.create_table(
|
||||
"dimension_collectors",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("task_type", sa.String(20), nullable=False),
|
||||
sa.Column("dimension_name", sa.String(100), nullable=False),
|
||||
sa.Column("source_type", sa.String(20), nullable=False),
|
||||
sa.Column("source_path", sa.String(200), nullable=True),
|
||||
sa.Column("value_type", sa.String(20), nullable=False, server_default="float"),
|
||||
sa.Column("transform_expression", sa.Text(), nullable=True),
|
||||
sa.Column("default_value", sa.String(100), nullable=True),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(source_type = 'computed' AND source_path IS NULL AND transform_expression IS NOT NULL) OR "
|
||||
"(source_type != 'computed' AND source_path IS NOT NULL)",
|
||||
name="chk_dimension_collectors_source_config",
|
||||
),
|
||||
)
|
||||
|
||||
if table_exists("dimension_collectors"):
|
||||
if not index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||
op.create_index(
|
||||
"uq_dimension_collectors_enabled",
|
||||
"dimension_collectors",
|
||||
["api_format", "task_type", "dimension_name", "priority"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop in reverse order
|
||||
if table_exists("dimension_collectors"):
|
||||
if index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||
op.drop_index("uq_dimension_collectors_enabled", table_name="dimension_collectors")
|
||||
op.drop_table("dimension_collectors")
|
||||
|
||||
if table_exists("billing_rules"):
|
||||
if index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||
op.drop_index("uq_billing_rules_model_task", table_name="billing_rules")
|
||||
if index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||
op.drop_index("uq_billing_rules_global_model_task", table_name="billing_rules")
|
||||
op.drop_table("billing_rules")
|
||||
|
||||
if column_exists("video_tasks", "request_metadata"):
|
||||
op.drop_column("video_tasks", "request_metadata")
|
||||
-462
@@ -1,462 +0,0 @@
|
||||
"""Add api_family/endpoint_kind and migrate api_format to endpoint signature keys
|
||||
|
||||
Revision ID: cf40e6a5c5b1
|
||||
Revises: c8d2e4f6a1b3
|
||||
Create Date: 2026-01-31 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Sequence, Union
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cf40e6a5c5b1"
|
||||
down_revision: Union[str, None] = "c8d2e4f6a1b3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _json_loads(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (dict, list)):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _json_dumps(val):
|
||||
"""将 dict/list 转为 JSON 字符串,None 保持 None"""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
return json.dumps(val)
|
||||
|
||||
|
||||
def _normalize_signature(value: str | None) -> str | None:
|
||||
"""
|
||||
Normalize legacy api_format / signature-ish strings to canonical signature key.
|
||||
|
||||
- canonical: `<family>:<kind>` (lowercase)
|
||||
- legacy examples: "OPENAI", "OPENAI_CLI", "GEMINI_VIDEO"
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
if ":" in raw:
|
||||
fam, kind = raw.split(":", 1)
|
||||
fam = fam.strip().lower()
|
||||
kind = kind.strip().lower()
|
||||
if not fam or not kind:
|
||||
return None
|
||||
return f"{fam}:{kind}"
|
||||
|
||||
upper = raw.upper()
|
||||
if upper.startswith("CLAUDE"):
|
||||
fam = "claude"
|
||||
elif upper.startswith("OPENAI"):
|
||||
fam = "openai"
|
||||
elif upper.startswith("GEMINI"):
|
||||
fam = "gemini"
|
||||
else:
|
||||
return None
|
||||
|
||||
kind = "chat"
|
||||
if upper.endswith("_CLI"):
|
||||
kind = "cli"
|
||||
elif upper.endswith("_VIDEO"):
|
||||
kind = "video"
|
||||
|
||||
return f"{fam}:{kind}"
|
||||
|
||||
|
||||
def _normalize_signature_list(values) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
if isinstance(values, str):
|
||||
values = _json_loads(values)
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in values:
|
||||
sig = _normalize_signature(str(v) if v is not None else None)
|
||||
if not sig:
|
||||
continue
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(sig)
|
||||
out.append(sig)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_signature_dict(values) -> dict | None:
|
||||
if values is None:
|
||||
return None
|
||||
if isinstance(values, str):
|
||||
values = _json_loads(values)
|
||||
if not isinstance(values, dict):
|
||||
return None
|
||||
|
||||
out: dict = {}
|
||||
for k, v in values.items():
|
||||
sig = _normalize_signature(str(k) if k is not None else None)
|
||||
if not sig:
|
||||
continue
|
||||
out[sig] = v
|
||||
return out
|
||||
|
||||
|
||||
def _add_video_variants(formats: list[str]) -> list[str]:
|
||||
"""
|
||||
保持原有格式,不自动补齐 video 变体。
|
||||
"""
|
||||
return formats
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _migrate_format_acceptance_config(cfg) -> dict | None:
|
||||
cfg_obj = _json_loads(cfg)
|
||||
if not isinstance(cfg_obj, dict):
|
||||
return cfg_obj if cfg_obj is None else None
|
||||
|
||||
for key in ("accept_formats", "reject_formats"):
|
||||
raw = cfg_obj.get(key)
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
normalized = _normalize_signature_list(raw) or []
|
||||
cfg_obj[key] = normalized
|
||||
|
||||
return cfg_obj
|
||||
|
||||
|
||||
def migrate_provider_endpoints(connection) -> None:
|
||||
"""
|
||||
- 将 provider_endpoints.api_format 统一迁移为 signature key(小写)
|
||||
- 填充/校准 api_family / endpoint_kind
|
||||
- 迁移 format_acceptance_config 中的 accept/reject formats
|
||||
"""
|
||||
rows = connection.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
api_format,
|
||||
api_family,
|
||||
endpoint_kind,
|
||||
format_acceptance_config
|
||||
FROM provider_endpoints
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
sig = _normalize_signature(row.api_format)
|
||||
if not sig:
|
||||
continue
|
||||
fam, kind = sig.split(":", 1)
|
||||
|
||||
cfg = _migrate_format_acceptance_config(row.format_acceptance_config)
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE provider_endpoints
|
||||
SET
|
||||
api_format = :api_format,
|
||||
api_family = :api_family,
|
||||
endpoint_kind = :endpoint_kind,
|
||||
format_acceptance_config = CAST(:format_acceptance_config AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"api_format": sig,
|
||||
"api_family": fam,
|
||||
"endpoint_kind": kind,
|
||||
"format_acceptance_config": _json_dumps(cfg),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_video_endpoints(connection) -> None:
|
||||
"""
|
||||
不再自动创建 video endpoint,保持原有配置。
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def migrate_provider_api_keys(connection) -> None:
|
||||
"""
|
||||
迁移 provider_api_keys:
|
||||
- api_formats -> signature keys(并补齐 video 变体)
|
||||
- dict 字段 key -> signature keys(rate_multipliers/global_priority/health/circuit_breaker)
|
||||
- rate_multipliers/global_priority_by_format 复制 chat -> video(如 openai:chat -> openai:video)
|
||||
"""
|
||||
rows = connection.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
api_formats,
|
||||
rate_multipliers,
|
||||
global_priority_by_format,
|
||||
health_by_format,
|
||||
circuit_breaker_by_format
|
||||
FROM provider_api_keys
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
api_formats = _normalize_signature_list(row.api_formats)
|
||||
if api_formats is not None:
|
||||
api_formats = _add_video_variants(api_formats)
|
||||
|
||||
rate_multipliers = _normalize_signature_dict(row.rate_multipliers)
|
||||
global_priority_by_format = _normalize_signature_dict(row.global_priority_by_format)
|
||||
|
||||
health_by_format = _normalize_signature_dict(row.health_by_format)
|
||||
circuit_breaker_by_format = _normalize_signature_dict(row.circuit_breaker_by_format)
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
api_formats = CAST(:api_formats AS json),
|
||||
rate_multipliers = CAST(:rate_multipliers AS json),
|
||||
global_priority_by_format = CAST(:global_priority_by_format AS json),
|
||||
health_by_format = CAST(:health_by_format AS json),
|
||||
circuit_breaker_by_format = CAST(:circuit_breaker_by_format AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"api_formats": _json_dumps(api_formats),
|
||||
"rate_multipliers": _json_dumps(rate_multipliers),
|
||||
"global_priority_by_format": _json_dumps(global_priority_by_format),
|
||||
"health_by_format": _json_dumps(health_by_format),
|
||||
"circuit_breaker_by_format": _json_dumps(circuit_breaker_by_format),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_allowed_api_formats(connection, *, table_name: str) -> None:
|
||||
"""迁移 users/api_keys.allowed_api_formats 为 signature keys(并补齐 video 变体)。"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
rows = connection.execute(text(f"""
|
||||
SELECT id, allowed_api_formats
|
||||
FROM {table_name}
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
allowed = _normalize_signature_list(row.allowed_api_formats)
|
||||
if allowed is None:
|
||||
continue
|
||||
allowed = _add_video_variants(allowed)
|
||||
connection.execute(
|
||||
text(f"""
|
||||
UPDATE {table_name}
|
||||
SET allowed_api_formats = CAST(:allowed_api_formats AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "allowed_api_formats": _json_dumps(allowed)},
|
||||
)
|
||||
|
||||
|
||||
def migrate_video_tasks(connection) -> None:
|
||||
"""
|
||||
video_tasks.*_api_format 迁移为 signature keys。
|
||||
|
||||
注意:video_tasks 表天然是 video 任务,因此将 openai/gemini 的 kind 强制归一为 video,
|
||||
以兼容历史上复用 chat 格式存储的旧记录。
|
||||
"""
|
||||
if not table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, client_api_format, provider_api_format
|
||||
FROM video_tasks
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
client_sig = _normalize_signature(row.client_api_format) or ""
|
||||
provider_sig = _normalize_signature(row.provider_api_format) or ""
|
||||
|
||||
def _force_video(sig: str) -> str:
|
||||
if not sig or ":" not in sig:
|
||||
return sig
|
||||
fam, _kind = sig.split(":", 1)
|
||||
fam = fam.strip().lower()
|
||||
if fam in ("openai", "gemini"):
|
||||
return f"{fam}:video"
|
||||
return sig
|
||||
|
||||
client_sig = _force_video(client_sig)
|
||||
provider_sig = _force_video(provider_sig)
|
||||
|
||||
if not client_sig or not provider_sig:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE video_tasks
|
||||
SET client_api_format = :client_api_format,
|
||||
provider_api_format = :provider_api_format
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"client_api_format": client_sig,
|
||||
"provider_api_format": provider_sig,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_model_provider_mappings(connection) -> None:
|
||||
"""迁移 models.provider_model_mappings[*].api_formats 为 signature keys。"""
|
||||
if not table_exists("models"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, provider_model_mappings
|
||||
FROM models
|
||||
WHERE provider_model_mappings IS NOT NULL
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
mappings = _json_loads(row.provider_model_mappings)
|
||||
if not isinstance(mappings, list):
|
||||
continue
|
||||
|
||||
changed = False
|
||||
new_mappings: list = []
|
||||
for item in mappings:
|
||||
if not isinstance(item, dict):
|
||||
new_mappings.append(item)
|
||||
continue
|
||||
raw_formats = item.get("api_formats")
|
||||
if isinstance(raw_formats, list):
|
||||
normalized = _normalize_signature_list(raw_formats) or []
|
||||
# 内容比较(而非引用比较),避免已迁移数据被无意义地重复 UPDATE
|
||||
if set(normalized) != set(raw_formats):
|
||||
changed = True
|
||||
item = dict(item)
|
||||
item["api_formats"] = normalized
|
||||
new_mappings.append(item)
|
||||
|
||||
if not changed:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE models
|
||||
SET provider_model_mappings = CAST(:provider_model_mappings AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "provider_model_mappings": _json_dumps(new_mappings)},
|
||||
)
|
||||
|
||||
|
||||
def migrate_dimension_collectors(connection) -> None:
|
||||
"""迁移 dimension_collectors.api_format 为 signature keys(如果存在历史数据)。"""
|
||||
if not table_exists("dimension_collectors"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, api_format
|
||||
FROM dimension_collectors
|
||||
WHERE api_format IS NOT NULL
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
sig = _normalize_signature(row.api_format)
|
||||
if not sig:
|
||||
continue
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE dimension_collectors
|
||||
SET api_format = :api_format
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "api_format": sig},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
|
||||
# ==================== provider_endpoints.api_family / endpoint_kind ====================
|
||||
if not column_exists("provider_endpoints", "api_family"):
|
||||
op.add_column("provider_endpoints", sa.Column("api_family", sa.String(50), nullable=True))
|
||||
if not column_exists("provider_endpoints", "endpoint_kind"):
|
||||
op.add_column(
|
||||
"provider_endpoints", sa.Column("endpoint_kind", sa.String(50), nullable=True)
|
||||
)
|
||||
|
||||
# ==================== idx_provider_family_kind ====================
|
||||
if not index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||
op.create_index(
|
||||
"idx_provider_family_kind",
|
||||
"provider_endpoints",
|
||||
["provider_id", "api_family", "endpoint_kind"],
|
||||
)
|
||||
|
||||
# ==================== data migrations (idempotent) ====================
|
||||
conn = op.get_bind()
|
||||
|
||||
migrate_provider_endpoints(conn)
|
||||
create_video_endpoints(conn)
|
||||
|
||||
if table_exists("provider_api_keys"):
|
||||
migrate_provider_api_keys(conn)
|
||||
|
||||
migrate_allowed_api_formats(conn, table_name="users")
|
||||
migrate_allowed_api_formats(conn, table_name="api_keys")
|
||||
migrate_video_tasks(conn)
|
||||
migrate_model_provider_mappings(conn)
|
||||
migrate_dimension_collectors(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/columns only; data changes are intentionally kept (safe rollback strategy).
|
||||
if table_exists("provider_endpoints"):
|
||||
if index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||
op.drop_index("idx_provider_family_kind", table_name="provider_endpoints")
|
||||
if column_exists("provider_endpoints", "endpoint_kind"):
|
||||
op.drop_column("provider_endpoints", "endpoint_kind")
|
||||
if column_exists("provider_endpoints", "api_family"):
|
||||
op.drop_column("provider_endpoints", "api_family")
|
||||
-329
@@ -1,329 +0,0 @@
|
||||
"""Add usage billing, video_tasks fields, gemini_file_mappings, provider format conversion, and indexes
|
||||
|
||||
Revision ID: a2f1b3c4d5e6
|
||||
Revises: cf40e6a5c5b1
|
||||
Create Date: 2026-02-01 12:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. usage 表:
|
||||
- 添加 billing_status (pending/settled/void),用于表示结算状态
|
||||
- 添加 finalized_at,用于记录结算完成时间
|
||||
- 添加 (provider_name, created_at) 和 (model, created_at) 索引
|
||||
|
||||
2. video_tasks 表:
|
||||
- 添加 request_id(全局唯一),用于与 Usage/RequestCandidate 建立稳定关联
|
||||
- 添加 short_id (Gemini-style short ID)
|
||||
|
||||
3. gemini_file_mappings 表:
|
||||
- 创建新表用于文件映射
|
||||
- 添加 source_hash 字段用于关联相同源文件
|
||||
|
||||
4. providers 表:
|
||||
- 添加 enable_format_conversion 开关字段
|
||||
|
||||
5. request_candidates 表:
|
||||
- 添加 created_at 索引
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2f1b3c4d5e6"
|
||||
down_revision: Union[str, None] = "cf40e6a5c5b1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
# Clear cached schema info to get fresh data
|
||||
inspector.clear_cache()
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def unique_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
return any(c.get("name") == constraint_name for c in constraints)
|
||||
|
||||
|
||||
def generate_short_id(length: int = 12) -> str:
|
||||
"""Generate a Gemini-style short ID (lowercase letters + digits)"""
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
# =========================================================================
|
||||
# 1. usage 表: billing_status + finalized_at + 索引
|
||||
# =========================================================================
|
||||
if table_exists("usage"):
|
||||
if not column_exists("usage", "billing_status"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column(
|
||||
"billing_status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="settled",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("usage", "finalized_at"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not index_exists("usage", "idx_usage_billing_status"):
|
||||
op.create_index("idx_usage_billing_status", "usage", ["billing_status"])
|
||||
|
||||
# (provider_name, created_at) — provider list / dashboard queries
|
||||
if (
|
||||
column_exists("usage", "provider_name")
|
||||
and column_exists("usage", "created_at")
|
||||
and not index_exists("usage", "idx_usage_provider_created")
|
||||
):
|
||||
op.create_index("idx_usage_provider_created", "usage", ["provider_name", "created_at"])
|
||||
|
||||
# (model, created_at) — model analytics / recent requests queries
|
||||
if (
|
||||
column_exists("usage", "model")
|
||||
and column_exists("usage", "created_at")
|
||||
and not index_exists("usage", "idx_usage_model_created")
|
||||
):
|
||||
op.create_index("idx_usage_model_created", "usage", ["model", "created_at"])
|
||||
|
||||
# =========================================================================
|
||||
# 2. video_tasks 表: request_id + short_id
|
||||
# =========================================================================
|
||||
if table_exists("video_tasks"):
|
||||
# --- request_id ---
|
||||
if not column_exists("video_tasks", "request_id"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("request_id", sa.String(100), nullable=True),
|
||||
)
|
||||
|
||||
# 回填 request_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = COALESCE(request_metadata->>'request_id', id)
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
elif dialect == "sqlite":
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = COALESCE(json_extract(request_metadata, '$.request_id'), id)
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
else:
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = id
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("video_tasks", "request_id", nullable=False)
|
||||
|
||||
if not index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||
op.create_index("idx_video_tasks_request_id", "video_tasks", ["request_id"])
|
||||
|
||||
if not unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||
op.create_unique_constraint(
|
||||
"uq_video_tasks_request_id",
|
||||
"video_tasks",
|
||||
["request_id"],
|
||||
)
|
||||
|
||||
# --- short_id ---
|
||||
if not column_exists("video_tasks", "short_id"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("short_id", sa.String(16), nullable=True),
|
||||
)
|
||||
|
||||
# Populate existing rows with unique short_ids
|
||||
result = bind.execute(text("SELECT id FROM video_tasks WHERE short_id IS NULL"))
|
||||
for row in result:
|
||||
short_id = generate_short_id()
|
||||
bind.execute(
|
||||
text("UPDATE video_tasks SET short_id = :short_id WHERE id = :id"),
|
||||
{"short_id": short_id, "id": row[0]},
|
||||
)
|
||||
|
||||
op.alter_column("video_tasks", "short_id", nullable=False)
|
||||
op.create_index("ix_video_tasks_short_id", "video_tasks", ["short_id"], unique=True)
|
||||
|
||||
# =========================================================================
|
||||
# 3. gemini_file_mappings 表
|
||||
# =========================================================================
|
||||
if not table_exists("gemini_file_mappings"):
|
||||
op.create_table(
|
||||
"gemini_file_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("file_name", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("display_name", sa.String(255), nullable=True),
|
||||
sa.Column("mime_type", sa.String(100), nullable=True),
|
||||
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_index("ix_gemini_file_mappings_id", "gemini_file_mappings", ["id"])
|
||||
op.create_index(
|
||||
"ix_gemini_file_mappings_file_name", "gemini_file_mappings", ["file_name"], unique=True
|
||||
)
|
||||
op.create_index("ix_gemini_file_mappings_key_id", "gemini_file_mappings", ["key_id"])
|
||||
op.create_index("ix_gemini_file_mappings_user_id", "gemini_file_mappings", ["user_id"])
|
||||
op.create_index("idx_gemini_file_mappings_expires", "gemini_file_mappings", ["expires_at"])
|
||||
op.create_index(
|
||||
"idx_gemini_file_mappings_source_hash", "gemini_file_mappings", ["source_hash"]
|
||||
)
|
||||
else:
|
||||
# 表已存在,只添加 source_hash
|
||||
if not column_exists("gemini_file_mappings", "source_hash"):
|
||||
op.add_column(
|
||||
"gemini_file_mappings",
|
||||
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_gemini_file_mappings_source_hash",
|
||||
"gemini_file_mappings",
|
||||
["source_hash"],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 4. providers 表: enable_format_conversion
|
||||
# =========================================================================
|
||||
if table_exists("providers") and not column_exists("providers", "enable_format_conversion"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column(
|
||||
"enable_format_conversion",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 5. request_candidates 表: created_at 索引
|
||||
# =========================================================================
|
||||
if table_exists("request_candidates"):
|
||||
if not index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||
op.create_index(
|
||||
"idx_request_candidates_created_at",
|
||||
"request_candidates",
|
||||
["created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
# =========================================================================
|
||||
# 5. request_candidates 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("request_candidates"):
|
||||
if index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||
op.drop_index("idx_request_candidates_created_at", table_name="request_candidates")
|
||||
|
||||
# =========================================================================
|
||||
# 4. providers 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("providers") and column_exists("providers", "enable_format_conversion"):
|
||||
op.drop_column("providers", "enable_format_conversion")
|
||||
|
||||
# =========================================================================
|
||||
# 3. gemini_file_mappings 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("gemini_file_mappings"):
|
||||
op.drop_index("idx_gemini_file_mappings_source_hash", table_name="gemini_file_mappings")
|
||||
op.drop_index("idx_gemini_file_mappings_expires", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_user_id", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_key_id", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_file_name", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_id", table_name="gemini_file_mappings")
|
||||
op.drop_table("gemini_file_mappings")
|
||||
|
||||
# =========================================================================
|
||||
# 2. video_tasks 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("video_tasks"):
|
||||
# short_id
|
||||
if column_exists("video_tasks", "short_id"):
|
||||
if index_exists("video_tasks", "ix_video_tasks_short_id"):
|
||||
op.drop_index("ix_video_tasks_short_id", table_name="video_tasks")
|
||||
op.drop_column("video_tasks", "short_id")
|
||||
|
||||
# request_id
|
||||
if column_exists("video_tasks", "request_id"):
|
||||
if dialect == "postgresql":
|
||||
if unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||
op.drop_constraint("uq_video_tasks_request_id", "video_tasks", type_="unique")
|
||||
if index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||
op.drop_index("idx_video_tasks_request_id", table_name="video_tasks")
|
||||
op.drop_column("video_tasks", "request_id")
|
||||
|
||||
# =========================================================================
|
||||
# 1. usage 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("usage"):
|
||||
if index_exists("usage", "idx_usage_model_created"):
|
||||
op.drop_index("idx_usage_model_created", table_name="usage")
|
||||
if index_exists("usage", "idx_usage_provider_created"):
|
||||
op.drop_index("idx_usage_provider_created", table_name="usage")
|
||||
if index_exists("usage", "idx_usage_billing_status"):
|
||||
op.drop_index("idx_usage_billing_status", table_name="usage")
|
||||
if column_exists("usage", "finalized_at"):
|
||||
op.drop_column("usage", "finalized_at")
|
||||
if column_exists("usage", "billing_status"):
|
||||
op.drop_column("usage", "billing_status")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Add video_duration_seconds to video_tasks and body_rules to provider_endpoints
|
||||
|
||||
Revision ID: b3c4d5e6f7a8
|
||||
Revises: a2f1b3c4d5e6
|
||||
Create Date: 2026-02-03 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b3c4d5e6f7a8"
|
||||
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a table."""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add video_duration_seconds to video_tasks
|
||||
if not _column_exists("video_tasks", "video_duration_seconds"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Add body_rules to provider_endpoints
|
||||
# 请求体规则支持三种操作:
|
||||
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||
if not _column_exists("provider_endpoints", "body_rules"):
|
||||
op.add_column(
|
||||
"provider_endpoints",
|
||||
sa.Column("body_rules", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove body_rules from provider_endpoints
|
||||
if _column_exists("provider_endpoints", "body_rules"):
|
||||
op.drop_column("provider_endpoints", "body_rules")
|
||||
|
||||
# Remove video_duration_seconds from video_tasks
|
||||
if _column_exists("video_tasks", "video_duration_seconds"):
|
||||
op.drop_column("video_tasks", "video_duration_seconds")
|
||||
@@ -1,347 +0,0 @@
|
||||
"""add_stats_hourly_and_daily_complete_flag
|
||||
|
||||
Revision ID: c4e8f9a1b2c3
|
||||
Revises: b3c4d5e6f7a8
|
||||
Create Date: 2026-02-04 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c4e8f9a1b2c3"
|
||||
down_revision: Union[str, None] = "b3c4d5e6f7a8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx["name"] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
# Use information_schema for more reliable detection (inspector can have caching issues)
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS ("
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
")"
|
||||
),
|
||||
{"table": table_name, "column": column_name},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _table_exists("stats_daily"):
|
||||
if not _column_exists("stats_daily", "is_complete"):
|
||||
op.add_column(
|
||||
"stats_daily",
|
||||
sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.execute("UPDATE stats_daily SET is_complete = true")
|
||||
if not _column_exists("stats_daily", "aggregated_at"):
|
||||
op.add_column(
|
||||
"stats_daily",
|
||||
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly"):
|
||||
op.create_table(
|
||||
"stats_hourly",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||
sa.Column("is_complete", sa.Boolean(), nullable=False),
|
||||
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", name="uq_stats_hourly_hour"),
|
||||
)
|
||||
op.create_index("idx_stats_hourly_hour", "stats_hourly", ["hour_utc"], unique=False)
|
||||
|
||||
if not _table_exists("stats_hourly_user"):
|
||||
op.create_table(
|
||||
"stats_hourly_user",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "user_id", name="uq_stats_hourly_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_user_hour", "stats_hourly_user", ["hour_utc"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_user_user_hour",
|
||||
"stats_hourly_user",
|
||||
["user_id", "hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly_model"):
|
||||
op.create_table(
|
||||
"stats_hourly_model",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("model", sa.String(length=100), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "model", name="uq_stats_hourly_model"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_model_hour", "stats_hourly_model", ["hour_utc"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_model_model_hour",
|
||||
"stats_hourly_model",
|
||||
["model", "hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly_provider"):
|
||||
op.create_table(
|
||||
"stats_hourly_provider",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("provider_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_provider_hour",
|
||||
"stats_hourly_provider",
|
||||
["hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_daily_api_key"):
|
||||
op.create_table(
|
||||
"stats_daily_api_key",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(length=36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
|
||||
)
|
||||
|
||||
if _table_exists("stats_daily_api_key"):
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||
op.create_index("idx_stats_daily_api_key_date", "stats_daily_api_key", ["date"])
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_key_date",
|
||||
"stats_daily_api_key",
|
||||
["api_key_id", "date"],
|
||||
)
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_date_requests",
|
||||
"stats_daily_api_key",
|
||||
["date", "total_requests"],
|
||||
)
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_date_cost",
|
||||
"stats_daily_api_key",
|
||||
["date", "total_cost"],
|
||||
)
|
||||
|
||||
if _table_exists("usage"):
|
||||
if not _column_exists("usage", "error_category"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("error_category", sa.String(length=50), nullable=True),
|
||||
)
|
||||
op.create_index("idx_usage_error_category", "usage", ["error_category"], unique=False)
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
for name in (
|
||||
"p50_response_time_ms",
|
||||
"p90_response_time_ms",
|
||||
"p99_response_time_ms",
|
||||
"p50_first_byte_time_ms",
|
||||
"p90_first_byte_time_ms",
|
||||
"p99_first_byte_time_ms",
|
||||
):
|
||||
if not _column_exists("stats_daily", name):
|
||||
op.add_column("stats_daily", sa.Column(name, sa.Integer(), nullable=True))
|
||||
|
||||
if not _table_exists("stats_daily_error"):
|
||||
op.create_table(
|
||||
"stats_daily_error",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("error_category", sa.String(length=50), nullable=False),
|
||||
sa.Column("provider_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("model", sa.String(length=100), nullable=True),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"date",
|
||||
"error_category",
|
||||
"provider_name",
|
||||
"model",
|
||||
name="uq_stats_daily_error",
|
||||
),
|
||||
)
|
||||
|
||||
if _table_exists("stats_daily_error"):
|
||||
if not _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||
op.create_index("idx_stats_daily_error_date", "stats_daily_error", ["date"])
|
||||
if not _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_error_category",
|
||||
"stats_daily_error",
|
||||
["date", "error_category"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("stats_daily_error"):
|
||||
if _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||
op.drop_index("idx_stats_daily_error_category", table_name="stats_daily_error")
|
||||
if _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||
op.drop_index("idx_stats_daily_error_date", table_name="stats_daily_error")
|
||||
op.drop_table("stats_daily_error")
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
for name in (
|
||||
"p50_response_time_ms",
|
||||
"p90_response_time_ms",
|
||||
"p99_response_time_ms",
|
||||
"p50_first_byte_time_ms",
|
||||
"p90_first_byte_time_ms",
|
||||
"p99_first_byte_time_ms",
|
||||
):
|
||||
if _column_exists("stats_daily", name):
|
||||
op.drop_column("stats_daily", name)
|
||||
|
||||
if _table_exists("usage") and _column_exists("usage", "error_category"):
|
||||
if _index_exists("usage", "idx_usage_error_category"):
|
||||
op.drop_index("idx_usage_error_category", table_name="usage")
|
||||
op.drop_column("usage", "error_category")
|
||||
|
||||
if _table_exists("stats_daily_api_key"):
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||
op.drop_index("idx_stats_daily_api_key_date_cost", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||
op.drop_index("idx_stats_daily_api_key_date_requests", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||
op.drop_index("idx_stats_daily_api_key_key_date", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||
op.drop_index("idx_stats_daily_api_key_date", table_name="stats_daily_api_key")
|
||||
op.drop_table("stats_daily_api_key")
|
||||
|
||||
if _table_exists("stats_hourly_provider"):
|
||||
if _index_exists("stats_hourly_provider", "idx_stats_hourly_provider_hour"):
|
||||
op.drop_index("idx_stats_hourly_provider_hour", table_name="stats_hourly_provider")
|
||||
op.drop_table("stats_hourly_provider")
|
||||
|
||||
if _table_exists("stats_hourly_model"):
|
||||
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_model_hour"):
|
||||
op.drop_index("idx_stats_hourly_model_model_hour", table_name="stats_hourly_model")
|
||||
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_hour"):
|
||||
op.drop_index("idx_stats_hourly_model_hour", table_name="stats_hourly_model")
|
||||
op.drop_table("stats_hourly_model")
|
||||
|
||||
if _table_exists("stats_hourly_user"):
|
||||
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_user_hour"):
|
||||
op.drop_index("idx_stats_hourly_user_user_hour", table_name="stats_hourly_user")
|
||||
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_hour"):
|
||||
op.drop_index("idx_stats_hourly_user_hour", table_name="stats_hourly_user")
|
||||
op.drop_table("stats_hourly_user")
|
||||
|
||||
if _table_exists("stats_hourly"):
|
||||
if _index_exists("stats_hourly", "idx_stats_hourly_hour"):
|
||||
op.drop_index("idx_stats_hourly_hour", table_name="stats_hourly")
|
||||
op.drop_table("stats_hourly")
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
if _column_exists("stats_daily", "aggregated_at"):
|
||||
op.drop_column("stats_daily", "aggregated_at")
|
||||
if _column_exists("stats_daily", "is_complete"):
|
||||
op.drop_column("stats_daily", "is_complete")
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Add provider_type, upstream_metadata, oauth_invalid fields and expand string columns to TEXT
|
||||
|
||||
- Add providers.provider_type (String(20), server_default="custom")
|
||||
- Add provider_api_keys.upstream_metadata (JSON, nullable)
|
||||
- Add provider_api_keys.oauth_invalid_at (DateTime, nullable) - OAuth Token 失效时间
|
||||
- Add provider_api_keys.oauth_invalid_reason (String(255), nullable) - OAuth Token 失效原因
|
||||
- Expand multiple VARCHAR columns to TEXT for long values (OAuth tokens, LDAP DN, URLs, etc.)
|
||||
|
||||
Revision ID: b5c6d7e8f9a0
|
||||
Revises: c4e8f9a1b2c3
|
||||
Create Date: 2026-02-04 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b5c6d7e8f9a0"
|
||||
down_revision: Union[str, None] = "c4e8f9a1b2c3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# 需要扩展为 TEXT 的列(表名, 列名, 原始类型长度)
|
||||
COLUMNS_TO_EXPAND = [
|
||||
("provider_api_keys", "api_key", 500), # OAuth tokens can be very long
|
||||
(
|
||||
"provider_api_keys",
|
||||
"auth_config",
|
||||
None,
|
||||
), # 确保 auth_config 是 TEXT 类型(可能从 JSON 迁移过来)
|
||||
("ldap_configs", "bind_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "base_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "user_search_filter", 500), # Complex LDAP filters
|
||||
("oauth_providers", "client_id", 255), # Some OAuth providers use JWT client_id
|
||||
]
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否已存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def is_sqlite() -> bool:
|
||||
"""检查是否为 SQLite 数据库"""
|
||||
bind = op.get_bind()
|
||||
return bind.dialect.name == "sqlite"
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str | None:
|
||||
"""获取列的数据类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col["name"] == column_name:
|
||||
return str(col["type"]).upper()
|
||||
return None
|
||||
|
||||
|
||||
def expand_column_to_text(table_name: str, column_name: str, original_length: int | None) -> None:
|
||||
"""将 VARCHAR 列扩展为 TEXT(兼容 SQLite)"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
if not column_exists(table_name, column_name):
|
||||
return
|
||||
|
||||
# 检查当前列类型,如果已经是 TEXT 则跳过
|
||||
col_type = get_column_type(table_name, column_name)
|
||||
if col_type and "TEXT" in col_type:
|
||||
return
|
||||
|
||||
# 如果是 JSON 类型(可能是历史遗留),先将 JSON 数据转为文本表示再变更类型
|
||||
is_json_col = col_type and "JSON" in col_type
|
||||
|
||||
if is_json_col and not is_sqlite():
|
||||
# PostgreSQL: 先用 CAST 把 JSON 值转为 TEXT,保留数据
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
|
||||
f"TYPE TEXT USING {column_name}::TEXT"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if is_sqlite():
|
||||
# SQLite 不支持直接 ALTER COLUMN,需要用 batch 模式
|
||||
# batch 模式会自动处理 JSON->TEXT 的数据迁移
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
)
|
||||
else:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def shrink_column_to_varchar(
|
||||
table_name: str, column_name: str, target_length: int, nullable: bool = False
|
||||
) -> None:
|
||||
"""将 TEXT 列缩小为 VARCHAR(兼容 SQLite)
|
||||
WARNING: 如果数据超过 target_length 会失败
|
||||
"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
if not column_exists(table_name, column_name):
|
||||
return
|
||||
|
||||
if is_sqlite():
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
type_=sa.String(target_length),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=nullable,
|
||||
)
|
||||
else:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
column_name,
|
||||
type_=sa.String(target_length),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=nullable,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add providers.provider_type
|
||||
if not column_exists("providers", "provider_type"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.upstream_metadata
|
||||
if not column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("upstream_metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_at
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_reason
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_reason", sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# Expand VARCHAR columns to TEXT
|
||||
for table_name, column_name, original_length in COLUMNS_TO_EXPAND:
|
||||
expand_column_to_text(table_name, column_name, original_length)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Shrink TEXT columns back to VARCHAR
|
||||
# WARNING: Downgrade may fail if any values exceed original length
|
||||
for table_name, column_name, original_length in reversed(COLUMNS_TO_EXPAND):
|
||||
# 跳过没有原始长度的列(如 auth_config,由其他迁移创建)
|
||||
if original_length is None:
|
||||
continue
|
||||
shrink_column_to_varchar(table_name, column_name, original_length)
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_reason
|
||||
if column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_reason")
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_at
|
||||
if column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_at")
|
||||
|
||||
# Drop provider_api_keys.upstream_metadata
|
||||
if column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.drop_column("provider_api_keys", "upstream_metadata")
|
||||
|
||||
# Drop providers.provider_type
|
||||
if column_exists("providers", "provider_type"):
|
||||
op.drop_column("providers", "provider_type")
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table (with manual fields)
|
||||
|
||||
Revision ID: e1b2c3d4f5a6
|
||||
Revises: b5c6d7e8f9a0
|
||||
Create Date: 2026-02-06 23:45:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e1b2c3d4f5a6"
|
||||
down_revision: str | None = "b5c6d7e8f9a0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# =========================================================================
|
||||
# Part 1: Antigravity endpoint signature migration (gemini:cli -> gemini:chat)
|
||||
# =========================================================================
|
||||
|
||||
# --- provider_endpoints ---
|
||||
# Update only when there is no conflicting gemini:chat endpoint for the same provider
|
||||
# (provider_endpoints has a unique constraint on (provider_id, api_format)).
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_format = 'gemini:chat',
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'chat'
|
||||
WHERE pe.api_format = 'gemini:cli'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM provider_endpoints pe2
|
||||
WHERE pe2.provider_id = pe.provider_id
|
||||
AND pe2.api_format = 'gemini:chat'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Best-effort normalization for already-existing Antigravity gemini:chat endpoints.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'chat'
|
||||
WHERE pe.api_format = 'gemini:chat'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
"""))
|
||||
|
||||
# --- provider_api_keys.api_formats (JSON array) ---
|
||||
# Replace "gemini:cli" with "gemini:chat" in the JSON array for Antigravity keys.
|
||||
# Uses text-level replace on the serialized JSON -- safe because the value is a
|
||||
# simple string with no special characters that could cause ambiguous replacements.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_api_keys pak
|
||||
SET api_formats = replace(pak.api_formats::text, '"gemini:cli"', '"gemini:chat"')::json
|
||||
WHERE pak.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND pak.api_formats IS NOT NULL
|
||||
AND pak.api_formats::text LIKE '%"gemini:cli"%'
|
||||
"""))
|
||||
|
||||
# =========================================================================
|
||||
# Part 2: Create proxy_nodes table with manual proxy fields (idempotent)
|
||||
# =========================================================================
|
||||
|
||||
# Create ENUM type (idempotent)
|
||||
op.execute(
|
||||
"DO $$ BEGIN "
|
||||
"CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline'); "
|
||||
"EXCEPTION WHEN duplicate_object THEN NULL; "
|
||||
"END $$"
|
||||
)
|
||||
|
||||
if table_exists("proxy_nodes"):
|
||||
# Table already exists — ensure manual proxy columns are present
|
||||
inspector = inspect(conn)
|
||||
existing_columns = {c["name"] for c in inspector.get_columns("proxy_nodes")}
|
||||
|
||||
# ip 列扩容:手动节点的 ip 存储 "socks5://hostname" 形式,45 字符可能不够
|
||||
ip_col = next((c for c in inspector.get_columns("proxy_nodes") if c["name"] == "ip"), None)
|
||||
if ip_col and hasattr(ip_col["type"], "length") and (ip_col["type"].length or 0) < 512:
|
||||
op.alter_column("proxy_nodes", "ip", type_=sa.String(512), existing_nullable=False)
|
||||
|
||||
manual_columns = [
|
||||
("is_manual", sa.Boolean(), False, sa.text("false"), "是否为手动添加的代理节点"),
|
||||
("proxy_url", sa.String(500), True, None, "手动节点的完整代理 URL"),
|
||||
("proxy_username", sa.String(255), True, None, "手动节点的代理用户名"),
|
||||
("proxy_password", sa.String(500), True, None, "手动节点的代理密码"),
|
||||
]
|
||||
for col_name, col_type, nullable, default, comment in manual_columns:
|
||||
if col_name not in existing_columns:
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
col_name,
|
||||
col_type, # type: ignore[arg-type]
|
||||
nullable=nullable,
|
||||
server_default=default,
|
||||
comment=comment,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"proxy_nodes",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("ip", sa.String(512), nullable=False),
|
||||
sa.Column("port", sa.Integer(), nullable=False),
|
||||
sa.Column("region", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
postgresql.ENUM(
|
||||
"online",
|
||||
"unhealthy",
|
||||
"offline",
|
||||
name="proxynodestatus",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
server_default=sa.text("'online'"),
|
||||
),
|
||||
sa.Column(
|
||||
"registered_by",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("heartbeat_interval", sa.Integer(), nullable=False, server_default=sa.text("30")),
|
||||
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
|
||||
# --- Manual proxy node fields ---
|
||||
sa.Column(
|
||||
"is_manual",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
comment="是否为手动添加的代理节点",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_url",
|
||||
sa.String(500),
|
||||
nullable=True,
|
||||
comment="手动节点的完整代理 URL",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_username",
|
||||
sa.String(255),
|
||||
nullable=True,
|
||||
comment="手动节点的代理用户名",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_password",
|
||||
sa.String(500),
|
||||
nullable=True,
|
||||
comment="手动节点的代理密码",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("ip", "port", name="uq_proxy_node_ip_port"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# =========================================================================
|
||||
# Part 2 rollback: Drop proxy_nodes table (and manual columns if present)
|
||||
# =========================================================================
|
||||
if table_exists("proxy_nodes"):
|
||||
op.drop_table("proxy_nodes")
|
||||
|
||||
# Best-effort: drop type (only used by proxy_nodes)
|
||||
op.execute("DROP TYPE IF EXISTS proxynodestatus")
|
||||
|
||||
# =========================================================================
|
||||
# Part 1 rollback: Revert Antigravity endpoint signature (gemini:chat -> gemini:cli)
|
||||
# =========================================================================
|
||||
|
||||
# --- provider_endpoints ---
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_format = 'gemini:cli',
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'cli'
|
||||
WHERE pe.api_format = 'gemini:chat'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM provider_endpoints pe2
|
||||
WHERE pe2.provider_id = pe.provider_id
|
||||
AND pe2.api_format = 'gemini:cli'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Best-effort normalization for already-existing Antigravity gemini:cli endpoints.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'cli'
|
||||
WHERE pe.api_format = 'gemini:cli'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
"""))
|
||||
|
||||
# --- provider_api_keys.api_formats (JSON array) ---
|
||||
conn.execute(text("""
|
||||
UPDATE provider_api_keys pak
|
||||
SET api_formats = replace(pak.api_formats::text, '"gemini:chat"', '"gemini:cli"')::json
|
||||
WHERE pak.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND pak.api_formats IS NOT NULL
|
||||
AND pak.api_formats::text LIKE '%"gemini:chat"%'
|
||||
"""))
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Add remote_config and config_version to proxy_nodes
|
||||
|
||||
Revision ID: 3aff3ffc4a0e
|
||||
Revises: e1b2c3d4f5a6
|
||||
Create Date: 2026-02-07 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3aff3ffc4a0e"
|
||||
down_revision: str | None = "e1b2c3d4f5a6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "remote_config"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"remote_config",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "config_version"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="远程配置版本号,每次更新 +1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "config_version"):
|
||||
op.drop_column("proxy_nodes", "config_version")
|
||||
if column_exists("proxy_nodes", "remote_config"):
|
||||
op.drop_column("proxy_nodes", "remote_config")
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Add tls_enabled and tls_cert_fingerprint to proxy_nodes
|
||||
|
||||
Revision ID: 4b5c6d7e8f9a
|
||||
Revises: 3aff3ffc4a0e
|
||||
Create Date: 2026-02-07 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4b5c6d7e8f9a"
|
||||
down_revision: str | None = "3aff3ffc4a0e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
comment="是否启用 TLS 加密",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_cert_fingerprint",
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||
if column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.drop_column("proxy_nodes", "tls_enabled")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Add hardware_info and estimated_max_concurrency to proxy_nodes
|
||||
|
||||
Revision ID: 5c6d7e8f9a0b
|
||||
Revises: 4b5c6d7e8f9a
|
||||
Create Date: 2026-02-08 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5c6d7e8f9a0b"
|
||||
down_revision: str | None = "4b5c6d7e8f9a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "hardware_info"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"hardware_info",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"estimated_max_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="基于硬件估算的最大并发连接数",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||
op.drop_column("proxy_nodes", "estimated_max_concurrency")
|
||||
if column_exists("proxy_nodes", "hardware_info"):
|
||||
op.drop_column("proxy_nodes", "hardware_info")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Add proxy column to provider_api_keys for per-key proxy configuration
|
||||
|
||||
Revision ID: 6d7e8f9a0b1c
|
||||
Revises: 5c6d7e8f9a0b
|
||||
Create Date: 2026-02-08 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6d7e8f9a0b1c"
|
||||
down_revision: str | None = "5c6d7e8f9a0b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "proxy"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column(
|
||||
"proxy",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="Key 级别代理配置(覆盖 Provider 级别代理),如 {node_id, enabled}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "proxy"):
|
||||
op.drop_column("provider_api_keys", "proxy")
|
||||
@@ -1,85 +0,0 @@
|
||||
# Aether - 数据库迁移说明
|
||||
|
||||
## 当前版本
|
||||
|
||||
- **Revision ID**: `aether_baseline`
|
||||
- **创建日期**: 2025-12-06
|
||||
- **状态**: 全新基线
|
||||
|
||||
## 迁移历史
|
||||
|
||||
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
|
||||
|
||||
## 核心数据库结构
|
||||
|
||||
### 用户系统
|
||||
- **users**: 用户账户管理
|
||||
- **api_keys**: API 密钥管理
|
||||
- **user_quotas**: 用户配额管理
|
||||
- **user_preferences**: 用户偏好设置
|
||||
|
||||
### Provider 三层架构
|
||||
- **providers**: LLM 提供商配置
|
||||
- **provider_endpoints**: Provider 的 API 端点配置
|
||||
- **provider_api_keys**: Endpoint 的具体 API 密钥
|
||||
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
|
||||
|
||||
### 模型系统
|
||||
- **global_models**: 统一模型定义(GlobalModel)
|
||||
- **models**: Provider 的模型实现和价格配置
|
||||
- **model_mappings**: 统一的别名与降级映射表
|
||||
|
||||
### 监控和追踪
|
||||
- **usage**: API 使用记录
|
||||
- **request_candidates**: 请求候选记录
|
||||
- **provider_usage_tracking**: Provider 使用统计
|
||||
- **audit_logs**: 系统审计日志
|
||||
|
||||
### 系统功能
|
||||
- **announcements**: 系统公告
|
||||
- **announcement_reads**: 公告阅读记录
|
||||
- **system_configs**: 系统配置
|
||||
|
||||
## 从旧数据库迁移
|
||||
|
||||
如需从旧数据库迁移数据,请使用迁移脚本:
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
|
||||
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
|
||||
|
||||
# 干运行(查看迁移量)
|
||||
python scripts/migrate_data.py --dry-run
|
||||
|
||||
# 执行迁移
|
||||
python scripts/migrate_data.py
|
||||
|
||||
# 只迁移特定表
|
||||
python scripts/migrate_data.py --tables users,providers,api_keys
|
||||
|
||||
# 跳过大表
|
||||
python scripts/migrate_data.py --skip usage,audit_logs
|
||||
```
|
||||
|
||||
## 新数据库初始化
|
||||
|
||||
```bash
|
||||
# 1. 运行迁移创建表结构
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
|
||||
|
||||
# 2. 初始化管理员账户
|
||||
python -m src.database.init_db
|
||||
```
|
||||
|
||||
## 未来迁移
|
||||
|
||||
基于 `aether_baseline` 创建增量迁移:
|
||||
|
||||
```bash
|
||||
# 修改模型后,生成新的迁移
|
||||
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
|
||||
|
||||
# 应用迁移
|
||||
DATABASE_URL="..." uv run alembic upgrade head
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
[package]
|
||||
name = "aether-gateway"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
jemalloc = [
|
||||
"dep:tikv-jemallocator",
|
||||
"dep:tikv-jemalloc-sys",
|
||||
"tikv-jemallocator/stats",
|
||||
"tikv-jemalloc-sys/stats",
|
||||
]
|
||||
testkit = []
|
||||
|
||||
[dependencies]
|
||||
aether-admin.workspace = true
|
||||
aether-ai-formats.workspace = true
|
||||
aether-ai-serving.workspace = true
|
||||
aether-billing.workspace = true
|
||||
aether-cache.workspace = true
|
||||
aether-contracts.workspace = true
|
||||
aether-crypto.workspace = true
|
||||
aether-data = { workspace = true, features = ["all-drivers"] }
|
||||
aether-data-contracts.workspace = true
|
||||
aether-dispatch-core.workspace = true
|
||||
aether-gateway-frontdoor.workspace = true
|
||||
aether-gateway-control.workspace = true
|
||||
aether-gateway-execution.workspace = true
|
||||
aether-http.workspace = true
|
||||
aether-gateway-workers.workspace = true
|
||||
aether-gateway-tunnel.workspace = true
|
||||
aether-model-fetch.workspace = true
|
||||
aether-oauth.workspace = true
|
||||
aether-pool-core.workspace = true
|
||||
aether-provider-pool.workspace = true
|
||||
aether-provider-transport.workspace = true
|
||||
aether-routing-core.workspace = true
|
||||
aether-scheduler-core.workspace = true
|
||||
aether-runtime.workspace = true
|
||||
aether-runtime-state.workspace = true
|
||||
aether-task-runtime.workspace = true
|
||||
aether-usage-runtime.workspace = true
|
||||
aether-video-tasks-core.workspace = true
|
||||
aether-wallet.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
async-stream.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64.workspace = true
|
||||
bcrypt.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dashmap = "6"
|
||||
flate2.workspace = true
|
||||
futures-util.workspace = true
|
||||
hmac.workspace = true
|
||||
http.workspace = true
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", features = ["client", "server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["client-legacy", "client-pool", "server-auto", "service", "tokio"] }
|
||||
ldap3 = { version = "0.11", default-features = false, features = ["sync", "tls-rustls"] }
|
||||
libc = "0.2"
|
||||
md-5 = "0.10"
|
||||
object_store.workspace = true
|
||||
parking_lot = "0.12"
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rsa = "0.9.10"
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2 = { workspace = true, features = ["oid"] }
|
||||
socket2.workspace = true
|
||||
tar.workspace = true
|
||||
sqlx = { workspace = true, features = ["postgres", "mysql", "sqlite", "migrate"] }
|
||||
sysinfo = "0.32"
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "compression-gzip", "set-header"] }
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
wreq.workspace = true
|
||||
wreq-util.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator = { version = "0.6", optional = true }
|
||||
tikv-jemalloc-sys = { version = "0.6", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
aether-test-support.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=AETHER_BUILD_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=AETHER_BUILD_TYPE");
|
||||
println!("cargo:rerun-if-env-changed=AETHER_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||
|
||||
let package_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string());
|
||||
let version = env::var("AETHER_BUILD_VERSION")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
.or_else(|| {
|
||||
env::var("AETHER_VERSION")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
})
|
||||
.or_else(|| {
|
||||
env::var("GITHUB_REF_NAME")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
})
|
||||
.or_else(git_describe_version)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(package_version);
|
||||
|
||||
println!("cargo:rustc-env=AETHER_BUILD_VERSION={version}");
|
||||
|
||||
let build_type = env::var("AETHER_BUILD_TYPE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "source".to_string());
|
||||
println!("cargo:rustc-env=AETHER_BUILD_TYPE={build_type}");
|
||||
}
|
||||
|
||||
fn git_describe_version() -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"describe", "--tags", "--match", "v[0-9]*", "--always", "--dirty",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let version = String::from_utf8(output.stdout).ok()?;
|
||||
let version = version.trim();
|
||||
normalize_gateway_version_source(version)
|
||||
}
|
||||
|
||||
fn normalize_gateway_version_source(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("tunnel-v") {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.strip_prefix('v').unwrap_or(trimmed).to_string())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
use aether_gateway::{serve_execution_runtime_tcp, serve_execution_runtime_unix};
|
||||
use aether_runtime::{init_service_runtime, ServiceRuntimeConfig};
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "execution-runtime-harness",
|
||||
about = "Internal execution runtime harness for Aether tests"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_TRANSPORT",
|
||||
default_value = "unix_socket"
|
||||
)]
|
||||
transport: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_BIND",
|
||||
default_value = "127.0.0.1:5219"
|
||||
)]
|
||||
bind: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_UNIX_SOCKET",
|
||||
default_value = "/tmp/aether-execution-runtime.sock"
|
||||
)]
|
||||
unix_socket: PathBuf,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_MAX_IN_FLIGHT_REQUESTS")]
|
||||
max_in_flight_requests: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LIMIT")]
|
||||
distributed_request_limit: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL")]
|
||||
distributed_request_redis_url: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
|
||||
)]
|
||||
distributed_request_redis_key_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
|
||||
default_value_t = 30_000
|
||||
)]
|
||||
distributed_request_lease_ttl_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
|
||||
default_value_t = 10_000
|
||||
)]
|
||||
distributed_request_renew_interval_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
|
||||
default_value_t = 1_000
|
||||
)]
|
||||
distributed_request_command_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
init_service_runtime(ServiceRuntimeConfig::new(
|
||||
"aether-execution-runtime-harness",
|
||||
"aether_gateway=info",
|
||||
))?;
|
||||
|
||||
let args = Args::parse();
|
||||
let distributed_request_gate = match args.distributed_request_limit.filter(|limit| *limit > 0) {
|
||||
Some(limit) => {
|
||||
let redis_url = args
|
||||
.distributed_request_redis_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
|
||||
)
|
||||
})?;
|
||||
let runtime = RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis_url.to_string(),
|
||||
key_prefix: args
|
||||
.distributed_request_redis_key_prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
)
|
||||
.await?;
|
||||
Some(runtime.semaphore(
|
||||
"execution_runtime_requests_distributed",
|
||||
limit,
|
||||
RuntimeSemaphoreConfig {
|
||||
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
||||
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
||||
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
},
|
||||
)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match args.transport.trim().to_ascii_lowercase().as_str() {
|
||||
"unix_socket" | "unix" | "uds" => {
|
||||
info!(
|
||||
socket = %args.unix_socket.display(),
|
||||
"aether execution-runtime harness started"
|
||||
);
|
||||
serve_execution_runtime_unix(
|
||||
&args.unix_socket,
|
||||
args.max_in_flight_requests,
|
||||
distributed_request_gate.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
"tcp" => {
|
||||
info!(
|
||||
bind = %args.bind,
|
||||
max_in_flight_requests = args.max_in_flight_requests.unwrap_or_default(),
|
||||
distributed_request_limit = args.distributed_request_limit.unwrap_or_default(),
|
||||
"aether execution-runtime harness started"
|
||||
);
|
||||
serve_execution_runtime_tcp(
|
||||
&args.bind,
|
||||
args.max_in_flight_requests,
|
||||
distributed_request_gate,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unsupported execution runtime transport: {other}").into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_gateway::{
|
||||
build_tunnel_runtime_router_with_state, TunnelConnConfig, TunnelControlPlaneClient,
|
||||
TunnelRuntimeState,
|
||||
};
|
||||
use aether_runtime::{init_service_runtime, ServiceRuntimeConfig};
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState};
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "aether-tunnel-runtime-harness",
|
||||
about = "Standalone tunnel relay harness backed by aether-gateway tunnel runtime"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0.0.0.0:8085",
|
||||
env = "AETHER_TUNNEL_STANDALONE_BIND"
|
||||
)]
|
||||
bind: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 15,
|
||||
env = "AETHER_TUNNEL_STANDALONE_PING_INTERVAL"
|
||||
)]
|
||||
ping_interval: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 2048,
|
||||
env = "AETHER_TUNNEL_STANDALONE_MAX_STREAMS"
|
||||
)]
|
||||
max_streams: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 512,
|
||||
env = "AETHER_TUNNEL_STANDALONE_OUTBOUND_QUEUE_CAPACITY"
|
||||
)]
|
||||
outbound_queue_capacity: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "http://127.0.0.1:8084",
|
||||
env = "AETHER_TUNNEL_STANDALONE_APP_BASE_URL"
|
||||
)]
|
||||
app_base_url: String,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_MAX_IN_FLIGHT_REQUESTS")]
|
||||
max_in_flight_requests: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LIMIT")]
|
||||
distributed_request_limit: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL")]
|
||||
distributed_request_redis_url: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
|
||||
)]
|
||||
distributed_request_redis_key_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
|
||||
default_value_t = 30_000
|
||||
)]
|
||||
distributed_request_lease_ttl_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
|
||||
default_value_t = 10_000
|
||||
)]
|
||||
distributed_request_renew_interval_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
|
||||
default_value_t = 1_000
|
||||
)]
|
||||
distributed_request_command_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_service_runtime(ServiceRuntimeConfig::new(
|
||||
"aether-tunnel-standalone",
|
||||
"aether_gateway=info",
|
||||
))?;
|
||||
|
||||
let args = Args::parse();
|
||||
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
|
||||
let ping_interval = Duration::from_secs(args.ping_interval);
|
||||
let mut state = TunnelRuntimeState::new(
|
||||
TunnelControlPlaneClient::new(args.app_base_url),
|
||||
TunnelConnConfig {
|
||||
ping_interval,
|
||||
idle_timeout: Duration::from_secs(0),
|
||||
outbound_queue_capacity,
|
||||
},
|
||||
args.max_streams,
|
||||
)
|
||||
.with_request_concurrency_limit(args.max_in_flight_requests);
|
||||
|
||||
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
|
||||
let redis_url = args
|
||||
.distributed_request_redis_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
|
||||
)
|
||||
})?;
|
||||
let runtime = RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis_url.to_string(),
|
||||
key_prefix: args
|
||||
.distributed_request_redis_key_prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
)
|
||||
.await?;
|
||||
state = state.with_distributed_request_gate(runtime.semaphore(
|
||||
"tunnel_requests_distributed",
|
||||
limit,
|
||||
RuntimeSemaphoreConfig {
|
||||
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
||||
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
||||
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
|
||||
let app = build_tunnel_runtime_router_with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||
info!(bind = %args.bind, "tunnel runtime harness started");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
pub(crate) use crate::handlers::admin::{
|
||||
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
||||
build_internal_control_error_response, create_provider_oauth_catalog_key,
|
||||
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
|
||||
maybe_build_local_admin_response, persist_provider_quota_refresh_state,
|
||||
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,
|
||||
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh,
|
||||
reconcile_admin_fixed_provider_template_endpoints,
|
||||
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
|
||||
store_admin_provider_ops_balance_cache, update_existing_provider_oauth_catalog_key,
|
||||
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
|
||||
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
|
||||
AdminStatsTimeRange, AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
|
||||
use crate::handlers::admin::{
|
||||
admin_stats_bad_request_response as admin_stats_bad_request_response_impl,
|
||||
parse_bounded_u32 as parse_bounded_u32_impl, round_to as round_to_impl,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_security_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_security_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_endpoint_health_status_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
lookback_hours: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
crate::handlers::admin::build_admin_endpoint_health_status_payload(state, lookback_hours).await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_video_tasks_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_video_tasks_response(state, request_context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_usage_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_usage_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn admin_stats_bad_request_response(detail: String) -> Response<Body> {
|
||||
admin_stats_bad_request_response_impl(detail)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_bounded_u32(
|
||||
field: &str,
|
||||
value: &str,
|
||||
min: u32,
|
||||
max: u32,
|
||||
) -> Result<u32, String> {
|
||||
parse_bounded_u32_impl(field, value, min, max)
|
||||
}
|
||||
|
||||
pub(crate) fn round_to(value: f64, decimals: u32) -> f64 {
|
||||
round_to_impl(value, decimals)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_provider_oauth_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_providers_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_providers_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub(crate) mod private_envelope;
|
||||
|
||||
pub(crate) mod kiro {
|
||||
pub(crate) use crate::ai_serving::pure::KiroToClaudeCliStreamState;
|
||||
}
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||
};
|
||||
pub(crate) use kiro::KiroToClaudeCliStreamState;
|
||||
pub(crate) use private_envelope::{
|
||||
maybe_build_provider_private_stream_normalizer,
|
||||
maybe_normalize_provider_private_sync_report_payload,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
#[path = "private_envelope/sync.rs"]
|
||||
mod sync;
|
||||
|
||||
pub(crate) use self::sync::maybe_normalize_provider_private_sync_report_payload;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
use super::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_normalize_provider_private_sync_report_payload(
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<GatewaySyncReportRequest>, GatewayError> {
|
||||
let Some(report_context) = payload.report_context.as_ref() else {
|
||||
return Ok(Some(payload.clone()));
|
||||
};
|
||||
if !report_context
|
||||
.get("has_envelope")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(Some(payload.clone()));
|
||||
}
|
||||
if !provider_private_response_allows_sync_finalize(report_context) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut normalized = payload.clone();
|
||||
normalized.report_context = normalize_provider_private_report_context(Some(report_context));
|
||||
if let (Some(body_json), Some(context)) = (
|
||||
payload.body_json.as_ref(),
|
||||
normalized.report_context.as_mut(),
|
||||
) {
|
||||
maybe_attach_gemini_cli_v1internal_credits_context(report_context, body_json, context);
|
||||
}
|
||||
|
||||
if let Some(body_json) = payload.body_json.clone() {
|
||||
normalized.body_json = normalize_provider_private_response_value(body_json, report_context);
|
||||
if normalized.body_json.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(body_base64) = payload.body_base64.as_deref() {
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let Some(normalized_bytes) =
|
||||
normalize_provider_private_stream_bytes(report_context, &body_bytes)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if stream_body_contains_error_event(&normalized_bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
normalized.body_base64 = (!normalized_bytes.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(normalized_bytes));
|
||||
}
|
||||
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
fn maybe_attach_gemini_cli_v1internal_credits_context(
|
||||
original_report_context: &Value,
|
||||
body_json: &Value,
|
||||
normalized_report_context: &mut Value,
|
||||
) {
|
||||
if !original_report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("gemini_cli:v1internal"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut credits = serde_json::Map::new();
|
||||
for (source, target) in [
|
||||
("remainingCredits", "remainingCredits"),
|
||||
("consumedCredits", "consumedCredits"),
|
||||
("traceId", "traceId"),
|
||||
] {
|
||||
if let Some(value) = body_json
|
||||
.get(source)
|
||||
.cloned()
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
credits.insert(target.to_string(), value);
|
||||
}
|
||||
}
|
||||
if credits.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(object) = normalized_report_context.as_object_mut() {
|
||||
object.insert(
|
||||
"gemini_cli_v1internal_credits".to_string(),
|
||||
Value::Object(credits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider_private_stream_bytes(
|
||||
report_context: &Value,
|
||||
body: &[u8],
|
||||
) -> Result<Option<Vec<u8>>, GatewayError> {
|
||||
let Some(mut normalizer): Option<ProviderPrivateStreamNormalizer<'_>> =
|
||||
maybe_build_provider_private_stream_normalizer(Some(report_context))
|
||||
else {
|
||||
return Ok(Some(body.to_vec()));
|
||||
};
|
||||
let mut normalized = normalizer.push_chunk(body).map_err(GatewayError::from)?;
|
||||
normalized.extend(normalizer.finish().map_err(GatewayError::from)?);
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use crate::ai_serving::{is_json_request, GatewayControlDecision};
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_attempt_source_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
build_local_image_stream_attempt_source_for_kind,
|
||||
build_local_image_stream_plan_and_reports_for_kind,
|
||||
build_local_image_sync_attempt_source_for_kind,
|
||||
build_local_image_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_stream_attempt_source_for_kind,
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_attempt_source_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_stream_attempt_source_for_kind,
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_attempt_source_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind,
|
||||
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
|
||||
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
|
||||
build_local_video_sync_attempt_source_for_kind,
|
||||
build_local_video_sync_plan_and_reports_for_kind,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
build_standard_family_stream_attempt_source, build_standard_family_stream_plan_and_reports,
|
||||
build_standard_family_sync_attempt_source, build_standard_family_sync_plan_and_reports,
|
||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
|
||||
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
|
||||
set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
set_local_openai_image_execution_exhausted_diagnostic,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
maybe_bridge_standard_sync_json_to_stream, maybe_build_provider_private_stream_normalizer,
|
||||
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
|
||||
maybe_compile_sync_finalize_response, LocalCoreSyncFinalizeOutcome,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
AiExecutionDecision, AiExecutionPlanPayload, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
pub(crate) use aether_ai_formats::api::{
|
||||
build_core_error_body_for_client_format, convert_standard_chat_response,
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, encode_kiro_sse_events,
|
||||
extract_provider_private_stream_error_body, implicit_sync_finalize_report_kind,
|
||||
is_core_error_finalize_kind, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
resolve_claude_stream_spec, resolve_claude_sync_spec, resolve_gemini_stream_spec,
|
||||
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
|
||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query, AiControlPlanRequest,
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter,
|
||||
ExecutionRuntimeAuthContext, LocalCoreSyncErrorKind, LocalOpenAiImageSpec,
|
||||
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
|
||||
LocalStandardSourceMode, LocalStandardSpec, OpenAIChatClientEmitter,
|
||||
OpenAIResponsesClientEmitter, StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
pub(crate) use aether_ai_formats::protocol::stream::CanonicalUsage as StreamingCanonicalUsage;
|
||||
pub(crate) use aether_ai_formats::CODEX_RESPONSES_LITE_HEADER;
|
||||
|
||||
pub(crate) fn parse_direct_request_body(
|
||||
parts: &http::request::Parts,
|
||||
body_bytes: &axum::body::Bytes,
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
let is_json_request = is_json_request(&parts.headers);
|
||||
let body_bytes = if is_json_request {
|
||||
crate::ai_serving::decoded_request_body_bytes(&parts.headers, body_bytes.as_ref()).ok()?
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(body_bytes.as_ref())
|
||||
};
|
||||
aether_ai_formats::api::parse_direct_request_body(is_json_request, body_bytes.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> bool {
|
||||
crate::ai_serving::planner_is_matching_stream_request(plan_kind, parts, body_json, body_base64)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
aether_ai_formats::api::supports_sync_execution_decision_kind(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
aether_ai_formats::api::supports_stream_execution_decision_kind(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_openai_chat_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_openai_responses_stream_sync_response(
|
||||
body: &[u8],
|
||||
) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_openai_responses_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_claude_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_gemini_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn gemini_generate_content_response_has_visible_output(
|
||||
body: &serde_json::Value,
|
||||
) -> bool {
|
||||
if aether_ai_formats::formats::gemini::generate_content::response::from_raw(body).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
openai_chat_response_has_visible_output(body) || openai_responses_body_has_visible_output(body)
|
||||
}
|
||||
|
||||
fn openai_chat_response_has_visible_output(body: &serde_json::Value) -> bool {
|
||||
body.get("choices")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|choices| {
|
||||
choices.iter().any(|choice| {
|
||||
choice
|
||||
.get("message")
|
||||
.or_else(|| choice.get("delta"))
|
||||
.is_some_and(message_like_value_has_visible_output)
|
||||
|| value_has_non_empty_text(choice.get("text"))
|
||||
|| choice
|
||||
.get("finish_reason")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty() && value != "length")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_body_has_visible_output(body: &serde_json::Value) -> bool {
|
||||
body.get("output")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "function_call" | "image_generation_call"))
|
||||
|| item
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(response_content_has_visible_output)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn message_like_value_has_visible_output(value: &serde_json::Value) -> bool {
|
||||
value_has_non_empty_text(value.get("content"))
|
||||
|| value
|
||||
.get("tool_calls")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| !items.is_empty())
|
||||
}
|
||||
|
||||
fn response_content_has_visible_output(value: &serde_json::Value) -> bool {
|
||||
value_has_non_empty_text(value.get("text"))
|
||||
|| value
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "function_call" | "output_image"))
|
||||
}
|
||||
|
||||
fn value_has_non_empty_text(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::String(text)) => !text.trim().is_empty(),
|
||||
Some(serde_json::Value::Array(items)) => items.iter().any(|item| {
|
||||
value_has_non_empty_text(item.get("text"))
|
||||
|| value_has_non_empty_text(item.get("content"))
|
||||
|| item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "image_url" | "input_image"))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_direct_request_body;
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{header, Request};
|
||||
|
||||
#[test]
|
||||
fn parse_direct_request_body_reads_zstd_encoded_json_body() {
|
||||
let (parts, _) = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/responses")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::CONTENT_ENCODING, "zstd")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts();
|
||||
let encoded =
|
||||
zstd::stream::encode_all(br#"{"model":"gpt-5.4","stream":true}"#.as_slice(), 0)
|
||||
.expect("zstd body should encode");
|
||||
|
||||
let (body_json, body_base64) =
|
||||
parse_direct_request_body(&parts, &Bytes::from(encoded)).expect("body should parse");
|
||||
|
||||
assert_eq!(body_json["model"].as_str(), Some("gpt-5.4"));
|
||||
assert_eq!(body_json["stream"].as_bool(), Some(true));
|
||||
assert!(body_base64.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) use crate::ai_serving::api::{
|
||||
normalize_provider_private_response_value as unwrap_local_finalize_response_value,
|
||||
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
build_generated_tool_call_id,
|
||||
build_local_success_background_report as build_local_success_background_report_impl,
|
||||
build_local_success_conversion_background_report as build_local_success_conversion_background_report_impl,
|
||||
canonicalize_tool_arguments,
|
||||
prepare_local_success_response_parts as prepare_local_success_response_parts_impl,
|
||||
GatewayControlDecision, LocalSyncReportParts,
|
||||
};
|
||||
use crate::api::response::build_client_response_from_parts;
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
pub(crate) struct LocalCoreSyncFinalizeOutcome {
|
||||
pub(crate) response: Response<Body>,
|
||||
pub(crate) background_report: Option<GatewaySyncReportRequest>,
|
||||
}
|
||||
|
||||
fn build_local_success_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
status_code: u16,
|
||||
body_bytes: Vec<u8>,
|
||||
headers: BTreeMap<String, String>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
build_client_response_from_parts(
|
||||
status_code,
|
||||
&headers,
|
||||
Body::from(body_bytes),
|
||||
trace_id,
|
||||
Some(decision),
|
||||
)
|
||||
}
|
||||
|
||||
fn surface_report_parts_from_gateway(payload: &GatewaySyncReportRequest) -> LocalSyncReportParts {
|
||||
LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: payload.report_kind.clone(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers: payload.headers.clone(),
|
||||
body_json: payload.body_json.clone(),
|
||||
client_body_json: payload.client_body_json.clone(),
|
||||
body_base64: payload.body_base64.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_report_from_surface(
|
||||
source: &GatewaySyncReportRequest,
|
||||
report: LocalSyncReportParts,
|
||||
) -> GatewaySyncReportRequest {
|
||||
GatewaySyncReportRequest {
|
||||
trace_id: report.trace_id,
|
||||
report_kind: report.report_kind,
|
||||
report_context: report.report_context,
|
||||
status_code: report.status_code,
|
||||
headers: report.headers,
|
||||
body_json: report.body_json,
|
||||
client_body_json: report.client_body_json,
|
||||
body_base64: report.body_base64,
|
||||
telemetry: source.telemetry.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
body_json: Value,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let report_headers = payload.headers.clone();
|
||||
let (body_bytes, response_headers) =
|
||||
prepare_local_success_response_parts_impl(&payload.headers, &body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let surface_payload = surface_report_parts_from_gateway(payload);
|
||||
let background_report =
|
||||
build_local_success_background_report_impl(&surface_payload, body_json, report_headers)
|
||||
.map(|report| gateway_report_from_surface(payload, report));
|
||||
build_local_success_outcome_with_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload.status_code,
|
||||
body_bytes,
|
||||
response_headers,
|
||||
background_report,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome_with_report(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
status_code: u16,
|
||||
body_bytes: Vec<u8>,
|
||||
headers: BTreeMap<String, String>,
|
||||
background_report: Option<GatewaySyncReportRequest>,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let response =
|
||||
build_local_success_response(trace_id, decision, status_code, body_bytes, headers)?;
|
||||
Ok(LocalCoreSyncFinalizeOutcome {
|
||||
response,
|
||||
background_report,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome_with_conversion_report(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
client_body_json: Value,
|
||||
provider_body_json: Value,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let (body_bytes, response_headers) =
|
||||
prepare_local_success_response_parts_impl(&payload.headers, &client_body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let surface_payload = surface_report_parts_from_gateway(payload);
|
||||
let report_payload = build_local_success_conversion_background_report_impl(
|
||||
&surface_payload,
|
||||
client_body_json,
|
||||
provider_body_json,
|
||||
)
|
||||
.map(|report| gateway_report_from_surface(payload, report));
|
||||
|
||||
build_local_success_outcome_with_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload.status_code,
|
||||
body_bytes,
|
||||
response_headers,
|
||||
report_payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
#[path = "stream_rewrite.rs"]
|
||||
pub(crate) mod stream;
|
||||
#[path = "sync_finalize.rs"]
|
||||
pub(crate) mod sync;
|
||||
#[path = "sync_to_stream.rs"]
|
||||
pub(crate) mod sync_to_stream;
|
||||
|
||||
pub(crate) use stream::LocalStreamRewriter;
|
||||
pub(crate) use sync::LocalCoreSyncFinalizeOutcome;
|
||||
pub(crate) use sync_to_stream::{
|
||||
maybe_bridge_standard_sync_json_to_stream, SyncToStreamBridgeOutcome,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_build_sync_finalize_outcome(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
sync::maybe_build_local_core_sync_finalize_response(trace_id, decision, payload)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_compile_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
Ok(
|
||||
maybe_build_sync_finalize_outcome(trace_id, decision, payload)?
|
||||
.map(|outcome| outcome.response),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_build_stream_response_rewriter(
|
||||
report_context: Option<&Value>,
|
||||
) -> Option<LocalStreamRewriter<'_>> {
|
||||
stream::maybe_build_local_stream_rewriter(report_context)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{
|
||||
maybe_build_ai_surface_stream_rewriter, AiSurfaceFinalizeError, AiSurfaceStreamRewriter,
|
||||
ResponseHistoryRecord,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
pub(crate) struct LocalStreamRewriter<'a> {
|
||||
inner: AiSurfaceStreamRewriter<'a>,
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_build_local_stream_rewriter<'a>(
|
||||
report_context: Option<&'a Value>,
|
||||
) -> Option<LocalStreamRewriter<'a>> {
|
||||
maybe_build_ai_surface_stream_rewriter(report_context)
|
||||
.map(|inner| LocalStreamRewriter { inner })
|
||||
}
|
||||
|
||||
impl LocalStreamRewriter<'_> {
|
||||
pub(crate) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> {
|
||||
self.inner.push_chunk(chunk).map_err(map_surface_error)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
||||
self.inner.finish().map_err(map_surface_error)
|
||||
}
|
||||
|
||||
pub(crate) fn take_response_history_record(&mut self) -> Option<ResponseHistoryRecord> {
|
||||
self.inner.take_response_history_record()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_surface_error(error: AiSurfaceFinalizeError) -> GatewayError {
|
||||
error.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests_stream.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
pub(crate) use crate::ai_serving::finalize::common::{
|
||||
build_local_success_outcome, build_local_success_outcome_with_conversion_report,
|
||||
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
||||
LocalCoreSyncFinalizeOutcome,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::finalize::standard::{
|
||||
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||
StandardSyncFinalizeNormalizedProduct,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
|
||||
maybe_build_openai_image_sync_finalize_product,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_claude_response_to_openai_responses,
|
||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_response_to_openai_responses,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_build_local_core_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
if let Some(outcome) =
|
||||
maybe_build_local_openai_image_sync_finalize_response(trace_id, decision, payload)?
|
||||
{
|
||||
return Ok(Some(outcome));
|
||||
}
|
||||
|
||||
let Some(normalized_payload) =
|
||||
crate::ai_serving::adaptation::private_envelope::maybe_normalize_provider_private_sync_report_payload(payload)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = &normalized_payload;
|
||||
let Some(report_context) = payload.report_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !local_finalize_allows_envelope(report_context) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(product) = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
payload.report_kind.as_str(),
|
||||
payload.status_code,
|
||||
Some(report_context),
|
||||
payload.body_json.as_ref(),
|
||||
payload.body_base64.as_deref(),
|
||||
)
|
||||
.map_err(GatewayError::from)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match product {
|
||||
StandardSyncFinalizeNormalizedProduct::SuccessBody(body_json) => {
|
||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(build_local_success_outcome(
|
||||
trace_id, decision, payload, body_json,
|
||||
)?))
|
||||
}
|
||||
StandardSyncFinalizeNormalizedProduct::CrossFormat(product) => {
|
||||
let Some(provider_body_json) =
|
||||
unwrap_local_finalize_response_value(product.provider_body_json, report_context)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload,
|
||||
product.client_body_json,
|
||||
provider_body_json,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_build_local_openai_image_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
let Some(product) = maybe_build_openai_image_sync_finalize_product(
|
||||
payload.report_kind.as_str(),
|
||||
payload.status_code,
|
||||
payload.report_context.as_ref(),
|
||||
payload.body_json.as_ref(),
|
||||
payload.body_base64.as_deref(),
|
||||
)
|
||||
.map_err(GatewayError::from)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload,
|
||||
product.client_body_json,
|
||||
product.provider_body_json,
|
||||
)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests_sync.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
pub(crate) use crate::ai_serving::pure::SyncToStreamBridgeOutcome;
|
||||
|
||||
pub(crate) fn maybe_bridge_standard_sync_json_to_stream(
|
||||
provider_body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
report_context: Option<&Value>,
|
||||
) -> Result<Option<SyncToStreamBridgeOutcome>, GatewayError> {
|
||||
crate::ai_serving::pure::maybe_bridge_standard_sync_json_to_stream(
|
||||
provider_body_json,
|
||||
provider_api_format,
|
||||
client_api_format,
|
||||
report_context,
|
||||
)
|
||||
.map_err(GatewayError::from)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod common;
|
||||
pub(crate) mod internal;
|
||||
pub(crate) mod sse;
|
||||
pub(crate) mod standard;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{
|
||||
encode_done_sse, encode_json_sse as encode_json_sse_impl, map_claude_stop_reason,
|
||||
AiSurfaceFinalizeError,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
fn map_error(err: AiSurfaceFinalizeError) -> GatewayError {
|
||||
err.into()
|
||||
}
|
||||
|
||||
pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
|
||||
encode_json_sse_impl(event, value).map_err(map_error)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Standard finalize surface for standard contract sync/stream compilation.
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||
build_openai_responses_response, convert_claude_chat_response_to_openai_chat,
|
||||
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
|
||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat, convert_standard_chat_response,
|
||||
convert_standard_cli_response,
|
||||
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_cross_format_sync_product,
|
||||
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user