Compare commits
704 Commits
release/2.
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cc29fa3c | ||
|
|
5e2eb667b4 | ||
|
|
1f9c9b082f | ||
|
|
722c1875af | ||
|
|
68471d0225 | ||
|
|
a6900545b0 | ||
|
|
808ceba848 | ||
|
|
a796a03a15 | ||
|
|
5a5dc67209 | ||
|
|
69ae54b523 | ||
|
|
b405227d51 | ||
|
|
44be39a9a4 | ||
|
|
5de0cc199c | ||
|
|
0c9e408eda | ||
|
|
1007f1f740 | ||
|
|
774e3d5948 | ||
|
|
4d866d066a | ||
|
|
da6544e981 | ||
|
|
3af9a7646d | ||
|
|
0e2cf82e3e | ||
|
|
97e69b9887 | ||
|
|
692f91263b | ||
|
|
8b61d8a9d2 | ||
|
|
25d51f9515 | ||
|
|
20b971dc1f | ||
|
|
7a76d749e3 | ||
|
|
123afd9462 | ||
|
|
ad83478b77 | ||
|
|
2ad0a65613 | ||
|
|
1f5762b8c8 | ||
|
|
0370b09ad0 | ||
|
|
5869a8948d | ||
|
|
56a840e207 | ||
|
|
a01dd005fd | ||
|
|
9ad6c16d43 | ||
|
|
9cc3e16db9 | ||
|
|
d02bcdba29 | ||
|
|
c708fe577c | ||
|
|
c92161bb22 | ||
|
|
138aa13fdc | ||
|
|
988a795def | ||
|
|
3f7a3053ff | ||
|
|
0c8c6865be | ||
|
|
2bbcae39b6 | ||
|
|
caf6b2aa0c | ||
|
|
a00f05fe32 | ||
|
|
9fcac1ab4f | ||
|
|
ae24ad4693 | ||
|
|
0f721b60a9 | ||
|
|
e8b49f53e1 | ||
|
|
27531a802b | ||
|
|
4bbf0ce0c0 | ||
|
|
e0c22ea3eb | ||
|
|
b7eb2ba068 | ||
|
|
affdb69568 | ||
|
|
763b7da65c | ||
|
|
42e9165347 | ||
|
|
16dd08a359 | ||
|
|
936494615c | ||
|
|
5769c0b98e | ||
|
|
b7e1caa8c6 | ||
|
|
e02ae6b2fb | ||
|
|
d9f131a2c5 | ||
|
|
ad1f7dbaa5 | ||
|
|
aa6da0f6d3 | ||
|
|
376071e408 | ||
|
|
d3544fb9b3 | ||
|
|
c8497b3944 | ||
|
|
5aa92b8413 | ||
|
|
bccb6694d4 | ||
|
|
506a11c658 | ||
|
|
bdc315a59d | ||
|
|
ec7d3bddfc | ||
|
|
762c1ccf28 | ||
|
|
8e44c8fa06 | ||
|
|
20db102327 | ||
|
|
1643cb8165 | ||
|
|
49e623dfeb | ||
|
|
a1208974ac | ||
|
|
d611087513 | ||
|
|
ac7cb2ee19 | ||
|
|
f866572cbf | ||
|
|
4c6942f60b | ||
|
|
d939897524 | ||
|
|
66c5589fd7 | ||
|
|
379b1d611b | ||
|
|
f16221f385 | ||
|
|
9b82560270 | ||
|
|
7271af03e6 | ||
|
|
4d564bbce2 | ||
|
|
d7afdf214b | ||
|
|
18e445ea02 | ||
|
|
cb70c705a3 | ||
|
|
9a77eb9872 | ||
|
|
ec82f646a0 | ||
|
|
2f0e384240 | ||
|
|
19a1426869 | ||
|
|
cc5cd8db6b | ||
|
|
e384e2edda | ||
|
|
dca044873f | ||
|
|
8aadddcc68 | ||
|
|
2e95229c51 | ||
|
|
8a1d02c23f | ||
|
|
d6bca4ea79 | ||
|
|
7b567a66ed | ||
|
|
2c8126e244 | ||
|
|
1b70fe5770 | ||
|
|
71c000756b | ||
|
|
a2a7ead82a | ||
|
|
ef0f1b10cc | ||
|
|
42bedce9c0 | ||
|
|
afcd44abad | ||
|
|
274830f533 | ||
|
|
9cb139d190 | ||
|
|
d681481ae9 | ||
|
|
5d377e602f | ||
|
|
f535c814d9 | ||
|
|
4f5073cd9e | ||
|
|
9cd2340007 | ||
|
|
9ca036e393 | ||
|
|
5340ecb6df | ||
|
|
1248d52161 | ||
|
|
3e2fdb1891 | ||
|
|
ac8fa7672e | ||
|
|
db57716130 | ||
|
|
b162814bd9 | ||
|
|
a889d57013 | ||
|
|
c6e9cdbf35 | ||
|
|
2a00d90134 | ||
|
|
2676cd7219 | ||
|
|
4f76b1fda4 | ||
|
|
1c56d5c59e | ||
|
|
be44eedeb8 | ||
|
|
36296d2f5d | ||
|
|
b4db75fb55 | ||
|
|
565c36040d | ||
|
|
36e7f821e8 | ||
|
|
009e1e25f5 | ||
|
|
69715ed1c8 | ||
|
|
e8cee12384 | ||
|
|
f2fd2c157c | ||
|
|
3f6cee5ded | ||
|
|
b1cb95c3b0 | ||
|
|
372bc3c97c | ||
|
|
fa684f95e0 | ||
|
|
e8fb8a6f88 | ||
|
|
93901336bb | ||
|
|
660f2095af | ||
|
|
13b27cf77a | ||
|
|
d1eb5a8466 | ||
|
|
5d0aefb07a | ||
|
|
78a23bb722 | ||
|
|
38c42cb47b | ||
|
|
c9c779d5d5 | ||
|
|
dabfd4249e | ||
|
|
e62db5f1d9 | ||
|
|
50c01c97ee | ||
|
|
68600dddf0 | ||
|
|
c80464d072 | ||
|
|
02a083fa02 | ||
|
|
36ff24c301 | ||
|
|
935f3b8754 | ||
|
|
eac9f649cf | ||
|
|
8bcd27e042 | ||
|
|
c3dbf51a16 | ||
|
|
36417a0726 | ||
|
|
20b87f8bb9 | ||
|
|
a1bac5a133 | ||
|
|
177da24e47 | ||
|
|
37ba8d17bf | ||
|
|
ee8b78fd3c | ||
|
|
83bc685e75 | ||
|
|
3781897e39 | ||
|
|
0efed6d8d3 | ||
|
|
8f2c33aec3 | ||
|
|
433b5bc974 | ||
|
|
aef27f475d | ||
|
|
28ccf19874 | ||
|
|
7e54f40033 | ||
|
|
bf8ccbcec6 | ||
|
|
2f5b083c5c | ||
|
|
5640e8c11a | ||
|
|
c239445454 | ||
|
|
a7b7ddbe76 | ||
|
|
d859272d43 | ||
|
|
d59a16a9a1 | ||
|
|
79f524865f | ||
|
|
6d0a09402b | ||
|
|
4bb160b281 | ||
|
|
24d27f421b | ||
|
|
3d0b8ec5f0 | ||
|
|
79e6271041 | ||
|
|
ecac526810 | ||
|
|
ad8d5a8694 | ||
|
|
2406d67bfc | ||
|
|
f0266e9316 | ||
|
|
c08f42315e | ||
|
|
d2649dac90 | ||
|
|
300681055e | ||
|
|
712dbc9396 | ||
|
|
f6b8e8615f | ||
|
|
4826c13848 | ||
|
|
80f497a185 | ||
|
|
d2a9adb4be | ||
|
|
8675086441 | ||
|
|
b79e784764 | ||
|
|
93ba3e700e | ||
|
|
bf6cb8d0b8 | ||
|
|
7010d7bf66 | ||
|
|
1a862157a0 | ||
|
|
532575cab5 | ||
|
|
0794d0f89f | ||
|
|
e227ffd6d8 | ||
|
|
5058b40871 | ||
|
|
5d847b59b2 | ||
|
|
c8d44b9416 | ||
|
|
14d67d1ec7 | ||
|
|
6866faf4fe | ||
|
|
567d628a52 | ||
|
|
a3eab75405 | ||
|
|
566f6b067c | ||
|
|
e73d07281c | ||
|
|
e59d4dea77 | ||
|
|
4ca5370b86 | ||
|
|
e831971dd1 | ||
|
|
99d996dde9 | ||
|
|
712d31b416 | ||
|
|
0394855b2f | ||
|
|
9024b021ee | ||
|
|
8071641179 | ||
|
|
0075374241 | ||
|
|
c35ddc8c76 | ||
|
|
4b4aef7ef8 | ||
|
|
6db4a62e01 | ||
|
|
db394b6145 | ||
|
|
53e7704724 | ||
|
|
f607c7c271 | ||
|
|
48c689e5d6 | ||
|
|
2f2251ff33 | ||
|
|
29254d1a66 | ||
|
|
19cbae1732 | ||
|
|
73ad27640c | ||
|
|
1be96e1bd1 | ||
|
|
a9834be2ff | ||
|
|
d8ab86d86f | ||
|
|
3f1bd8e290 | ||
|
|
34a7d75e10 | ||
|
|
ae53de42df | ||
|
|
b70321a0aa | ||
|
|
0ff39f9a61 | ||
|
|
876ba0fa0f | ||
|
|
c7c65d2f97 | ||
|
|
736f7e198f | ||
|
|
8cb3589fb8 | ||
|
|
56530d8791 | ||
|
|
da6b0e3dcc | ||
|
|
eb02f99cae | ||
|
|
cb0efae81c | ||
|
|
e5f98e6145 | ||
|
|
8a23007ad2 | ||
|
|
592b196848 | ||
|
|
8eb273e54b | ||
|
|
78c7e752f9 | ||
|
|
7c51a3b5ff | ||
|
|
3e77db4cee | ||
|
|
c1c831fea3 | ||
|
|
6734eab555 | ||
|
|
6ecfbf17c0 | ||
|
|
42fe068db7 | ||
|
|
6b3db56ab2 | ||
|
|
eee15d5ff2 | ||
|
|
7a618311d6 | ||
|
|
7dba9ff885 | ||
|
|
4c9c292316 | ||
|
|
00613efbd8 | ||
|
|
b7384874cf | ||
|
|
c8ee2ca4a1 | ||
|
|
f97bb4a439 | ||
|
|
d83b349016 | ||
|
|
657cd04af2 | ||
|
|
24a092836b | ||
|
|
290374f6fc | ||
|
|
2e7acc73d8 | ||
|
|
666d51482e | ||
|
|
eedf37d18a | ||
|
|
16f210966b | ||
|
|
30e70b6327 | ||
|
|
f91a2e3b65 | ||
|
|
fdc405c912 | ||
|
|
2f2e70bb86 | ||
|
|
eef54f4153 | ||
|
|
ad1c015f01 | ||
|
|
326fdcf6ea | ||
|
|
26a0c4e809 | ||
|
|
acb465ae33 | ||
|
|
5418a0bee6 | ||
|
|
a59815264d | ||
|
|
3ac0be4e35 | ||
|
|
feae930293 | ||
|
|
7ebb52ec6d | ||
|
|
8b73ad3b6f | ||
|
|
6fc2a8234d | ||
|
|
e2c2724e36 | ||
|
|
6abfbe8553 | ||
|
|
54f6add45d | ||
|
|
f8ae5368bf | ||
|
|
2ba348551d | ||
|
|
110f88f22d | ||
|
|
c90a15dd0f | ||
|
|
f4335e1e72 | ||
|
|
8d9e1a0ad5 | ||
|
|
48dcfcb08f | ||
|
|
def19be230 | ||
|
|
36154e9d33 | ||
|
|
7cf6bb78d6 | ||
|
|
541f281b29 | ||
|
|
965ef5246b | ||
|
|
9c88057bd1 | ||
|
|
8c52e92705 | ||
|
|
3a727d24ce | ||
|
|
185558a642 | ||
|
|
35aa525bd2 | ||
|
|
2ce8788487 | ||
|
|
ec0e98a64b | ||
|
|
121e9f03a4 | ||
|
|
a0295b1a39 | ||
|
|
30aba86380 | ||
|
|
89f5a20786 | ||
|
|
ef7caa260b | ||
|
|
39d50ef70e | ||
|
|
58a1392480 | ||
|
|
06f6bcc340 | ||
|
|
c9d18b614b | ||
|
|
2035c42c3c | ||
|
|
a760426b87 | ||
|
|
10b129a02e | ||
|
|
129b9d5db9 | ||
|
|
2c08becf6c | ||
|
|
a3bfe7cb0c | ||
|
|
7049a8a2bb | ||
|
|
1197b1dd8d | ||
|
|
7f167ff2fc | ||
|
|
3ade5cdf19 | ||
|
|
5f6fa4d79f | ||
|
|
3ee20863d6 | ||
|
|
8fe5eaee29 | ||
|
|
208534c9d9 | ||
|
|
3f030394c6 | ||
|
|
6ca0085ec8 | ||
|
|
2cf1649c67 | ||
|
|
64ed988169 | ||
|
|
85b7e881eb | ||
|
|
9325cb2872 | ||
|
|
e39dcc458b | ||
|
|
84b4b30f21 | ||
|
|
6c47598cd9 | ||
|
|
d00d71ecbf | ||
|
|
dc273b2d63 | ||
|
|
497b16e942 | ||
|
|
a472de1919 | ||
|
|
d306d7a983 | ||
|
|
163aa57e5c | ||
|
|
3eab294908 | ||
|
|
da30780ac2 | ||
|
|
ef53354193 | ||
|
|
e9ce3d2213 | ||
|
|
a46db61c4c | ||
|
|
5e271fd4a4 | ||
|
|
6481483074 | ||
|
|
7bcb37c761 | ||
|
|
e7d97d7a2b | ||
|
|
1afae99345 | ||
|
|
bdb2e2f417 | ||
|
|
bba3751268 | ||
|
|
60bc04bc33 | ||
|
|
a4cff13531 | ||
|
|
937456596a | ||
|
|
caf382b64c | ||
|
|
55cc250d2e | ||
|
|
eaa2be017d | ||
|
|
4e4c5ffdb6 | ||
|
|
383bcc4113 | ||
|
|
9f906b7417 | ||
|
|
db2e168540 | ||
|
|
2697d6c5d7 | ||
|
|
b6a6ce9aaf | ||
|
|
89f6a94bd8 | ||
|
|
96f2d69ae5 | ||
|
|
b7e906701a | ||
|
|
150d986179 | ||
|
|
ef10ea2a7d | ||
|
|
3bf84e8b0c | ||
|
|
ea4b334c7e | ||
|
|
4d11aa8655 | ||
|
|
302deb8299 | ||
|
|
0c80b1067d | ||
|
|
0a36d4fbfd | ||
|
|
c20a8b5a68 | ||
|
|
8ffe4e284a | ||
|
|
1332f718ae | ||
|
|
f4df51884c | ||
|
|
ce86129478 | ||
|
|
097b125e3a | ||
|
|
5c6b53922a | ||
|
|
e1b9f23f73 | ||
|
|
e1c480d3c3 | ||
|
|
363a62d885 | ||
|
|
c6ee9a5a52 | ||
|
|
cf5990ccba | ||
|
|
b6f3682a62 | ||
|
|
b43f864511 | ||
|
|
0556ffb4a1 | ||
|
|
303047656e | ||
|
|
8d29b5ae71 | ||
|
|
7d7ae24351 | ||
|
|
97838e614d | ||
|
|
c897baad20 | ||
|
|
d51e9205d9 | ||
|
|
e051c86bb5 | ||
|
|
c2b48cd003 | ||
|
|
a7009eb8d5 | ||
|
|
036b87b649 | ||
|
|
f07a3b1875 | ||
|
|
6e89ccc0ae | ||
|
|
cc67612432 | ||
|
|
17ebe221bb | ||
|
|
1963edda66 | ||
|
|
c9e3717ce3 | ||
|
|
9a85246631 | ||
|
|
75f165d1ff | ||
|
|
eaf0deb2f6 | ||
|
|
a9061e5258 | ||
|
|
caac45b834 | ||
|
|
24ff7a7911 | ||
|
|
b767dcb27e | ||
|
|
731afbee46 | ||
|
|
07dfd981a2 | ||
|
|
32ef208278 | ||
|
|
a80b185e10 | ||
|
|
b96328e098 | ||
|
|
45471ce86d | ||
|
|
1bc91d0c7c | ||
|
|
799325d9f8 | ||
|
|
b540709e03 | ||
|
|
44daab04ac | ||
|
|
ee65223ee7 | ||
|
|
d49fcd8f3e | ||
|
|
4ee349bd6b | ||
|
|
dfa32b6755 | ||
|
|
0b69729173 | ||
|
|
3b313b9308 | ||
|
|
1abdf42f99 | ||
|
|
9fdc535d6b | ||
|
|
b9b734ceda | ||
|
|
3b05505527 | ||
|
|
bc29419c17 | ||
|
|
4d4360b86b | ||
|
|
8cc28761d7 | ||
|
|
24b3499c70 | ||
|
|
4e4fd5a4b4 | ||
|
|
1a3df54c04 | ||
|
|
3edacee59b | ||
|
|
f25d31b92b | ||
|
|
c91c8a6467 | ||
|
|
61d6ac035d | ||
|
|
9a9373dd0f | ||
|
|
e319a7a5ae | ||
|
|
342549b546 | ||
|
|
bbe94f55b6 | ||
|
|
6fcf1893d3 | ||
|
|
01afe34df7 | ||
|
|
be3e8e3332 | ||
|
|
cf31700903 | ||
|
|
66dee6fd06 | ||
|
|
bfa55f8c67 | ||
|
|
5a2318d01f | ||
|
|
7de037029f | ||
|
|
730c1115ce | ||
|
|
2c37f32fa6 | ||
|
|
7aa9f8b1c3 | ||
|
|
c331ada086 | ||
|
|
ebc25e45d3 | ||
|
|
f82921d2a1 | ||
|
|
d68fe42918 | ||
|
|
823f2a7991 | ||
|
|
0ca9321db1 | ||
|
|
46eddbe7b9 | ||
|
|
64c796a8c3 | ||
|
|
264ff5457b | ||
|
|
ad89df4d0d | ||
|
|
0f10b8ba2b | ||
|
|
940bf990f9 | ||
|
|
1b8fbbe7d7 | ||
|
|
f6f07f4690 | ||
|
|
3800249921 | ||
|
|
a5d857d5e7 | ||
|
|
4c1e80ff58 | ||
|
|
7e5db1f55e | ||
|
|
1edc56c0ce | ||
|
|
4066a70ea5 | ||
|
|
a0d36cf87a | ||
|
|
1d12011eb5 | ||
|
|
7c01f84a5c | ||
|
|
81c5f4acc3 | ||
|
|
0ebfe047d1 | ||
|
|
e68bd53e30 | ||
|
|
cdd9851f72 | ||
|
|
995c3ef81b | ||
|
|
0dfde1374d | ||
|
|
34235199dd | ||
|
|
5d1cd670e9 | ||
|
|
1d8ea7b0ee | ||
|
|
4b218553c3 | ||
|
|
a61c1004d3 | ||
|
|
5d1b42b314 | ||
|
|
4b992c6f3e | ||
|
|
38562f9560 | ||
|
|
c01f0271fe | ||
|
|
0296998fae | ||
|
|
a67b917bdd | ||
|
|
2791bd123c | ||
|
|
e1f9b69cd5 | ||
|
|
2c05496962 | ||
|
|
66bcf9223a | ||
|
|
993f69db37 | ||
|
|
58317edb6d | ||
|
|
417891675d | ||
|
|
8b7aef883a | ||
|
|
b5961d79f8 | ||
|
|
0d25f3f430 | ||
|
|
798fa2396a | ||
|
|
28b222fffa | ||
|
|
b57855f20d | ||
|
|
438b1f9815 | ||
|
|
2bccb3589e | ||
|
|
52bb06eb7b | ||
|
|
8e6d0e7d42 | ||
|
|
5526fd8296 | ||
|
|
a554a8c49f | ||
|
|
7759d762ab | ||
|
|
dd98097897 | ||
|
|
cc73b7831f | ||
|
|
9c243cc8dd | ||
|
|
5d568a3f32 | ||
|
|
1b83542d41 | ||
|
|
cf95d91db3 | ||
|
|
41c1d88615 | ||
|
|
df8673ba40 | ||
|
|
96b1869a0c | ||
|
|
e45b852c09 | ||
|
|
2d3e5c3499 | ||
|
|
b25bf1e341 | ||
|
|
4bb80d3e3a | ||
|
|
03575186a7 | ||
|
|
935c7dd496 | ||
|
|
1b2dc6a133 | ||
|
|
d4e2b2188e | ||
|
|
9658f757c2 | ||
|
|
371e84d9a5 | ||
|
|
5423a2f1b9 | ||
|
|
7001f8e088 | ||
|
|
678cd54553 | ||
|
|
bc19d6592f | ||
|
|
5af0859f67 | ||
|
|
379711951c | ||
|
|
a50a9c5617 | ||
|
|
c0d30a455f | ||
|
|
9a3f6b21d2 | ||
|
|
9ea41f68bc | ||
|
|
e943aa8f03 | ||
|
|
17a4750d8e | ||
|
|
7d18c22aa1 | ||
|
|
c80cc6e268 | ||
|
|
b30a1b5250 | ||
|
|
b753371700 | ||
|
|
3ca5ab180f | ||
|
|
4971f5510c | ||
|
|
20fa7e508d | ||
|
|
ebffc340d9 | ||
|
|
9a86737caa | ||
|
|
d35d8a7307 | ||
|
|
701ff5d6bc | ||
|
|
9044b25a23 | ||
|
|
7f089fab86 | ||
|
|
a259c28678 | ||
|
|
db48da185a | ||
|
|
cab667c23b | ||
|
|
154ca9f1b1 | ||
|
|
2abe40b786 | ||
|
|
6be2420b32 | ||
|
|
9405cc0e04 | ||
|
|
55c98912ed | ||
|
|
45bd7984b0 | ||
|
|
1ed9a0106e | ||
|
|
f8b2ee8c0d | ||
|
|
d32b0f8b7e | ||
|
|
24fdb1f600 | ||
|
|
4010174f66 | ||
|
|
e2b812a611 | ||
|
|
d72b3a9ba2 | ||
|
|
85f52d2574 | ||
|
|
33ea22c0a9 | ||
|
|
0d52f9dd0e | ||
|
|
3caffe1e85 | ||
|
|
87b8dd61c3 | ||
|
|
ad77cd195c | ||
|
|
eb2a754580 | ||
|
|
9258db58db | ||
|
|
8d1c90f912 | ||
|
|
1c62bd6ca5 | ||
|
|
13317ec43c | ||
|
|
35dcb5ca46 | ||
|
|
4454b6b890 | ||
|
|
117e3500ae | ||
|
|
94fda6a720 | ||
|
|
e1388eff84 | ||
|
|
94d2e32b49 | ||
|
|
069f22afa4 | ||
|
|
52c90d4d0a | ||
|
|
ce7e0d8d60 | ||
|
|
40c7742e46 | ||
|
|
05e872337a | ||
|
|
aac9d001f7 | ||
|
|
d295968948 | ||
|
|
97e7a3c5e2 | ||
|
|
16a1825990 | ||
|
|
441afead10 | ||
|
|
783ab253af | ||
|
|
17648d12fe | ||
|
|
2f4f1be99c | ||
|
|
5d4d3888b8 | ||
|
|
473084e915 | ||
|
|
a8147b9713 | ||
|
|
3c3dc547b2 | ||
|
|
c5accd0f16 | ||
|
|
cb949e443e | ||
|
|
bb6815f681 | ||
|
|
a261f60764 | ||
|
|
d393529026 | ||
|
|
219c9593e0 | ||
|
|
faa6b2b790 | ||
|
|
4046bf7b31 | ||
|
|
4f708309af | ||
|
|
f2e7680bf3 | ||
|
|
5d2689b139 | ||
|
|
145ffeea40 | ||
|
|
13143bc7ea | ||
|
|
ee0dbf2d22 | ||
|
|
4265ae4dae | ||
|
|
821c1fdbef | ||
|
|
fe29d6aee3 | ||
|
|
c0c7144539 | ||
|
|
20e3d3a15b | ||
|
|
07d1eedae3 | ||
|
|
4ad3d70739 | ||
|
|
e6a1c29655 | ||
|
|
333dfe1ebf | ||
|
|
c59872553a | ||
|
|
1a39370f5b | ||
|
|
bc44056815 | ||
|
|
17c92343e0 | ||
|
|
cd6935b07a | ||
|
|
47d428f3eb | ||
|
|
2baae7072f | ||
|
|
2e9e459aa3 | ||
|
|
7444e2c1c7 | ||
|
|
d6469eb33d | ||
|
|
a2da6f1827 | ||
|
|
e6508140f8 | ||
|
|
a7127bc74f | ||
|
|
55aa0c0c5d | ||
|
|
d25de4f459 | ||
|
|
6d31f4876a | ||
|
|
e6577ca269 | ||
|
|
08d77b4333 | ||
|
|
1ead121c9b | ||
|
|
ad19b4a421 | ||
|
|
6bc52dd39c | ||
|
|
fd2b00bf3b | ||
|
|
cd8c6d1ce0 | ||
|
|
e9fc6d5598 | ||
|
|
8ed7cd80cb | ||
|
|
81322664ea | ||
|
|
458d722d47 | ||
|
|
3c0d25f3bd | ||
|
|
ca7e4dd66e | ||
|
|
c1316532eb | ||
|
|
d418784346 | ||
|
|
1061601714 | ||
|
|
2f3d4a5511 | ||
|
|
9ea62bda28 | ||
|
|
94b1d446c0 | ||
|
|
6c57a00a65 | ||
|
|
8808531cd5 | ||
|
|
966fca950b | ||
|
|
e528cff615 | ||
|
|
1d037f2f1f | ||
|
|
b2d67795b3 | ||
|
|
959c527be7 | ||
|
|
cc75167437 | ||
|
|
3114d4b5c5 | ||
|
|
ac293cda1c | ||
|
|
7b88975bcb | ||
|
|
da4b2e3a56 |
52
.air.toml
Normal file
52
.air.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = ".tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./dist/portainer"
|
||||||
|
cmd = "SKIP_GO_GET=true make build-server"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = []
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = "./dist/portainer --log-level=DEBUG"
|
||||||
|
include_dir = ["api"]
|
||||||
|
include_ext = ["go"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 0
|
||||||
|
enabled = false
|
||||||
|
proxy_port = 0
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -17,7 +17,7 @@ plugins:
|
|||||||
- import
|
- import
|
||||||
|
|
||||||
parserOptions:
|
parserOptions:
|
||||||
ecmaVersion: 2018
|
ecmaVersion: latest
|
||||||
sourceType: module
|
sourceType: module
|
||||||
project: './tsconfig.json'
|
project: './tsconfig.json'
|
||||||
ecmaFeatures:
|
ecmaFeatures:
|
||||||
@@ -114,7 +114,13 @@ overrides:
|
|||||||
'@typescript-eslint/explicit-module-boundary-types': off
|
'@typescript-eslint/explicit-module-boundary-types': off
|
||||||
'@typescript-eslint/no-unused-vars': 'error'
|
'@typescript-eslint/no-unused-vars': 'error'
|
||||||
'@typescript-eslint/no-explicit-any': 'error'
|
'@typescript-eslint/no-explicit-any': 'error'
|
||||||
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
|
'jsx-a11y/label-has-associated-control':
|
||||||
|
- error
|
||||||
|
- assert: either
|
||||||
|
controlComponents:
|
||||||
|
- Input
|
||||||
|
- Checkbox
|
||||||
|
'jsx-a11y/control-has-associated-label': off
|
||||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||||
'react/jsx-no-bind': off
|
'react/jsx-no-bind': off
|
||||||
'no-await-in-loop': 'off'
|
'no-await-in-loop': 'off'
|
||||||
@@ -133,15 +139,19 @@ overrides:
|
|||||||
'react/jsx-props-no-spreading': off
|
'react/jsx-props-no-spreading': off
|
||||||
- files:
|
- files:
|
||||||
- app/**/*.test.*
|
- app/**/*.test.*
|
||||||
|
plugins:
|
||||||
|
- '@vitest'
|
||||||
extends:
|
extends:
|
||||||
- 'plugin:vitest/recommended'
|
- 'plugin:@vitest/legacy-recommended'
|
||||||
env:
|
env:
|
||||||
'vitest/env': true
|
'@vitest/env': true
|
||||||
rules:
|
rules:
|
||||||
'react/jsx-no-constructed-context-values': off
|
'react/jsx-no-constructed-context-values': off
|
||||||
'@typescript-eslint/no-restricted-imports': off
|
'@typescript-eslint/no-restricted-imports': off
|
||||||
no-restricted-imports: off
|
no-restricted-imports: off
|
||||||
'react/jsx-props-no-spreading': off
|
'react/jsx-props-no-spreading': off
|
||||||
|
'@vitest/no-conditional-expect': warn
|
||||||
|
'max-classes-per-file': off
|
||||||
- files:
|
- files:
|
||||||
- app/**/*.stories.*
|
- app/**/*.stories.*
|
||||||
rules:
|
rules:
|
||||||
@@ -149,3 +159,4 @@ overrides:
|
|||||||
'@typescript-eslint/no-restricted-imports': off
|
'@typescript-eslint/no-restricted-imports': off
|
||||||
no-restricted-imports: off
|
no-restricted-imports: off
|
||||||
'react/jsx-props-no-spreading': off
|
'react/jsx-props-no-spreading': off
|
||||||
|
'storybook/no-renderer-packages': off
|
||||||
|
|||||||
2
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
2
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
|
|
||||||
Thanks for suggesting an idea for Portainer!
|
Thanks for suggesting an idea for Portainer!
|
||||||
|
|
||||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||||
|
|
||||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||||
|
|
||||||
|
|||||||
74
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
74
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -2,7 +2,6 @@ name: Bug Report
|
|||||||
description: Create a report to help us improve.
|
description: Create a report to help us improve.
|
||||||
labels: kind/bug,bug/need-confirmation
|
labels: kind/bug,bug/need-confirmation
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
@@ -12,6 +11,8 @@ body:
|
|||||||
|
|
||||||
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
||||||
|
|
||||||
|
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
|
||||||
|
|
||||||
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
@@ -21,7 +22,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
||||||
required: true
|
required: true
|
||||||
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
|
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io).
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
@@ -90,33 +91,58 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Portainer version
|
label: Portainer version
|
||||||
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||||
multiple: false
|
multiple: false
|
||||||
options:
|
options:
|
||||||
|
- '2.39.0'
|
||||||
|
- '2.38.1'
|
||||||
|
- '2.38.0'
|
||||||
|
- '2.37.0'
|
||||||
|
- '2.36.0'
|
||||||
|
- '2.35.0'
|
||||||
|
- '2.34.0'
|
||||||
|
- '2.33.7'
|
||||||
|
- '2.33.6'
|
||||||
|
- '2.33.5'
|
||||||
|
- '2.33.4'
|
||||||
|
- '2.33.3'
|
||||||
|
- '2.33.2'
|
||||||
|
- '2.33.1'
|
||||||
|
- '2.33.0'
|
||||||
|
- '2.32.0'
|
||||||
|
- '2.31.3'
|
||||||
|
- '2.31.2'
|
||||||
|
- '2.31.1'
|
||||||
|
- '2.31.0'
|
||||||
|
- '2.30.1'
|
||||||
|
- '2.30.0'
|
||||||
|
- '2.29.2'
|
||||||
|
- '2.29.1'
|
||||||
|
- '2.29.0'
|
||||||
|
- '2.28.1'
|
||||||
|
- '2.28.0'
|
||||||
|
- '2.27.9'
|
||||||
|
- '2.27.8'
|
||||||
|
- '2.27.7'
|
||||||
|
- '2.27.6'
|
||||||
|
- '2.27.5'
|
||||||
|
- '2.27.4'
|
||||||
|
- '2.27.3'
|
||||||
|
- '2.27.2'
|
||||||
|
- '2.27.1'
|
||||||
|
- '2.27.0'
|
||||||
|
- '2.26.1'
|
||||||
|
- '2.26.0'
|
||||||
|
- '2.25.1'
|
||||||
|
- '2.25.0'
|
||||||
|
- '2.24.1'
|
||||||
|
- '2.24.0'
|
||||||
|
- '2.23.0'
|
||||||
- '2.22.0'
|
- '2.22.0'
|
||||||
|
- '2.21.5'
|
||||||
|
- '2.21.4'
|
||||||
- '2.21.3'
|
- '2.21.3'
|
||||||
- '2.21.2'
|
- '2.21.2'
|
||||||
- '2.21.1'
|
|
||||||
- '2.21.0'
|
|
||||||
- '2.20.3'
|
|
||||||
- '2.20.2'
|
|
||||||
- '2.20.1'
|
|
||||||
- '2.20.0'
|
|
||||||
- '2.19.5'
|
|
||||||
- '2.19.4'
|
|
||||||
- '2.19.3'
|
|
||||||
- '2.19.2'
|
|
||||||
- '2.19.1'
|
|
||||||
- '2.19.0'
|
|
||||||
- '2.18.4'
|
|
||||||
- '2.18.3'
|
|
||||||
- '2.18.2'
|
|
||||||
- '2.18.1'
|
|
||||||
- '2.17.1'
|
|
||||||
- '2.17.0'
|
|
||||||
- '2.16.2'
|
|
||||||
- '2.16.1'
|
|
||||||
- '2.16.0'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
16
.github/workflows/label-conflcts.yaml
vendored
16
.github/workflows/label-conflcts.yaml
vendored
@@ -1,16 +0,0 @@
|
|||||||
name: Label Conflicts
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- 'release/**'
|
|
||||||
jobs:
|
|
||||||
triage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: mschilde/auto-label-merge-conflicts@master
|
|
||||||
with:
|
|
||||||
CONFLICT_LABEL_NAME: 'has conflicts'
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
MAX_RETRIES: 10
|
|
||||||
WAIT_MS: 60000
|
|
||||||
19
.github/workflows/rebase.yml
vendored
19
.github/workflows/rebase.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
name: Automatic Rebase
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
jobs:
|
|
||||||
rebase:
|
|
||||||
name: Rebase
|
|
||||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout the latest code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
|
|
||||||
- name: Automatic Rebase
|
|
||||||
uses: cirrus-actions/rebase@1.4
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
28
.github/workflows/stale.yml
vendored
28
.github/workflows/stale.yml
vendored
@@ -1,28 +0,0 @@
|
|||||||
name: Close Stale Issues
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 12 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v8
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Issue Config
|
|
||||||
days-before-issue-stale: 60
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: 'status/stale'
|
|
||||||
exempt-all-issue-milestones: true # Do not stale issues in a milestone
|
|
||||||
exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss
|
|
||||||
stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.'
|
|
||||||
close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.'
|
|
||||||
|
|
||||||
# Pull Request Config
|
|
||||||
days-before-pr-stale: -1 # Do not stale pull request
|
|
||||||
days-before-pr-close: -1 # Do not close pull request
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ api/docs
|
|||||||
.env
|
.env
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
|
.vitest
|
||||||
|
|
||||||
|
|||||||
16
.golangci-forward.yaml
Normal file
16
.golangci-forward.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: "2"
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- forbidigo
|
||||||
|
settings:
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||||
|
msg: Use a transaction instead
|
||||||
|
analyze-types: true
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
126
.golangci.yaml
126
.golangci.yaml
@@ -1,42 +1,108 @@
|
|||||||
linters:
|
version: "2"
|
||||||
# Disable all linters, the defaults don't pass on our code yet
|
|
||||||
disable-all: true
|
|
||||||
|
|
||||||
# Enable these for now
|
run:
|
||||||
|
allow-parallel-runners: true
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
enable:
|
enable:
|
||||||
- unused
|
- bodyclose
|
||||||
- depguard
|
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- errorlint
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
|
- depguard
|
||||||
|
- errcheck
|
||||||
|
- errorlint
|
||||||
|
- forbidigo
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
- intrange
|
- intrange
|
||||||
- perfsprint
|
- perfsprint
|
||||||
|
- staticcheck
|
||||||
linters-settings:
|
- unused
|
||||||
|
- mirror
|
||||||
|
- durationcheck
|
||||||
|
- errorlint
|
||||||
|
- govet
|
||||||
|
- usetesting
|
||||||
|
- zerologlint
|
||||||
|
- testifylint
|
||||||
|
- modernize
|
||||||
|
- unconvert
|
||||||
|
- unused
|
||||||
|
- zerologlint
|
||||||
|
- exptostd
|
||||||
|
settings:
|
||||||
|
staticcheck:
|
||||||
|
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
|
||||||
depguard:
|
depguard:
|
||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
deny:
|
|
||||||
- pkg: 'encoding/json'
|
|
||||||
desc: 'use github.com/segmentio/encoding/json'
|
|
||||||
- pkg: 'github.com/sirupsen/logrus'
|
|
||||||
desc: 'logging is allowed only by github.com/rs/zerolog'
|
|
||||||
- pkg: 'golang.org/x/exp'
|
|
||||||
desc: 'exp is not allowed'
|
|
||||||
- pkg: 'github.com/portainer/libcrypto'
|
|
||||||
desc: 'use github.com/portainer/portainer/pkg/libcrypto'
|
|
||||||
- pkg: 'github.com/portainer/libhttp'
|
|
||||||
desc: 'use github.com/portainer/portainer/pkg/libhttp'
|
|
||||||
files:
|
files:
|
||||||
- '!**/*_test.go'
|
- '!**/*_test.go'
|
||||||
- '!**/base.go'
|
- '!**/base.go'
|
||||||
- '!**/base_tx.go'
|
- '!**/base_tx.go'
|
||||||
|
deny:
|
||||||
# errorlint is causing a typecheck error for some reason. The go compiler will report these
|
- pkg: encoding/json
|
||||||
# anyway, so ignore them from the linter
|
desc: use github.com/segmentio/encoding/json
|
||||||
issues:
|
- pkg: golang.org/x/exp
|
||||||
exclude-rules:
|
desc: exp is not allowed
|
||||||
- path: ./
|
- pkg: github.com/portainer/libcrypto
|
||||||
linters:
|
desc: use github.com/portainer/portainer/pkg/libcrypto
|
||||||
- typecheck
|
- pkg: github.com/portainer/libhttp
|
||||||
|
desc: use github.com/portainer/portainer/pkg/libhttp
|
||||||
|
- pkg: golang.org/x/crypto
|
||||||
|
desc: golang.org/x/crypto is not allowed because of FIPS mode
|
||||||
|
- pkg: github.com/ProtonMail/go-crypto/openpgp
|
||||||
|
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
||||||
|
- pkg: github.com/cosi-project/runtime
|
||||||
|
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
|
||||||
|
- pkg: gopkg.in/yaml.v2
|
||||||
|
desc: use go.yaml.in/yaml/v3 instead
|
||||||
|
- pkg: gopkg.in/yaml.v3
|
||||||
|
desc: use go.yaml.in/yaml/v3 instead
|
||||||
|
- pkg: github.com/golang-jwt/jwt/v4
|
||||||
|
desc: use github.com/golang-jwt/jwt/v5 instead
|
||||||
|
- pkg: github.com/mitchellh/mapstructure
|
||||||
|
desc: use github.com/go-viper/mapstructure/v2 instead
|
||||||
|
- pkg: gopkg.in/alecthomas/kingpin.v2
|
||||||
|
desc: use github.com/alecthomas/kingpin/v2 instead
|
||||||
|
- pkg: github.com/jcmturner/gokrb5$
|
||||||
|
desc: use github.com/jcmturner/gokrb5/v8 instead
|
||||||
|
- pkg: github.com/gofrs/uuid
|
||||||
|
desc: use github.com/google/uuid
|
||||||
|
- pkg: github.com/Masterminds/semver$
|
||||||
|
desc: use github.com/Masterminds/semver/v3
|
||||||
|
- pkg: github.com/blang/semver
|
||||||
|
desc: use github.com/Masterminds/semver/v3
|
||||||
|
- pkg: github.com/coreos/go-semver
|
||||||
|
desc: use github.com/Masterminds/semver/v3
|
||||||
|
- pkg: github.com/hashicorp/go-version
|
||||||
|
desc: use github.com/Masterminds/semver/v3
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- pattern: ^tls\.Config$
|
||||||
|
msg: Use crypto.CreateTLSConfiguration() instead
|
||||||
|
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
|
||||||
|
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
|
||||||
|
- pattern: ^object\.(Commit|Tag)\.Verify$
|
||||||
|
msg: "Not allowed because of FIPS mode"
|
||||||
|
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
|
||||||
|
msg: "Not allowed because of FIPS mode"
|
||||||
|
analyze-types: true
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
presets:
|
||||||
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
cd $(dirname -- "$0") && yarn lint-staged
|
cd $(dirname -- "$0") && pnpm lint-staged
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
dist
|
dist
|
||||||
api/datastore/test_data
|
api/datastore/test_data
|
||||||
|
coverage
|
||||||
@@ -9,10 +9,21 @@ const config: StorybookConfig = {
|
|||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-links',
|
'@storybook/addon-links',
|
||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-webpack5-compiler-swc',
|
||||||
|
'@chromatic-com/storybook',
|
||||||
{
|
{
|
||||||
name: '@storybook/addon-styling',
|
name: '@storybook/addon-styling-webpack',
|
||||||
|
|
||||||
|
options: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
sideEffects: true,
|
||||||
|
use: [
|
||||||
|
require.resolve('style-loader'),
|
||||||
|
{
|
||||||
|
loader: require.resolve('css-loader'),
|
||||||
options: {
|
options: {
|
||||||
cssLoaderOptions: {
|
|
||||||
importLoaders: 1,
|
importLoaders: 1,
|
||||||
modules: {
|
modules: {
|
||||||
localIdentName: '[path][name]__[local]',
|
localIdentName: '[path][name]__[local]',
|
||||||
@@ -20,10 +31,17 @@ const config: StorybookConfig = {
|
|||||||
exportLocalsConvention: 'camelCaseOnly',
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
postCss: {
|
},
|
||||||
|
{
|
||||||
|
loader: require.resolve('postcss-loader'),
|
||||||
|
options: {
|
||||||
implementation: postcss,
|
implementation: postcss,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webpackFinal: (config) => {
|
webpackFinal: (config) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import '../app/assets/css';
|
import '../app/assets/css';
|
||||||
import React from 'react';
|
|
||||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||||
import { handlers } from '../app/setup-tests/server-handlers';
|
import { handlers } from '../app/setup-tests/server-handlers';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Preview } from '@storybook/react';
|
||||||
|
|
||||||
initMSW(
|
initMSW(
|
||||||
{
|
{
|
||||||
@@ -21,8 +21,20 @@ initMSW(
|
|||||||
handlers
|
handlers
|
||||||
);
|
);
|
||||||
|
|
||||||
export const parameters = {
|
const testQueryClient = new QueryClient({
|
||||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
decorators: (Story) => (
|
||||||
|
<QueryClientProvider client={testQueryClient}>
|
||||||
|
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||||
|
<Story />
|
||||||
|
</UIRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
loaders: [mswLoader],
|
||||||
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -32,20 +44,7 @@ export const parameters = {
|
|||||||
msw: {
|
msw: {
|
||||||
handlers,
|
handlers,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const testQueryClient = new QueryClient({
|
export default preview;
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const decorators = [
|
|
||||||
(Story) => (
|
|
||||||
<QueryClientProvider client={testQueryClient}>
|
|
||||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
|
||||||
<Story />
|
|
||||||
</UIRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const loaders = [mswLoader];
|
|
||||||
|
|||||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Portainer Community Edition
|
||||||
|
|
||||||
|
Open-source container management platform with full Docker and Kubernetes support.
|
||||||
|
|
||||||
|
see also:
|
||||||
|
|
||||||
|
- docs/guidelines/server-architecture.md
|
||||||
|
- docs/guidelines/go-conventions.md
|
||||||
|
- docs/guidelines/typescript-conventions.md
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
|
||||||
|
- **PNPM** 10+ (for frontend)
|
||||||
|
- **Go** 1.25.7 (for backend)
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full build
|
||||||
|
make build # Build both client and server
|
||||||
|
make build-client # Build React/AngularJS frontend
|
||||||
|
make build-server # Build Go binary
|
||||||
|
make build-image # Build Docker image
|
||||||
|
|
||||||
|
# Development
|
||||||
|
make dev # Run both in dev mode
|
||||||
|
make dev-client # Start webpack-dev-server (port 8999)
|
||||||
|
make dev-server # Run containerized Go server
|
||||||
|
|
||||||
|
pnpm run dev # Webpack dev server
|
||||||
|
pnpm run build # Build frontend with webpack
|
||||||
|
pnpm run test # Run frontend tests
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
make test # All tests (backend + frontend)
|
||||||
|
make test-server # Backend tests only
|
||||||
|
make lint # Lint all code
|
||||||
|
make format # Format code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Servers
|
||||||
|
|
||||||
|
- Frontend: http://localhost:8999
|
||||||
|
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||||
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
|
|||||||
|
|
||||||
## Build and run Portainer locally
|
## Build and run Portainer locally
|
||||||
|
|
||||||
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
|
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
|
|||||||
49
Makefile
49
Makefile
@@ -1,9 +1,3 @@
|
|||||||
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
|
||||||
# For a list of valid GOOS and GOARCH values
|
|
||||||
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
|
|
||||||
PLATFORM=$(shell go env GOOS)
|
|
||||||
ARCH=$(shell go env GOARCH)
|
|
||||||
|
|
||||||
# build target, can be one of "production", "testing", "development"
|
# build target, can be one of "production", "testing", "development"
|
||||||
ENV=development
|
ENV=development
|
||||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||||
@@ -17,14 +11,16 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
|
|||||||
|
|
||||||
|
|
||||||
##@ Building
|
##@ Building
|
||||||
.PHONY: init-dist build-storybook build build-client build-server build-image devops
|
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
|
||||||
init-dist:
|
init-dist:
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
|
|
||||||
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||||
|
|
||||||
|
build-all: all ## Alias for the 'all' target (used by CI)
|
||||||
|
|
||||||
build-client: init-dist ## Build the client
|
build-client: init-dist ## Build the client
|
||||||
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
|
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
|
||||||
|
|
||||||
build-server: init-dist ## Build the server binary
|
build-server: init-dist ## Build the server binary
|
||||||
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
||||||
@@ -33,11 +29,7 @@ build-image: build-all ## Build the Portainer image locally
|
|||||||
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
||||||
|
|
||||||
build-storybook: ## Build and serve the storybook files
|
build-storybook: ## Build and serve the storybook files
|
||||||
yarn storybook:build
|
pnpm run storybook:build
|
||||||
|
|
||||||
devops: clean deps build-client ## Build the everything target specifically for CI
|
|
||||||
echo "Building the devops binary..."
|
|
||||||
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
|
|
||||||
|
|
||||||
##@ Build dependencies
|
##@ Build dependencies
|
||||||
.PHONY: deps server-deps client-deps tidy
|
.PHONY: deps server-deps client-deps tidy
|
||||||
@@ -47,11 +39,10 @@ server-deps: init-dist ## Download dependant server binaries
|
|||||||
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
||||||
|
|
||||||
client-deps: ## Install client dependencies
|
client-deps: ## Install client dependencies
|
||||||
yarn
|
pnpm install
|
||||||
|
|
||||||
tidy: ## Tidy up the go.mod file
|
tidy: ## Tidy up the go.mod file
|
||||||
cd api && go mod tidy
|
@go mod tidy
|
||||||
|
|
||||||
|
|
||||||
##@ Cleanup
|
##@ Cleanup
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
@@ -59,19 +50,15 @@ clean: ## Remove all build and download artifacts
|
|||||||
@echo "Clearing the dist directory..."
|
@echo "Clearing the dist directory..."
|
||||||
@rm -rf dist/*
|
@rm -rf dist/*
|
||||||
|
|
||||||
|
|
||||||
##@ Testing
|
##@ Testing
|
||||||
.PHONY: test test-client test-server
|
.PHONY: test test-client test-server
|
||||||
test: test-server test-client ## Run all tests
|
test: test-server test-client ## Run all tests
|
||||||
|
|
||||||
test-deps: init-dist
|
|
||||||
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
|
|
||||||
|
|
||||||
test-client: ## Run client tests
|
test-client: ## Run client tests
|
||||||
yarn test $(ARGS)
|
pnpm run test $(ARGS) --coverage
|
||||||
|
|
||||||
test-server: ## Run server tests
|
test-server: ## Run server tests
|
||||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
##@ Dev
|
##@ Dev
|
||||||
.PHONY: dev dev-client dev-server
|
.PHONY: dev dev-client dev-server
|
||||||
@@ -80,7 +67,7 @@ dev: ## Run both the client and server in development mode
|
|||||||
make dev-client
|
make dev-client
|
||||||
|
|
||||||
dev-client: ## Run the client in development mode
|
dev-client: ## Run the client in development mode
|
||||||
yarn dev
|
pnpm install && pnpm run dev
|
||||||
|
|
||||||
dev-server: build-server ## Run the server in development mode
|
dev-server: build-server ## Run the server in development mode
|
||||||
@./dev/run_container.sh
|
@./dev/run_container.sh
|
||||||
@@ -94,7 +81,7 @@ dev-server-podman: build-server ## Run the server in development mode
|
|||||||
format: format-client format-server ## Format all code
|
format: format-client format-server ## Format all code
|
||||||
|
|
||||||
format-client: ## Format client code
|
format-client: ## Format client code
|
||||||
yarn format
|
pnpm run format
|
||||||
|
|
||||||
format-server: ## Format server code
|
format-server: ## Format server code
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
@@ -104,26 +91,26 @@ format-server: ## Format server code
|
|||||||
lint: lint-client lint-server ## Lint all code
|
lint: lint-client lint-server ## Lint all code
|
||||||
|
|
||||||
lint-client: ## Lint client code
|
lint-client: ## Lint client code
|
||||||
yarn lint
|
pnpm run lint
|
||||||
|
|
||||||
lint-server: ## Lint server code
|
lint-server: tidy ## Lint server code
|
||||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||||
|
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
||||||
|
|
||||||
##@ Extension
|
##@ Extension
|
||||||
.PHONY: dev-extension
|
.PHONY: dev-extension
|
||||||
dev-extension: build-server build-client ## Run the extension in development mode
|
dev-extension: build-server build-client ## Run the extension in development mode
|
||||||
make local -f build/docker-extension/Makefile
|
make local -f build/docker-extension/Makefile
|
||||||
|
|
||||||
|
|
||||||
##@ Docs
|
##@ Docs
|
||||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||||
docs-build: init-dist ## Build docs
|
docs-build: init-dist ## Build docs
|
||||||
|
go mod download -x
|
||||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||||
|
|
||||||
docs-validate: docs-build ## Validate docs
|
docs-validate: docs-build ## Validate docs
|
||||||
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||||
yarn swagger-cli validate dist/docs/openapi.yaml
|
pnpm swagger-cli validate dist/docs/openapi.yaml
|
||||||
|
|
||||||
##@ Helpers
|
##@ Helpers
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -8,9 +8,9 @@ Portainer consists of a single container that can run on any cluster. It can be
|
|||||||
|
|
||||||
**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users.
|
**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users.
|
||||||
|
|
||||||
- [Compare Portainer CE and Compare Portainer BE](https://portainer.io/products)
|
- [Compare Portainer CE and Compare Portainer BE](https://www.portainer.io/features)
|
||||||
- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3)
|
- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3)
|
||||||
- [Portainer BE install guide](https://install.portainer.io)
|
- [Portainer BE install guide](https://academy.portainer.io/install/)
|
||||||
|
|
||||||
## Latest Version
|
## Latest Version
|
||||||
|
|
||||||
@@ -20,22 +20,19 @@ Portainer CE is updated regularly. We aim to do an update release every couple o
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
- [Deploy Portainer](https://docs.portainer.io/start/install)
|
- [Deploy Portainer](https://docs.portainer.io/start/install-ce)
|
||||||
- [Documentation](https://docs.portainer.io)
|
- [Documentation](https://docs.portainer.io)
|
||||||
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
||||||
|
|
||||||
## Features & Functions
|
## Features & Functions
|
||||||
|
|
||||||
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
View [this](https://www.portainer.io/features) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||||
|
|
||||||
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
|
|
||||||
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
|
|
||||||
|
|
||||||
## Getting help
|
## Getting help
|
||||||
|
|
||||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||||
|
|
||||||
Learn more about Portainer's community support channels [here.](https://www.portainer.io/get-support-for-portainer)
|
Learn more about Portainer's community support channels [here.](https://www.portainer.io/resources/get-help/get-support)
|
||||||
|
|
||||||
- Issues: https://github.com/portainer/portainer/issues
|
- Issues: https://github.com/portainer/portainer/issues
|
||||||
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
||||||
@@ -49,17 +46,17 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
|
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
|
||||||
|
|
||||||
## Work for us
|
## Work for us
|
||||||
|
|
||||||
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and/or visit our [careers page](https://portainer.io/careers).
|
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to success@portainer.io with your details and/or visit our [careers page](https://apply.workable.com/portainer/).
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
||||||
|
|
||||||
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/legal/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
|
|||||||
61
SECURITY.md
Normal file
61
SECURITY.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
||||||
|
|
||||||
|
| Version Type | Support Status |
|
||||||
|
| --- | --- |
|
||||||
|
| LTS (Long-Term Support) | Supported for critical security fixes |
|
||||||
|
| STS (Short-Term Support) | Supported until the next STS or LTS release |
|
||||||
|
| Legacy / EOL | Not supported |
|
||||||
|
|
||||||
|
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
|
||||||
|
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly.
|
||||||
|
|
||||||
|
**Please do not report security vulnerabilities via public GitHub issues.**
|
||||||
|
|
||||||
|
### Disclosure Process
|
||||||
|
|
||||||
|
1. **Report**: You can report in one of two ways:
|
||||||
|
|
||||||
|
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
|
||||||
|
|
||||||
|
- **Email**: Send your findings to security@portainer.io.
|
||||||
|
|
||||||
|
2. **Details**: To help us verify the issue, please include:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its potential impact.
|
||||||
|
|
||||||
|
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
|
||||||
|
|
||||||
|
- The version of the software and the environment in which it was found.
|
||||||
|
|
||||||
|
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
|
||||||
|
|
||||||
|
4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users.
|
||||||
|
|
||||||
|
## Our Commitment
|
||||||
|
|
||||||
|
If you follow the responsible disclosure process, we will:
|
||||||
|
|
||||||
|
- Respond to your report in a timely manner.
|
||||||
|
|
||||||
|
- Provide an estimated timeline for remediation.
|
||||||
|
|
||||||
|
- Notify you when the vulnerability has been patched.
|
||||||
|
|
||||||
|
- Give credit for the discovery (if desired) once the fix is public.
|
||||||
|
|
||||||
|
|
||||||
|
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
|
||||||
|
|
||||||
|
Thank you for helping keep Portainer and our community secure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase)
|
||||||
@@ -11,20 +11,18 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/url"
|
"github.com/portainer/portainer/api/url"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetAgentVersionAndPlatform returns the agent version and platform
|
// GetAgentVersionAndPlatform returns the agent version and platform
|
||||||
//
|
//
|
||||||
// it sends a ping to the agent and parses the version and platform from the headers
|
// it sends a ping to the agent and parses the version and platform from the headers
|
||||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) {
|
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
|
||||||
httpCli := &http.Client{
|
httpCli := &http.Client{Timeout: 3 * time.Second}
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
if tlsConfig != nil {
|
if tlsConfig != nil {
|
||||||
httpCli.Transport = &http.Transport{
|
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
|
||||||
TLSClientConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||||
@@ -44,8 +42,10 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
|
|||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
io.Copy(io.Discard, resp.Body)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to close response body")
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -11,30 +11,30 @@ func Test_generateRandomKey(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
wantLenth int
|
wantLength int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 16",
|
name: "Generate a random key of length 16",
|
||||||
wantLenth: 16,
|
wantLength: 16,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 32",
|
name: "Generate a random key of length 32",
|
||||||
wantLenth: 32,
|
wantLength: 32,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 64",
|
name: "Generate a random key of length 64",
|
||||||
wantLenth: 64,
|
wantLength: 64,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 128",
|
name: "Generate a random key of length 128",
|
||||||
wantLenth: 128,
|
wantLength: 128,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := GenerateRandomKey(tt.wantLenth)
|
got := GenerateRandomKey(tt.wantLength)
|
||||||
is.Equal(tt.wantLenth, len(got))
|
is.Len(got, tt.wantLength)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||||
@@ -30,7 +31,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
t.Run("Successfully generates API key", func(t *testing.T) {
|
t.Run("Successfully generates API key", func(t *testing.T) {
|
||||||
desc := "test-1"
|
desc := "test-1"
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.NotEmpty(rawKey)
|
is.NotEmpty(rawKey)
|
||||||
is.NotEmpty(apiKey)
|
is.NotEmpty(apiKey)
|
||||||
is.Equal(desc, apiKey.Description)
|
is.Equal(desc, apiKey.Description)
|
||||||
@@ -38,7 +39,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
is.Equal(rawKey[:7], apiKey.Prefix)
|
is.Equal(rawKey[:7], apiKey.Prefix)
|
||||||
is.Len(apiKey.Prefix, 7)
|
is.Len(apiKey.Prefix, 7)
|
||||||
@@ -46,7 +47,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
||||||
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
||||||
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
||||||
@@ -55,7 +56,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
t.Run("Successfully caches API key", func(t *testing.T) {
|
t.Run("Successfully caches API key", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -65,7 +66,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||||
|
|
||||||
@@ -83,10 +84,10 @@ func Test_GetAPIKey(t *testing.T) {
|
|||||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
is.Equal(apiKey, apiKeyGot)
|
is.Equal(apiKey, apiKeyGot)
|
||||||
})
|
})
|
||||||
@@ -102,12 +103,12 @@ func Test_GetAPIKeys(t *testing.T) {
|
|||||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, _, err := service.GenerateApiKey(user, "test-1")
|
_, _, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
_, _, err = service.GenerateApiKey(user, "test-2")
|
_, _, err = service.GenerateApiKey(user, "test-2")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
keys, err := service.GetAPIKeys(user.ID)
|
keys, err := service.GetAPIKeys(user.ID)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Len(keys, 2)
|
is.Len(keys, 2)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -122,10 +123,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
|||||||
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(user, userGot)
|
is.Equal(user, userGot)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
})
|
})
|
||||||
@@ -133,10 +134,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
|||||||
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(user, userGot)
|
is.Equal(user, userGot)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
|
|
||||||
@@ -156,16 +157,19 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
store.User().Create(&user)
|
|
||||||
|
err := store.User().Create(&user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
apiKey.LastUsed = time.Now().UTC().Unix()
|
apiKey.LastUsed = time.Now().UTC().Unix()
|
||||||
err = service.UpdateAPIKey(apiKey)
|
err = service.UpdateAPIKey(apiKey)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
||||||
|
|
||||||
@@ -174,7 +178,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
||||||
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -184,7 +188,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
is.NotEqual(*apiKey, apiKeyFromCache)
|
is.NotEqual(*apiKey, apiKeyFromCache)
|
||||||
|
|
||||||
err = service.UpdateAPIKey(apiKey)
|
err = service.UpdateAPIKey(apiKey)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -202,30 +206,30 @@ func Test_DeleteAPIKey(t *testing.T) {
|
|||||||
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
|
|
||||||
err = service.DeleteAPIKey(apiKey.ID)
|
err = service.DeleteAPIKey(apiKey.ID)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
is.Error(err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
is.Equal(*apiKey, apiKeyFromCache)
|
is.Equal(*apiKey, apiKeyFromCache)
|
||||||
|
|
||||||
err = service.DeleteAPIKey(apiKey.ID)
|
err = service.DeleteAPIKey(apiKey.ID)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, _, ok = service.cache.Get(apiKey.Digest)
|
_, _, ok = service.cache.Get(apiKey.Digest)
|
||||||
is.False(ok)
|
is.False(ok)
|
||||||
@@ -243,10 +247,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
|||||||
// generate api keys
|
// generate api keys
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// verify api keys are present in cache
|
// verify api keys are present in cache
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||||
@@ -273,11 +277,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
|||||||
// generate keys for 2 users
|
// generate keys for 2 users
|
||||||
user1 := portainer.User{ID: 1}
|
user1 := portainer.User{ID: 1}
|
||||||
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
user2 := portainer.User{ID: 2}
|
user2 := portainer.User{ID: 2}
|
||||||
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// verify keys in cache
|
// verify keys in cache
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||||
|
|||||||
@@ -17,18 +17,15 @@ func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, e
|
|||||||
Size: int64(len(fileContent)),
|
Size: int64(len(fileContent)),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tarWriter.WriteHeader(header)
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tarWriter.Write(fileContent)
|
if _, err := tarWriter.Write(fileContent); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tarWriter.Close()
|
if err := tarWriter.Close(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +40,7 @@ type tarFileInBuffer struct {
|
|||||||
|
|
||||||
func NewTarFileInBuffer() *tarFileInBuffer {
|
func NewTarFileInBuffer() *tarFileInBuffer {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
return &tarFileInBuffer{
|
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
|
||||||
b: &b,
|
|
||||||
w: tar.NewWriter(&b),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put puts a single file to tar archive buffer.
|
// Put puts a single file to tar archive buffer.
|
||||||
@@ -61,11 +55,9 @@ func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := t.w.Write(fileContent); err != nil {
|
_, err := t.w.Write(fileContent)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes returns the archive as a byte array.
|
// Bytes returns the archive as a byte array.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TarGzDir creates a tar.gz archive and returns it's path.
|
// TarGzDir creates a tar.gz archive and returns it's path.
|
||||||
@@ -20,12 +23,13 @@ func TarGzDir(absolutePath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer logs.CloseAndLogErr(outFile)
|
||||||
|
|
||||||
zipWriter := gzip.NewWriter(outFile)
|
zipWriter := gzip.NewWriter(outFile)
|
||||||
defer zipWriter.Close()
|
defer logs.CloseAndLogErr(zipWriter)
|
||||||
|
|
||||||
tarWriter := tar.NewWriter(zipWriter)
|
tarWriter := tar.NewWriter(zipWriter)
|
||||||
defer tarWriter.Close()
|
defer logs.CloseAndLogErr(tarWriter)
|
||||||
|
|
||||||
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
|
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +90,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer logs.CloseAndLogErr(zipReader)
|
||||||
|
|
||||||
tarReader := tar.NewReader(zipReader)
|
tarReader := tar.NewReader(zipReader)
|
||||||
|
|
||||||
@@ -105,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
// skip, dir will be created with a file
|
// skip, dir will be created with a file
|
||||||
case tar.TypeReg:
|
case tar.TypeReg:
|
||||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||||
return fmt.Errorf("Failed to extract file %s", header.Name)
|
return fmt.Errorf("Failed to extract file %s", header.Name)
|
||||||
}
|
}
|
||||||
outFile.Close()
|
logs.CloseAndLogErr(outFile)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("tar: unknown type: %v in %s",
|
return fmt.Errorf("tar: unknown type: %v in %s",
|
||||||
header.Typeflag,
|
header.Typeflag,
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
package archive
|
package archive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listFiles(dir string) []string {
|
func listFiles(dir string) []string {
|
||||||
items := make([]string, 0)
|
items := make([]string, 0)
|
||||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
|
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if path == dir {
|
if path == dir {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, path)
|
items = append(items, path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to list files in directory")
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@@ -26,13 +36,21 @@ func listFiles(dir string) []string {
|
|||||||
func Test_shouldCreateArchive(t *testing.T) {
|
func Test_shouldCreateArchive(t *testing.T) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
content := []byte("content")
|
content := []byte("content")
|
||||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
|
||||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
require.NoError(t, err)
|
||||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
|
||||||
|
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
gzPath, err := TarGzDir(tmpdir)
|
gzPath, err := TarGzDir(tmpdir)
|
||||||
assert.Nil(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||||
|
|
||||||
extractionDir := t.TempDir()
|
extractionDir := t.TempDir()
|
||||||
@@ -45,7 +63,8 @@ func Test_shouldCreateArchive(t *testing.T) {
|
|||||||
wasExtracted := func(p string) {
|
wasExtracted := func(p string) {
|
||||||
fullpath := path.Join(extractionDir, p)
|
fullpath := path.Join(extractionDir, p)
|
||||||
assert.Contains(t, extractedFiles, fullpath)
|
assert.Contains(t, extractedFiles, fullpath)
|
||||||
copyContent, _ := os.ReadFile(fullpath)
|
copyContent, err := os.ReadFile(fullpath)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, copyContent)
|
assert.Equal(t, content, copyContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +76,21 @@ func Test_shouldCreateArchive(t *testing.T) {
|
|||||||
func Test_shouldCreateArchive2(t *testing.T) {
|
func Test_shouldCreateArchive2(t *testing.T) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
content := []byte("content")
|
content := []byte("content")
|
||||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
|
||||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
require.NoError(t, err)
|
||||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
|
||||||
|
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
gzPath, err := TarGzDir(tmpdir)
|
gzPath, err := TarGzDir(tmpdir)
|
||||||
assert.Nil(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||||
|
|
||||||
extractionDir := t.TempDir()
|
extractionDir := t.TempDir()
|
||||||
@@ -84,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
|||||||
wasExtracted("dir/inner")
|
wasExtracted("dir/inner")
|
||||||
wasExtracted("dir/.dotfile")
|
wasExtracted("dir/.dotfile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||||
|
testDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create an evil file with a path traversal attempt
|
||||||
|
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||||
|
|
||||||
|
evilFile, err := os.Create(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gzWriter := gzip.NewWriter(evilFile)
|
||||||
|
tarWriter := tar.NewWriter(gzWriter)
|
||||||
|
|
||||||
|
content := []byte("evil content")
|
||||||
|
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: "../evil.txt",
|
||||||
|
Mode: 0600,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tarWriter.WriteHeader(header)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = tarWriter.Write(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tarWriter.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = gzWriter.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = evilFile.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Attempt to extract the evil file
|
||||||
|
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||||
|
err = os.Mkdir(extractionDir, 0700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tarFile, err := os.Open(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that the file didn't escape
|
||||||
|
err = ExtractTarGz(tarFile, extractionDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||||
|
|
||||||
|
err = tarFile.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,60 +2,17 @@ package archive
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
|
||||||
func UnzipArchive(archiveData []byte, dest string) error {
|
|
||||||
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, zipFile := range zipReader.File {
|
|
||||||
err := extractFileFromArchive(zipFile, dest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractFileFromArchive(file *zip.File, dest string) error {
|
|
||||||
f, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fpath := filepath.Join(dest, file.Name)
|
|
||||||
|
|
||||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(outFile, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return outFile.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||||
func UnzipFile(src string, dest string) error {
|
func UnzipFile(src string, dest string) error {
|
||||||
@@ -63,7 +20,7 @@ func UnzipFile(src string, dest string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer r.Close()
|
defer logs.CloseAndLogErr(r)
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
p := filepath.Join(dest, f.Name)
|
p := filepath.Join(dest, f.Name)
|
||||||
@@ -75,12 +32,14 @@ func UnzipFile(src string, dest string) error {
|
|||||||
|
|
||||||
if f.FileInfo().IsDir() {
|
if f.FileInfo().IsDir() {
|
||||||
// Make Folder
|
// Make Folder
|
||||||
os.MkdirAll(p, os.ModePerm)
|
if err := os.MkdirAll(p, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = unzipFile(f, p)
|
if err := unzipFile(f, p); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,20 +52,20 @@ func unzipFile(f *zip.File, p string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer logs.CloseAndLogErr(outFile)
|
||||||
|
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||||
}
|
}
|
||||||
defer rc.Close()
|
defer logs.CloseAndLogErr(rc)
|
||||||
|
|
||||||
_, err = io.Copy(outFile, rc)
|
if _, err = io.Copy(outFile, rc); err != nil {
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUnzipFile(t *testing.T) {
|
func TestUnzipFile(t *testing.T) {
|
||||||
@@ -20,7 +21,7 @@ func TestUnzipFile(t *testing.T) {
|
|||||||
|
|
||||||
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
archiveDir := dir + "/sample_archive"
|
archiveDir := dir + "/sample_archive"
|
||||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Registry represents an ECR registry endpoint information.
|
||||||
|
// This struct is used to parse and validate ECR endpoint URLs.
|
||||||
|
type Registry struct {
|
||||||
|
ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com")
|
||||||
|
FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL)
|
||||||
|
Region string // AWS region (e.g., "us-east-1", "us-gov-west-1")
|
||||||
|
Public bool // Whether this is ecr-public.aws.com
|
||||||
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Service struct {
|
Service struct {
|
||||||
accessKey string
|
accessKey string
|
||||||
|
|||||||
70
api/aws/ecr/parse_endpoints.go
Normal file
70
api/aws/ecr/parse_endpoints.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package ecr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats.
|
||||||
|
// Based on AWS ECR credential helper regex but extended to support accountless endpoints.
|
||||||
|
//
|
||||||
|
// Supported formats:
|
||||||
|
// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com
|
||||||
|
// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws
|
||||||
|
// - Accountless service: ecr-fips.us-west-1.amazonaws.com
|
||||||
|
// - Accountless API: ecr-fips.us-east-1.api.aws
|
||||||
|
// - Non-FIPS variants: All formats above without "-fips"
|
||||||
|
//
|
||||||
|
// Regex groups:
|
||||||
|
// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-"
|
||||||
|
// - Group 2: Account ID (optional) - e.g., "123456789012"
|
||||||
|
// - Group 3: FIPS flag (optional) - either "-fips" or empty string
|
||||||
|
// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1"
|
||||||
|
// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws"
|
||||||
|
var ecrEndpointPattern = regexp.MustCompile(
|
||||||
|
`^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseECREndpoint parses an ECR registry URL and extracts registry information.
|
||||||
|
|
||||||
|
// This function replaces the AWS ECR credential helper library's ExtractRegistry function,
|
||||||
|
// which only supports account-prefixed endpoints.
|
||||||
|
//
|
||||||
|
// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html
|
||||||
|
func ParseECREndpoint(urlStr string) (*Registry, error) {
|
||||||
|
// Normalize URL by adding https:// prefix if not present
|
||||||
|
if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") {
|
||||||
|
urlStr = "https://" + urlStr
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := u.Hostname()
|
||||||
|
|
||||||
|
// Special case: ECR Public
|
||||||
|
// ECR Public uses a different domain and doesn't have FIPS variant
|
||||||
|
if hostname == "ecr-public.aws.com" {
|
||||||
|
return &Registry{
|
||||||
|
FIPS: false,
|
||||||
|
Public: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse standard ECR endpoints using regex
|
||||||
|
matches := ecrEndpointPattern.FindStringSubmatch(hostname)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Registry{
|
||||||
|
ID: matches[2], // Account ID (may be empty for accountless endpoints)
|
||||||
|
FIPS: matches[3] == "-fips", // Check if "-fips" is present
|
||||||
|
Region: matches[4], // AWS region
|
||||||
|
Public: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
253
api/aws/ecr/parse_endpoints_test.go
Normal file
253
api/aws/ecr/parse_endpoints_test.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package ecr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseECREndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want *Registry
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
// Standard AWS Commercial - Account-prefixed FIPS
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS us-east-1",
|
||||||
|
url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-east-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS us-west-2",
|
||||||
|
url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-west-2",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accountless FIPS service endpoints
|
||||||
|
{
|
||||||
|
name: "accountless FIPS us-west-1",
|
||||||
|
url: "ecr-fips.us-west-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless FIPS us-east-2",
|
||||||
|
url: "ecr-fips.us-east-2.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-east-2",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accountless FIPS API endpoints
|
||||||
|
{
|
||||||
|
name: "accountless FIPS API us-west-1",
|
||||||
|
url: "ecr-fips.us-west-1.api.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless FIPS API us-east-1",
|
||||||
|
url: "ecr-fips.us-east-1.api.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-east-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// on.aws domain with hyphen separator
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS hyphen us-west-1",
|
||||||
|
url: "123456789012.dkr-ecr-fips.us-west-1.on.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS hyphen us-east-2",
|
||||||
|
url: "123456789012.dkr-ecr-fips.us-east-2.on.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-east-2",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// AWS GovCloud
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS us-gov-east-1",
|
||||||
|
url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-gov-east-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account-prefixed FIPS us-gov-west-1",
|
||||||
|
url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-gov-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless FIPS us-gov-west-1",
|
||||||
|
url: "ecr-fips.us-gov-west-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-gov-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless FIPS API us-gov-east-1",
|
||||||
|
url: "ecr-fips.us-gov-east-1.api.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-gov-east-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ECR Public
|
||||||
|
{
|
||||||
|
name: "ecr-public",
|
||||||
|
url: "ecr-public.aws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: false,
|
||||||
|
Region: "",
|
||||||
|
Public: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Non-FIPS endpoints (valid ECR but FIPS=false)
|
||||||
|
{
|
||||||
|
name: "account-prefixed non-FIPS us-east-1",
|
||||||
|
url: "123456789012.dkr.ecr.us-east-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "123456789012",
|
||||||
|
FIPS: false,
|
||||||
|
Region: "us-east-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless non-FIPS us-west-1",
|
||||||
|
url: "ecr.us-west-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: false,
|
||||||
|
Region: "us-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accountless non-FIPS API us-east-2",
|
||||||
|
url: "ecr.us-east-2.api.aws",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: false,
|
||||||
|
Region: "us-east-2",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// URLs with https:// prefix
|
||||||
|
{
|
||||||
|
name: "with https prefix",
|
||||||
|
url: "https://ecr-fips.us-west-1.amazonaws.com",
|
||||||
|
want: &Registry{
|
||||||
|
ID: "",
|
||||||
|
FIPS: true,
|
||||||
|
Region: "us-west-1",
|
||||||
|
Public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invalid endpoints
|
||||||
|
{
|
||||||
|
name: "not an ECR URL",
|
||||||
|
url: "not-an-ecr-url.com",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid account ID length",
|
||||||
|
url: "123.dkr.ecr-fips.us-east-1.amazonaws.com",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
url: "",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "docker hub",
|
||||||
|
url: "docker.io",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseECREndpoint(tt.url)
|
||||||
|
|
||||||
|
if tt.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseECREndpoint() expected error but got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseECREndpoint() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ID != tt.want.ID {
|
||||||
|
t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID)
|
||||||
|
}
|
||||||
|
if got.FIPS != tt.want.FIPS {
|
||||||
|
t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS)
|
||||||
|
}
|
||||||
|
if got.Region != tt.want.Region {
|
||||||
|
t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region)
|
||||||
|
}
|
||||||
|
if got.Public != tt.want.Public {
|
||||||
|
t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/offlinegate"
|
"github.com/portainer/portainer/api/http/offlinegate"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -21,6 +22,7 @@ const rwxr__r__ os.FileMode = 0o744
|
|||||||
|
|
||||||
var filesToBackup = []string{
|
var filesToBackup = []string{
|
||||||
"certs",
|
"certs",
|
||||||
|
"chisel",
|
||||||
"compose",
|
"compose",
|
||||||
"config.json",
|
"config.json",
|
||||||
"custom_templates",
|
"custom_templates",
|
||||||
@@ -30,40 +32,13 @@ var filesToBackup = []string{
|
|||||||
"portainer.key",
|
"portainer.key",
|
||||||
"portainer.pub",
|
"portainer.pub",
|
||||||
"tls",
|
"tls",
|
||||||
"chisel",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||||
unlock := gate.Lock()
|
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
|
||||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
// new export
|
|
||||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
|
||||||
|
|
||||||
err := datastore.Export(exportFilename)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
return "", err
|
||||||
} else {
|
|
||||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to backup database")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, filename := range filesToBackup {
|
|
||||||
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to create backup file")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
archivePath, err := archive.TarGzDir(backupDirPath)
|
archivePath, err := archive.TarGzDir(backupDirPath)
|
||||||
@@ -81,6 +56,37 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
|||||||
return archivePath, nil
|
return archivePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||||
|
unlock := gate.Lock()
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||||
|
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// new export
|
||||||
|
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||||
|
|
||||||
|
if err := datastore.Export(exportFilename); err != nil {
|
||||||
|
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to backup database")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filename := range filesToBackup {
|
||||||
|
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to create backup file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backupDirPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
dbFileName := datastore.Connection().GetDatabaseFileName()
|
||||||
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
|
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
|
||||||
@@ -92,7 +98,7 @@ func encrypt(path string, passphrase string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer in.Close()
|
defer logs.CloseAndLogErr(in)
|
||||||
|
|
||||||
outFileName := path + ".encrypted"
|
outFileName := path + ".encrypted"
|
||||||
out, err := os.Create(outFileName)
|
out, err := os.Create(outFileName)
|
||||||
@@ -100,7 +106,5 @@ func encrypt(path string, passphrase string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = crypto.AesEncrypt(in, out, []byte(passphrase))
|
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||||
|
|
||||||
return outFileName, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/offlinegate"
|
"github.com/portainer/portainer/api/http/offlinegate"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var filesToRestore = append(filesToBackup, "portainer.db")
|
var filesToRestore = append(filesToBackup, "portainer.db")
|
||||||
@@ -31,17 +33,20 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
|||||||
}
|
}
|
||||||
|
|
||||||
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
||||||
defer os.RemoveAll(filepath.Dir(restorePath))
|
defer func() {
|
||||||
|
if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to clean up restore files")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
err = extractArchive(archive, restorePath)
|
if err := extractArchive(archive, restorePath); err != nil {
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
||||||
}
|
}
|
||||||
|
|
||||||
unlock := gate.Lock()
|
unlock := gate.Lock()
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
if err = datastore.Close(); err != nil {
|
if err := datastore.Close(); err != nil {
|
||||||
return errors.Wrap(err, "Failed to stop db")
|
return errors.Wrap(err, "Failed to stop db")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
|||||||
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
|
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = restoreFiles(restorePath, filestorePath); err != nil {
|
if err := restoreFiles(restorePath, filestorePath); err != nil {
|
||||||
return errors.Wrap(err, "failed to restore the system state")
|
return errors.Wrap(err, "failed to restore the system state")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +94,7 @@ func getRestoreSourcePath(dir string) (string, error) {
|
|||||||
|
|
||||||
func restoreFiles(srcDir string, destinationDir string) error {
|
func restoreFiles(srcDir string, destinationDir string) error {
|
||||||
for _, filename := range filesToRestore {
|
for _, filename := range filesToRestore {
|
||||||
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
|
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,14 +102,18 @@ func restoreFiles(srcDir string, destinationDir string) error {
|
|||||||
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
|
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
|
||||||
|
|
||||||
// Prevent the possibility of having both databases. Remove any default new instance
|
// Prevent the possibility of having both databases. Remove any default new instance
|
||||||
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
|
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
||||||
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Now copy the database. It'll be either portainer.db or portainer.edb
|
// Now copy the database. It'll be either portainer.db or portainer.edb
|
||||||
|
|
||||||
// Note: CopyPath does not return an error if the source file doesn't exist
|
// Note: CopyPath does not return an error if the source file doesn't exist
|
||||||
err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir)
|
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package build
|
|
||||||
|
|
||||||
import "runtime"
|
|
||||||
|
|
||||||
// Variables to be set during the build time
|
|
||||||
var BuildNumber string
|
|
||||||
var ImageTag string
|
|
||||||
var NodejsVersion string
|
|
||||||
var YarnVersion string
|
|
||||||
var WebpackVersion string
|
|
||||||
var GoVersion string = runtime.Version()
|
|
||||||
var GitCommit string
|
|
||||||
@@ -54,8 +54,8 @@ func ecdsaGenerateKey(c elliptic.Curve, rand io.Reader) (*ecdsa.PrivateKey, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
priv := new(ecdsa.PrivateKey)
|
priv := new(ecdsa.PrivateKey)
|
||||||
priv.PublicKey.Curve = c
|
priv.Curve = c
|
||||||
priv.D = k
|
priv.D = k
|
||||||
priv.PublicKey.X, priv.PublicKey.Y = c.ScalarBaseMult(k.Bytes())
|
priv.X, priv.Y = c.ScalarBaseMult(k.Bytes())
|
||||||
return priv, nil
|
return priv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,10 +89,8 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
io.Copy(io.Discard, resp.Body)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
return resp.Body.Close()
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/pkg/fips"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
fips.InitFIPS(false)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPingAgentPanic(t *testing.T) {
|
func TestPingAgentPanic(t *testing.T) {
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
"github.com/portainer/portainer/pkg/libcrypto"
|
"github.com/portainer/portainer/pkg/libcrypto"
|
||||||
|
"github.com/portainer/portainer/pkg/librand"
|
||||||
|
|
||||||
"github.com/dchest/uniuri"
|
"github.com/dchest/uniuri"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -142,7 +142,9 @@ func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.Close()
|
if err := conn.Close(); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to close tcp connection")
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,9 @@ func (service *Service) getUnusedPort() int {
|
|||||||
|
|
||||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
conn.Close()
|
if err := conn.Close(); err != nil {
|
||||||
|
log.Warn().Msg("failed to close tcp connection that checks if port is free")
|
||||||
|
}
|
||||||
|
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Int("port", port).
|
Int("port", port).
|
||||||
@@ -213,7 +217,7 @@ func (service *Service) getUnusedPort() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func randomInt(min, max int) int {
|
func randomInt(min, max int) int {
|
||||||
return min + rand.Intn(max-min)
|
return min + librand.Intn(max-min)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRandomCredentials() (string, string) {
|
func generateRandomCredentials() (string, string) {
|
||||||
|
|||||||
79
api/chisel/tunnel_test.go
Normal file
79
api/chisel/tunnel_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package chisel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testSettingsService struct {
|
||||||
|
dataservices.SettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testSettingsService) Settings() (*portainer.Settings, error) {
|
||||||
|
return &portainer.Settings{
|
||||||
|
EdgeAgentCheckinInterval: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testStore struct {
|
||||||
|
dataservices.DataStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) Settings() dataservices.SettingsService {
|
||||||
|
return &testSettingsService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUnusedPort(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple case",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing tunnels",
|
||||||
|
existingTunnels: map[portainer.EndpointID]*portainer.TunnelDetails{
|
||||||
|
portainer.EndpointID(1): {
|
||||||
|
Port: 53072,
|
||||||
|
},
|
||||||
|
portainer.EndpointID(2): {
|
||||||
|
Port: 63072,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
store := &testStore{}
|
||||||
|
s := NewService(store, nil, nil)
|
||||||
|
s.activeTunnels = tc.existingTunnels
|
||||||
|
port := s.getUnusedPort()
|
||||||
|
|
||||||
|
if port < 49152 || port > 65535 {
|
||||||
|
t.Fatalf("Expected port to be inbetween 49152 and 65535 but got %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tun := range tc.existingTunnels {
|
||||||
|
if tun.Port == port {
|
||||||
|
t.Fatalf("returned port %d already has an existing tunnel", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||||
|
if err == nil {
|
||||||
|
// Ignore error
|
||||||
|
_ = conn.Close()
|
||||||
|
t.Fatalf("expected port %d to be unused", port)
|
||||||
|
} else if !strings.Contains(err.Error(), "connection refused") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
|
"github.com/alecthomas/kingpin/v2"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gopkg.in/alecthomas/kingpin.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the CLIService interface
|
// Service implements the CLIService interface
|
||||||
@@ -32,19 +32,12 @@ func CLIFlags() *portainer.CLIFlags {
|
|||||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||||
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
||||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
|
||||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||||
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
|
|
||||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
|
||||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
|
||||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
|
||||||
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
||||||
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
||||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
|
|
||||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
|
|
||||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
|
|
||||||
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
||||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
||||||
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
||||||
@@ -59,15 +52,53 @@ func CLIFlags() *portainer.CLIFlags {
|
|||||||
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
||||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||||
|
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||||
|
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
||||||
|
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
|
||||||
|
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||||
func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||||
kingpin.Version(version)
|
kingpin.Version(version)
|
||||||
|
|
||||||
|
var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool
|
||||||
|
sslFlag := kingpin.Flag(
|
||||||
|
"ssl",
|
||||||
|
"Secure Portainer instance using SSL (deprecated)",
|
||||||
|
).Default(defaultSSL).IsSetByUser(&hasSSLFlag)
|
||||||
|
ssl := sslFlag.Bool()
|
||||||
|
sslCertFlag := kingpin.Flag(
|
||||||
|
"sslcert",
|
||||||
|
"Path to the SSL certificate used to secure the Portainer instance",
|
||||||
|
).IsSetByUser(&hasSSLCertFlag)
|
||||||
|
sslCert := sslCertFlag.String()
|
||||||
|
sslKeyFlag := kingpin.Flag(
|
||||||
|
"sslkey",
|
||||||
|
"Path to the SSL key used to secure the Portainer instance",
|
||||||
|
).IsSetByUser(&hasSSLKeyFlag)
|
||||||
|
sslKey := sslKeyFlag.String()
|
||||||
|
|
||||||
flags := CLIFlags()
|
flags := CLIFlags()
|
||||||
|
|
||||||
|
var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool
|
||||||
|
tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag)
|
||||||
|
flags.TLS = tlsFlag.Bool()
|
||||||
|
tlsCertFlag := kingpin.Flag(
|
||||||
|
"tlscert",
|
||||||
|
"Path to the TLS certificate file",
|
||||||
|
).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag)
|
||||||
|
flags.TLSCert = tlsCertFlag.String()
|
||||||
|
tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag)
|
||||||
|
flags.TLSKey = tlsKeyFlag.String()
|
||||||
|
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
||||||
|
|
||||||
|
flags.KubectlShellImage = kingpin.Flag(
|
||||||
|
"kubectl-shell-image",
|
||||||
|
"Kubectl shell image",
|
||||||
|
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
|
||||||
|
|
||||||
kingpin.Parse()
|
kingpin.Parse()
|
||||||
|
|
||||||
if !filepath.IsAbs(*flags.Assets) {
|
if !filepath.IsAbs(*flags.Assets) {
|
||||||
@@ -79,11 +110,46 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||||||
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user didn't provide a tls flag remove the defaults to match previous behaviour
|
||||||
|
if !hasTLSFlag {
|
||||||
|
if !hasTLSCertFlag {
|
||||||
|
*flags.TLSCert = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasTLSKeyFlag {
|
||||||
|
*flags.TLSKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSSLFlag {
|
||||||
|
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name)
|
||||||
|
|
||||||
|
if !hasTLSFlag {
|
||||||
|
flags.TLS = ssl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSSLCertFlag {
|
||||||
|
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name)
|
||||||
|
|
||||||
|
if !hasTLSCertFlag {
|
||||||
|
flags.TLSCert = sslCert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSSLKeyFlag {
|
||||||
|
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name)
|
||||||
|
|
||||||
|
if !hasTLSKeyFlag {
|
||||||
|
flags.TLSKey = sslKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return flags, nil
|
return flags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFlags validates the values of the flags.
|
// ValidateFlags validates the values of the flags.
|
||||||
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
displayDeprecationWarnings(flags)
|
displayDeprecationWarnings(flags)
|
||||||
|
|
||||||
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
|
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
|
||||||
@@ -105,10 +171,6 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
|||||||
if *flags.NoAnalytics {
|
if *flags.NoAnalytics {
|
||||||
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.SSL {
|
|
||||||
log.Warn().Msg("SSL is enabled by default and there is no need for the --ssl flag, it has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpointURL(endpointURL string) error {
|
func validateEndpointURL(endpointURL string) error {
|
||||||
|
|||||||
209
api/cli/cli_test.go
Normal file
209
api/cli/cli_test.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
zerolog "github.com/rs/zerolog/log"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOptionParser(t *testing.T) {
|
||||||
|
p := Service{}
|
||||||
|
require.NotNil(t, p)
|
||||||
|
|
||||||
|
a := os.Args
|
||||||
|
defer func() { os.Args = a }()
|
||||||
|
|
||||||
|
os.Args = []string{"portainer", "--edge-compute"}
|
||||||
|
|
||||||
|
opts, err := p.ParseFlags("2.34.5")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.False(t, *opts.HTTPDisabled)
|
||||||
|
require.True(t, *opts.EnableEdgeComputeFeatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTLSFlags(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
expectedTLSFlag bool
|
||||||
|
expectedTLSCertFlag string
|
||||||
|
expectedTLSKeyFlag string
|
||||||
|
expectedLogMessages []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no flags",
|
||||||
|
expectedTLSFlag: false,
|
||||||
|
expectedTLSCertFlag: "",
|
||||||
|
expectedTLSKeyFlag: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only ssl flag",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--ssl",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "",
|
||||||
|
expectedTLSKeyFlag: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only tls flag",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--tlsverify",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: defaultTLSCertPath,
|
||||||
|
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial ssl flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--ssl",
|
||||||
|
"--sslcert=ssl-cert-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial tls flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--tlsverify",
|
||||||
|
"--tlscert=tls-cert-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial tls and ssl flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--tlsverify",
|
||||||
|
"--tlscert=tls-cert-flag-value",
|
||||||
|
"--sslkey=ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial tls and ssl flags 2",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--ssl",
|
||||||
|
"--tlscert=tls-cert-flag-value",
|
||||||
|
"--sslkey=ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssl flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--ssl",
|
||||||
|
"--sslcert=ssl-cert-flag-value",
|
||||||
|
"--sslkey=ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||||
|
expectedLogMessages: []string{
|
||||||
|
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||||
|
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||||
|
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tls flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--tlsverify",
|
||||||
|
"--tlscert=tls-cert-flag-value",
|
||||||
|
"--tlskey=tls-key-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tls and ssl flags",
|
||||||
|
args: []string{
|
||||||
|
"portainer",
|
||||||
|
"--tlsverify",
|
||||||
|
"--tlscert=tls-cert-flag-value",
|
||||||
|
"--tlskey=tls-key-flag-value",
|
||||||
|
"--ssl",
|
||||||
|
"--sslcert=ssl-cert-flag-value",
|
||||||
|
"--sslkey=ssl-key-flag-value",
|
||||||
|
},
|
||||||
|
expectedTLSFlag: true,
|
||||||
|
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||||
|
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||||
|
expectedLogMessages: []string{
|
||||||
|
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||||
|
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||||
|
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var logOutput strings.Builder
|
||||||
|
setupLogOutput(t, &logOutput)
|
||||||
|
|
||||||
|
if tc.args == nil {
|
||||||
|
tc.args = []string{"portainer"}
|
||||||
|
}
|
||||||
|
setOsArgs(t, tc.args)
|
||||||
|
|
||||||
|
s := Service{}
|
||||||
|
flags, err := s.ParseFlags("test-version")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error parsing flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.TLS == nil {
|
||||||
|
t.Fatal("TLS flag was nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match")
|
||||||
|
require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match")
|
||||||
|
require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match")
|
||||||
|
|
||||||
|
for _, expectedLogMessage := range tc.expectedLogMessages {
|
||||||
|
require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOsArgs(t *testing.T, args []string) {
|
||||||
|
t.Helper()
|
||||||
|
previousArgs := os.Args
|
||||||
|
os.Args = args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLogOutput(t *testing.T, w io.Writer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldLogger := zerolog.Logger
|
||||||
|
zerolog.Logger = zerolog.Output(w)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
zerolog.Logger = oldLogger
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
answer = strings.ReplaceAll(answer, "\n", "")
|
answer = strings.ReplaceAll(answer, "\n", "")
|
||||||
answer = strings.ToLower(answer)
|
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
||||||
|
|
||||||
return answer == "y" || answer == "yes", nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build !windows
|
//go:build !windows
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
@@ -20,4 +19,5 @@ const (
|
|||||||
defaultSSL = "false"
|
defaultSSL = "false"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
|
defaultPullLimitCheckDisabled = "false"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ const (
|
|||||||
defaultSnapshotInterval = "5m"
|
defaultSnapshotInterval = "5m"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
|
defaultPullLimitCheckDisabled = "false"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/alecthomas/kingpin.v2"
|
"github.com/alecthomas/kingpin/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pairList []portainer.Pair
|
type pairList []portainer.Pair
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
|
|
||||||
"gopkg.in/alecthomas/kingpin.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pairListBool []portainer.Pair
|
|
||||||
|
|
||||||
// Set implementation for a list of portainer.Pair
|
|
||||||
func (l *pairListBool) Set(value string) error {
|
|
||||||
p := new(portainer.Pair)
|
|
||||||
|
|
||||||
// default to true. example setting=true is equivalent to setting
|
|
||||||
parts := strings.SplitN(value, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
p.Name = parts[0]
|
|
||||||
p.Value = "true"
|
|
||||||
} else {
|
|
||||||
p.Name = parts[0]
|
|
||||||
p.Value = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
*l = append(*l, *p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// String implementation for a list of pair
|
|
||||||
func (l *pairListBool) String() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsCumulative implementation for a list of pair
|
|
||||||
func (l *pairListBool) IsCumulative() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
|
|
||||||
target = new([]portainer.Pair)
|
|
||||||
s.SetValue((*pairListBool)(target))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/apikey"
|
"github.com/portainer/portainer/api/apikey"
|
||||||
"github.com/portainer/portainer/api/build"
|
|
||||||
"github.com/portainer/portainer/api/chisel"
|
"github.com/portainer/portainer/api/chisel"
|
||||||
"github.com/portainer/portainer/api/cli"
|
"github.com/portainer/portainer/api/cli"
|
||||||
"github.com/portainer/portainer/api/crypto"
|
"github.com/portainer/portainer/api/crypto"
|
||||||
@@ -40,6 +39,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/kubernetes"
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
"github.com/portainer/portainer/api/ldap"
|
"github.com/portainer/portainer/api/ldap"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
"github.com/portainer/portainer/api/oauth"
|
"github.com/portainer/portainer/api/oauth"
|
||||||
"github.com/portainer/portainer/api/pendingactions"
|
"github.com/portainer/portainer/api/pendingactions"
|
||||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||||
@@ -47,17 +47,20 @@ import (
|
|||||||
"github.com/portainer/portainer/api/platform"
|
"github.com/portainer/portainer/api/platform"
|
||||||
"github.com/portainer/portainer/api/scheduler"
|
"github.com/portainer/portainer/api/scheduler"
|
||||||
"github.com/portainer/portainer/api/stacks/deployments"
|
"github.com/portainer/portainer/api/stacks/deployments"
|
||||||
|
"github.com/portainer/portainer/pkg/build"
|
||||||
"github.com/portainer/portainer/pkg/featureflags"
|
"github.com/portainer/portainer/pkg/featureflags"
|
||||||
|
"github.com/portainer/portainer/pkg/fips"
|
||||||
"github.com/portainer/portainer/pkg/libhelm"
|
"github.com/portainer/portainer/pkg/libhelm"
|
||||||
"github.com/portainer/portainer/pkg/libstack"
|
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||||
|
"github.com/portainer/portainer/pkg/validate"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initCLI() *portainer.CLIFlags {
|
func initCLI() *portainer.CLIFlags {
|
||||||
cliService := &cli.Service{}
|
cliService := cli.Service{}
|
||||||
|
|
||||||
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,7 +84,7 @@ func initFileService(dataStorePath string) portainer.FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
|
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
|
||||||
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
|
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed creating database connection")
|
log.Fatal().Err(err).Msg("failed creating database connection")
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
||||||
}
|
}
|
||||||
|
|
||||||
store := datastore.NewStore(*flags.Data, fileService, connection)
|
store := datastore.NewStore(flags, fileService, connection)
|
||||||
|
|
||||||
isNew, err := store.Open()
|
isNew, err := store.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,12 +119,12 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isNew {
|
if isNew {
|
||||||
instanceId, err := uuid.NewV4()
|
instanceId, err := uuid.NewRandom()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||||
}
|
}
|
||||||
|
|
||||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
|
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
|
||||||
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
||||||
|
|
||||||
// from MigrateData
|
// from MigrateData
|
||||||
@@ -131,16 +134,17 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
InstanceID: instanceId.String(),
|
InstanceID: instanceId.String(),
|
||||||
MigratorCount: migratorCount,
|
MigratorCount: migratorCount,
|
||||||
}
|
}
|
||||||
store.VersionService.UpdateVersion(&v)
|
|
||||||
|
if err := store.VersionService.UpdateVersion(&v); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to update version")
|
||||||
|
}
|
||||||
|
|
||||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
if err := updateSettingsFromFlags(store, flags); err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||||
}
|
}
|
||||||
} else {
|
} else if err := store.MigrateData(); err != nil {
|
||||||
if err := store.MigrateData(); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed migration")
|
log.Fatal().Err(err).Msg("failed migration")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
if err := updateSettingsFromFlags(store, flags); err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||||
@@ -150,7 +154,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
go func() {
|
go func() {
|
||||||
<-shutdownCtx.Done()
|
<-shutdownCtx.Done()
|
||||||
|
|
||||||
defer connection.Close()
|
defer logs.CloseAndLogErr(connection)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return store
|
return store
|
||||||
@@ -166,32 +170,12 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
|||||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||||
}
|
}
|
||||||
|
|
||||||
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
|
||||||
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed creating compose manager")
|
|
||||||
}
|
|
||||||
|
|
||||||
return composeWrapper
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initSwarmStackManager(
|
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||||
assetsPath string,
|
return libhelm.NewHelmPackageManager()
|
||||||
configPath string,
|
|
||||||
signatureService portainer.DigitalSignatureService,
|
|
||||||
fileService portainer.FileService,
|
|
||||||
reverseTunnelService portainer.ReverseTunnelService,
|
|
||||||
dataStore dataservices.DataStore,
|
|
||||||
) (portainer.SwarmStackManager, error) {
|
|
||||||
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
|
||||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
|
||||||
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||||
@@ -259,10 +243,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
|
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
|
||||||
settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
|
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
|
||||||
settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
|
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||||
settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
|
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
settings.BlackListedLabels = *flags.Labels
|
settings.BlackListedLabels = *flags.Labels
|
||||||
@@ -324,8 +308,19 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
|||||||
return generateAndStoreKeyPair(fileService, signatureService)
|
return generateAndStoreKeyPair(fileService, signatureService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbSecretPath build the path to the file that contains the db encryption
|
||||||
|
// secret. Normally in Docker this is built from the static path inside
|
||||||
|
// /run/secrets for example: /run/secrets/<keyFilenameFlag> but for ease of
|
||||||
|
// use outside Docker it also accepts an absolute path
|
||||||
|
func dbSecretPath(keyFilenameFlag string) string {
|
||||||
|
if path.IsAbs(keyFilenameFlag) {
|
||||||
|
return keyFilenameFlag
|
||||||
|
}
|
||||||
|
return path.Join("/run/secrets", keyFilenameFlag)
|
||||||
|
}
|
||||||
|
|
||||||
func loadEncryptionSecretKey(keyfilename string) []byte {
|
func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||||
content, err := os.ReadFile(path.Join("/run/secrets", keyfilename))
|
content, err := os.ReadFile(keyfilename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
||||||
@@ -337,6 +332,7 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// return a 32 byte hash of the secret (required for AES)
|
// return a 32 byte hash of the secret (required for AES)
|
||||||
|
// fips compliant version of this is not implemented in -ce
|
||||||
hash := sha256.Sum256(content)
|
hash := sha256.Sum256(content)
|
||||||
|
|
||||||
return hash[:]
|
return hash[:]
|
||||||
@@ -349,8 +345,23 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trustedOrigins := []string{}
|
||||||
|
if *flags.TrustedOrigins != "" {
|
||||||
|
// validate if the trusted origins are valid urls
|
||||||
|
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
|
||||||
|
if !validate.IsTrustedOrigin(origin) {
|
||||||
|
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedOrigins = append(trustedOrigins, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -ce can not ever be run in FIPS mode
|
||||||
|
fips.InitFIPS(false)
|
||||||
|
|
||||||
fileService := initFileService(*flags.Data)
|
fileService := initFileService(*flags.Data)
|
||||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName))
|
||||||
if encryptionKey == nil {
|
if encryptionKey == nil {
|
||||||
log.Info().Msg("proceeding without encryption key")
|
log.Info().Msg("proceeding without encryption key")
|
||||||
}
|
}
|
||||||
@@ -383,21 +394,22 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
||||||
}
|
}
|
||||||
|
|
||||||
ldapService := &ldap.Service{}
|
ldapService := ldap.Service{}
|
||||||
|
|
||||||
oauthService := oauth.NewService()
|
oauthService := oauth.NewService()
|
||||||
|
|
||||||
gitService := git.NewService(shutdownCtx)
|
gitService := git.NewService(shutdownCtx)
|
||||||
|
|
||||||
openAMTService := openamt.NewService()
|
// Setting insecureSkipVerify to true to preserve the old behaviour.
|
||||||
|
openAMTService := openamt.NewService(true)
|
||||||
|
|
||||||
cryptoService := &crypto.Service{}
|
cryptoService := crypto.Service{}
|
||||||
|
|
||||||
signatureService := initDigitalSignatureService()
|
signatureService := initDigitalSignatureService()
|
||||||
|
|
||||||
edgeStacksService := edgestacks.NewService(dataStore)
|
edgeStacksService := edgestacks.NewService(dataStore)
|
||||||
|
|
||||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("")
|
log.Fatal().Err(err).Msg("")
|
||||||
}
|
}
|
||||||
@@ -433,19 +445,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
|
|
||||||
dockerConfigPath := fileService.GetDockerConfigPath()
|
dockerConfigPath := fileService.GetDockerConfigPath()
|
||||||
|
|
||||||
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
|
composeDeployer := compose.NewComposeDeployer()
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed initializing compose deployer")
|
|
||||||
}
|
|
||||||
|
|
||||||
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
|
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
|
||||||
|
|
||||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets)
|
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||||
|
|
||||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
||||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
||||||
@@ -459,9 +468,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
|
|
||||||
snapshotService.Start()
|
snapshotService.Start()
|
||||||
|
|
||||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
||||||
|
|
||||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
helmPackageManager, err := initHelmPackageManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||||
}
|
}
|
||||||
@@ -521,7 +530,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
|
|
||||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||||
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
|
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
|
||||||
deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
|
if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to start stack scheduler")
|
||||||
|
}
|
||||||
|
|
||||||
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -567,6 +578,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
Status: applicationStatus,
|
Status: applicationStatus,
|
||||||
BindAddress: *flags.Addr,
|
BindAddress: *flags.Addr,
|
||||||
BindAddressHTTPS: *flags.AddrHTTPS,
|
BindAddressHTTPS: *flags.AddrHTTPS,
|
||||||
|
CSP: *flags.CSP,
|
||||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||||
AssetsPath: *flags.Assets,
|
AssetsPath: *flags.Assets,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
@@ -599,17 +611,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
AdminCreationDone: adminCreationDone,
|
AdminCreationDone: adminCreationDone,
|
||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
PlatformService: platformService,
|
||||||
|
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||||
|
TrustedOrigins: trustedOrigins,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configureLogger()
|
logs.ConfigureLogger()
|
||||||
setLoggingMode("PRETTY")
|
logs.SetLoggingMode("PRETTY")
|
||||||
|
|
||||||
flags := initCLI()
|
flags := initCLI()
|
||||||
|
|
||||||
setLoggingLevel(*flags.LogLevel)
|
logs.SetLoggingLevel(*flags.LogLevel)
|
||||||
setLoggingMode(*flags.LogMode)
|
logs.SetLoggingMode(*flags.LogMode)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
server := buildServer(flags)
|
server := buildServer(flags)
|
||||||
@@ -619,7 +633,7 @@ func main() {
|
|||||||
Str("build_number", build.BuildNumber).
|
Str("build_number", build.BuildNumber).
|
||||||
Str("image_tag", build.ImageTag).
|
Str("image_tag", build.ImageTag).
|
||||||
Str("nodejs_version", build.NodejsVersion).
|
Str("nodejs_version", build.NodejsVersion).
|
||||||
Str("yarn_version", build.YarnVersion).
|
Str("pnpm_version", build.PnpmVersion).
|
||||||
Str("webpack_version", build.WebpackVersion).
|
Str("webpack_version", build.WebpackVersion).
|
||||||
Str("go_version", build.GoVersion).
|
Str("go_version", build.GoVersion).
|
||||||
Msg("starting Portainer")
|
Msg("starting Portainer")
|
||||||
|
|||||||
57
api/cmd/portainer/main_test.go
Normal file
57
api/cmd/portainer/main_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const secretFileName = "secret.txt"
|
||||||
|
|
||||||
|
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||||
|
err := os.WriteFile(secretPath, []byte(password), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return secretPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
secretPath := path.Join(tempDir, secretFileName)
|
||||||
|
|
||||||
|
// first pointing to file that does not exist, gives nil hash (no encryption)
|
||||||
|
encryptionKey := loadEncryptionSecretKey(secretPath)
|
||||||
|
require.Nil(t, encryptionKey)
|
||||||
|
|
||||||
|
// point to a directory instead of a file
|
||||||
|
encryptionKey = loadEncryptionSecretKey(tempDir)
|
||||||
|
require.Nil(t, encryptionKey)
|
||||||
|
|
||||||
|
password := "portainer@1234"
|
||||||
|
createPasswordFile(t, secretPath, password)
|
||||||
|
|
||||||
|
encryptionKey = loadEncryptionSecretKey(secretPath)
|
||||||
|
require.NotNil(t, encryptionKey)
|
||||||
|
// should be 32 bytes for aes256 encryption
|
||||||
|
require.Len(t, encryptionKey, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBSecretPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
keyFilenameFlag string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{keyFilenameFlag: "secret.txt", expected: "/run/secrets/secret.txt"},
|
||||||
|
{keyFilenameFlag: "/tmp/secret.txt", expected: "/tmp/secret.txt"},
|
||||||
|
{keyFilenameFlag: "/run/secrets/secret.txt", expected: "/run/secrets/secret.txt"},
|
||||||
|
{keyFilenameFlag: "./secret.txt", expected: "/run/secrets/secret.txt"},
|
||||||
|
{keyFilenameFlag: "../secret.txt", expected: "/run/secret.txt"},
|
||||||
|
{keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/secrets/foo/bar/secret.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
assert.Equal(t, test.expected, dbSecretPath(test.keyFilenameFlag))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
|
|
||||||
type ReadTransaction interface {
|
type ReadTransaction interface {
|
||||||
GetObject(bucketName string, key []byte, object any) error
|
GetObject(bucketName string, key []byte, object any) error
|
||||||
|
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||||
|
KeyExists(bucketName string, key []byte) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Transaction interface {
|
type Transaction interface {
|
||||||
@@ -40,6 +42,7 @@ type Connection interface {
|
|||||||
GetDatabaseFileName() string
|
GetDatabaseFileName() string
|
||||||
GetDatabaseFilePath() string
|
GetDatabaseFilePath() string
|
||||||
GetStorePath() string
|
GetStorePath() string
|
||||||
|
GetDatabaseFileSize() (int64, error)
|
||||||
|
|
||||||
IsEncryptedStore() bool
|
IsEncryptedStore() bool
|
||||||
NeedsEncryptionMigration() (bool, error)
|
NeedsEncryptionMigration() (bool, error)
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"crypto/pbkdf2"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"github.com/portainer/portainer/pkg/fips"
|
||||||
"golang.org/x/crypto/scrypt"
|
|
||||||
|
// Not allowed in FIPS mode
|
||||||
|
"golang.org/x/crypto/argon2" //nolint:depguard
|
||||||
|
"golang.org/x/crypto/scrypt" //nolint:depguard
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,35 +25,68 @@ const (
|
|||||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||||
|
|
||||||
|
aesGcmFIPSHeader = "FIPS-AES256-GCM"
|
||||||
|
aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm
|
||||||
|
|
||||||
// Argon2 settings
|
// Argon2 settings
|
||||||
// Recommded settings lower memory hardware according to current OWASP recommendations
|
// Recommended settings lower memory hardware according to current OWASP recommendations
|
||||||
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||||
argon2MemoryCost = 12 * 1024
|
argon2MemoryCost = 12 * 1024
|
||||||
argon2TimeCost = 3
|
argon2TimeCost = 3
|
||||||
argon2Threads = 1
|
argon2Threads = 1
|
||||||
argon2KeyLength = 32
|
argon2KeyLength = 32
|
||||||
|
|
||||||
|
pbkdf2Iterations = 600_000 // use recommended iterations from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 a little overkill for this use
|
||||||
|
pbkdf2SaltLength = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
||||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||||
|
if fips.FIPSMode() {
|
||||||
|
if err := aesEncryptGCMFIPS(input, output, passphrase); err != nil {
|
||||||
|
return fmt.Errorf("error encrypting file: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if err := aesEncryptGCM(input, output, passphrase); err != nil {
|
if err := aesEncryptGCM(input, output, passphrase); err != nil {
|
||||||
return fmt.Errorf("error encrypting file: %w", err)
|
return fmt.Errorf("error encrypting file: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
||||||
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
return aesDecrypt(input, passphrase, fips.FIPSMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesDecrypt(input io.Reader, passphrase []byte, fipsMode bool) (io.Reader, error) {
|
||||||
// Read file header to determine how it was encrypted
|
// Read file header to determine how it was encrypted
|
||||||
inputReader := bufio.NewReader(input)
|
inputReader := bufio.NewReader(input)
|
||||||
header, err := inputReader.Peek(len(aesGcmHeader))
|
header, err := inputReader.Peek(len(aesGcmFIPSHeader))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header) == aesGcmHeader {
|
if strings.HasPrefix(string(header), aesGcmFIPSHeader) {
|
||||||
|
if !fipsMode {
|
||||||
|
return nil, errors.New("fips encrypted file detected but fips mode is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := aesDecryptGCMFIPS(inputReader, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(string(header), aesGcmHeader) {
|
||||||
|
if fipsMode {
|
||||||
|
return nil, errors.New("fips mode is enabled but non-fips encrypted file detected")
|
||||||
|
}
|
||||||
|
|
||||||
reader, err := aesDecryptGCM(inputReader, passphrase)
|
reader, err := aesDecryptGCM(inputReader, passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||||
@@ -114,19 +153,20 @@ func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
|
|||||||
break // end of plaintext input
|
break // end of plaintext input
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
||||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
||||||
|
|
||||||
_, err = output.Write(ciphertext)
|
if _, err := output.Write(ciphertext); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce.Increment()
|
if err := nonce.Increment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -183,7 +223,7 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|||||||
break // end of ciphertext
|
break // end of ciphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +237,134 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce.Increment()
|
if err := nonce.Increment(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aesEncryptGCMFIPS reads from input, encrypts with AES-256 in a fips compliant
|
||||||
|
// way and writes to output. passphrase is used to generate an encryption key.
|
||||||
|
func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||||
|
salt := make([]byte, pbkdf2SaltLength)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deriving key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the header
|
||||||
|
if _, err := output.Write([]byte(aesGcmFIPSHeader)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write nonce and salt to the output file
|
||||||
|
if _, err := output.Write(salt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer for reading plaintext blocks
|
||||||
|
buf := make([]byte, aesGcmFIPSBlockSize)
|
||||||
|
|
||||||
|
// Encrypt plaintext in blocks
|
||||||
|
for {
|
||||||
|
// new random nonce for each block
|
||||||
|
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating gcm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := io.ReadFull(input, buf)
|
||||||
|
if n == 0 {
|
||||||
|
break // end of plaintext input
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal encrypts the plaintext
|
||||||
|
ciphertext := aesgcm.Seal(nil, nil, buf[:n], nil)
|
||||||
|
|
||||||
|
if _, err := output.Write(ciphertext); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aesDecryptGCMFIPS reads from input, decrypts with AES-256 in a fips compliant
|
||||||
|
// way and returns the reader to read the decrypted content from.
|
||||||
|
func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
// Reader & verify header
|
||||||
|
header := make([]byte, len(aesGcmFIPSHeader))
|
||||||
|
if _, err := io.ReadFull(input, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(header) != aesGcmFIPSHeader {
|
||||||
|
return nil, errors.New("invalid header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read salt
|
||||||
|
salt := make([]byte, pbkdf2SaltLength)
|
||||||
|
if _, err := io.ReadFull(input, salt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error deriving key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize AES cipher block
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a buffer to store decrypted data
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
|
||||||
|
// Decrypt the ciphertext in blocks
|
||||||
|
for {
|
||||||
|
// Create GCM mode with the cipher block
|
||||||
|
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a block of ciphertext from the input reader
|
||||||
|
ciphertextBlock := make([]byte, aesGcmFIPSBlockSize+aesgcm.Overhead())
|
||||||
|
n, err := io.ReadFull(input, ciphertextBlock)
|
||||||
|
if n == 0 {
|
||||||
|
break // end of ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the block of ciphertext
|
||||||
|
plaintext, err := aesgcm.Open(nil, nil, ciphertextBlock[:n], nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := buf.Write(plaintext); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &buf, nil
|
return &buf, nil
|
||||||
@@ -207,11 +374,9 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|||||||
// passphrase is used to generate an encryption key.
|
// passphrase is used to generate an encryption key.
|
||||||
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
||||||
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
var emptySalt []byte = make([]byte, 0)
|
|
||||||
|
|
||||||
// making a 32 bytes key that would correspond to AES-256
|
// making a 32 bytes key that would correspond to AES-256
|
||||||
// don't necessarily need a salt, so just kept in empty
|
// don't necessarily need a salt, so just kept in empty
|
||||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -228,3 +393,18 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasEncryptedHeader checks if the data has an encrypted header, note that fips
|
||||||
|
// mode changes this behavior and so will only recognize data encrypted by the
|
||||||
|
// same mode (fips enabled or disabled)
|
||||||
|
func HasEncryptedHeader(data []byte) bool {
|
||||||
|
return hasEncryptedHeader(data, fips.FIPSMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasEncryptedHeader(data []byte, fipsMode bool) bool {
|
||||||
|
if fipsMode {
|
||||||
|
return bytes.HasPrefix(data, []byte(aesGcmFIPSHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.HasPrefix(data, []byte(aesGcmHeader))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
"github.com/portainer/portainer/pkg/fips"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/scrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
fips.InitFIPS(false)
|
||||||
|
}
|
||||||
|
|
||||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|
||||||
func randBytes(n int) []byte {
|
func randBytes(n int) []byte {
|
||||||
@@ -17,12 +28,17 @@ func randBytes(n int) []byte {
|
|||||||
for i := range b {
|
for i := range b {
|
||||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||||
}
|
}
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type encryptFunc func(input io.Reader, output io.Writer, passphrase []byte) error
|
||||||
|
type decryptFunc func(input io.Reader, passphrase []byte) (io.Reader, error)
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||||
const passphrase = "passphrase"
|
const passphrase = "passphrase"
|
||||||
|
|
||||||
|
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,38 +48,102 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1024*1024*100 + 523)
|
content := randBytes(1024*1024*100 + 523)
|
||||||
os.WriteFile(originFilePath, content, 0600)
|
err := os.WriteFile(originFilePath, content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
originFile, _ := os.Open(originFilePath)
|
||||||
defer originFile.Close()
|
defer logs.CloseAndLogErr(originFile)
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||||
|
|
||||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||||
assert.Nil(t, err, "Failed to encrypt a file")
|
require.NoError(t, err, "Failed to encrypt a file")
|
||||||
encryptedFileWriter.Close()
|
logs.CloseAndLogErr(encryptedFileWriter)
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
require.NoError(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||||
defer encryptedFileReader.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||||
|
|
||||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||||
defer decryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||||
|
|
||||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||||
assert.Nil(t, err, "Failed to decrypt file")
|
if !decryptShouldSucceed {
|
||||||
|
require.Error(t, err, "Failed to decrypt file as indicated by decryptShouldSucceed")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed")
|
||||||
|
|
||||||
io.Copy(decryptedFileWriter, decryptedReader)
|
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCM, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("system_fips_mode_public_entry_points", func(t *testing.T) {
|
||||||
|
// use the init mode, public entry points
|
||||||
|
testFunc(t, AesEncrypt, AesDecrypt, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fips_encrypted_file_header_fails_in_non_fips_mode", func(t *testing.T) {
|
||||||
|
// use aesDecrypt which checks the header, confirm that it fails
|
||||||
|
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
return aesDecrypt(input, passphrase, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, decrypt, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips_encrypted_file_header_fails_in_fips_mode", func(t *testing.T) {
|
||||||
|
// use aesDecrypt which checks the header, confirm that it fails
|
||||||
|
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
return aesDecrypt(input, passphrase, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFunc(t, aesEncryptGCM, decrypt, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fips_encrypted_file_fails_in_non_fips_mode", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCM, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips_encrypted_file_with_fips_mode_should_fail", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCMFIPS, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fips_with_base_aesDecrypt", func(t *testing.T) {
|
||||||
|
// maximize coverage, use the base aesDecrypt function with valid fips mode
|
||||||
|
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
return aesDecrypt(input, passphrase, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, decrypt, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("legacy", func(t *testing.T) {
|
||||||
|
testFunc(t, legacyAesEncrypt, aesDecryptOFB, true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||||
|
|
||||||
|
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -73,37 +153,54 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(500)
|
content := randBytes(500)
|
||||||
os.WriteFile(originFilePath, content, 0600)
|
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
err := os.WriteFile(originFilePath, content, 0600)
|
||||||
defer originFile.Close()
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
originFile, err := os.Open(originFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(originFile)
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||||
|
|
||||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||||
assert.Nil(t, err, "Failed to encrypt a file")
|
require.NoError(t, err, "Failed to encrypt a file")
|
||||||
encryptedFileWriter.Close()
|
logs.CloseAndLogErr(encryptedFileWriter)
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
require.NoError(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||||
defer encryptedFileReader.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||||
|
|
||||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||||
defer decryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||||
|
|
||||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||||
assert.Nil(t, err, "Failed to decrypt file")
|
require.NoError(t, err, "Failed to decrypt file")
|
||||||
|
|
||||||
io.Copy(decryptedFileWriter, decryptedReader)
|
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||||
|
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -113,37 +210,54 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(500)
|
content := randBytes(500)
|
||||||
os.WriteFile(originFilePath, content, 0600)
|
err := os.WriteFile(originFilePath, content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
originFile, err := os.Open(originFilePath)
|
||||||
defer originFile.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(originFile)
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||||
assert.Nil(t, err, "Failed to encrypt a file")
|
require.NoError(t, err, "Failed to encrypt a file")
|
||||||
encryptedFileWriter.Close()
|
logs.CloseAndLogErr(encryptedFileWriter)
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
require.NoError(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||||
defer encryptedFileReader.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||||
|
|
||||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||||
defer decryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||||
|
|
||||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase"))
|
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
|
||||||
assert.Nil(t, err, "Failed to decrypt file")
|
require.NoError(t, err, "Failed to decrypt file")
|
||||||
|
|
||||||
io.Copy(decryptedFileWriter, decryptedReader)
|
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||||
|
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -153,36 +267,54 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1024 * 50)
|
content := randBytes(1024 * 50)
|
||||||
os.WriteFile(originFilePath, content, 0600)
|
err := os.WriteFile(originFilePath, content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
originFile, err := os.Open(originFilePath)
|
||||||
defer originFile.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(originFile)
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||||
defer encryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||||
|
|
||||||
|
err = encrypt(originFile, encryptedFileWriter, []byte(""))
|
||||||
|
require.NoError(t, err, "Failed to encrypt a file")
|
||||||
|
|
||||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(""))
|
|
||||||
assert.Nil(t, err, "Failed to encrypt a file")
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
require.NoError(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||||
defer encryptedFileReader.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||||
|
|
||||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||||
defer decryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||||
|
|
||||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(""))
|
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
|
||||||
assert.Nil(t, err, "Failed to decrypt file")
|
require.NoError(t, err, "Failed to decrypt file")
|
||||||
|
|
||||||
io.Copy(decryptedFileWriter, decryptedReader)
|
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
||||||
|
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -192,26 +324,121 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
|||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1034)
|
content := randBytes(1034)
|
||||||
os.WriteFile(originFilePath, content, 0600)
|
err := os.WriteFile(originFilePath, content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
originFile, err := os.Open(originFilePath)
|
||||||
defer originFile.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(originFile)
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||||
defer encryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||||
|
|
||||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||||
assert.Nil(t, err, "Failed to encrypt a file")
|
require.NoError(t, err, "Failed to encrypt a file")
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
require.NoError(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||||
defer encryptedFileReader.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||||
|
|
||||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||||
defer decryptedFileWriter.Close()
|
require.NoError(t, err)
|
||||||
|
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||||
|
|
||||||
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
|
_, err = decrypt(encryptedFileReader, []byte("garbage"))
|
||||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_fips", func(t *testing.T) {
|
||||||
|
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||||
|
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var iv [aes.BlockSize]byte
|
||||||
|
stream := cipher.NewOFB(block, iv[:])
|
||||||
|
|
||||||
|
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||||
|
if _, err := io.Copy(writer, input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_hasEncryptedHeader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
fipsMode bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "non-FIPS mode with valid header",
|
||||||
|
data: []byte("AES256-GCM" + "some encrypted data"),
|
||||||
|
fipsMode: false,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-FIPS mode with FIPS header",
|
||||||
|
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
||||||
|
fipsMode: false,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FIPS mode with valid header",
|
||||||
|
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
||||||
|
fipsMode: true,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FIPS mode with non-FIPS header",
|
||||||
|
data: []byte("AES256-GCM" + "some encrypted data"),
|
||||||
|
fipsMode: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid header",
|
||||||
|
data: []byte("INVALID-HEADER" + "some data"),
|
||||||
|
fipsMode: false,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty data",
|
||||||
|
data: []byte{},
|
||||||
|
fipsMode: false,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil data",
|
||||||
|
data: nil,
|
||||||
|
fipsMode: false,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := hasEncryptedHeader(tt.data, tt.fipsMode)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
|
|||||||
message = service.secret
|
message = service.secret
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := libcrypto.HashFromBytes([]byte(message))
|
hash := libcrypto.InsecureHashFromBytes([]byte(message))
|
||||||
|
|
||||||
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
22
api/crypto/ecdsa_test.go
Normal file
22
api/crypto/ecdsa_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateSignature(t *testing.T) {
|
||||||
|
var s = NewECDSAService("secret")
|
||||||
|
|
||||||
|
privKey, pubKey, err := s.GenerateKeyPair()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, privKey)
|
||||||
|
require.NotEmpty(t, pubKey)
|
||||||
|
|
||||||
|
m := "test message"
|
||||||
|
r, err := s.CreateSignature(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, r, m)
|
||||||
|
require.NotEmpty(t, r)
|
||||||
|
}
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
// Not allowed in FIPS mode
|
||||||
|
"golang.org/x/crypto/bcrypt" //nolint:depguard
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service for encrypting/hashing data.
|
// Service represents a service for encrypting/hashing data.
|
||||||
type Service struct{}
|
type Service struct{}
|
||||||
|
|
||||||
// Hash hashes a string using the bcrypt algorithm
|
// Hash hashes a string using the bcrypt algorithm
|
||||||
func (*Service) Hash(data string) (string, error) {
|
func (Service) Hash(data string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
||||||
func (*Service) CompareHashAndData(hash string, data string) error {
|
func (Service) CompareHashAndData(hash string, data string) error {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package crypto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestService_Hash(t *testing.T) {
|
func TestService_Hash(t *testing.T) {
|
||||||
var s = &Service{}
|
var s = Service{}
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
hash string
|
hash string
|
||||||
@@ -51,3 +53,11 @@ func TestService_Hash(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHash(t *testing.T) {
|
||||||
|
s := Service{}
|
||||||
|
|
||||||
|
hash, err := s.Hash("Passw0rd!")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, hash)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func NewNonce(size int) *Nonce {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
||||||
// This ensures there are plenty of nonce values availble before rolling over
|
// This ensures there are plenty of nonce values available before rolling over
|
||||||
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
||||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
||||||
func NewRandomNonce(size int) (*Nonce, error) {
|
func NewRandomNonce(size int) (*Nonce, error) {
|
||||||
|
|||||||
@@ -4,11 +4,32 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/pkg/fips"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
||||||
func CreateTLSConfiguration() *tls.Config {
|
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||||
return &tls.Config{
|
return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||||
|
if fipsEnabled {
|
||||||
|
return &tls.Config{ //nolint:forbidigo
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
MaxVersion: tls.VersionTLS13,
|
||||||
|
CipherSuites: []uint16{
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
},
|
||||||
|
CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tls.Config{ //nolint:forbidigo
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
CipherSuites: []uint16{
|
CipherSuites: []uint16{
|
||||||
tls.TLS_AES_128_GCM_SHA256,
|
tls.TLS_AES_128_GCM_SHA256,
|
||||||
@@ -29,24 +50,33 @@ func CreateTLSConfiguration() *tls.Config {
|
|||||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||||
},
|
},
|
||||||
|
InsecureSkipVerify: insecureSkipVerify, //nolint:forbidigo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||||
// loaded from memory.
|
// loaded from memory.
|
||||||
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||||
config := CreateTLSConfiguration()
|
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
|
||||||
config.InsecureSkipVerify = skipServerVerification
|
}
|
||||||
|
|
||||||
if !skipClientVerification {
|
func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||||
|
if !useTLS {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config := createTLSConfiguration(fipsEnabled, skipServerVerification)
|
||||||
|
|
||||||
|
if !skipClientVerification || fipsEnabled {
|
||||||
certificate, err := tls.X509KeyPair(cert, key)
|
certificate, err := tls.X509KeyPair(cert, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Certificates = []tls.Certificate{certificate}
|
config.Certificates = []tls.Certificate{certificate}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !skipServerVerification {
|
if !skipServerVerification || fipsEnabled {
|
||||||
caCertPool := x509.NewCertPool()
|
caCertPool := x509.NewCertPool()
|
||||||
caCertPool.AppendCertsFromPEM(caCert)
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
config.RootCAs = caCertPool
|
config.RootCAs = caCertPool
|
||||||
@@ -57,29 +87,38 @@ func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerific
|
|||||||
|
|
||||||
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
||||||
// loaded from disk.
|
// loaded from disk.
|
||||||
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
|
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||||
config := CreateTLSConfiguration()
|
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
|
||||||
config.InsecureSkipVerify = skipServerVerification
|
}
|
||||||
|
|
||||||
if certPath != "" && keyPath != "" {
|
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
if !config.TLS && fipsEnabled {
|
||||||
|
return nil, fips.ErrTLSRequired
|
||||||
|
} else if !config.TLS {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := createTLSConfiguration(fipsEnabled, config.TLSSkipVerify)
|
||||||
|
|
||||||
|
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||||
|
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Certificates = []tls.Certificate{cert}
|
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !skipServerVerification && caCertPath != "" {
|
if !tlsConfig.InsecureSkipVerify && config.TLSCACertPath != "" { //nolint:forbidigo
|
||||||
caCert, err := os.ReadFile(caCertPath)
|
caCert, err := os.ReadFile(config.TLSCACertPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
caCertPool := x509.NewCertPool()
|
caCertPool := x509.NewCertPool()
|
||||||
caCertPool.AppendCertsFromPEM(caCert)
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
config.RootCAs = caCertPool
|
tlsConfig.RootCAs = caCertPool
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
|
|||||||
87
api/crypto/tls_test.go
Normal file
87
api/crypto/tls_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateTLSConfiguration(t *testing.T) {
|
||||||
|
// InsecureSkipVerify = false
|
||||||
|
config := CreateTLSConfiguration(false)
|
||||||
|
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||||
|
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||||
|
|
||||||
|
// InsecureSkipVerify = true
|
||||||
|
config = CreateTLSConfiguration(true)
|
||||||
|
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||||
|
require.True(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
||||||
|
fips := true
|
||||||
|
|
||||||
|
fipsCipherSuites := []uint16{
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
}
|
||||||
|
|
||||||
|
fipsCurvePreferences := []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521}
|
||||||
|
|
||||||
|
config := createTLSConfiguration(fips, false)
|
||||||
|
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||||
|
require.Equal(t, config.MaxVersion, uint16(tls.VersionTLS13)) //nolint:forbidigo
|
||||||
|
require.Equal(t, config.CipherSuites, fipsCipherSuites) //nolint:forbidigo
|
||||||
|
require.Equal(t, config.CurvePreferences, fipsCurvePreferences) //nolint:forbidigo
|
||||||
|
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||||
|
// No TLS
|
||||||
|
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, config)
|
||||||
|
|
||||||
|
// Skip TLS client/server verifications
|
||||||
|
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
|
||||||
|
// Empty TLS
|
||||||
|
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, false, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
||||||
|
// No TLS
|
||||||
|
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, config)
|
||||||
|
|
||||||
|
// Skip TLS verifications
|
||||||
|
config, err = CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{
|
||||||
|
TLS: true,
|
||||||
|
TLSSkipVerify: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) {
|
||||||
|
fips := true
|
||||||
|
|
||||||
|
// Skipping TLS verifications cannot be done in FIPS mode
|
||||||
|
config, err := createTLSConfigurationFromDisk(fips, portainer.TLSConfiguration{
|
||||||
|
TLS: true,
|
||||||
|
TLSSkipVerify: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
DatabaseFileName = "portainer.db"
|
DatabaseFileName = "portainer.db"
|
||||||
EncryptedDatabaseFileName = "portainer.edb"
|
EncryptedDatabaseFileName = "portainer.edb"
|
||||||
|
|
||||||
|
txMaxSize = 65536
|
||||||
|
compactedSuffix = ".compacted"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -35,6 +38,7 @@ type DbConnection struct {
|
|||||||
InitialMmapSize int
|
InitialMmapSize int
|
||||||
EncryptionKey []byte
|
EncryptionKey []byte
|
||||||
isEncrypted bool
|
isEncrypted bool
|
||||||
|
Compact bool
|
||||||
|
|
||||||
*bolt.DB
|
*bolt.DB
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,15 @@ func (connection *DbConnection) GetStorePath() string {
|
|||||||
return connection.Path
|
return connection.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
||||||
|
file, err := os.Stat(connection.GetDatabaseFilePath())
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||||
connection.isEncrypted = flag
|
connection.isEncrypted = flag
|
||||||
}
|
}
|
||||||
@@ -123,13 +136,8 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
|||||||
func (connection *DbConnection) Open() error {
|
func (connection *DbConnection) Open() error {
|
||||||
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
||||||
|
|
||||||
// Now we open the db
|
|
||||||
databasePath := connection.GetDatabaseFilePath()
|
databasePath := connection.GetDatabaseFilePath()
|
||||||
|
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
|
||||||
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
|
||||||
Timeout: 1 * time.Second,
|
|
||||||
InitialMmapSize: connection.InitialMmapSize,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,24 @@ func (connection *DbConnection) Open() error {
|
|||||||
db.MaxBatchDelay = connection.MaxBatchDelay
|
db.MaxBatchDelay = connection.MaxBatchDelay
|
||||||
connection.DB = db
|
connection.DB = db
|
||||||
|
|
||||||
|
if connection.Compact {
|
||||||
|
log.Info().Msg("compacting database")
|
||||||
|
if err := connection.compact(); err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to compact database")
|
||||||
|
|
||||||
|
// Close the read-only database and re-open in read-write mode
|
||||||
|
if err := connection.Close(); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.Compact = false
|
||||||
|
|
||||||
|
return connection.Open()
|
||||||
|
} else {
|
||||||
|
log.Info().Msg("database compaction completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +261,32 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||||
|
var value []byte
|
||||||
|
|
||||||
|
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
value, err = tx.GetRawBytes(bucketName, key)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
exists, err = tx.KeyExists(bucketName, key)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||||
if !connection.isEncrypted {
|
if !connection.isEncrypted {
|
||||||
return nil
|
return nil
|
||||||
@@ -377,3 +429,48 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compact attempts to compact the database and replace it iff it succeeds
|
||||||
|
func (connection *DbConnection) compact() (err error) {
|
||||||
|
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
|
||||||
|
|
||||||
|
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failure to create the compacted database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
compactedDB.MaxBatchSize = connection.MaxBatchSize
|
||||||
|
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
|
||||||
|
|
||||||
|
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
|
||||||
|
return fmt.Errorf("failure to compact the database: %w",
|
||||||
|
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
|
||||||
|
return fmt.Errorf("failure to move the compacted database: %w",
|
||||||
|
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connection.Close(); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failure to close the database after compaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.DB = compactedDB
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
|
||||||
|
return &bolt.Options{
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
InitialMmapSize: connection.InitialMmapSize,
|
||||||
|
FreelistType: bolt.FreelistMapType,
|
||||||
|
NoFreelistSync: true,
|
||||||
|
ReadOnly: readOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_NeedsEncryptionMigration(t *testing.T) {
|
func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||||
@@ -94,18 +98,36 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
|||||||
// Special case. If portainer.db and portainer.edb exist.
|
// Special case. If portainer.db and portainer.edb exist.
|
||||||
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
||||||
f, _ := os.Create(dbFile1)
|
f, _ := os.Create(dbFile1)
|
||||||
f.Close()
|
|
||||||
defer os.Remove(dbFile1)
|
err := f.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := os.Remove(dbFile1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
||||||
f, _ = os.Create(dbFile2)
|
f, _ = os.Create(dbFile2)
|
||||||
f.Close()
|
|
||||||
defer os.Remove(dbFile2)
|
err = f.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := os.Remove(dbFile2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
} else if tc.dbname != "" {
|
} else if tc.dbname != "" {
|
||||||
dbFile := path.Join(connection.Path, tc.dbname)
|
dbFile := path.Join(connection.Path, tc.dbname)
|
||||||
f, _ := os.Create(dbFile)
|
f, _ := os.Create(dbFile)
|
||||||
f.Close()
|
|
||||||
defer os.Remove(dbFile)
|
err := f.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := os.Remove(dbFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.key {
|
if tc.key {
|
||||||
@@ -119,3 +141,60 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDBCompaction(t *testing.T) {
|
||||||
|
db := &DbConnection{Path: t.TempDir()}
|
||||||
|
|
||||||
|
err := db.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.Put([]byte("key"), []byte("value"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Reopen the DB to trigger compaction
|
||||||
|
db.Compact = true
|
||||||
|
err = db.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that the data is still there
|
||||||
|
err = db.View(func(tx *bbolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("testbucket"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
val := b.Get([]byte("key"))
|
||||||
|
require.Equal(t, []byte("value"), val)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Failures
|
||||||
|
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
|
||||||
|
err = os.Mkdir(compactedPath, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
err = db.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package boltdb
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
@@ -37,7 +38,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return []byte("{}"), err
|
return []byte("{}"), err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer logs.CloseAndLogErr(connection)
|
||||||
|
|
||||||
backup := make(map[string]any)
|
backup := make(map[string]any)
|
||||||
if metadata {
|
if metadata {
|
||||||
@@ -49,8 +50,8 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
backup["__metadata"] = meta
|
backup["__metadata"] = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
err = connection.View(func(tx *bolt.Tx) error {
|
if err := connection.View(func(tx *bolt.Tx) error {
|
||||||
err = tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||||
bucketName := string(name)
|
bucketName := string(name)
|
||||||
var list []any
|
var list []any
|
||||||
version := make(map[string]string)
|
version := make(map[string]string)
|
||||||
@@ -84,7 +85,6 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(list) > 0 {
|
|
||||||
if bucketName == "ssl" ||
|
if bucketName == "ssl" ||
|
||||||
bucketName == "settings" ||
|
bucketName == "settings" ||
|
||||||
bucketName == "tunnel_server" {
|
bucketName == "tunnel_server" {
|
||||||
@@ -92,19 +92,15 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
if len(list) > 0 {
|
if len(list) > 0 {
|
||||||
backup[bucketName] = list[0]
|
backup[bucketName] = list[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
backup[bucketName] = list
|
backup[bucketName] = list
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
}); err != nil {
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return []byte("{}"), err
|
return []byte("{}"), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
@@ -47,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if e := json.Unmarshal(data, object); e != nil {
|
if err := json.Unmarshal(data, object); err != nil {
|
||||||
// Special case for the VERSION bucket. Here we're not using json
|
// Special case for the VERSION bucket. Here we're not using json
|
||||||
// So we need to return it as a string
|
// So we need to return it as a string
|
||||||
s, ok := object.(*string)
|
s, ok := object.(*string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.Wrap(err, e.Error())
|
return errors.Wrap(err, "Failed unmarshalling object")
|
||||||
}
|
}
|
||||||
|
|
||||||
*s = string(data)
|
*s = string(data)
|
||||||
@@ -65,18 +63,18 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
|||||||
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
||||||
|
|
||||||
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||||
block, _ := aes.NewCipher(passphrase)
|
block, err := aes.NewCipher(passphrase)
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, err
|
return encrypted, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
// NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||||
|
if err != nil {
|
||||||
return encrypted, err
|
return encrypted, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
return gcm.Seal(nil, nil, plaintext, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
||||||
@@ -89,19 +87,17 @@ func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err err
|
|||||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||||
}
|
}
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
// NewGCMWithRandomNonce in go 1.24 handles reading the nonce from the encrypted input for us
|
||||||
|
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||||
}
|
}
|
||||||
|
|
||||||
nonceSize := gcm.NonceSize()
|
if len(encrypted) < gcm.NonceSize() {
|
||||||
if len(encrypted) < nonceSize {
|
|
||||||
return encrypted, errEncryptedStringTooShort
|
return encrypted, errEncryptedStringTooShort
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
|
||||||
|
|
||||||
plaintextByte, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
package boltdb
|
package boltdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||||
passphrase = "my secret key"
|
passphrase = "my secret key"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +29,7 @@ func secretToEncryptionKey(passphrase string) []byte {
|
|||||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
uuid := uuid.Must(uuid.NewV4())
|
uuid := uuid.New()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
object any
|
object any
|
||||||
@@ -87,7 +94,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
data, err := conn.MarshalObject(test.object)
|
data, err := conn.MarshalObject(test.object)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(test.expected, string(data))
|
is.Equal(test.expected, string(data))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,7 +135,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
|||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
var object string
|
var object string
|
||||||
err := conn.UnmarshalObject(test.object, &object)
|
err := conn.UnmarshalObject(test.object, &object)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(test.expected, object)
|
is.Equal(test.expected, object)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -160,18 +167,109 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := secretToEncryptionKey(passphrase)
|
key := secretToEncryptionKey(passphrase)
|
||||||
conn := DbConnection{EncryptionKey: key}
|
conn := DbConnection{EncryptionKey: key, isEncrypted: true}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
|
|
||||||
data, err := conn.MarshalObject(test.object)
|
data, err := conn.MarshalObject(test.object)
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var object []byte
|
var object []byte
|
||||||
err = conn.UnmarshalObject(data, &object)
|
err = conn.UnmarshalObject(data, &object)
|
||||||
|
|
||||||
is.NoError(err)
|
require.NoError(t, err)
|
||||||
is.Equal(test.object, object)
|
is.Equal(test.object, object)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_NonceSources(t *testing.T) {
|
||||||
|
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
|
||||||
|
// the old way of creating and including the nonce
|
||||||
|
|
||||||
|
encryptOldFn := func(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||||
|
block, _ := aes.NewCipher(passphrase)
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return encrypted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return encrypted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptOldFn := func(encrypted []byte, passphrase []byte) (plaintext []byte, err error) {
|
||||||
|
block, err := aes.NewCipher(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(encrypted) < nonceSize {
|
||||||
|
return encrypted, errEncryptedStringTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||||
|
|
||||||
|
plaintext, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
|
||||||
|
if err != nil {
|
||||||
|
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptNewFn := encrypt
|
||||||
|
decryptNewFn := decrypt
|
||||||
|
|
||||||
|
passphrase := make([]byte, 32)
|
||||||
|
_, err := io.ReadFull(rand.Reader, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
junk := make([]byte, 1024)
|
||||||
|
_, err = io.ReadFull(rand.Reader, junk)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
junkEnc := make([]byte, base64.StdEncoding.EncodedLen(len(junk)))
|
||||||
|
base64.StdEncoding.Encode(junkEnc, junk)
|
||||||
|
|
||||||
|
cases := [][]byte{
|
||||||
|
[]byte("test"),
|
||||||
|
[]byte("35"),
|
||||||
|
[]byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"),
|
||||||
|
[]byte(jsonobject),
|
||||||
|
passphrase,
|
||||||
|
junk,
|
||||||
|
junkEnc,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, plain := range cases {
|
||||||
|
var enc, dec []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
enc, err = encryptOldFn(plain, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dec, err = decryptNewFn(enc, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, plain, dec)
|
||||||
|
|
||||||
|
enc, err = encryptNewFn(plain, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dec, err = decryptOldFn(enc, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, plain, dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,33 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
|||||||
return tx.conn.UnmarshalObject(value, object)
|
return tx.conn.UnmarshalObject(value, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||||
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
|
value := bucket.Get(key)
|
||||||
|
if value == nil {
|
||||||
|
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx.conn.getEncryptionKey() != nil {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||||
|
return value, errors.Wrap(err, "Failed decrypting object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||||
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
|
value := bucket.Get(key)
|
||||||
|
|
||||||
|
return value != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||||
data, err := tx.conn.MarshalObject(object)
|
data, err := tx.conn.MarshalObject(object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBucketName = "test-bucket"
|
const testBucketName = "test-bucket"
|
||||||
@@ -17,70 +18,55 @@ type testStruct struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTxs(t *testing.T) {
|
func TestTxs(t *testing.T) {
|
||||||
conn := DbConnection{
|
conn := DbConnection{Path: t.TempDir()}
|
||||||
Path: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := conn.Open()
|
err := conn.Open()
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
defer func() {
|
||||||
}
|
err := conn.Close()
|
||||||
defer conn.Close()
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
// Error propagation
|
// Error propagation
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return errors.New("this is an error")
|
return errors.New("this is an error")
|
||||||
})
|
})
|
||||||
if err == nil {
|
require.Error(t, err)
|
||||||
t.Fatal("an error was expected, got nil instead")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an object
|
// Create an object
|
||||||
newObj := testStruct{
|
newObj := testStruct{Key: "key", Value: "value"}
|
||||||
Key: "key",
|
|
||||||
Value: "value",
|
|
||||||
}
|
|
||||||
|
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
err = tx.SetServiceName(testBucketName)
|
if err := tx.SetServiceName(testBucketName); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||||
})
|
})
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
obj := testStruct{}
|
obj := testStruct{}
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if obj.Key != newObj.Key || obj.Value != newObj.Value {
|
if obj.Key != newObj.Key || obj.Value != newObj.Value {
|
||||||
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
|
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an object
|
// Update an object
|
||||||
updatedObj := testStruct{
|
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
|
||||||
Key: "updated-key",
|
|
||||||
Value: "updated-value",
|
|
||||||
}
|
|
||||||
|
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
||||||
})
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
|
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
|
||||||
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
|
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
|
||||||
@@ -90,16 +76,12 @@ func TestTxs(t *testing.T) {
|
|||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
||||||
})
|
})
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
if !dataservices.IsErrObjectNotFound(err) {
|
require.True(t, dataservices.IsErrObjectNotFound(err))
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next identifier
|
// Get next identifier
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
@@ -112,15 +94,11 @@ func TestTxs(t *testing.T) {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to write in a read transaction
|
// Try to write in a read transaction
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||||
})
|
})
|
||||||
if err == nil {
|
require.Error(t, err)
|
||||||
t.Fatal("an error was expected, got nil instead")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewDatabase should use config options to return a connection to the requested database
|
// NewDatabase should use config options to return a connection to the requested database
|
||||||
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
|
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
|
||||||
if storeType == "boltdb" {
|
if storeType == "boltdb" {
|
||||||
return &boltdb.DbConnection{
|
return &boltdb.DbConnection{
|
||||||
Path: storePath,
|
Path: storePath,
|
||||||
EncryptionKey: encryptionKey,
|
EncryptionKey: encryptionKey,
|
||||||
|
Compact: compact,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
api/database/database_test.go
Normal file
24
api/database/database_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/database/boltdb"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewDatabase(t *testing.T) {
|
||||||
|
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
|
||||||
|
connection, err := NewDatabase("boltdb", dbPath, nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, connection)
|
||||||
|
|
||||||
|
_, ok := connection.(*boltdb.DbConnection)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
connection, err = NewDatabase("unknown", dbPath, nil, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, connection)
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||||
Create(element *T) error
|
Create(element *T) error
|
||||||
Read(ID I) (*T, error)
|
Read(ID I) (*T, error)
|
||||||
ReadAll() ([]T, error)
|
Exists(ID I) (bool, error)
|
||||||
|
ReadAll(predicates ...func(T) bool) ([]T, error)
|
||||||
Update(ID I, element *T) error
|
Update(ID I, element *T) error
|
||||||
Delete(ID I) error
|
Delete(ID I) error
|
||||||
}
|
}
|
||||||
@@ -42,12 +43,26 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
exists, err = service.Tx(tx).Exists(ID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
var err error
|
var err error
|
||||||
collection, err = service.Tx(tx).ReadAll()
|
collection, err = service.Tx(tx).ReadAll(predicates...)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|||||||
91
api/dataservices/base_test.go
Normal file
91
api/dataservices/base_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package dataservices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testObject struct {
|
||||||
|
ID int
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockConnection struct {
|
||||||
|
store map[int]testObject
|
||||||
|
|
||||||
|
portainer.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockConnection) UpdateObject(bucket string, key []byte, value any) error {
|
||||||
|
obj := value.(*testObject)
|
||||||
|
|
||||||
|
m.store[obj.ID] = *obj
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
||||||
|
for _, v := range m.store {
|
||||||
|
if _, err := appendFn(&v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error {
|
||||||
|
return fn(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
|
||||||
|
return fn(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockConnection) ConvertToKey(v int) []byte {
|
||||||
|
return []byte(strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
func TestReadAll(t *testing.T) {
|
||||||
|
service := BaseDataService[testObject, int]{
|
||||||
|
Bucket: "testBucket",
|
||||||
|
Connection: mockConnection{store: make(map[int]testObject)},
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []testObject{
|
||||||
|
{ID: 1, Value: 1},
|
||||||
|
{ID: 2, Value: 2},
|
||||||
|
{ID: 3, Value: 3},
|
||||||
|
{ID: 4, Value: 4},
|
||||||
|
{ID: 5, Value: 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
err := service.Update(item.ID, &item)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAll without predicates
|
||||||
|
result, err := service.ReadAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := append([]testObject{}, data...)
|
||||||
|
|
||||||
|
require.ElementsMatch(t, expected, result)
|
||||||
|
|
||||||
|
// ReadAll with predicates
|
||||||
|
hasLowID := func(obj testObject) bool { return obj.ID < 3 }
|
||||||
|
isEven := func(obj testObject) bool { return obj.Value%2 == 0 }
|
||||||
|
|
||||||
|
result, err = service.ReadAll(hasLowID, isEven)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected = slicesx.Filter(expected, hasLowID)
|
||||||
|
expected = slicesx.Filter(expected, isEven)
|
||||||
|
|
||||||
|
require.ElementsMatch(t, expected, result)
|
||||||
|
}
|
||||||
@@ -28,14 +28,39 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
|||||||
return &element, nil
|
return &element, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||||
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
|
if len(predicates) == 0 {
|
||||||
return collection, service.Tx.GetAll(
|
return collection, service.Tx.GetAll(
|
||||||
service.Bucket,
|
service.Bucket,
|
||||||
new(T),
|
new(T),
|
||||||
AppendFn(&collection),
|
AppendFn(&collection),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterFn := func(element T) bool {
|
||||||
|
for _, p := range predicates {
|
||||||
|
if !p(element) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection, service.Tx.GetAll(
|
||||||
|
service.Bucket,
|
||||||
|
new(T),
|
||||||
|
FilterFn(&collection, filterFn),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error {
|
func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error {
|
||||||
@@ -47,3 +72,13 @@ func (service BaseDataServiceTx[T, I]) Delete(ID I) error {
|
|||||||
identifier := service.Connection.ConvertToKey(int(ID))
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
return service.Tx.DeleteObject(service.Bucket, identifier)
|
return service.Tx.DeleteObject(service.Bucket, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Read[T any](tx portainer.Transaction, bucket string, key []byte) (*T, error) {
|
||||||
|
var element T
|
||||||
|
|
||||||
|
if err := tx.GetObject(bucket, key, &element); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &element, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCustomTemplate uses the existing id and saves it.
|
|
||||||
// TODO: where does the ID come from, and is it safe?
|
|
||||||
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
|
|
||||||
return service.Connection.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNextIdentifier returns the next identifier for a custom template.
|
|
||||||
func (service *Service) GetNextIdentifier() int {
|
func (service *Service) GetNextIdentifier() int {
|
||||||
return service.Connection.GetNextIdentifier(BucketName)
|
return service.Connection.GetNextIdentifier(BucketName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
|
||||||
|
return service.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.Tx(tx).Create(customTemplate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
19
api/dataservices/customtemplate/customtemplate_test.go
Normal file
19
api/dataservices/customtemplate/customtemplate_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package customtemplate_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomTemplateCreate(t *testing.T) {
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
require.NotNil(t, ds)
|
||||||
|
|
||||||
|
require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
|
||||||
|
e, err := ds.CustomTemplate().Read(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, portainer.CustomTemplateID(1), e.ID)
|
||||||
|
}
|
||||||
31
api/dataservices/customtemplate/tx.go
Normal file
31
api/dataservices/customtemplate/tx.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package customtemplate
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service for managing custom template data.
|
||||||
|
type ServiceTx struct {
|
||||||
|
dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||||
|
return ServiceTx{
|
||||||
|
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]{
|
||||||
|
Bucket: BucketName,
|
||||||
|
Connection: service.Connection,
|
||||||
|
Tx: tx,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) GetNextIdentifier() int {
|
||||||
|
return service.Tx.GetNextIdentifier(BucketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomTemplate uses the existing id and saves it.
|
||||||
|
// TODO: where does the ID come from, and is it safe?
|
||||||
|
func (service ServiceTx) Create(customTemplate *portainer.CustomTemplate) error {
|
||||||
|
return service.Tx.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
|
||||||
|
}
|
||||||
28
api/dataservices/customtemplate/tx_test.go
Normal file
28
api/dataservices/customtemplate/tx_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package customtemplate_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomTemplateCreateTx(t *testing.T) {
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
require.NotNil(t, ds)
|
||||||
|
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1})
|
||||||
|
}))
|
||||||
|
|
||||||
|
var template *portainer.CustomTemplate
|
||||||
|
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
var err error
|
||||||
|
template, err = tx.CustomTemplate().Read(1)
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Equal(t, portainer.CustomTemplateID(1), template.ID)
|
||||||
|
}
|
||||||
@@ -17,11 +17,29 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
||||||
return service.Tx.CreateObject(
|
es := group.Endpoints
|
||||||
|
group.Endpoints = nil // Clear deprecated field
|
||||||
|
|
||||||
|
err := service.Tx.CreateObject(
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, any) {
|
func(id uint64) (int, any) {
|
||||||
group.ID = portainer.EdgeGroupID(id)
|
group.ID = portainer.EdgeGroupID(id)
|
||||||
return int(group.ID), group
|
return int(group.ID), group
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
group.Endpoints = es // Restore endpoints after create
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error {
|
||||||
|
es := group.Endpoints
|
||||||
|
group.Endpoints = nil // Clear deprecated field
|
||||||
|
|
||||||
|
err := service.BaseDataServiceTx.Update(ID, group)
|
||||||
|
|
||||||
|
group.Endpoints = es // Restore endpoints after update
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type Service struct {
|
|||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
idxVersion map[portainer.EdgeStackID]int
|
idxVersion map[portainer.EdgeStackID]int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cacheInvalidationFn func(portainer.EdgeStackID)
|
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new instance of a service.
|
// NewService creates a new instance of a service.
|
||||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
|
||||||
err := connection.SetServiceName(BucketName)
|
err := connection.SetServiceName(BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.cacheInvalidationFn == nil {
|
if s.cacheInvalidationFn == nil {
|
||||||
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
es, err := s.EdgeStacks()
|
es, err := s.EdgeStacks()
|
||||||
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
|||||||
|
|
||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
service.idxVersion[id] = edgeStack.Version
|
service.idxVersion[id] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(id)
|
service.cacheInvalidationFn(service.connection, id)
|
||||||
service.mu.Unlock()
|
service.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
|||||||
}
|
}
|
||||||
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(ID)
|
service.cacheInvalidationFn(service.connection, ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
|||||||
updateFunc(edgeStack)
|
updateFunc(edgeStack)
|
||||||
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(ID)
|
service.cacheInvalidationFn(service.connection, ID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
|||||||
|
|
||||||
delete(service.idxVersion, ID)
|
delete(service.idxVersion, ID)
|
||||||
|
|
||||||
service.cacheInvalidationFn(ID)
|
service.cacheInvalidationFn(service.connection, ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
51
api/dataservices/edgestack/edgestack_test.go
Normal file
51
api/dataservices/edgestack/edgestack_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package edgestack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/database/boltdb"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdate(t *testing.T) {
|
||||||
|
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||||
|
err := conn.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer logs.CloseAndLogErr(conn)
|
||||||
|
|
||||||
|
service, err := NewService(conn, func(portainer.Transaction, portainer.EdgeStackID) {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
const edgeStackID = 1
|
||||||
|
edgeStack := &portainer.EdgeStack{
|
||||||
|
ID: edgeStackID,
|
||||||
|
Name: "Test Stack",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = service.Create(edgeStackID, edgeStack)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = service.UpdateEdgeStackFunc(edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.Name = "Updated Stack"
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedStack, err := service.EdgeStack(edgeStackID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Updated Stack", updatedStack.Name)
|
||||||
|
|
||||||
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.UpdateEdgeStackFuncTx(tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.Name = "Updated Stack Again"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedStack, err = service.EdgeStack(edgeStackID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Updated Stack Again", updatedStack.Name)
|
||||||
|
}
|
||||||
@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
|
|||||||
var stack portainer.EdgeStack
|
var stack portainer.EdgeStack
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
err := service.tx.GetObject(BucketName, identifier, &stack)
|
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
|||||||
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
||||||
edgeStack.ID = id
|
edgeStack.ID = id
|
||||||
|
|
||||||
err := service.tx.CreateObjectWithId(
|
if err := service.tx.CreateObjectWithId(
|
||||||
BucketName,
|
BucketName,
|
||||||
int(edgeStack.ID),
|
int(edgeStack.ID),
|
||||||
edgeStack,
|
edgeStack,
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.mu.Lock()
|
service.service.mu.Lock()
|
||||||
service.service.idxVersion[id] = edgeStack.Version
|
service.service.idxVersion[id] = edgeStack.Version
|
||||||
service.service.cacheInvalidationFn(id)
|
service.service.cacheInvalidationFn(service.tx, id)
|
||||||
service.service.mu.Unlock()
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
|
|||||||
|
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
|
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.idxVersion[ID] = edgeStack.Version
|
service.service.idxVersion[ID] = edgeStack.Version
|
||||||
service.service.cacheInvalidationFn(ID)
|
service.service.cacheInvalidationFn(service.tx, ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
|||||||
|
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
err := service.tx.DeleteObject(BucketName, identifier)
|
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(service.service.idxVersion, ID)
|
delete(service.service.idxVersion, ID)
|
||||||
|
|
||||||
service.service.cacheInvalidationFn(ID)
|
service.service.cacheInvalidationFn(service.tx, ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal file
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package edgestackstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ dataservices.EdgeStackStatusService = &Service{}
|
||||||
|
|
||||||
|
const BucketName = "edge_stack_status"
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
conn portainer.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) BucketName() string {
|
||||||
|
return BucketName
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
|
if err := connection.SetServiceName(BucketName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{conn: connection}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||||
|
return ServiceTx{
|
||||||
|
service: s,
|
||||||
|
tx: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Create(edgeStackID, endpointID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var element *portainer.EdgeStackStatusForEnv
|
||||||
|
|
||||||
|
return element, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
element, err = s.Tx(tx).Read(edgeStackID, endpointID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var collection = make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
return collection, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
collection, err = s.Tx(tx).ReadAll(edgeStackID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Update(edgeStackID, endpointID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Delete(edgeStackID, endpointID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).DeleteAll(edgeStackID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
|
||||||
|
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
|
||||||
|
}
|
||||||
95
api/dataservices/edgestackstatus/tx.go
Normal file
95
api/dataservices/edgestackstatus/tx.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package edgestackstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ dataservices.EdgeStackStatusService = &Service{}
|
||||||
|
|
||||||
|
type ServiceTx struct {
|
||||||
|
service *Service
|
||||||
|
tx portainer.Transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
identifier := service.service.key(edgeStackID, endpointID)
|
||||||
|
return service.tx.CreateObjectWithStringId(BucketName, identifier, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var status portainer.EdgeStackStatusForEnv
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
|
||||||
|
if err := s.tx.GetObject(BucketName, identifier, &status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
||||||
|
|
||||||
|
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
return s.tx.UpdateObject(BucketName, identifier, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
return s.tx.DeleteObject(BucketName, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
||||||
|
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
||||||
|
|
||||||
|
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
||||||
|
return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range statuses {
|
||||||
|
if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil {
|
||||||
|
return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||||
|
for _, envID := range relatedEnvironmentsIDs {
|
||||||
|
existingStatus, err := s.Read(edgeStackID, envID)
|
||||||
|
if err != nil && !dataservices.IsErrObjectNotFound(err) {
|
||||||
|
return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentInfo portainer.StackDeploymentInfo
|
||||||
|
if existingStatus != nil {
|
||||||
|
deploymentInfo = existingStatus.DeploymentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{
|
||||||
|
EndpointID: envID,
|
||||||
|
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||||
|
DeploymentInfo: deploymentInfo,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -119,6 +119,19 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
|
|||||||
return endpoints, nil
|
return endpoints, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||||
|
var endpoints []portainer.Endpoint
|
||||||
|
var err error
|
||||||
|
|
||||||
|
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
endpoints, err = service.Tx(tx).ReadAll(predicates...)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return endpoints, err
|
||||||
|
}
|
||||||
|
|
||||||
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
||||||
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||||
service.mu.RLock()
|
service.mu.RLock()
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||||
|
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
|
||||||
|
}
|
||||||
|
|
||||||
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||||
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package endpointrelation
|
package endpointrelation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// BucketName represents the name of the bucket where this service stores data.
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
@@ -14,19 +14,20 @@ const BucketName = "endpoint_relations"
|
|||||||
// Service represents a service for managing environment(endpoint) relation data.
|
// Service represents a service for managing environment(endpoint) relation data.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
|
||||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||||
|
endpointRelationsCache []portainer.EndpointRelation
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ dataservices.EndpointRelationService = &Service{}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
return BucketName
|
return BucketName
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) RegisterUpdateStackFunction(
|
func (service *Service) RegisterUpdateStackFunction(
|
||||||
updateFunc func(portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
|
|
||||||
updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
|
updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
|
||||||
) {
|
) {
|
||||||
service.updateStackFn = updateFunc
|
|
||||||
service.updateStackFnTx = updateFuncTx
|
service.updateStackFnTx = updateFuncTx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,107 +77,35 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
|
|||||||
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||||
cache.Del(endpointRelation.EndpointID)
|
cache.Del(endpointRelation.EndpointID)
|
||||||
|
|
||||||
|
service.mu.Lock()
|
||||||
|
service.endpointRelationsCache = nil
|
||||||
|
service.mu.Unlock()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateEndpointRelation updates an Environment(Endpoint) relation object
|
// UpdateEndpointRelation updates an Environment(Endpoint) relation object
|
||||||
func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error {
|
func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error {
|
||||||
previousRelationState, _ := service.EndpointRelation(endpointID)
|
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.Tx(tx).UpdateEndpointRelation(endpointID, endpointRelation)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
identifier := service.connection.ConvertToKey(int(endpointID))
|
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
|
||||||
err := service.connection.UpdateObject(BucketName, identifier, endpointRelation)
|
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
cache.Del(endpointID)
|
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStack)
|
||||||
if err != nil {
|
})
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.Tx(tx).DeleteEndpointRelation(endpointID)
|
||||||
identifier := service.connection.ConvertToKey(int(endpointID))
|
})
|
||||||
err := service.connection.DeleteObject(BucketName, identifier)
|
|
||||||
cache.Del(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
|
||||||
rels, err := service.EndpointRelations()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rel := range rels {
|
|
||||||
for id := range rel.EdgeStacks {
|
|
||||||
if edgeStackID == id {
|
|
||||||
cache.Del(rel.EndpointID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
|
||||||
relations, _ := service.EndpointRelations()
|
|
||||||
|
|
||||||
stacksToUpdate := map[portainer.EdgeStackID]bool{}
|
|
||||||
|
|
||||||
if previousRelationState != nil {
|
|
||||||
for stackId, enabled := range previousRelationState.EdgeStacks {
|
|
||||||
// flag stack for update if stack is not in the updated relation state
|
|
||||||
// = stack has been removed for this relation
|
|
||||||
// or this relation has been deleted
|
|
||||||
if enabled && (updatedRelationState == nil || !updatedRelationState.EdgeStacks[stackId]) {
|
|
||||||
stacksToUpdate[stackId] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if updatedRelationState != nil {
|
|
||||||
for stackId, enabled := range updatedRelationState.EdgeStacks {
|
|
||||||
// flag stack for update if stack is not in the previous relation state
|
|
||||||
// = stack has been added for this relation
|
|
||||||
if enabled && (previousRelationState == nil || !previousRelationState.EdgeStacks[stackId]) {
|
|
||||||
stacksToUpdate[stackId] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for each stack referenced by the updated relation
|
|
||||||
// list how many time this stack is referenced in all relations
|
|
||||||
// in order to update the stack deployments count
|
|
||||||
for refStackId, refStackEnabled := range stacksToUpdate {
|
|
||||||
if !refStackEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
numDeployments := 0
|
|
||||||
|
|
||||||
for _, r := range relations {
|
|
||||||
for sId, enabled := range r.EdgeStacks {
|
|
||||||
if enabled && sId == refStackId {
|
|
||||||
numDeployments += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := service.updateStackFn(refStackId, func(edgeStack *portainer.EdgeStack) {
|
|
||||||
edgeStack.NumDeployments = numDeployments
|
|
||||||
}); err != nil {
|
|
||||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
141
api/dataservices/endpointrelation/endpointrelation_test.go
Normal file
141
api/dataservices/endpointrelation/endpointrelation_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package endpointrelation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/database/boltdb"
|
||||||
|
"github.com/portainer/portainer/api/dataservices/edgestack"
|
||||||
|
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateRelation(t *testing.T) {
|
||||||
|
const endpointID = 1
|
||||||
|
const edgeStackID1 = 1
|
||||||
|
const edgeStackID2 = 2
|
||||||
|
|
||||||
|
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||||
|
err := conn.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer logs.CloseAndLogErr(conn)
|
||||||
|
|
||||||
|
service, err := NewService(conn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updateStackFnTxCalled := false
|
||||||
|
|
||||||
|
edgeStacks := make(map[portainer.EdgeStackID]portainer.EdgeStack)
|
||||||
|
edgeStacks[edgeStackID1] = portainer.EdgeStack{ID: edgeStackID1}
|
||||||
|
edgeStacks[edgeStackID2] = portainer.EdgeStack{ID: edgeStackID2}
|
||||||
|
|
||||||
|
service.RegisterUpdateStackFunction(func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error {
|
||||||
|
updateStackFnTxCalled = true
|
||||||
|
|
||||||
|
s, ok := edgeStacks[ID]
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
updateFunc(&s)
|
||||||
|
edgeStacks[ID] = s
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nil relation
|
||||||
|
|
||||||
|
cache.Set(endpointID, []byte("value"))
|
||||||
|
|
||||||
|
err = service.UpdateEndpointRelation(endpointID, nil)
|
||||||
|
_, cacheKeyExists := cache.Get(endpointID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, updateStackFnTxCalled)
|
||||||
|
require.False(t, cacheKeyExists)
|
||||||
|
|
||||||
|
// Add a relation to two edge stacks
|
||||||
|
|
||||||
|
cache.Set(endpointID, []byte("value"))
|
||||||
|
|
||||||
|
err = service.UpdateEndpointRelation(endpointID, &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpointID,
|
||||||
|
EdgeStacks: map[portainer.EdgeStackID]bool{
|
||||||
|
edgeStackID1: true,
|
||||||
|
edgeStackID2: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, cacheKeyExists = cache.Get(endpointID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, updateStackFnTxCalled)
|
||||||
|
require.False(t, cacheKeyExists)
|
||||||
|
require.Equal(t, 1, edgeStacks[edgeStackID1].NumDeployments)
|
||||||
|
require.Equal(t, 1, edgeStacks[edgeStackID2].NumDeployments)
|
||||||
|
|
||||||
|
// Remove a relation to one edge stack
|
||||||
|
|
||||||
|
updateStackFnTxCalled = false
|
||||||
|
cache.Set(endpointID, []byte("value"))
|
||||||
|
|
||||||
|
err = service.UpdateEndpointRelation(endpointID, &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpointID,
|
||||||
|
EdgeStacks: map[portainer.EdgeStackID]bool{
|
||||||
|
2: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, cacheKeyExists = cache.Get(endpointID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, updateStackFnTxCalled)
|
||||||
|
require.False(t, cacheKeyExists)
|
||||||
|
require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments)
|
||||||
|
require.Equal(t, 1, edgeStacks[edgeStackID2].NumDeployments)
|
||||||
|
|
||||||
|
// Delete the relation
|
||||||
|
|
||||||
|
updateStackFnTxCalled = false
|
||||||
|
cache.Set(endpointID, []byte("value"))
|
||||||
|
|
||||||
|
err = service.DeleteEndpointRelation(endpointID)
|
||||||
|
|
||||||
|
_, cacheKeyExists = cache.Get(endpointID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, updateStackFnTxCalled)
|
||||||
|
require.False(t, cacheKeyExists)
|
||||||
|
require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments)
|
||||||
|
require.Equal(t, 0, edgeStacks[edgeStackID2].NumDeployments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
|
||||||
|
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||||
|
err := conn.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer logs.CloseAndLogErr(conn)
|
||||||
|
|
||||||
|
service, err := NewService(conn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
edgeStackService, err := edgestack.NewService(conn, func(t portainer.Transaction, esi portainer.EdgeStackID) {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
service.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFuncTx)
|
||||||
|
require.NoError(t, edgeStackService.Create(1, &portainer.EdgeStack{}))
|
||||||
|
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}}))
|
||||||
|
require.NoError(t, service.AddEndpointRelationsForEdgeStack([]portainer.EndpointID{1}, &portainer.EdgeStack{ID: 1}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpointRelations(t *testing.T) {
|
||||||
|
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||||
|
err := conn.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer logs.CloseAndLogErr(conn)
|
||||||
|
|
||||||
|
service, err := NewService(conn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1}))
|
||||||
|
rels, err := service.EndpointRelations()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rels, 1)
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ type ServiceTx struct {
|
|||||||
tx portainer.Transaction
|
tx portainer.Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||||
|
|
||||||
func (service ServiceTx) BucketName() string {
|
func (service ServiceTx) BucketName() string {
|
||||||
return BucketName
|
return BucketName
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
|
|||||||
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||||
cache.Del(endpointRelation.EndpointID)
|
cache.Del(endpointRelation.EndpointID)
|
||||||
|
|
||||||
|
service.service.mu.Lock()
|
||||||
|
service.service.endpointRelationsCache = nil
|
||||||
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +67,79 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
|||||||
|
|
||||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
|
service.service.mu.Lock()
|
||||||
|
service.service.endpointRelationsCache = nil
|
||||||
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
rel, err := service.EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel.EdgeStacks[edgeStack.ID] = true
|
||||||
|
|
||||||
|
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||||
|
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||||
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.service.mu.Lock()
|
||||||
|
service.service.endpointRelationsCache = nil
|
||||||
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
|
if err := service.service.updateStackFnTx(service.tx, edgeStack.ID, func(es *portainer.EdgeStack) {
|
||||||
|
es.NumDeployments += len(endpointIDs)
|
||||||
|
|
||||||
|
// sync changes in `edgeStack` in case it is re-persisted after `AddEndpointRelationsForEdgeStack` call
|
||||||
|
// to avoid overriding with the previous values
|
||||||
|
edgeStack.NumDeployments = es.NumDeployments
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
rel, err := service.EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(rel.EdgeStacks, edgeStackID)
|
||||||
|
|
||||||
|
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||||
|
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||||
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.service.mu.Lock()
|
||||||
|
service.service.endpointRelationsCache = nil
|
||||||
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
|
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.NumDeployments -= len(endpointIDs)
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||||
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||||
@@ -77,74 +151,88 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.service.mu.Lock()
|
||||||
|
service.service.endpointRelationsCache = nil
|
||||||
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||||
rels, err := service.EndpointRelations()
|
rels, err := service.cachedEndpointRelations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rel := range rels {
|
for _, rel := range rels {
|
||||||
for id := range rel.EdgeStacks {
|
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
|
||||||
if edgeStackID == id {
|
|
||||||
cache.Del(rel.EndpointID)
|
cache.Del(rel.EndpointID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||||
|
service.service.mu.Lock()
|
||||||
|
defer service.service.mu.Unlock()
|
||||||
|
|
||||||
|
if service.service.endpointRelationsCache == nil {
|
||||||
|
var err error
|
||||||
|
service.service.endpointRelationsCache, err = service.EndpointRelations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.service.endpointRelationsCache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||||
relations, _ := service.EndpointRelations()
|
|
||||||
|
|
||||||
stacksToUpdate := map[portainer.EdgeStackID]bool{}
|
|
||||||
|
|
||||||
if previousRelationState != nil {
|
if previousRelationState != nil {
|
||||||
for stackId, enabled := range previousRelationState.EdgeStacks {
|
for stackId, enabled := range previousRelationState.EdgeStacks {
|
||||||
// flag stack for update if stack is not in the updated relation state
|
// flag stack for update if stack is not in the updated relation state
|
||||||
// = stack has been removed for this relation
|
// = stack has been removed for this relation
|
||||||
// or this relation has been deleted
|
// or this relation has been deleted
|
||||||
if enabled && (updatedRelationState == nil || !updatedRelationState.EdgeStacks[stackId]) {
|
if enabled && (updatedRelationState == nil || !updatedRelationState.EdgeStacks[stackId]) {
|
||||||
stacksToUpdate[stackId] = true
|
if err := service.service.updateStackFnTx(service.tx, stackId, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
// Sanity check
|
||||||
|
if edgeStack.NumDeployments <= 0 {
|
||||||
|
log.Error().
|
||||||
|
Int("edgestack_id", int(edgeStack.ID)).
|
||||||
|
Int("endpoint_id", int(previousRelationState.EndpointID)).
|
||||||
|
Int("num_deployments", edgeStack.NumDeployments).
|
||||||
|
Msg("cannot decrement the number of deployments for an edge stack with zero deployments")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStack.NumDeployments--
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Del(previousRelationState.EndpointID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if updatedRelationState != nil {
|
if updatedRelationState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for stackId, enabled := range updatedRelationState.EdgeStacks {
|
for stackId, enabled := range updatedRelationState.EdgeStacks {
|
||||||
// flag stack for update if stack is not in the previous relation state
|
// flag stack for update if stack is not in the previous relation state
|
||||||
// = stack has been added for this relation
|
// = stack has been added for this relation
|
||||||
if enabled && (previousRelationState == nil || !previousRelationState.EdgeStacks[stackId]) {
|
if enabled && (previousRelationState == nil || !previousRelationState.EdgeStacks[stackId]) {
|
||||||
stacksToUpdate[stackId] = true
|
if err := service.service.updateStackFnTx(service.tx, stackId, func(edgeStack *portainer.EdgeStack) {
|
||||||
}
|
edgeStack.NumDeployments++
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for each stack referenced by the updated relation
|
|
||||||
// list how many time this stack is referenced in all relations
|
|
||||||
// in order to update the stack deployments count
|
|
||||||
for refStackId, refStackEnabled := range stacksToUpdate {
|
|
||||||
if !refStackEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
numDeployments := 0
|
|
||||||
for _, r := range relations {
|
|
||||||
for sId, enabled := range r.EdgeStacks {
|
|
||||||
if enabled && sId == refStackId {
|
|
||||||
numDeployments += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := service.service.updateStackFnTx(service.tx, refStackId, func(edgeStack *portainer.EdgeStack) {
|
|
||||||
edgeStack.NumDeployments = numDeployments
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.Del(updatedRelationState.EndpointID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrObjectNotFound = errors.New("object not found inside the database")
|
ErrObjectNotFound = errors.New("object not found inside the database")
|
||||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||||
ErrDBImportFailed = errors.New("importing backup failed")
|
ErrDBImportFailed = errors.New("importing backup failed")
|
||||||
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type (
|
|||||||
EdgeGroup() EdgeGroupService
|
EdgeGroup() EdgeGroupService
|
||||||
EdgeJob() EdgeJobService
|
EdgeJob() EdgeJobService
|
||||||
EdgeStack() EdgeStackService
|
EdgeStack() EdgeStackService
|
||||||
|
EdgeStackStatus() EdgeStackStatusService
|
||||||
Endpoint() EndpointService
|
Endpoint() EndpointService
|
||||||
EndpointGroup() EndpointGroupService
|
EndpointGroup() EndpointGroupService
|
||||||
EndpointRelation() EndpointRelationService
|
EndpointRelation() EndpointRelationService
|
||||||
@@ -39,8 +40,8 @@ type (
|
|||||||
Open() (newStore bool, err error)
|
Open() (newStore bool, err error)
|
||||||
Init() error
|
Init() error
|
||||||
Close() error
|
Close() error
|
||||||
UpdateTx(func(DataStoreTx) error) error
|
UpdateTx(func(tx DataStoreTx) error) error
|
||||||
ViewTx(func(DataStoreTx) error) error
|
ViewTx(func(tx DataStoreTx) error) error
|
||||||
MigrateData() error
|
MigrateData() error
|
||||||
Rollback(force bool) error
|
Rollback(force bool) error
|
||||||
CheckCurrentEdition() error
|
CheckCurrentEdition() error
|
||||||
@@ -89,8 +90,21 @@ type (
|
|||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EdgeStackStatusService interface {
|
||||||
|
Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
|
||||||
|
Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error)
|
||||||
|
ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error)
|
||||||
|
Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
|
||||||
|
Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error
|
||||||
|
DeleteAll(edgeStackID portainer.EdgeStackID) error
|
||||||
|
Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error
|
||||||
|
}
|
||||||
|
|
||||||
// EndpointService represents a service for managing environment(endpoint) data
|
// EndpointService represents a service for managing environment(endpoint) data
|
||||||
EndpointService interface {
|
EndpointService interface {
|
||||||
|
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
|
||||||
|
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
|
||||||
|
|
||||||
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
||||||
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
|
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
|
||||||
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
|
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
|
||||||
@@ -115,6 +129,8 @@ type (
|
|||||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||||
Create(endpointRelation *portainer.EndpointRelation) error
|
Create(endpointRelation *portainer.EndpointRelation) error
|
||||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||||
|
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error
|
||||||
|
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
@@ -157,6 +173,7 @@ type (
|
|||||||
|
|
||||||
SnapshotService interface {
|
SnapshotService interface {
|
||||||
BaseCRUD[portainer.Snapshot, portainer.EndpointID]
|
BaseCRUD[portainer.Snapshot, portainer.EndpointID]
|
||||||
|
ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSLSettingsService represents a service for managing application settings
|
// SSLSettingsService represents a service for managing application settings
|
||||||
@@ -209,6 +226,7 @@ type (
|
|||||||
UserService interface {
|
UserService interface {
|
||||||
BaseCRUD[portainer.User, portainer.UserID]
|
BaseCRUD[portainer.User, portainer.UserID]
|
||||||
UserByUsername(username string) (*portainer.User, error)
|
UserByUsername(username string) (*portainer.User, error)
|
||||||
|
UserIDByUsername(username string) (portainer.UserID, error)
|
||||||
UsersByRole(role portainer.UserRole) ([]portainer.User, error)
|
UsersByRole(role portainer.UserRole) ([]portainer.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
package pendingactions
|
package pendingactions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const BucketName = "pending_actions"
|
||||||
BucketName = "pending_actions"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]
|
dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceTx struct {
|
|
||||||
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(connection portainer.Connection) (*Service, error) {
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
err := connection.SetServiceName(BucketName)
|
err := connection.SetServiceName(BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +25,11 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for a custom template.
|
||||||
|
func (service *Service) GetNextIdentifier() int {
|
||||||
|
return service.Connection.GetNextIdentifier(BucketName)
|
||||||
|
}
|
||||||
|
|
||||||
func (s Service) Create(config *portainer.PendingAction) error {
|
func (s Service) Create(config *portainer.PendingAction) error {
|
||||||
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return s.Tx(tx).Create(config)
|
return s.Tx(tx).Create(config)
|
||||||
@@ -62,44 +57,3 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s ServiceTx) Create(config *portainer.PendingAction) error {
|
|
||||||
return s.Tx.CreateObject(BucketName, func(id uint64) (int, any) {
|
|
||||||
config.ID = portainer.PendingActionID(id)
|
|
||||||
config.CreatedAt = time.Now().Unix()
|
|
||||||
|
|
||||||
return int(config.ID), config
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
|
|
||||||
return s.BaseDataServiceTx.Update(ID, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
|
|
||||||
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
|
|
||||||
pendingActions, err := s.BaseDataServiceTx.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pendingAction := range pendingActions {
|
|
||||||
if pendingAction.EndpointID == ID {
|
|
||||||
err := s.BaseDataServiceTx.Delete(pendingAction.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNextIdentifier returns the next identifier for a custom template.
|
|
||||||
func (service ServiceTx) GetNextIdentifier() int {
|
|
||||||
return service.Tx.GetNextIdentifier(BucketName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNextIdentifier returns the next identifier for a custom template.
|
|
||||||
func (service *Service) GetNextIdentifier() int {
|
|
||||||
return service.Connection.GetNextIdentifier(BucketName)
|
|
||||||
}
|
|
||||||
|
|||||||
32
api/dataservices/pendingactions/pendingactions_test.go
Normal file
32
api/dataservices/pendingactions/pendingactions_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package pendingactions_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteByEndpoint(t *testing.T) {
|
||||||
|
_, store := datastore.MustNewTestStore(t, false, false)
|
||||||
|
|
||||||
|
// Create Endpoint 1
|
||||||
|
err := store.PendingActions().Create(&portainer.PendingAction{EndpointID: 1})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create Endpoint 2
|
||||||
|
err = store.PendingActions().Create(&portainer.PendingAction{EndpointID: 2})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Delete Endpoint 1
|
||||||
|
err = store.PendingActions().DeleteByEndpointID(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that only Endpoint 2 remains
|
||||||
|
pendingActions, err := store.PendingActions().ReadAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pendingActions, 1)
|
||||||
|
require.Equal(t, portainer.EndpointID(2), pendingActions[0].EndpointID)
|
||||||
|
}
|
||||||
49
api/dataservices/pendingactions/tx.go
Normal file
49
api/dataservices/pendingactions/tx.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package pendingactions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceTx struct {
|
||||||
|
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Create(config *portainer.PendingAction) error {
|
||||||
|
return s.Tx.CreateObject(BucketName, func(id uint64) (int, any) {
|
||||||
|
config.ID = portainer.PendingActionID(id)
|
||||||
|
config.CreatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
return int(config.ID), config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
|
||||||
|
return s.BaseDataServiceTx.Update(ID, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
|
||||||
|
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
|
||||||
|
pendingActions, err := s.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pendingAction := range pendingActions {
|
||||||
|
if pendingAction.EndpointID == ID {
|
||||||
|
if err := s.Delete(pendingAction.ID); err != nil {
|
||||||
|
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for a custom template.
|
||||||
|
func (service ServiceTx) GetNextIdentifier() int {
|
||||||
|
return service.Tx.GetNextIdentifier(BucketName)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package resourcecontrol
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
@@ -64,12 +65,10 @@ func (service *Service) ResourceControlByResourceIDAndType(resourceID string, re
|
|||||||
return nil, stop
|
return nil, stop
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, subResourceID := range rc.SubResourceIDs {
|
if slices.Contains(rc.SubResourceIDs, resourceID) {
|
||||||
if subResourceID == resourceID {
|
|
||||||
resourceControl = rc
|
resourceControl = rc
|
||||||
return nil, stop
|
return nil, stop
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return &portainer.ResourceControl{}, nil
|
return &portainer.ResourceControl{}, nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package resourcecontrol
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
@@ -35,12 +36,10 @@ func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, r
|
|||||||
return nil, stop
|
return nil, stop
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, subResourceID := range rc.SubResourceIDs {
|
if slices.Contains(rc.SubResourceIDs, resourceID) {
|
||||||
if subResourceID == resourceID {
|
|
||||||
resourceControl = rc
|
resourceControl = rc
|
||||||
return nil, stop
|
return nil, stop
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return &portainer.ResourceControl{}, nil
|
return &portainer.ResourceControl{}, nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,3 +38,33 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
|||||||
func (service *Service) Create(snapshot *portainer.Snapshot) error {
|
func (service *Service) Create(snapshot *portainer.Snapshot) error {
|
||||||
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||||
|
var snapshot *portainer.Snapshot
|
||||||
|
|
||||||
|
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
snapshot, err = service.Tx(tx).ReadWithoutSnapshotRaw(ID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
|
||||||
|
var snapshot *portainer.SnapshotRawMessage
|
||||||
|
|
||||||
|
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
snapshot, err = service.Tx(tx).ReadRawMessage(ID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
|
||||||
|
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,3 +12,42 @@ type ServiceTx struct {
|
|||||||
func (service ServiceTx) Create(snapshot *portainer.Snapshot) error {
|
func (service ServiceTx) Create(snapshot *portainer.Snapshot) error {
|
||||||
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||||
|
var snapshot struct {
|
||||||
|
Docker *struct {
|
||||||
|
X struct{} `json:"DockerSnapshotRaw"`
|
||||||
|
*portainer.DockerSnapshot
|
||||||
|
} `json:"Docker"`
|
||||||
|
|
||||||
|
portainer.Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.Docker != nil {
|
||||||
|
snapshot.Snapshot.Docker = snapshot.Docker.DockerSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
return &snapshot.Snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
|
||||||
|
var snapshot = portainer.SnapshotRawMessage{}
|
||||||
|
|
||||||
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
|
||||||
|
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||||
|
return ServiceTx{
|
||||||
|
service: service,
|
||||||
|
tx: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings retrieve the ssl settings object.
|
// Settings retrieve the ssl settings object.
|
||||||
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
||||||
var settings portainer.SSLSettings
|
var settings portainer.SSLSettings
|
||||||
|
|||||||
31
api/dataservices/ssl/tx.go
Normal file
31
api/dataservices/ssl/tx.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package ssl
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceTx struct {
|
||||||
|
service *Service
|
||||||
|
tx portainer.Transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) BucketName() string {
|
||||||
|
return BucketName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings retrieve the settings object.
|
||||||
|
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
|
||||||
|
var settings portainer.SSLSettings
|
||||||
|
|
||||||
|
err := service.tx.GetObject(BucketName, []byte(key), &settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings persists a Settings object.
|
||||||
|
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||||
|
return service.tx.UpdateObject(BucketName, []byte(key), settings)
|
||||||
|
}
|
||||||
@@ -4,17 +4,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newGuidString(t *testing.T) string {
|
func newGuidString(t *testing.T) string {
|
||||||
uuid, err := uuid.NewV4()
|
uuid, err := uuid.NewRandom()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return uuid.String()
|
return uuid.String()
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ func TestService_StackByWebhookID(t *testing.T) {
|
|||||||
|
|
||||||
// can find a stack by webhook ID
|
// can find a stack by webhook ID
|
||||||
got, err := store.StackService.StackByWebhookID(webhookID)
|
got, err := store.StackService.StackByWebhookID(webhookID)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, stack, *got)
|
assert.Equal(t, stack, *got)
|
||||||
|
|
||||||
// returns nil and object not found error if there's no stack associated with the webhook
|
// returns nil and object not found error if there's no stack associated with the webhook
|
||||||
@@ -94,10 +95,10 @@ func Test_RefreshableStacks(t *testing.T) {
|
|||||||
|
|
||||||
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
|
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
|
||||||
err := store.Stack().Create(stack)
|
err := store.Stack().Create(stack)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stacks, err := store.Stack().RefreshableStacks()
|
stacks, err := store.Stack().RefreshableStacks()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
|
|
||||||
"github.com/portainer/portainer/api/dataservices/errors"
|
"github.com/portainer/portainer/api/dataservices/errors"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_teamByName(t *testing.T) {
|
func Test_teamByName(t *testing.T) {
|
||||||
@@ -13,7 +15,7 @@ func Test_teamByName(t *testing.T) {
|
|||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
_, store := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
_, err := store.Team().TeamByName("name")
|
_, err := store.Team().TeamByName("name")
|
||||||
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
|
require.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ func Test_teamByName(t *testing.T) {
|
|||||||
teamBuilder.createNew("name1")
|
teamBuilder.createNew("name1")
|
||||||
|
|
||||||
_, err := store.Team().TeamByName("name")
|
_, err := store.Team().TeamByName("name")
|
||||||
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
|
require.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
|
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
|
||||||
@@ -44,7 +46,7 @@ func Test_teamByName(t *testing.T) {
|
|||||||
expectedTeam := teamBuilder.createNew("name1")
|
expectedTeam := teamBuilder.createNew("name1")
|
||||||
|
|
||||||
team, err := store.Team().TeamByName("name1")
|
team, err := store.Team().TeamByName("name1")
|
||||||
assert.NoError(t, err, "TeamByName should succeed")
|
require.NoError(t, err, "TeamByName should succeed")
|
||||||
assert.Equal(t, expectedTeam, team)
|
assert.Equal(t, expectedTeam, team)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ func (service ServiceTx) UserByUsername(username string) (*portainer.User, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) UserIDByUsername(username string) (portainer.UserID, error) {
|
||||||
|
user, err := service.UserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return 0, dserrors.ErrObjectNotFound
|
||||||
|
}
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UsersByRole return an array containing all the users with the specified role.
|
// UsersByRole return an array containing all the users with the specified role.
|
||||||
func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||||
var users = make([]portainer.User, 0)
|
var users = make([]portainer.User, 0)
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) UserIDByUsername(username string) (portainer.UserID, error) {
|
||||||
|
user, err := service.UserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return 0, dserrors.ErrObjectNotFound
|
||||||
|
}
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UsersByRole return an array containing all the users with the specified role.
|
// UsersByRole return an array containing all the users with the specified role.
|
||||||
func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||||
var users = make([]portainer.User, 0)
|
var users = make([]portainer.User, 0)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user