Compare commits
895 Commits
yd-develop
...
feat/3-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f658d67ccb | ||
|
|
be3bfd0513 | ||
|
|
6171806528 | ||
|
|
922f506fe5 | ||
|
|
70f7fe5e84 | ||
|
|
cdf17d904d | ||
|
|
32a2b7a9ae | ||
|
|
21b5ec3e05 | ||
|
|
b3ae5f3659 | ||
|
|
ccd5897915 | ||
|
|
f7cb0f3241 | ||
|
|
7eaff4dab0 | ||
|
|
f69eb3f9eb | ||
|
|
b233f75ab7 | ||
|
|
51957d2f98 | ||
|
|
9e9bb1bbff | ||
|
|
d0a0395337 | ||
|
|
88589e4cb3 | ||
|
|
af74986e66 | ||
|
|
e664bf0e19 | ||
|
|
152c89972b | ||
|
|
25c69c6e9b | ||
|
|
a6370808ae | ||
|
|
6bfd2360d8 | ||
|
|
872d1e03f6 | ||
|
|
a5cacd712d | ||
|
|
f596c862b3 | ||
|
|
5395dee4c6 | ||
|
|
217fe870ef | ||
|
|
26334e9088 | ||
|
|
cc45af2873 | ||
|
|
37bd8c06b5 | ||
|
|
c821a1c59f | ||
|
|
f5d0b3d849 | ||
|
|
0dfd27f08c | ||
|
|
0dfa0266c7 | ||
|
|
9b807ca314 | ||
|
|
de5d84ade4 | ||
|
|
4d539a691d | ||
|
|
ee8e73d7f9 | ||
|
|
32c6bedb98 | ||
|
|
cd9bb18ba1 | ||
|
|
f365035563 | ||
|
|
d9673e33ec | ||
|
|
491df61fbf | ||
|
|
ca1d9dc6a2 | ||
|
|
16b5554f66 | ||
|
|
fcdd6b4510 | ||
|
|
04048c3818 | ||
|
|
1afbc621a4 | ||
|
|
ef807950f1 | ||
|
|
d37f3aa504 | ||
|
|
39b3eb3d64 | ||
|
|
8b21dfc318 | ||
|
|
f87fec6d61 | ||
|
|
391eb22d98 | ||
|
|
0da42c01b6 | ||
|
|
f3f0ca8e21 | ||
|
|
96dc79e253 | ||
|
|
ac3416c5a2 | ||
|
|
ade5b2a3db | ||
|
|
1cd6017df6 | ||
|
|
06caea7b16 | ||
|
|
114779d3af | ||
|
|
96d694b66b | ||
|
|
babb4ffb37 | ||
|
|
0c2f07988a | ||
|
|
d7a1d34be7 | ||
|
|
6a465637d4 | ||
|
|
154c19403a | ||
|
|
c9e1467244 | ||
|
|
1765e41fd4 | ||
|
|
d34ee82754 | ||
|
|
5cdd0023d7 | ||
|
|
df7a4b5d6f | ||
|
|
63eb96859d | ||
|
|
e3e2a3b782 | ||
|
|
eeafa5e0a5 | ||
|
|
7e5e71ae67 | ||
|
|
8daf0bb2a9 | ||
|
|
a779c839b7 | ||
|
|
0da57f8747 | ||
|
|
d01d241af1 | ||
|
|
dd08d09d14 | ||
|
|
0143393a8c | ||
|
|
d2b56efcb4 | ||
|
|
dab0cf48c6 | ||
|
|
916367dccb | ||
|
|
580a9fdfcf | ||
|
|
2ba8b582e2 | ||
|
|
bc81eb7a22 | ||
|
|
a54fc041b0 | ||
|
|
10a2b25527 | ||
|
|
cf476953d6 | ||
|
|
b233453cf7 | ||
|
|
bc5136a197 | ||
|
|
e08ee08fd8 | ||
|
|
eb5ee3bfdb | ||
|
|
86a84c3c6a | ||
|
|
edb348c273 | ||
|
|
ba91b41d36 | ||
|
|
99547044bc | ||
|
|
1fa756372e | ||
|
|
484af3c2c8 | ||
|
|
742551e592 | ||
|
|
50081cbdaa | ||
|
|
61198a0c04 | ||
|
|
67590aa27d | ||
|
|
6c059c41f9 | ||
|
|
f1db82934d | ||
|
|
28dd6b767f | ||
|
|
98b1d7f585 | ||
|
|
f7b8e3d84b | ||
|
|
4b4fa39670 | ||
|
|
ab4626e7de | ||
|
|
7164146626 | ||
|
|
3b4f688223 | ||
|
|
ee2706c5ee | ||
|
|
2d9fc5d8af | ||
|
|
49c9a4fdd3 | ||
|
|
bafdbc8313 | ||
|
|
eca28fd4b5 | ||
|
|
3d09c70e13 | ||
|
|
4cd8c04691 | ||
|
|
f7764cd5cb | ||
|
|
afae689ea9 | ||
|
|
e2d7491bc9 | ||
|
|
4c55508f01 | ||
|
|
064a4304cc | ||
|
|
09c6222ecd | ||
|
|
cad197266d | ||
|
|
5b9976433f | ||
|
|
df48afff17 | ||
|
|
e4e8cf4942 | ||
|
|
c89f34770f | ||
|
|
ca5f695459 | ||
|
|
10e0185c49 | ||
|
|
8cdc2f49d8 | ||
|
|
29db3df98d | ||
|
|
52d9fbc9f2 | ||
|
|
7e80d88bce | ||
|
|
6163008108 | ||
|
|
6945fa4496 | ||
|
|
06ad0b2d78 | ||
|
|
2570a30a15 | ||
|
|
93e5486db3 | ||
|
|
49ef33d9f3 | ||
|
|
ca8201b023 | ||
|
|
2cb94116a3 | ||
|
|
a81b66c6b0 | ||
|
|
c9d24c3684 | ||
|
|
8a22e05284 | ||
|
|
3b0f1eca4b | ||
|
|
a66f114f24 | ||
|
|
2c00f4d40b | ||
|
|
2e88f7a245 | ||
|
|
dd68560ad0 | ||
|
|
d1b702ef37 | ||
|
|
7f3389d6f4 | ||
|
|
d9a415f011 | ||
|
|
edff47fd41 | ||
|
|
b3a9386607 | ||
|
|
300a8abc97 | ||
|
|
2bb2b78e82 | ||
|
|
540c9ba6d5 | ||
|
|
872b824dc6 | ||
|
|
9ecd8d3efb | ||
|
|
080d75acae | ||
|
|
62f4d47ee5 | ||
|
|
c0ac6c56ac | ||
|
|
3e60c2306c | ||
|
|
59614d31f2 | ||
|
|
a117e514e4 | ||
|
|
8d098a2bb9 | ||
|
|
899e4b6f67 | ||
|
|
dba86594e1 | ||
|
|
8885038b7e | ||
|
|
76f525fd38 | ||
|
|
3d741ad58d | ||
|
|
ff169ed356 | ||
|
|
ed7f074380 | ||
|
|
9eb6ebfe9b | ||
|
|
29cfde99ae | ||
|
|
c3b0b9a2e0 | ||
|
|
e7ec69708e | ||
|
|
ff9c10f641 | ||
|
|
0eba817aab | ||
|
|
6cb6f2e9b4 | ||
|
|
6faa0939d8 | ||
|
|
68f93fb281 | ||
|
|
1ea8c1cb4e | ||
|
|
d749d05359 | ||
|
|
b18b4418c8 | ||
|
|
a3935ce445 | ||
|
|
92bbfb8fa3 | ||
|
|
6c097dcf51 | ||
|
|
0688e6bbdd | ||
|
|
c49e682df4 | ||
|
|
538d57fe19 | ||
|
|
3053990411 | ||
|
|
49011d4d03 | ||
|
|
6a30138b3c | ||
|
|
6aac4f38e4 | ||
|
|
bc6c5da2dc | ||
|
|
1c55555ad0 | ||
|
|
3f8fcb3914 | ||
|
|
24a879add6 | ||
|
|
ae1b6b8a71 | ||
|
|
da36002d37 | ||
|
|
a611e12b5c | ||
|
|
d4114c510d | ||
|
|
5eaf145eda | ||
|
|
2c2ec6f6e6 | ||
|
|
39ac164890 | ||
|
|
8140c834ca | ||
|
|
742523de17 | ||
|
|
dd1c1071ce | ||
|
|
b9713f7e9e | ||
|
|
9c0a13a828 | ||
|
|
dc56aae7b8 | ||
|
|
ba11fe920b | ||
|
|
7f2da7811c | ||
|
|
62cf2e42d5 | ||
|
|
64745e70d0 | ||
|
|
f49cd6e932 | ||
|
|
ac1e333dde | ||
|
|
b5bc5f65ad | ||
|
|
463d539194 | ||
|
|
7e544ee449 | ||
|
|
1f320c976f | ||
|
|
825a7669a6 | ||
|
|
f6a72b089c | ||
|
|
73ea33f36c | ||
|
|
744a31a354 | ||
|
|
42c7f10e79 | ||
|
|
3e57bc5aa0 | ||
|
|
4880e61e0f | ||
|
|
79a93cfd01 | ||
|
|
0af7bc2004 | ||
|
|
ada103e910 | ||
|
|
a0e964c27d | ||
|
|
a2624b7467 | ||
|
|
9abd7eaeea | ||
|
|
3502ed0293 | ||
|
|
3101738adc | ||
|
|
0b390dd274 | ||
|
|
9d3f7b710d | ||
|
|
3a8ed40943 | ||
|
|
aef1d982c2 | ||
|
|
b287961758 | ||
|
|
8d5675a7d7 | ||
|
|
544e302fe1 | ||
|
|
b417b04a69 | ||
|
|
6ecb99898d | ||
|
|
236c5e2415 | ||
|
|
2d2b68e867 | ||
|
|
f841ea527a | ||
|
|
169548cc4c | ||
|
|
8f93a1a8cf | ||
|
|
8e85fa9f83 | ||
|
|
181a83a889 | ||
|
|
b78504aa04 | ||
|
|
a21ec9299b | ||
|
|
7708ace1d8 | ||
|
|
218b5d5900 | ||
|
|
2983b94cf7 | ||
|
|
25e082ea63 | ||
|
|
3313376fac | ||
|
|
a96c6efcbd | ||
|
|
4dd6b88cdf | ||
|
|
0d836f1e30 | ||
|
|
ab3e0956a4 | ||
|
|
615fceb4a5 | ||
|
|
68453ebcb8 | ||
|
|
635c49d04d | ||
|
|
886af7d55a | ||
|
|
8f563220df | ||
|
|
def415b6f3 | ||
|
|
c21d043183 | ||
|
|
769ea73cec | ||
|
|
d140726c46 | ||
|
|
1f42559279 | ||
|
|
b6d6c7fd2a | ||
|
|
1298fc629e | ||
|
|
30ca5e298c | ||
|
|
2240d0516c | ||
|
|
b87095dc7a | ||
|
|
d30503a40c | ||
|
|
7fbda4fe54 | ||
|
|
24a2b29f70 | ||
|
|
ca9e197d12 | ||
|
|
51f86eb4c6 | ||
|
|
5aba61cc49 | ||
|
|
fcf9888677 | ||
|
|
9c9caeb57a | ||
|
|
a58ad25533 | ||
|
|
11f5150190 | ||
|
|
1c72dfe5ad | ||
|
|
b49830db8f | ||
|
|
e035c490dc | ||
|
|
0d8544b3ee | ||
|
|
50056bef70 | ||
|
|
e68e14787b | ||
|
|
0ab2c5cf98 | ||
|
|
1ca56fd027 | ||
|
|
c4cc9cf1c7 | ||
|
|
b53684a89e | ||
|
|
d93508a272 | ||
|
|
ad9b9cf5b1 | ||
|
|
ac5fb731bc | ||
|
|
d36799020b | ||
|
|
7aa08053e0 | ||
|
|
61b9bc248f | ||
|
|
e33f9573e8 | ||
|
|
186624d267 | ||
|
|
7c9d4cd7d8 | ||
|
|
541b8df735 | ||
|
|
2900bfa1d6 | ||
|
|
5ea0f682a6 | ||
|
|
019cbfd972 | ||
|
|
792c95b8bb | ||
|
|
4d1f432266 | ||
|
|
1e00a58b57 | ||
|
|
0a26ac0279 | ||
|
|
63b0802ad7 | ||
|
|
a5062dbe35 | ||
|
|
f84e657707 | ||
|
|
cd8a42edaf | ||
|
|
e37f8a5eb9 | ||
|
|
7fc8d3f2b1 | ||
|
|
6f2d1a2b49 | ||
|
|
d5a3e46791 | ||
|
|
1f4724c537 | ||
|
|
e6f8736cae | ||
|
|
54fbe54953 | ||
|
|
3e92a2881a | ||
|
|
bd9c3c1593 | ||
|
|
f199d0882f | ||
|
|
a2fee4fc4c | ||
|
|
5670216d7e | ||
|
|
7569266e46 | ||
|
|
23f6cb8bae | ||
|
|
931c2b3ddb | ||
|
|
8b3edb4e28 | ||
|
|
a0b03d36bd | ||
|
|
df1cd0af2e | ||
|
|
5df7146828 | ||
|
|
bec5d829f1 | ||
|
|
ee0e9f6ff8 | ||
|
|
9c7eef3144 | ||
|
|
3110fe4e74 | ||
|
|
565ac2c15a | ||
|
|
9cba6c7475 | ||
|
|
07b3bdb62d | ||
|
|
ac7ff0fff4 | ||
|
|
0d20839d5f | ||
|
|
13fb3118ee | ||
|
|
364027054c | ||
|
|
31a861394f | ||
|
|
0fccc0357e | ||
|
|
5550a71dea | ||
|
|
0ec6f638a1 | ||
|
|
748b4bcf19 | ||
|
|
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 |
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test/
|
||||
151
.eslintrc.yml
151
.eslintrc.yml
@@ -1,151 +0,0 @@
|
||||
env:
|
||||
browser: true
|
||||
jquery: true
|
||||
node: true
|
||||
es6: true
|
||||
|
||||
globals:
|
||||
angular: true
|
||||
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- 'plugin:import/typescript'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
modules: true
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
no-alert: error
|
||||
no-control-regex: 'off'
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/named: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups:
|
||||
[
|
||||
{ pattern: '@@/**', group: 'internal', position: 'after' },
|
||||
{ pattern: '@/**', group: 'internal' },
|
||||
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
|
||||
],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
no-restricted-imports:
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
alias:
|
||||
map:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
typescript: true
|
||||
node: true
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
- app/**/*.ts{,x}
|
||||
parserOptions:
|
||||
project: './tsconfig.json'
|
||||
parser: '@typescript-eslint/parser'
|
||||
plugins:
|
||||
- '@typescript-eslint'
|
||||
- 'regex'
|
||||
extends:
|
||||
- airbnb
|
||||
- airbnb-typescript
|
||||
- 'plugin:eslint-comments/recommended'
|
||||
- 'plugin:react-hooks/recommended'
|
||||
- 'plugin:react/jsx-runtime'
|
||||
- 'plugin:@typescript-eslint/recommended'
|
||||
- 'plugin:@typescript-eslint/eslint-recommended'
|
||||
- 'plugin:promise/recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- prettier # should be last
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
]
|
||||
no-plusplus: off
|
||||
func-style: [error, 'declaration']
|
||||
import/prefer-default-export: off
|
||||
no-use-before-define: 'off'
|
||||
'@typescript-eslint/no-use-before-define': ['error', { functions: false, 'allowNamedExports': true }]
|
||||
no-shadow: 'off'
|
||||
'@typescript-eslint/no-shadow': off
|
||||
jsx-a11y/no-autofocus: warn
|
||||
react/forbid-prop-types: off
|
||||
react/require-default-props: off
|
||||
react/no-array-index-key: off
|
||||
no-underscore-dangle: off
|
||||
react/jsx-filename-extension: [0]
|
||||
import/no-extraneous-dependencies: ['error', { devDependencies: true }]
|
||||
'@typescript-eslint/explicit-module-boundary-types': off
|
||||
'@typescript-eslint/no-unused-vars': 'error'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
|
||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||
'react/jsx-no-bind': off
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
|
||||
'@typescript-eslint/no-restricted-imports':
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
overrides: # allow props spreading for hoc files
|
||||
- files:
|
||||
- app/**/with*.ts{,x}
|
||||
rules:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
env:
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
'no-alert': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
6
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
6
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -3,13 +3,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
**DO NOT FILE DUPLICATE REQUESTS.**
|
||||
|
||||
- type: textarea
|
||||
|
||||
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -2,18 +2,17 @@ name: Bug Report
|
||||
description: Create a report to help us improve.
|
||||
labels: kind/bug,bug/need-confirmation
|
||||
body:
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
|
||||
|
||||
|
||||
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**.
|
||||
|
||||
- type: checkboxes
|
||||
@@ -23,7 +22,7 @@ body:
|
||||
options:
|
||||
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
||||
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
|
||||
|
||||
- type: markdown
|
||||
@@ -45,7 +44,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -71,7 +70,7 @@ body:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -92,35 +91,33 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
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 [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
|
||||
options:
|
||||
- '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.21.5'
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '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.42.0'
|
||||
- '2.41.1'
|
||||
- '2.41.0'
|
||||
- '2.40.0'
|
||||
- '2.39.3'
|
||||
- '2.39.2'
|
||||
- '2.39.1'
|
||||
- '2.39.0'
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.8'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
- '2.33.3'
|
||||
- '2.33.2'
|
||||
- '2.33.1'
|
||||
- '2.33.0'
|
||||
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -158,7 +155,7 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
86
.github/workflows/build-image.yml
vendored
Normal file
86
.github/workflows/build-image.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Build image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/vvzvlad/portainer-ce
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install client dependencies
|
||||
# CI forces pnpm into --frozen-lockfile, which fails with
|
||||
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
|
||||
# the pnpmfileChecksum for the configDependencies in package.json.
|
||||
# Reconcile the lockfile explicitly; the later frozen install in
|
||||
# `make client-deps` then finds a matching lockfile. pnpm ignores the
|
||||
# npm_config_frozen_lockfile env var, so an explicit flag is required.
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build client and server
|
||||
env:
|
||||
SKIP_GO_GET: "true"
|
||||
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
|
||||
BUILDNUMBER: ${{ github.run_number }}
|
||||
# Pin the embedded commit to the full SHA so it matches the image
|
||||
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
|
||||
GIT_COMMIT_HASH: ${{ github.sha }}
|
||||
# ENV=production selects webpack/webpack.production.js (minified bundle),
|
||||
# matching the official CE image; the Makefile default is development.
|
||||
run: make build-all ENV=production
|
||||
|
||||
- name: Ensure storybook directory exists
|
||||
# make build-all does not produce dist/storybook, but alpine.Dockerfile
|
||||
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
|
||||
run: mkdir -p dist/storybook
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image (linux/amd64, alpine base)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: build/linux/alpine.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
|
||||
${{ env.IMAGE }}:latest
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
||||
portainer-checksum.txt
|
||||
api/cmd/portainer/portainer*
|
||||
storybook-static
|
||||
debug-storybook.log
|
||||
.tmp
|
||||
**/.vscode/settings.json
|
||||
**/.vscode/tasks.json
|
||||
@@ -18,3 +19,5 @@ api/docs
|
||||
.env
|
||||
go.work.sum
|
||||
|
||||
.vitest
|
||||
|
||||
|
||||
13
.golangci-forward.yaml
Normal file
13
.golangci-forward.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
- pattern: ^(filepath|path)\.Join$
|
||||
msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks
|
||||
analyze-types: true
|
||||
154
.golangci.yaml
154
.golangci.yaml
@@ -1,40 +1,126 @@
|
||||
linters:
|
||||
# Disable all linters, the defaults don't pass on our code yet
|
||||
disable-all: true
|
||||
version: '2'
|
||||
|
||||
# Enable these for now
|
||||
run:
|
||||
allow-parallel-runners: true
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- unused
|
||||
- depguard
|
||||
- gosimple
|
||||
- govet
|
||||
- errorlint
|
||||
- gocritic
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- depguard
|
||||
- errcheck
|
||||
- errorlint
|
||||
- forbidigo
|
||||
- govet
|
||||
- ineffassign
|
||||
- intrange
|
||||
- perfsprint
|
||||
|
||||
linters-settings:
|
||||
depguard:
|
||||
- staticcheck
|
||||
- unused
|
||||
- mirror
|
||||
- durationcheck
|
||||
- errorlint
|
||||
- govet
|
||||
- usetesting
|
||||
- zerologlint
|
||||
- testifylint
|
||||
- modernize
|
||||
- unconvert
|
||||
- unused
|
||||
- zerologlint
|
||||
- exptostd
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
files:
|
||||
- '!**/*_test.go'
|
||||
- '!**/base.go'
|
||||
- '!**/base_tx.go'
|
||||
deny:
|
||||
- pkg: encoding/json
|
||||
desc: use github.com/segmentio/encoding/json
|
||||
- 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
|
||||
- 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
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- ruleguard
|
||||
settings:
|
||||
ruleguard:
|
||||
rules: './analysis/ssrf.go,./analysis/git.go'
|
||||
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'
|
||||
- pattern: ^git\.PlainClone(Context|WithOptions)?$
|
||||
msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: 'encoding/json'
|
||||
desc: 'use github.com/segmentio/encoding/json'
|
||||
- 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:
|
||||
- '!**/*_test.go'
|
||||
- '!**/base.go'
|
||||
- '!**/base_tx.go'
|
||||
|
||||
# errorlint is causing a typecheck error for some reason. The go compiler will report these
|
||||
# anyway, so ignore them from the linter
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: ./
|
||||
linters:
|
||||
- typecheck
|
||||
- path: pkg/libhttp/ssrf
|
||||
linters:
|
||||
- gocritic
|
||||
text: ruleguard
|
||||
- path: pkg/libhttp/ssrf/builder\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd $(dirname -- "$0") && yarn lint-staged
|
||||
cd $(dirname -- "$0") && pnpm lint-staged
|
||||
@@ -1,2 +1,5 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
15
.prettierrc
15
.prettierrc
@@ -5,21 +5,18 @@
|
||||
"trailingComma": "es5",
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.{j,t}sx",
|
||||
"*.ts"
|
||||
],
|
||||
"files": ["*.{j,t}sx", "*.ts"],
|
||||
"options": {
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["clsx"]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,56 @@
|
||||
// This file has been automatically migrated to valid ESM format by Storybook.
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import path, { dirname } from 'path';
|
||||
|
||||
import { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
||||
import { Configuration } from 'webpack';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
webpackFinal: (config) => {
|
||||
const rules = config?.module?.rules || [];
|
||||
@@ -67,12 +93,7 @@ const config: StorybookConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
plugins: [
|
||||
...(config.resolve?.plugins || []),
|
||||
new TsconfigPathsPlugin({
|
||||
extensions: config.resolve?.extensions,
|
||||
}),
|
||||
],
|
||||
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
|
||||
},
|
||||
module: {
|
||||
...config.module,
|
||||
@@ -82,12 +103,13 @@ const config: StorybookConfig = {
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgen: 'react-docgen',
|
||||
},
|
||||
framework: {
|
||||
name: '@storybook/react-webpack5',
|
||||
options: {},
|
||||
},
|
||||
docs: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react-webpack5';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
@@ -21,31 +22,65 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
];
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'Portainer color theme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light', icon: 'sun' },
|
||||
{ value: 'dark', title: 'Dark', icon: 'moon' },
|
||||
{ value: 'highcontrast', title: 'High Contrast', icon: 'eye' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: 'light',
|
||||
},
|
||||
decorators: (Story, context) => {
|
||||
const theme = context.globals.theme;
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.removeAttribute('theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('theme', theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
order: ['Design System', 'Components', '*'],
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (2.0.11).
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
|
||||
const PACKAGE_VERSION = '2.12.10';
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
|
||||
const activeClientIds = new Set();
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id;
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id');
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return;
|
||||
@@ -48,7 +48,10 @@ self.addEventListener('message', async function (event) {
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -58,16 +61,16 @@ self.addEventListener('message', async function (event) {
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId);
|
||||
|
||||
@@ -85,72 +88,91 @@ self.addEventListener('message', async function (event) {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event;
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now();
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
if (event.request.mode === 'navigate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID();
|
||||
event.respondWith(handleRequest(event, requestId));
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
|
||||
});
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
const requestCloneForEvents = event.request.clone();
|
||||
const response = await getResponse(event, client, requestId, requestInterceptedAt);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
(async function () {
|
||||
const responseClone = response.clone();
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
[responseClone.body]
|
||||
);
|
||||
})();
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : []
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
@@ -171,20 +193,37 @@ async function resolveMainClient(event) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone();
|
||||
const requestClone = event.request.clone();
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries());
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers);
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention'];
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept');
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim());
|
||||
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '));
|
||||
} else {
|
||||
headers.delete('accept');
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers });
|
||||
}
|
||||
@@ -202,37 +241,19 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
const mswIntention = request.headers.get('x-msw-intention');
|
||||
if (['bypass', 'passthrough'].includes(mswIntention)) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer();
|
||||
const serializedRequest = await serializeRequest(event.request);
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[requestBuffer]
|
||||
[serializedRequest.body]
|
||||
);
|
||||
|
||||
switch (clientMessage.type) {
|
||||
@@ -240,7 +261,7 @@ async function getResponse(event, client, requestId) {
|
||||
return respondWithMock(clientMessage.data);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough();
|
||||
}
|
||||
}
|
||||
@@ -248,6 +269,12 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
@@ -260,11 +287,15 @@ function sendToClient(client, message, transferrables = []) {
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
|
||||
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
|
||||
});
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
@@ -282,3 +313,24 @@ async function respondWithMock(response) {
|
||||
|
||||
return mockedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
};
|
||||
}
|
||||
|
||||
59
CLAUDE.md
Normal file
59
CLAUDE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
## Project Structure
|
||||
|
||||
For a detailed breakdown of frontend and backend directory layout, feature locations, and common development tasks, see [docs/guidelines/project-structure.md](../../docs/guidelines/project-structure.md).
|
||||
|
||||
## Frontend Guidelines
|
||||
|
||||
- [docs/guidelines/frontend-conventions.md](../../docs/guidelines/frontend-conventions.md) — component structure, React Query patterns, shared components, forms, theming
|
||||
- [docs/guidelines/typescript-conventions.md](../../docs/guidelines/typescript-conventions.md) — types, anti-patterns, union types, named constants
|
||||
- [docs/guidelines/frontend-unit-testing.md](../../docs/guidelines/frontend-unit-testing.md) — Vitest, React Testing Library
|
||||
|
||||
## Backend Guidelines
|
||||
|
||||
- [docs/guidelines/go-conventions.md](../../docs/guidelines/go-conventions.md) — error handling, naming, testing, code style
|
||||
- [docs/guidelines/server-architecture.md](../../docs/guidelines/server-architecture.md) — Clean Architecture layers, transactions, CE/EE sharing patterns
|
||||
- [docs/guidelines/logging.md](../../docs/guidelines/logging.md) — zerolog usage, log levels, message style
|
||||
- [docs/guidelines/backend-code-reusability.md](../../docs/guidelines/backend-code-reusability.md) — how CE and EE share backend code
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.26.1 (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
|
||||
|
||||
# Frontend
|
||||
pnpm dev # Webpack dev server
|
||||
pnpm build # Build frontend with webpack
|
||||
pnpm typecheck # Run typecheck for frontend (with tsc)
|
||||
pnpm lint # lint frontend (with eslint)
|
||||
pnpm test # test frontend (with vitest)
|
||||
pnpm format # format frontend (with prettier)
|
||||
|
||||
# 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)
|
||||
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -147,7 +147,9 @@ When adding a new route to an existing handler use the following as a template (
|
||||
// @router /{id} [get]
|
||||
```
|
||||
|
||||
explanation about each line can be found (here)[https://github.com/swaggo/swag#api-operation]
|
||||
explanation about each line can be found [here](https://github.com/swaggo/swag#api-operation)
|
||||
|
||||
After changing these annotations, regenerate the TypeScript API client and types — see [Generating API types](./README.md#generating-api-types).
|
||||
|
||||
## Licensing
|
||||
|
||||
|
||||
75
Makefile
75
Makefile
@@ -1,16 +1,12 @@
|
||||
# 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"
|
||||
ENV=development
|
||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||
TAG=local
|
||||
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@latest
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
GOTESTSUM_VERSION?=v1.13.0
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
|
||||
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
|
||||
|
||||
# Don't change anything below this line unless you know what you're doing
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -26,7 +22,7 @@ all: tidy deps build-server build-client ## Build the client, server and downloa
|
||||
build-all: all ## Alias for the 'all' target (used by CI)
|
||||
|
||||
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/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
||||
@@ -35,42 +31,38 @@ build-image: build-all ## Build the Portainer image locally
|
||||
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
||||
|
||||
build-storybook: ## Build and serve the storybook files
|
||||
yarn 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)"
|
||||
pnpm run storybook:build
|
||||
|
||||
##@ Build dependencies
|
||||
.PHONY: deps server-deps client-deps tidy
|
||||
deps: server-deps client-deps ## Download all client and server build dependancies
|
||||
|
||||
## This is empty because the pipeline requires it but ce has no server deps
|
||||
server-deps: init-dist ## Download dependant server binaries
|
||||
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
||||
|
||||
client-deps: ## Install client dependencies
|
||||
yarn
|
||||
pnpm install
|
||||
|
||||
tidy: ## Tidy up the go.mod file
|
||||
@go mod tidy
|
||||
|
||||
|
||||
##@ Cleanup
|
||||
.PHONY: clean
|
||||
clean: ## Remove all build and download artifacts
|
||||
@echo "Clearing the dist directory..."
|
||||
@rm -rf dist/*
|
||||
|
||||
|
||||
##@ Testing
|
||||
.PHONY: test test-client test-server
|
||||
test: test-server test-client ## Run all tests
|
||||
|
||||
test-client: ## Run client tests
|
||||
yarn test $(ARGS) --coverage
|
||||
pnpm run test $(ARGS) --coverage
|
||||
|
||||
TEST_PACKAGES?=./...
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
|
||||
|
||||
##@ Dev
|
||||
.PHONY: dev dev-client dev-server
|
||||
@@ -79,7 +71,7 @@ dev: ## Run both the client and server in development mode
|
||||
make dev-client
|
||||
|
||||
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/run_container.sh
|
||||
@@ -93,36 +85,59 @@ dev-server-podman: build-server ## Run the server in development mode
|
||||
format: format-client format-server ## Format all code
|
||||
|
||||
format-client: ## Format client code
|
||||
yarn format
|
||||
pnpm run format
|
||||
|
||||
format-server: ## Format server code
|
||||
go fmt ./...
|
||||
|
||||
##@ Lint
|
||||
.PHONY: lint lint-client lint-server
|
||||
.PHONY: lint lint-client lint-server check-lint-version
|
||||
lint: lint-client lint-server ## Lint all code
|
||||
|
||||
lint-client: ## Lint client code
|
||||
yarn lint
|
||||
pnpm run lint
|
||||
|
||||
lint-server: ## Lint server code
|
||||
check-lint-version:
|
||||
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
|
||||
if [ "$$installed" = "v" ]; then \
|
||||
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
|
||||
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
lint-server: tidy check-lint-version ## Lint server code
|
||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||
|
||||
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
||||
|
||||
##@ Extension
|
||||
.PHONY: dev-extension
|
||||
dev-extension: build-server build-client ## Run the extension in development mode
|
||||
make local -f build/docker-extension/Makefile
|
||||
|
||||
|
||||
##@ Docs
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
go mod download
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./ --overridesFile .swaggo
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
yarn swagger-cli validate dist/docs/openapi.yaml
|
||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
pnpm swagger-cli validate dist/docs/openapi.yaml
|
||||
|
||||
.PHONY: docs-serve
|
||||
docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
|
||||
docker run -p 8080:8080 \
|
||||
-e SWAGGER_JSON=/foo/swagger.yaml \
|
||||
-v $(PWD)/dist/docs:/foo \
|
||||
swaggerapi/swagger-ui
|
||||
|
||||
.PHONY: generate-api
|
||||
generate-api: docs-validate ## Generate API client and types from OpenAPI spec
|
||||
pnpm generate-api
|
||||
|
||||
##@ Helpers
|
||||
.PHONY: help
|
||||
|
||||
45
README.md
45
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.
|
||||
|
||||
- [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)
|
||||
- [Portainer BE install guide](https://install.portainer.io)
|
||||
- [Portainer BE install guide](https://academy.portainer.io/install/)
|
||||
|
||||
## Latest Version
|
||||
|
||||
@@ -20,22 +20,19 @@ Portainer CE is updated regularly. We aim to do an update release every couple o
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Deploy Portainer](https://docs.portainer.io/start/install)
|
||||
- [Deploy Portainer](https://docs.portainer.io/start/install-ce)
|
||||
- [Documentation](https://docs.portainer.io)
|
||||
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
||||
|
||||
## Features & Functions
|
||||
|
||||
View [this](https://www.portainer.io/products) 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)
|
||||
View [this](https://www.portainer.io/features) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
||||
@@ -47,19 +44,45 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://docs.portainer.io/contribute/contribute) to build it locally and make a pull request.
|
||||
|
||||
## Generating API types
|
||||
|
||||
The frontend consumes a TypeScript API client (SDK functions and request/response types) that is generated from the Go API's Swagger annotations. Regenerate it after any API change — a new endpoint, a changed request/response shape, or a removed endpoint:
|
||||
|
||||
```bash
|
||||
make generate-api
|
||||
```
|
||||
|
||||
This runs the following pipeline:
|
||||
|
||||
```
|
||||
Go Swagger annotations
|
||||
→ dist/docs/swagger.yaml (make docs-build, via swaggo/swag)
|
||||
→ dist/docs/openapi.yaml (swagger2openapi + validation)
|
||||
→ app/react/portainer/generated-api/portainer/ (hey-api/openapi-ts)
|
||||
```
|
||||
|
||||
The generator is configured in [`openapi-ts.config.ts`](./openapi-ts.config.ts), which controls the output path, plugins, and tag filters (for example, `deprecated` endpoints and `edge_agent`-tagged routes are excluded).
|
||||
|
||||
The generated files live in `app/react/portainer/generated-api/portainer/` and must **not** be edited by hand — your changes would be overwritten on the next run. Import the generated SDK functions and types instead of writing direct HTTP calls:
|
||||
|
||||
- `@api/sdk.gen` — SDK functions
|
||||
- `@api/types.gen` — request/response types
|
||||
|
||||
See [Adding api docs](./CONTRIBUTING.md#adding-api-docs) for how to annotate handlers so they are picked up by the generator.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
|
||||
60
SECURITY.md
Normal file
60
SECURITY.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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)
|
||||
118
__mocks__/@reach/menu-button.tsx
Normal file
118
__mocks__/@reach/menu-button.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Children,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type MenuCtxType = {
|
||||
isOpen: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
label: string;
|
||||
setLabel: (v: string) => void;
|
||||
};
|
||||
|
||||
const MenuCtx = createContext<MenuCtxType | null>(null);
|
||||
|
||||
export function Menu({ children }: { children?: ReactNode }) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocDown(e: MouseEvent) {
|
||||
const target = e.target as Node | null;
|
||||
if (
|
||||
isOpen &&
|
||||
menuRef.current &&
|
||||
target &&
|
||||
!menuRef.current.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleDocDown);
|
||||
return () => document.removeEventListener('mousedown', handleDocDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
|
||||
<div ref={menuRef}>{children}</div>
|
||||
</MenuCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuButton({
|
||||
children,
|
||||
onClick: externalOnClick,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
|
||||
useEffect(() => {
|
||||
const firstText = Children.toArray(children).find(
|
||||
(c) => typeof c === 'string'
|
||||
);
|
||||
if (firstText) ctx?.setLabel(firstText as string);
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
externalOnClick?.();
|
||||
ctx?.setOpen(!ctx.isOpen);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuList({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
if (!ctx?.isOpen) return null;
|
||||
return (
|
||||
<div role="menu" aria-label={ctx.label || undefined} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItem({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
|
||||
function handleClick() {
|
||||
onSelect?.();
|
||||
ctx?.setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
|
||||
<div role="menuitem" onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
analysis/git.go
Normal file
18
analysis/git.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as
|
||||
// the storer while also writing files to a real worktree. This holds all git objects
|
||||
// in heap for the duration of the clone, which is unbounded for user-supplied repos.
|
||||
func inMemoryCloneWithWorktree(m dsl.Matcher) {
|
||||
m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
|
||||
m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
}
|
||||
75
analysis/ssrf.go
Normal file
75
analysis/ssrf.go
Normal file
@@ -0,0 +1,75 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
|
||||
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
|
||||
// which clone http.DefaultTransport and handle SSRF protection internally.
|
||||
func unwrappedHTTPTransport(m dsl.Matcher) {
|
||||
m.Match(`$f(&http.Transport{$*_})`).
|
||||
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_ := &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_.Transport = &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
|
||||
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
|
||||
// passed here must be created via ssrf.NewTransport.
|
||||
func helmGetterTransport(m dsl.Matcher) {
|
||||
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
|
||||
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
|
||||
}
|
||||
|
||||
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
|
||||
// The one legitimate clone is in main.go where http.DefaultTransport is globally
|
||||
// wrapped with SSRF protection at server startup.
|
||||
func cloneDefaultTransport(m dsl.Matcher) {
|
||||
m.Match(`$_.(*http.Transport).Clone()`).
|
||||
Where(!m.File().Name.Matches(`^main\.go$`)).
|
||||
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
|
||||
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
|
||||
func internalTransportMisuse(m dsl.Matcher) {
|
||||
m.Match(`ssrf.NewInternalTransport($*_)`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
|
||||
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
|
||||
}
|
||||
|
||||
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
|
||||
// The only valid assignments are in docker_unix.go and docker_windows.go where a
|
||||
// custom dialer is required for unix sockets and named pipes.
|
||||
func dialerOverride(m dsl.Matcher) {
|
||||
m.Match(`$_.DialContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.Dial = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLSContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLS = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
}
|
||||
5
analysis/tools.go
Normal file
5
analysis/tools.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build tools
|
||||
|
||||
package gorules
|
||||
|
||||
import _ "github.com/quasilyte/go-ruleguard/dsl"
|
||||
1
api/.swaggo
Normal file
1
api/.swaggo
Normal file
@@ -0,0 +1 @@
|
||||
replace k8s.io/apimachinery/pkg/apis/meta/v1.Duration string
|
||||
@@ -19,24 +19,22 @@ const RedirectReasonAdminInitTimeout string = "AdminInitTimeout"
|
||||
type Monitor struct {
|
||||
timeout time.Duration
|
||||
datastore dataservices.DataStore
|
||||
shutdownCtx context.Context
|
||||
cancellationFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
adminInitDisabled bool
|
||||
}
|
||||
|
||||
// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized.
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore, shutdownCtx context.Context) *Monitor {
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore) *Monitor {
|
||||
return &Monitor{
|
||||
timeout: timeout,
|
||||
datastore: datastore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
adminInitDisabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Starts starts the monitor. Active monitor could be stopped or shuttted down by cancelling the shutdown context.
|
||||
func (m *Monitor) Start() {
|
||||
// Start starts the monitor. The monitor will stop when ctx is cancelled, or when Stop is called.
|
||||
func (m *Monitor) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -44,7 +42,7 @@ func (m *Monitor) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(context.Background())
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(ctx)
|
||||
m.cancellationFunc = cancellationFunc
|
||||
|
||||
go func() {
|
||||
@@ -69,8 +67,6 @@ func (m *Monitor) Start() {
|
||||
}
|
||||
case <-cancellationCtx.Done():
|
||||
log.Debug().Msg("canceling initialization monitor")
|
||||
case <-m.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down initialization monitor")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package adminmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -11,21 +11,28 @@ import (
|
||||
)
|
||||
|
||||
func Test_stopWithoutStarting(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_stopCouldBeCalledMultipleTimes(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
t.Parallel()
|
||||
synctest.Test(t, test_startOrStopCouldBeCalledMultipleTimesConcurrently)
|
||||
}
|
||||
|
||||
go monitor.Start()
|
||||
monitor.Start()
|
||||
func test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil)
|
||||
|
||||
go monitor.Start(t.Context())
|
||||
monitor.Start(t.Context())
|
||||
|
||||
go monitor.Stop()
|
||||
monitor.Stop()
|
||||
@@ -34,8 +41,9 @@ func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_canStopStartedMonitor(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
monitor.Start()
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Start(t.Context())
|
||||
assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor")
|
||||
|
||||
monitor.Stop()
|
||||
@@ -43,11 +51,12 @@ func Test_canStopStartedMonitor(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
timeout := 10 * time.Millisecond
|
||||
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}))
|
||||
monitor := New(timeout, datastore, context.Background())
|
||||
monitor.Start()
|
||||
monitor := New(timeout, datastore)
|
||||
monitor.Start(t.Context())
|
||||
|
||||
<-time.After(20 * timeout)
|
||||
assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,20 +12,23 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/url"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// GetAgentVersionAndPlatform returns the agent version and platform
|
||||
//
|
||||
// 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) {
|
||||
httpCli := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
|
||||
if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
httpCli := &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
if tlsConfig != nil {
|
||||
httpCli.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
httpCli.Transport = ssrf.NewTransport(tlsConfig)
|
||||
}
|
||||
|
||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||
@@ -44,8 +48,10 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||
|
||||
119
api/agent/version_test.go
Normal file
119
api/agent/version_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func tlsServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
platform, version, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.AgentPlatformDocker, platform)
|
||||
require.Equal(t, "2.19.0", version)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_NonOKStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_MissingVersionHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_MissingPlatformHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_InvalidPlatformZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "0")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_NonNumericPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "docker")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_PingPathAppended(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotPath string
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformKubernetes)))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/ping", gotPath)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
|
||||
Examples are available at https://documentation.portainer.io/api/api-examples/
|
||||
You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
|
||||
|
||||
# Authentication
|
||||
|
||||
Most of the API environments(endpoints) require to be authenticated as well as some level of authorization to be used.
|
||||
Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
|
||||
with the **Bearer** authentication mechanism.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
Each API environment(endpoint) has an associated access policy, it is documented in the description of each environment(endpoint).
|
||||
|
||||
Different access policies are available:
|
||||
|
||||
- Public access
|
||||
- Authenticated access
|
||||
- Restricted access
|
||||
- Administrator access
|
||||
|
||||
### Public access
|
||||
|
||||
No authentication is required to access the environments(endpoints) with this access policy.
|
||||
|
||||
### Authenticated access
|
||||
|
||||
Authentication is required to access the environments(endpoints) with this access policy.
|
||||
|
||||
### Restricted access
|
||||
|
||||
Authentication is required to access the environments(endpoints) with this access policy.
|
||||
Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
|
||||
|
||||
### Administrator access
|
||||
|
||||
Authentication as well as an administrator role are required to access the environments(endpoints) with this access policy.
|
||||
|
||||
# Execute Docker requests
|
||||
|
||||
Portainer **DO NOT** expose specific environments(endpoints) to manage your Docker resources (create a container, remove a volume, etc...).
|
||||
|
||||
Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
|
||||
|
||||
To do so, you can use the `/endpoints/{id}/docker` Portainer API environment(endpoint) (which is not documented below due to Swagger limitations). This environment(endpoint) has a restricted access policy so you still need to be authenticated to be able to query this environment(endpoint). Any query on this environment(endpoint) will be proxied to the Docker API of the associated environment(endpoint) (requests and responses objects are the same as documented in the Docker API).
|
||||
|
||||
# Private Registry
|
||||
|
||||
Using private registry, you will need to pass a based64 encoded JSON string ‘{"registryId":\<registryID value\>}’ inside the Request Header. The parameter name is "X-Registry-Auth".
|
||||
\<registryID value\> - The registry ID where the repository was created.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
eyJyZWdpc3RyeUlkIjoxfQ==
|
||||
```
|
||||
|
||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/).
|
||||
61
api/api.md
Normal file
61
api/api.md
Normal file
@@ -0,0 +1,61 @@
|
||||
The Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI, and anything you can do in the UI can also be done via the HTTP API.
|
||||
|
||||
API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/)
|
||||
|
||||
You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
|
||||
|
||||
# Authentication
|
||||
|
||||
Most of the API endpoints require authentication, as well as some level of authorization.
|
||||
Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
Each API endpoint has an associated access policy, documented in its description.
|
||||
|
||||
The following policies are available:
|
||||
|
||||
- Public access
|
||||
- Authenticated access
|
||||
- Restricted access
|
||||
- Administrator access
|
||||
|
||||
### Public access
|
||||
|
||||
No authentication is required.
|
||||
|
||||
### Authenticated access
|
||||
|
||||
Authentication is required.
|
||||
|
||||
### Restricted access
|
||||
|
||||
Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered.
|
||||
|
||||
### Administrator access
|
||||
|
||||
Authentication and an administrator role are both required.
|
||||
|
||||
# Execute Docker requests
|
||||
|
||||
Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc).
|
||||
|
||||
Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API.
|
||||
|
||||
To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api).
|
||||
|
||||
# Private Registry
|
||||
|
||||
When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\<registryId\>}’ where `<registryId>` is the ID of the registry where the repository was created.
|
||||
|
||||
Example encoded value:
|
||||
|
||||
```
|
||||
eyJyZWdpc3RyeUlkIjoxfQ==
|
||||
```
|
||||
@@ -7,34 +7,35 @@ import (
|
||||
)
|
||||
|
||||
func Test_generateRandomKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantLenth int
|
||||
name string
|
||||
wantLength int
|
||||
}{
|
||||
{
|
||||
name: "Generate a random key of length 16",
|
||||
wantLenth: 16,
|
||||
name: "Generate a random key of length 16",
|
||||
wantLength: 16,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 32",
|
||||
wantLenth: 32,
|
||||
name: "Generate a random key of length 32",
|
||||
wantLength: 32,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 64",
|
||||
wantLenth: 64,
|
||||
name: "Generate a random key of length 64",
|
||||
wantLength: 64,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 128",
|
||||
wantLenth: 128,
|
||||
name: "Generate a random key of length 128",
|
||||
wantLength: 128,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateRandomKey(tt.wantLenth)
|
||||
is.Equal(tt.wantLenth, len(got))
|
||||
got := GenerateRandomKey(tt.wantLength)
|
||||
is.Len(got, tt.wantLength)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||
for _, k := range c.cache.Keys() {
|
||||
user, _, _ := c.Get(k.(string))
|
||||
if c.userCmpFn(user, userId) {
|
||||
present = c.cache.Remove(k)
|
||||
present = c.cache.Remove(k) || present
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_apiKeyCacheGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -43,6 +44,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -68,6 +70,7 @@ func Test_apiKeyCacheSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -87,6 +90,7 @@ func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
@@ -148,6 +152,7 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
@@ -10,17 +10,20 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil))
|
||||
}
|
||||
|
||||
func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -30,7 +33,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully generates API key", func(t *testing.T) {
|
||||
desc := "test-1"
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.NotEmpty(rawKey)
|
||||
is.NotEmpty(apiKey)
|
||||
is.Equal(desc, apiKey.Description)
|
||||
@@ -38,7 +41,7 @@ func Test_GenerateApiKey(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")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(rawKey[:7], apiKey.Prefix)
|
||||
is.Len(apiKey.Prefix, 7)
|
||||
@@ -46,7 +49,7 @@ func Test_GenerateApiKey(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")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
||||
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
||||
@@ -55,7 +58,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully caches API key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -65,7 +68,7 @@ func Test_GenerateApiKey(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")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
@@ -74,6 +77,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -83,16 +87,17 @@ func Test_GetAPIKey(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(apiKey, apiKeyGot)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -102,17 +107,18 @@ func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, _, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
_, _, err = service.GenerateApiKey(user, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := service.GetAPIKeys(user.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Len(keys, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -122,10 +128,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
})
|
||||
@@ -133,10 +139,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
@@ -148,6 +154,7 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_UpdateAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -156,16 +163,19 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
|
||||
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
||||
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")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKey.LastUsed = time.Now().UTC().Unix()
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, 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("")
|
||||
|
||||
@@ -174,7 +184,7 @@ func Test_UpdateAPIKey(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")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -184,7 +194,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
is.NotEqual(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -193,6 +203,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -202,30 +213,30 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, 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) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
is.Equal(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, ok = service.cache.Get(apiKey.Digest)
|
||||
is.False(ok)
|
||||
@@ -233,6 +244,7 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -243,10 +255,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate api keys
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify api keys are present in cache
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||
@@ -273,11 +285,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate keys for 2 users
|
||||
user1 := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
user2 := portainer.User{ID: 2}
|
||||
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify keys in cache
|
||||
_, 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)),
|
||||
}
|
||||
|
||||
err := tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tarWriter.Write(fileContent)
|
||||
if err != nil {
|
||||
if _, err := tarWriter.Write(fileContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tarWriter.Close()
|
||||
if err != nil {
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -43,10 +40,7 @@ type tarFileInBuffer struct {
|
||||
|
||||
func NewTarFileInBuffer() *tarFileInBuffer {
|
||||
var b bytes.Buffer
|
||||
return &tarFileInBuffer{
|
||||
b: &b,
|
||||
w: tar.NewWriter(&b),
|
||||
}
|
||||
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if _, err := t.w.Write(fileContent); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := t.w.Write(fileContent)
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Bytes returns the archive as a byte array.
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
// TarGzDir creates a tar.gz archive and returns it's path.
|
||||
@@ -20,12 +23,13 @@ func TarGzDir(absolutePath string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer logs.CloseAndLogErr(outFile)
|
||||
|
||||
zipWriter := gzip.NewWriter(outFile)
|
||||
defer zipWriter.Close()
|
||||
defer logs.CloseAndLogErr(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 {
|
||||
if err != nil {
|
||||
@@ -86,7 +90,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipReader.Close()
|
||||
defer logs.CloseAndLogErr(zipReader)
|
||||
|
||||
tarReader := tar.NewReader(zipReader)
|
||||
|
||||
@@ -105,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("Failed to extract file %s", header.Name)
|
||||
}
|
||||
outFile.Close()
|
||||
logs.CloseAndLogErr(outFile)
|
||||
default:
|
||||
return fmt.Errorf("tar: unknown type: %v in %s",
|
||||
header.Typeflag,
|
||||
|
||||
@@ -1,39 +1,57 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func listFiles(dir string) []string {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items = append(items, path)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to list files in directory")
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func Test_shouldCreateArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
||||
@@ -43,9 +61,10 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
copyContent, err := os.ReadFile(fullpath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
@@ -55,16 +74,25 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_shouldCreateArchive2(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
r, _ := os.Open(gzPath)
|
||||
@@ -74,7 +102,7 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
assert.Equal(t, content, copyContent)
|
||||
@@ -84,3 +112,57 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
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 (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"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
|
||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||
func UnzipFile(src string, dest string) error {
|
||||
@@ -63,7 +20,7 @@ func UnzipFile(src string, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
defer logs.CloseAndLogErr(r)
|
||||
|
||||
for _, f := range r.File {
|
||||
p := filepath.Join(dest, f.Name)
|
||||
@@ -75,12 +32,14 @@ func UnzipFile(src string, dest string) error {
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
os.MkdirAll(p, os.ModePerm)
|
||||
if err := os.MkdirAll(p, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = unzipFile(f, p)
|
||||
if err != nil {
|
||||
if err := unzipFile(f, p); err != nil {
|
||||
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 {
|
||||
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())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer logs.CloseAndLogErr(outFile)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
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 != nil {
|
||||
if _, err = io.Copy(outFile, rc); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
/*
|
||||
Archive structure.
|
||||
@@ -20,10 +23,10 @@ func TestUnzipFile(t *testing.T) {
|
||||
|
||||
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
||||
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
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", "2.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1", "2.txt"))
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
|
||||
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
|
||||
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import (
|
||||
"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 (
|
||||
Service struct {
|
||||
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
|
||||
}
|
||||
254
api/aws/ecr/parse_endpoints_test.go
Normal file
254
api/aws/ecr/parse_endpoints_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package ecr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseECREndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
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/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -97,7 +98,7 @@ func encrypt(path string, passphrase string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
defer logs.CloseAndLogErr(in)
|
||||
|
||||
outFileName := path + ".encrypted"
|
||||
out, err := os.Create(outFileName)
|
||||
@@ -105,7 +106,5 @@ func encrypt(path string, passphrase string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||
|
||||
return outFileName, err
|
||||
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||
}
|
||||
|
||||
274
api/backup/backup_test.go
Normal file
274
api/backup/backup_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_DBAtRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.db"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_EncryptedDBAtRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.edb"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_DBInSubdirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
sub := filesystem.JoinPaths(dir, "backup-2024-01-01")
|
||||
err := os.Mkdir(sub, 0o700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(sub, "portainer.db"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sub, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_NoDBFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "other.file"), []byte("data"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_EmptyDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
plaintext := []byte("sensitive portainer backup data")
|
||||
|
||||
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
|
||||
err := os.WriteFile(srcPath, plaintext, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedPath, err := encrypt(srcPath, "mysecretpassword")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, srcPath+".encrypted", encryptedPath)
|
||||
|
||||
encryptedData, err := os.ReadFile(encryptedPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("mysecretpassword"))
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := io.ReadAll(decryptedReader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_WrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
|
||||
err := os.WriteFile(srcPath, []byte("data"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedPath, err := encrypt(srcPath, "correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedData, err := os.ReadFile(encryptedPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("wrongpassword"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateBackupArchive_NoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
storePath := store.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("", gate, store, storePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := os.Open(archivePath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := f.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
extractDir := t.TempDir()
|
||||
err = archive.ExtractTarGz(f, extractDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFound := false
|
||||
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Name() == "portainer.db" {
|
||||
dbFound = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, dbFound, "archive should contain portainer.db")
|
||||
}
|
||||
|
||||
func TestCreateBackupArchive_WithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
storePath := store.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("backup-secret", gate, store, storePath)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, archivePath, ".encrypted")
|
||||
|
||||
encryptedData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("backup-secret"))
|
||||
require.NoError(t, err)
|
||||
|
||||
extractDir := t.TempDir()
|
||||
err = archive.ExtractTarGz(decryptedReader, extractDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFound := false
|
||||
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Name() == "portainer.db" {
|
||||
dbFound = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, dbFound, "decrypted archive should contain portainer.db")
|
||||
}
|
||||
|
||||
func TestRestoreArchive_NoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "", storePath2, gate, store2, cancel)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
||||
|
||||
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRestoreArchive_WithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("restore-secret", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "restore-secret", storePath2, gate, store2, cancel)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
||||
|
||||
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRestoreArchive_WrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("correct-password", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
_, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "wrong-password", storePath2, gate, store2, cancel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
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"))
|
||||
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 != nil {
|
||||
if err := extractArchive(archive, restorePath); err != nil {
|
||||
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
if err = datastore.Close(); err != nil {
|
||||
if err := datastore.Close(); err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
if err = restoreFiles(restorePath, filestorePath); err != nil {
|
||||
if err := restoreFiles(restorePath, filestorePath); err != nil {
|
||||
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 {
|
||||
for _, filename := range filesToRestore {
|
||||
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||
if err != nil {
|
||||
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
|
||||
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
|
||||
|
||||
// Prevent the possibility of having both databases. Remove any default new instance
|
||||
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
|
||||
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
|
||||
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
||||
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
|
||||
|
||||
// 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 != nil {
|
||||
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ func ecdsaGenerateKey(c elliptic.Curve, rand io.Reader) (*ecdsa.PrivateKey, erro
|
||||
}
|
||||
|
||||
priv := new(ecdsa.PrivateKey)
|
||||
priv.PublicKey.Curve = c
|
||||
priv.Curve = c
|
||||
priv.D = k
|
||||
priv.PublicKey.X, priv.PublicKey.Y = c.ScalarBaseMult(k.Bytes())
|
||||
priv.X, priv.Y = c.ScalarBaseMult(k.Bytes())
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGenerateGo119CompatibleKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
seed string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/pkg/schedule"
|
||||
|
||||
chserver "github.com/jpillora/chisel/server"
|
||||
"github.com/jpillora/chisel/share/ccrypto"
|
||||
@@ -89,10 +90,8 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
||||
@@ -235,27 +234,18 @@ func (service *Service) startTunnelVerificationLoop() {
|
||||
Float64("check_interval_seconds", tunnelCleanupInterval.Seconds()).
|
||||
Msg("starting tunnel management process")
|
||||
|
||||
ticker := time.NewTicker(tunnelCleanupInterval)
|
||||
schedule.RunOnInterval(service.shutdownCtx, tunnelCleanupInterval, service.checkTunnels, func() {
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.checkTunnels()
|
||||
case <-service.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
|
||||
ticker.Stop()
|
||||
return
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// checkTunnels finds the first tunnel that has not had any activity recently
|
||||
// and attempts to take a snapshot, then closes it and returns
|
||||
// checkTunnels finds tunnels that need snapshots and processes them one at a time.
|
||||
// For active tunnels missing an initial snapshot, it takes one without closing the tunnel.
|
||||
// For tunnels idle past activeTimeout, it snapshots and closes them.
|
||||
func (service *Service) checkTunnels() {
|
||||
service.mu.RLock()
|
||||
|
||||
@@ -266,12 +256,32 @@ func (service *Service) checkTunnels() {
|
||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
||||
Msg("environment tunnel monitoring")
|
||||
|
||||
tunnelPort := tunnel.Port
|
||||
|
||||
if !tunnel.HasSnapshot && elapsed < activeTimeout {
|
||||
service.mu.RUnlock()
|
||||
|
||||
if endpointHasSnapshot(service.dataStore, endpointID) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Msg("taking initial snapshot for active Edge environment")
|
||||
|
||||
if service.snapshotAndLog(endpointID, tunnelPort) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
tunnelPort := tunnel.Port
|
||||
|
||||
service.mu.RUnlock()
|
||||
|
||||
log.Debug().
|
||||
@@ -280,13 +290,7 @@ func (service *Service) checkTunnels() {
|
||||
Float64("timeout_seconds", activeTimeout.Seconds()).
|
||||
Msg("last activity timeout exceeded")
|
||||
|
||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
}
|
||||
|
||||
service.snapshotAndLog(endpointID, tunnelPort)
|
||||
service.close(endpointID)
|
||||
|
||||
return
|
||||
@@ -295,6 +299,32 @@ func (service *Service) checkTunnels() {
|
||||
service.mu.RUnlock()
|
||||
}
|
||||
|
||||
func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool {
|
||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
|
||||
if service.dataStore.IsErrObjectNotFound(err) {
|
||||
service.close(endpointID)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if tun, ok := service.activeTunnels[endpointID]; ok {
|
||||
tun.HasSnapshot = true
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -9,19 +10,47 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
type mockSnapshotService struct {
|
||||
snapshotFn func(endpoint *portainer.Endpoint) error
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) Start(_ context.Context) {}
|
||||
|
||||
func (m *mockSnapshotService) SetSnapshotInterval(_ string) error { return nil }
|
||||
|
||||
func (m *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
|
||||
if m.snapshotFn != nil {
|
||||
return m.snapshotFn(endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) FillSnapshotData(_ *portainer.Endpoint, _ bool) error { return nil }
|
||||
|
||||
func newEdgeEndpoint(id portainer.EndpointID) *portainer.Endpoint {
|
||||
return &portainer.Endpoint{
|
||||
ID: id,
|
||||
EdgeID: "test-edge-id",
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
UserTrusted: true,
|
||||
}
|
||||
}
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
@@ -49,6 +78,161 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
require.Error(t, s.pingAgent(endpoint.ID))
|
||||
require.NoError(t, srv.Shutdown(context.Background()))
|
||||
require.NoError(t, srv.Shutdown(t.Context()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
}
|
||||
|
||||
func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
err := s.Open(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(2)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snap := &portainer.Snapshot{
|
||||
EndpointID: endpoint.ID,
|
||||
Docker: &portainer.DockerSnapshot{},
|
||||
}
|
||||
err = store.Snapshot().Create(snap)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50003,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(3)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50000,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(4)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
return errors.New("snapshot failed")
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50001,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot")
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
// Endpoint is not created in the store, simulates deletion while tunnel stays open.
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[1] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50010,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(5)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50002,
|
||||
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
|
||||
}
|
||||
|
||||
@@ -4,16 +4,17 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/pkg/libcrypto"
|
||||
"github.com/portainer/portainer/pkg/librand"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -81,17 +82,24 @@ func (s *Service) Open(endpoint *portainer.Endpoint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// close removes the tunnel from the map so the agent will close it
|
||||
// close removes the tunnel from the map so the agent will close it.
|
||||
// The lock is released before cleaning up the chisel user and proxy to avoid
|
||||
// blocking Config/Open callers while DeleteUser interacts with chisel internals.
|
||||
func (s *Service) close(endpointID portainer.EndpointID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tun, ok := s.activeTunnels[endpointID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if len(tun.Credentials) > 0 && s.chiselServer != nil {
|
||||
delete(s.activeTunnels, endpointID)
|
||||
cache.Del(endpointID)
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.chiselServer != nil {
|
||||
user, _, _ := strings.Cut(tun.Credentials, ":")
|
||||
s.chiselServer.DeleteUser(user)
|
||||
}
|
||||
@@ -99,10 +107,6 @@ func (s *Service) close(endpointID portainer.EndpointID) {
|
||||
if s.ProxyManager != nil {
|
||||
s.ProxyManager.DeleteEndpointProxy(endpointID)
|
||||
}
|
||||
|
||||
delete(s.activeTunnels, endpointID)
|
||||
|
||||
cache.Del(endpointID)
|
||||
}
|
||||
|
||||
// Config returns the tunnel details needed for the agent to connect
|
||||
@@ -142,7 +146,9 @@ func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close tcp connection")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
@@ -200,7 +206,9 @@ func (service *Service) getUnusedPort() int {
|
||||
|
||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||
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().
|
||||
Int("port", port).
|
||||
@@ -213,7 +221,7 @@ func (service *Service) getUnusedPort() int {
|
||||
}
|
||||
|
||||
func randomInt(min, max int) int {
|
||||
return min + rand.Intn(max-min)
|
||||
return min + librand.Intn(max-min)
|
||||
}
|
||||
|
||||
func generateRandomCredentials() (string, string) {
|
||||
@@ -233,3 +241,18 @@ func encryptCredentials(username, password, key string) (string, error) {
|
||||
|
||||
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
|
||||
}
|
||||
|
||||
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
|
||||
var hasSnapshot bool
|
||||
_ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
s, err := tx.Snapshot().Read(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSnapshot = s.Docker != nil || s.Kubernetes != nil
|
||||
return nil
|
||||
})
|
||||
|
||||
return hasSnapshot
|
||||
}
|
||||
|
||||
80
api/chisel/tunnel_test.go
Normal file
80
api/chisel/tunnel_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
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) {
|
||||
t.Parallel()
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
api/cli/cli.go
110
api/cli/cli.go
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
// 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(),
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
||||
@@ -59,18 +52,64 @@ 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(),
|
||||
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"),
|
||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||
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(),
|
||||
NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(),
|
||||
SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
var hasKubectlShellImageFlag bool
|
||||
kubectlShellImageFlag := kingpin.Flag(
|
||||
"kubectl-shell-image",
|
||||
"Kubectl shell image",
|
||||
).Envar(portainer.KubectlShellImageEnvVar).
|
||||
Default(portainer.DefaultKubectlShellImage).
|
||||
IsSetByUser(&hasKubectlShellImageFlag)
|
||||
flags.KubectlShellImage = kubectlShellImageFlag.String()
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
|
||||
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
|
||||
|
||||
if !filepath.IsAbs(*flags.Assets) {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -80,18 +119,53 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
*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
|
||||
}
|
||||
|
||||
// ValidateFlags validates the values of the flags.
|
||||
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
displayDeprecationWarnings(flags)
|
||||
|
||||
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
|
||||
if err := ValidateEndpointURL(*flags.EndpointURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
||||
if err := ValidateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -106,13 +180,9 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
if endpointURL == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -137,7 +207,7 @@ func validateEndpointURL(endpointURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnapshotInterval(snapshotInterval string) error {
|
||||
func ValidateSnapshotInterval(snapshotInterval string) error {
|
||||
if snapshotInterval == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
263
api/cli/cli_test.go
Normal file
263
api/cli/cli_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
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 TestParseKubectlShellImageFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
envVars map[string]string
|
||||
expectedKubectlShellImageSet bool
|
||||
expectedKubectlShellFlag string
|
||||
}{
|
||||
{
|
||||
name: "no flag, no env var",
|
||||
expectedKubectlShellImageSet: false,
|
||||
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
|
||||
},
|
||||
{
|
||||
name: "explicit flag",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
{
|
||||
name: "env var",
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v3",
|
||||
},
|
||||
{
|
||||
name: "both env var and flag set",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.args == nil {
|
||||
tc.args = []string{"portainer"}
|
||||
}
|
||||
setOsArgs(t, tc.args)
|
||||
|
||||
for k, v := range tc.envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
flags, err := Service{}.ParseFlags("test-version")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
|
||||
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -4,14 +4,17 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
nethttp "net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/containerautomation"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/database"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
@@ -25,10 +28,10 @@ import (
|
||||
"github.com/portainer/portainer/api/exec"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/git"
|
||||
"github.com/portainer/portainer/api/hostmanagement/openamt"
|
||||
"github.com/portainer/portainer/api/http"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security/setuptoken"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -39,6 +42,7 @@ import (
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
@@ -48,15 +52,23 @@ import (
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/build"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git"
|
||||
gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
cliService := &cli.Service{}
|
||||
cliService := cli.Service{}
|
||||
|
||||
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
||||
if err != nil {
|
||||
@@ -80,7 +92,7 @@ func initFileService(dataStorePath string) portainer.FileService {
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Fatal().Err(err).Msg("failed creating database connection")
|
||||
}
|
||||
@@ -115,7 +127,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
}
|
||||
|
||||
if isNew {
|
||||
instanceId, err := uuid.NewV4()
|
||||
instanceId, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
@@ -130,15 +142,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
InstanceID: instanceId.String(),
|
||||
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 {
|
||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||
}
|
||||
} else {
|
||||
if err := store.MigrateData(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed migration")
|
||||
}
|
||||
} else if err := store.MigrateData(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed migration")
|
||||
}
|
||||
|
||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
||||
@@ -149,7 +162,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
go func() {
|
||||
<-shutdownCtx.Done()
|
||||
|
||||
defer connection.Close()
|
||||
defer logs.CloseAndLogErr(connection)
|
||||
}()
|
||||
|
||||
return store
|
||||
@@ -165,12 +178,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
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 initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
@@ -211,13 +220,12 @@ func initSnapshotService(
|
||||
dataStore dataservices.DataStore,
|
||||
dockerClientFactory *dockerclient.ClientFactory,
|
||||
kubernetesClientFactory *kubecli.ClientFactory,
|
||||
shutdownCtx context.Context,
|
||||
pendingActionsService *pendingactions.PendingActionsService,
|
||||
) (portainer.SnapshotService, error) {
|
||||
dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory)
|
||||
kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory)
|
||||
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, shutdownCtx, pendingActionsService)
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, pendingActionsService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,6 +233,32 @@ func initSnapshotService(
|
||||
return snapshotService, nil
|
||||
}
|
||||
|
||||
func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) {
|
||||
admins, err := tx.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(admins) > 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if providedToken != "" {
|
||||
log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header")
|
||||
return providedToken, nil
|
||||
}
|
||||
|
||||
token, err := setuptoken.Generate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("setup_token", token).
|
||||
Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.")
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func initStatus(instanceID string) *portainer.Status {
|
||||
return &portainer.Status{
|
||||
Version: portainer.APIVersion,
|
||||
@@ -243,6 +277,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||
|
||||
if flags.KubectlShellImageSet {
|
||||
settings.KubectlShellImage = *flags.KubectlShellImage
|
||||
}
|
||||
|
||||
if *flags.Labels != nil {
|
||||
settings.BlackListedLabels = *flags.Labels
|
||||
}
|
||||
@@ -303,8 +341,19 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
||||
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 {
|
||||
content, err := os.ReadFile(path.Join("/run/secrets", keyfilename))
|
||||
content, err := os.ReadFile(keyfilename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
||||
@@ -316,20 +365,34 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
|
||||
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
|
||||
if flags.FeatureFlags != nil {
|
||||
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 trusted origin: must be scheme://host or scheme://host:port (e.g. https://example.com)")
|
||||
}
|
||||
|
||||
trustedOrigins = append(trustedOrigins, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// -ce can not ever be run in FIPS mode
|
||||
fips.InitFIPS(false)
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||
encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName))
|
||||
if encryptionKey == nil {
|
||||
log.Info().Msg("proceeding without encryption key")
|
||||
}
|
||||
@@ -345,6 +408,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
|
||||
}
|
||||
|
||||
if err := ssrf.Configure(dataStore.AllowList()); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing ssrf service")
|
||||
}
|
||||
|
||||
if !ssrf.WrapDefaultTransport() {
|
||||
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
|
||||
}
|
||||
|
||||
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
|
||||
gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient))
|
||||
gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient))
|
||||
gogitclient.InstallProtocol("file", nil)
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed getting instance id")
|
||||
@@ -362,21 +438,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
||||
}
|
||||
|
||||
ldapService := &ldap.Service{}
|
||||
ldapService := ldap.Service{}
|
||||
|
||||
oauthService := oauth.NewService()
|
||||
|
||||
gitService := git.NewService(shutdownCtx)
|
||||
|
||||
openAMTService := openamt.NewService()
|
||||
|
||||
cryptoService := &crypto.Service{}
|
||||
cryptoService := crypto.Service{}
|
||||
|
||||
signatureService := initDigitalSignatureService()
|
||||
|
||||
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 {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
@@ -410,37 +484,29 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
reverseTunnelService.ProxyManager = proxyManager
|
||||
|
||||
dockerConfigPath := fileService.GetDockerConfigPath()
|
||||
|
||||
composeDeployer := compose.NewComposeDeployer()
|
||||
|
||||
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
|
||||
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
|
||||
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||
}
|
||||
swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
||||
pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
|
||||
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
|
||||
snapshotService.Start()
|
||||
snapshotService.Start(shutdownCtx)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
helmPackageManager := libhelm.NewHelmPackageManager()
|
||||
|
||||
applicationStatus := initStatus(instanceID)
|
||||
|
||||
@@ -491,23 +557,37 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
}
|
||||
|
||||
setupToken := ""
|
||||
if adminPasswordHash == "" && !*flags.NoSetupToken {
|
||||
if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var txErr error
|
||||
setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken)
|
||||
return txErr
|
||||
}); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing setup token")
|
||||
}
|
||||
}
|
||||
|
||||
if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed starting tunnel server")
|
||||
}
|
||||
|
||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||
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")
|
||||
}
|
||||
|
||||
containerService := docker.NewContainerService(dockerClientFactory, dataStore)
|
||||
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService, stackDeployer)
|
||||
containerAutomationService.Start()
|
||||
|
||||
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
||||
if err != nil {
|
||||
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
||||
}
|
||||
|
||||
platformService, err := platform.NewService(dataStore)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing platform service")
|
||||
}
|
||||
platformService := platform.NewService(dataStore)
|
||||
|
||||
upgradeService, err := upgrade.NewService(
|
||||
*flags.Assets,
|
||||
@@ -537,12 +617,20 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failure during post init migrations")
|
||||
}
|
||||
|
||||
if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return recoverStaleDeployingStacks(tx)
|
||||
}); err != nil {
|
||||
log.Info().Err(err).
|
||||
Msg("Error recovering stale deploying stacks")
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
BindAddressHTTPS: *flags.AddrHTTPS,
|
||||
CSP: *flags.CSP,
|
||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||
AssetsPath: *flags.Assets,
|
||||
DataStore: dataStore,
|
||||
@@ -558,7 +646,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
LDAPService: ldapService,
|
||||
OAuthService: oauthService,
|
||||
GitService: gitService,
|
||||
OpenAMTService: openAMTService,
|
||||
ProxyManager: proxyManager,
|
||||
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
KubeClusterAccessService: kubeClusterAccessService,
|
||||
@@ -568,40 +655,80 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
Scheduler: scheduler,
|
||||
ShutdownCtx: shutdownCtx,
|
||||
ContainerAutomationService: containerAutomationService,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
StackDeployer: stackDeployer,
|
||||
UpgradeService: upgradeService,
|
||||
AdminCreationDone: adminCreationDone,
|
||||
PendingActionsService: pendingActionsService,
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
TrustedOrigins: trustedOrigins,
|
||||
SetupToken: setupToken,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
configureLogger()
|
||||
setLoggingMode("PRETTY")
|
||||
logs.ConfigureLogger()
|
||||
logs.SetLoggingMode("PRETTY")
|
||||
|
||||
flags := initCLI()
|
||||
|
||||
setLoggingLevel(*flags.LogLevel)
|
||||
setLoggingMode(*flags.LogMode)
|
||||
logs.SetLoggingLevel(*flags.LogLevel)
|
||||
logs.SetLoggingMode(*flags.LogMode)
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
server := buildServer(flags, shutdownCtx, shutdownTrigger)
|
||||
|
||||
log.Info().
|
||||
Str("version", portainer.APIVersion).
|
||||
Str("build_number", build.BuildNumber).
|
||||
Str("image_tag", build.ImageTag).
|
||||
Str("nodejs_version", build.NodejsVersion).
|
||||
Str("yarn_version", build.YarnVersion).
|
||||
Str("pnpm_version", build.PnpmVersion).
|
||||
Str("webpack_version", build.WebpackVersion).
|
||||
Str("go_version", build.GoVersion).
|
||||
Msg("starting Portainer")
|
||||
|
||||
err := server.Start()
|
||||
err := server.Start(shutdownCtx)
|
||||
|
||||
log.Info().Err(err).Msg("HTTP server exited")
|
||||
}
|
||||
}
|
||||
|
||||
// recoverStaleDeployingStacks resets any stack that was left in the Deploying state
|
||||
// (e.g. because the server was restarted mid-deployment) to the Error state so the
|
||||
// user can retry.
|
||||
func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error {
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.Status == portainer.StackStatusDeploying
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
stack.Status = portainer.StackStatusError
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusError,
|
||||
Time: time.Now().Unix(),
|
||||
Message: "Deployment interrupted by server restart",
|
||||
})
|
||||
|
||||
if err := tx.Stack().Update(stack.ID, &stack); err != nil {
|
||||
log.Warn().Err(err).
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Unable to recover stale deploying stack")
|
||||
continue
|
||||
}
|
||||
log.Debug().
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("stack_name", stack.Name).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Recovered stale deploying stack to error state")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
159
api/cmd/portainer/main_test.go
Normal file
159
api/cmd/portainer/main_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_resolveSetupToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("admin already exists — returns empty token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("no admin — generates a 64-char hex token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, token, 64)
|
||||
|
||||
token2, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, token, token2)
|
||||
})
|
||||
|
||||
t.Run("no admin — uses provided token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mysecrettoken", token)
|
||||
})
|
||||
|
||||
t.Run("admin already exists — ignores provided token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
const secretFileName = "secret.txt"
|
||||
|
||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||
err := os.WriteFile(secretPath, []byte(password), 0o600)
|
||||
require.NoError(t, err)
|
||||
return secretPath
|
||||
}
|
||||
|
||||
func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
secretPath := filesystem.JoinPaths(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 TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
|
||||
const existingImage = "existing-image:v1"
|
||||
const newImage = "new-image:v2"
|
||||
|
||||
emptyString := ""
|
||||
falseBool := false
|
||||
var emptyLabels []portainer.Pair
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
imageSet bool
|
||||
flagImage string
|
||||
expectedKubectlShellImage string
|
||||
}{
|
||||
{
|
||||
name: "flag not set — DB image unchanged",
|
||||
imageSet: false,
|
||||
flagImage: portainer.DefaultKubectlShellImage,
|
||||
expectedKubectlShellImage: existingImage,
|
||||
},
|
||||
{
|
||||
name: "flag set — DB image updated",
|
||||
imageSet: true,
|
||||
flagImage: newImage,
|
||||
expectedKubectlShellImage: newImage,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(
|
||||
testhelpers.WithSettingsService(&portainer.Settings{
|
||||
KubectlShellImage: existingImage,
|
||||
}),
|
||||
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
|
||||
)
|
||||
|
||||
flags := &portainer.CLIFlags{
|
||||
SnapshotInterval: &emptyString,
|
||||
Logo: &emptyString,
|
||||
EnableEdgeComputeFeatures: &falseBool,
|
||||
Templates: &emptyString,
|
||||
Labels: &emptyLabels,
|
||||
HTTPDisabled: &falseBool,
|
||||
HTTPEnabled: &falseBool,
|
||||
}
|
||||
flags.KubectlShellImage = &tc.flagImage
|
||||
flags.KubectlShellImageSet = tc.imageSet
|
||||
|
||||
err := updateSettingsFromFlags(store, flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBSecretPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
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))
|
||||
}
|
||||
}
|
||||
149
api/concurrent/concurrent_test.go
Normal file
149
api/concurrent/concurrent_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package concurrent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_AllSucceed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fn1 := func(ctx context.Context) (any, error) { return "one", nil }
|
||||
fn2 := func(ctx context.Context) (any, error) { return "two", nil }
|
||||
fn3 := func(ctx context.Context) (any, error) { return "three", nil }
|
||||
|
||||
results, err := Run(t.Context(), 0, fn1, fn2, fn3)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3)
|
||||
|
||||
values := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
values = append(values, r.Result.(string))
|
||||
}
|
||||
require.ElementsMatch(t, []string{"one", "two", "three"}, values)
|
||||
}
|
||||
|
||||
func TestRun_OneError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sentinel := errors.New("task failed")
|
||||
|
||||
fn1 := func(ctx context.Context) (any, error) { return "ok", nil }
|
||||
fn2 := func(ctx context.Context) (any, error) { return nil, sentinel }
|
||||
|
||||
_, err := Run(t.Context(), 0, fn1, fn2)
|
||||
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
func TestRun_NoTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results, err := Run(t.Context(), 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestRun_MaxConcurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numTasks = 10
|
||||
var peak atomic.Int32
|
||||
var active atomic.Int32
|
||||
|
||||
task := func(ctx context.Context) (any, error) {
|
||||
current := active.Add(1)
|
||||
if current > peak.Load() {
|
||||
peak.Store(current)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
active.Add(-1)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tasks := make([]Func, numTasks)
|
||||
for i := range tasks {
|
||||
tasks[i] = task
|
||||
}
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
results, err := Run(t.Context(), 3, tasks...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, numTasks)
|
||||
require.LessOrEqual(t, peak.Load(), int32(3))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRun_ZeroConcurrencyUsesAllTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numTasks = 5
|
||||
var peak atomic.Int32
|
||||
var active atomic.Int32
|
||||
|
||||
task := func(ctx context.Context) (any, error) {
|
||||
current := active.Add(1)
|
||||
if current > peak.Load() {
|
||||
peak.Store(current)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
active.Add(-1)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tasks := make([]Func, numTasks)
|
||||
for i := range tasks {
|
||||
tasks[i] = task
|
||||
}
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
results, err := Run(t.Context(), 0, tasks...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, numTasks)
|
||||
require.Equal(t, int32(numTasks), peak.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRun_ContextCancelledBeforeStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
called := atomic.Bool{}
|
||||
fn := func(ctx context.Context) (any, error) {
|
||||
called.Store(true)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
_, err := Run(ctx, 1, fn, fn, fn)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRun_ContextPassedToTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type key struct{}
|
||||
ctx := context.WithValue(t.Context(), key{}, "testvalue")
|
||||
|
||||
fn := func(ctx context.Context) (any, error) {
|
||||
return ctx.Value(key{}), nil
|
||||
}
|
||||
|
||||
results, err := Run(ctx, 0, fn)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "testvalue", results[0].Result)
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
type ReadTransaction interface {
|
||||
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
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||
KeyExists(bucketName string, key []byte) (bool, error)
|
||||
}
|
||||
|
||||
type Transaction interface {
|
||||
@@ -44,7 +46,7 @@ type Connection interface {
|
||||
|
||||
IsEncryptedStore() bool
|
||||
NeedsEncryptionMigration() (bool, error)
|
||||
SetEncrypted(encrypted bool)
|
||||
SetEncrypted(encrypted bool) error
|
||||
|
||||
BackupMetadata() (map[string]any, error)
|
||||
RestoreMetadata(s map[string]any) error
|
||||
|
||||
190
api/containerautomation/autoheal.go
Normal file
190
api/containerautomation/autoheal.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// retryWindow is the rolling window over which max restarts per container are counted.
|
||||
retryWindow = 10 * time.Minute
|
||||
// restartCooldown is the minimum delay between two restarts of the same container,
|
||||
// giving its healthcheck time to recover before we try again.
|
||||
restartCooldown = 60 * time.Second
|
||||
// endpointTimeout bounds the container-list call for a single endpoint.
|
||||
endpointTimeout = 30 * time.Second
|
||||
// restartTimeoutBuffer is added on top of a container's stop-timeout to derive
|
||||
// the deadline of its own restart context, leaving room for the engine to kill
|
||||
// and start the container after the graceful stop window elapses.
|
||||
restartTimeoutBuffer = 15 * time.Second
|
||||
)
|
||||
|
||||
// retryState tracks restart accounting for a single container across ticks.
|
||||
type retryState struct {
|
||||
attempts int
|
||||
windowStart time.Time
|
||||
lastRestart time.Time
|
||||
}
|
||||
|
||||
// retryPolicy holds the cooldown/window parameters applied to a container.
|
||||
type retryPolicy struct {
|
||||
maxRetries int
|
||||
window time.Duration
|
||||
cooldown time.Duration
|
||||
}
|
||||
|
||||
// decideRestart is a pure function that decides whether an unhealthy container
|
||||
// should be restarted now, given its current retry state and policy. It returns
|
||||
// the decision and the updated state to persist.
|
||||
//
|
||||
// Rules, in order:
|
||||
// - reset the window (and attempts) when the window has elapsed;
|
||||
// - deny while still within the cooldown since the last restart;
|
||||
// - deny once the max number of restarts in the current window is reached;
|
||||
// - otherwise restart, incrementing the attempt counter.
|
||||
func decideRestart(state retryState, policy retryPolicy, now time.Time) (bool, retryState) {
|
||||
if state.windowStart.IsZero() || now.Sub(state.windowStart) >= policy.window {
|
||||
state.windowStart = now
|
||||
state.attempts = 0
|
||||
}
|
||||
|
||||
if !state.lastRestart.IsZero() && now.Sub(state.lastRestart) < policy.cooldown {
|
||||
return false, state
|
||||
}
|
||||
|
||||
if state.attempts >= policy.maxRetries {
|
||||
return false, state
|
||||
}
|
||||
|
||||
state.attempts++
|
||||
state.lastRestart = now
|
||||
|
||||
return true, state
|
||||
}
|
||||
|
||||
// heal runs a single auto-heal pass over every reachable Docker endpoint.
|
||||
// It is registered with the scheduler and guarded against overlapping ticks by
|
||||
// the Service. Errors are logged per endpoint/container so one failure does not
|
||||
// abort the whole pass; it always returns nil so the scheduler keeps the job.
|
||||
func (s *Service) heal() error {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
log.Debug().Msg("auto-heal: previous run still in progress, skipping tick")
|
||||
return nil
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
|
||||
scope := s.scope()
|
||||
|
||||
endpoints, err := s.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-heal: unable to list environments")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
|
||||
// M1 scope: native Docker endpoints only. Kubernetes is not applicable and
|
||||
// Edge/async endpoints are not reachable synchronously from the scheduler.
|
||||
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
|
||||
// independently of the global switch. Zero value participates, so existing
|
||||
// installs are unaffected.
|
||||
if !AutomationEnabledForEndpoint(endpoint) {
|
||||
log.Debug().Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-heal: automation disabled for this environment, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
s.healEndpoint(endpoint, scope)
|
||||
}
|
||||
|
||||
// Drop retry state only for containers whose retry window has fully elapsed
|
||||
// since their last restart. A container that briefly leaves the unhealthy
|
||||
// filter (e.g. while "starting" after a restart) keeps its accounting, so the
|
||||
// cooldown / max-retries storm guard survives flapping.
|
||||
s.pruneRetries(time.Now())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// healEndpoint restarts the in-scope unhealthy containers of a single endpoint.
|
||||
func (s *Service) healEndpoint(endpoint *portainer.Endpoint, scope string) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Swarm note (M1 limitation): we connect to the endpoint's primary node only
|
||||
// (nodeName ""). Containers scheduled on other Swarm nodes are not healed here;
|
||||
// per-node iteration is deferred to a later milestone.
|
||||
clientTimeout := endpointTimeout
|
||||
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to create Docker client")
|
||||
return
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
// List running unhealthy containers only (All:false). Docker keeps
|
||||
// Health.Status=="unhealthy" on stopped containers, so listing with All:true
|
||||
// would let us "restart" (i.e. start) an intentionally-stopped container.
|
||||
listFilters := filters.NewArgs(filters.Arg("health", "unhealthy"))
|
||||
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false, Filters: listFilters})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range containers {
|
||||
if !InScope(scope, c.Labels) {
|
||||
continue
|
||||
}
|
||||
|
||||
policy := retryPolicy{
|
||||
maxRetries: MaxRetries(c.Labels),
|
||||
window: retryWindow,
|
||||
cooldown: restartCooldown,
|
||||
}
|
||||
|
||||
ok, newState := decideRestart(s.getRetry(c.ID), policy, time.Now())
|
||||
s.setRetry(c.ID, newState)
|
||||
if !ok {
|
||||
log.Debug().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-heal: restart skipped (cooldown or max retries reached)")
|
||||
continue
|
||||
}
|
||||
|
||||
timeout := StopTimeout(c.Labels)
|
||||
|
||||
// Each restart gets its own context, bounded by the container's stop-timeout
|
||||
// plus a buffer, so one slow restart cannot starve the others and a hung
|
||||
// engine call is bounded independently of the list deadline.
|
||||
restartTimeout := time.Duration(timeout)*time.Second + restartTimeoutBuffer
|
||||
restartCtx, restartCancel := context.WithTimeout(s.baseCtx, restartTimeout)
|
||||
err := cli.ContainerRestart(restartCtx, c.ID, container.StopOptions{Timeout: &timeout})
|
||||
restartCancel()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-heal: failed to restart unhealthy container")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).Int("attempt", newState.attempts).
|
||||
Msg("auto-heal: restarted unhealthy container")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventHealRestarted, EndpointID: endpointID, ContainerID: c.ID,
|
||||
Message: "restarted unhealthy container",
|
||||
})
|
||||
}
|
||||
}
|
||||
137
api/containerautomation/autoheal_test.go
Normal file
137
api/containerautomation/autoheal_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecideRestart(t *testing.T) {
|
||||
policy := retryPolicy{
|
||||
maxRetries: 3,
|
||||
window: 10 * time.Minute,
|
||||
cooldown: 60 * time.Second,
|
||||
}
|
||||
base := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("first restart on empty state", func(t *testing.T) {
|
||||
ok, state := decideRestart(retryState{}, policy, base)
|
||||
if !ok {
|
||||
t.Fatal("expected restart on first unhealthy observation")
|
||||
}
|
||||
if state.attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", state.attempts)
|
||||
}
|
||||
if !state.windowStart.Equal(base) || !state.lastRestart.Equal(base) {
|
||||
t.Error("windowStart/lastRestart should be set to now")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blocked during cooldown", func(t *testing.T) {
|
||||
_, state := decideRestart(retryState{}, policy, base)
|
||||
ok, _ := decideRestart(state, policy, base.Add(30*time.Second))
|
||||
if ok {
|
||||
t.Error("expected restart to be blocked within cooldown")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowed after cooldown", func(t *testing.T) {
|
||||
_, state := decideRestart(retryState{}, policy, base)
|
||||
ok, state := decideRestart(state, policy, base.Add(61*time.Second))
|
||||
if !ok {
|
||||
t.Error("expected restart allowed after cooldown")
|
||||
}
|
||||
if state.attempts != 2 {
|
||||
t.Errorf("attempts = %d, want 2", state.attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max retries enforced within window", func(t *testing.T) {
|
||||
state := retryState{}
|
||||
now := base
|
||||
allowed := 0
|
||||
for i := 0; i < 6; i++ {
|
||||
ok, newState := decideRestart(state, policy, now)
|
||||
state = newState
|
||||
if ok {
|
||||
allowed++
|
||||
}
|
||||
now = now.Add(policy.cooldown + time.Second)
|
||||
}
|
||||
if allowed != policy.maxRetries {
|
||||
t.Errorf("allowed %d restarts, want %d (max per window)", allowed, policy.maxRetries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("counter resets after window elapses", func(t *testing.T) {
|
||||
state := retryState{attempts: 3, windowStart: base, lastRestart: base}
|
||||
ok, newState := decideRestart(state, policy, base.Add(policy.window+time.Second))
|
||||
if !ok {
|
||||
t.Error("expected restart allowed once the window elapsed")
|
||||
}
|
||||
if newState.attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1 after window reset", newState.attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPruneRetries(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
s := &Service{retries: map[string]retryState{
|
||||
// within the window -> retained
|
||||
"fresh": {attempts: 1, windowStart: now.Add(-time.Minute), lastRestart: now.Add(-time.Minute)},
|
||||
// exactly at the window boundary -> pruned
|
||||
"edge": {attempts: 2, windowStart: now.Add(-retryWindow), lastRestart: now.Add(-retryWindow)},
|
||||
// long past the window -> pruned
|
||||
"stale": {attempts: 3, windowStart: now.Add(-2 * retryWindow), lastRestart: now.Add(-2 * retryWindow)},
|
||||
}}
|
||||
|
||||
s.pruneRetries(now)
|
||||
|
||||
if _, ok := s.retries["fresh"]; !ok {
|
||||
t.Error("entry within the retry window should be retained")
|
||||
}
|
||||
if _, ok := s.retries["edge"]; ok {
|
||||
t.Error("entry exactly at the window boundary should be pruned")
|
||||
}
|
||||
if _, ok := s.retries["stale"]; ok {
|
||||
t.Error("entry past the retry window should be pruned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryStateSurvivesStartingTick locks in the F1 fix: a container that flaps
|
||||
// through "starting" right after a restart (and so briefly drops out of the
|
||||
// health=unhealthy filter) must keep its retry accounting across the tick where
|
||||
// it is not observed, otherwise the cooldown / max-retries storm guard is
|
||||
// defeated and the next unhealthy observation triggers an immediate restart.
|
||||
func TestRetryStateSurvivesStartingTick(t *testing.T) {
|
||||
policy := retryPolicy{maxRetries: 3, window: retryWindow, cooldown: restartCooldown}
|
||||
const id = "flapper"
|
||||
s := &Service{retries: make(map[string]retryState)}
|
||||
|
||||
t0 := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Tick 1: container is unhealthy -> first restart.
|
||||
ok, state := decideRestart(s.getRetry(id), policy, t0)
|
||||
s.setRetry(id, state)
|
||||
if !ok || state.attempts != 1 {
|
||||
t.Fatalf("tick 1: ok=%v attempts=%d, want restart with attempts=1", ok, state.attempts)
|
||||
}
|
||||
|
||||
// Tick 2 (t0+30s): the container is "starting" and not in the unhealthy list.
|
||||
// Prune must NOT drop its state because the window has not elapsed.
|
||||
s.pruneRetries(t0.Add(30 * time.Second))
|
||||
if _, kept := s.retries[id]; !kept {
|
||||
t.Fatal("tick 2: retry state was pruned while the container was 'starting'")
|
||||
}
|
||||
|
||||
// Tick 3 (t0+45s): unhealthy again, still within the cooldown. The surviving
|
||||
// state must block the restart and the attempt count must not be reset.
|
||||
ok, state = decideRestart(s.getRetry(id), policy, t0.Add(45*time.Second))
|
||||
s.setRetry(id, state)
|
||||
if ok {
|
||||
t.Error("tick 3: restart should be blocked by the surviving cooldown")
|
||||
}
|
||||
if state.attempts != 1 {
|
||||
t.Errorf("tick 3: attempts = %d, want 1 (state survived, not reset)", state.attempts)
|
||||
}
|
||||
}
|
||||
559
api/containerautomation/autoupdate.go
Normal file
559
api/containerautomation/autoupdate.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// statusCheckTimeout bounds a single container image-status resolution
|
||||
// (container inspect + remote digest fetch).
|
||||
statusCheckTimeout = 30 * time.Second
|
||||
// recreateTimeout bounds a standalone recreate (pull + stop + create + start).
|
||||
// Pulls can be slow, so it is generous.
|
||||
recreateTimeout = 10 * time.Minute
|
||||
// stackRedeployTimeout bounds a single stack redeploy-with-pull.
|
||||
stackRedeployTimeout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// update runs a single auto-update pass over every reachable Docker endpoint.
|
||||
// It is registered with the scheduler and guarded against overlapping ticks by
|
||||
// the Service. Errors are logged per endpoint/container so one failure does not
|
||||
// abort the whole pass; it always returns nil so the scheduler keeps the job.
|
||||
func (s *Service) update() error {
|
||||
if !s.updateRunning.CompareAndSwap(false, true) {
|
||||
log.Debug().Msg("auto-update: previous run still in progress, skipping tick")
|
||||
return nil
|
||||
}
|
||||
defer s.updateRunning.Store(false)
|
||||
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to read settings")
|
||||
return nil
|
||||
}
|
||||
|
||||
scope := ScopeLabeled
|
||||
if settings.ContainerAutomation.AutoUpdate.Scope == ScopeAll {
|
||||
scope = ScopeAll
|
||||
}
|
||||
|
||||
opts := updateOptions{
|
||||
cleanup: settings.ContainerAutomation.AutoUpdate.Cleanup,
|
||||
rollback: settings.ContainerAutomation.AutoUpdate.RollbackOnFailure,
|
||||
rollbackTimeout: parseRollbackTimeout(settings.ContainerAutomation.AutoUpdate.RollbackTimeout),
|
||||
}
|
||||
|
||||
endpoints, err := s.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to list environments")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
|
||||
// Native Docker endpoints only: Kubernetes is not applicable and
|
||||
// Edge/async endpoints are not reachable synchronously from the scheduler.
|
||||
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
|
||||
// independently of the global switch. Zero value participates, so existing
|
||||
// installs are unaffected.
|
||||
if !AutomationEnabledForEndpoint(endpoint) {
|
||||
log.Debug().Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: automation disabled for this environment, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
s.updateEndpoint(endpoint, scope, opts)
|
||||
}
|
||||
|
||||
// Drop rolled-back records whose cooldown has fully elapsed (mirrors auto-heal's
|
||||
// pruneRetries), so the loop-guard map cannot grow unbounded.
|
||||
s.pruneRolledBack(time.Now())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateOptions carries the per-pass auto-update toggles resolved from settings.
|
||||
type updateOptions struct {
|
||||
// cleanup removes the now-dangling old image after a confirmed-good update.
|
||||
cleanup bool
|
||||
// rollback enables the health gate + rollback of a failed standalone update.
|
||||
rollback bool
|
||||
// rollbackTimeout bounds how long the health gate waits before rolling back.
|
||||
rollbackTimeout time.Duration
|
||||
}
|
||||
|
||||
// parseRollbackTimeout resolves the configured rollback timeout, falling back to
|
||||
// the default when empty or unparseable.
|
||||
func parseRollbackTimeout(raw string) time.Duration {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
return defaultRollbackTimeout
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// updateEndpoint applies image updates to the in-scope, outdated containers of a
|
||||
// single endpoint, routing each container to the standalone / stack / external
|
||||
// apply path. Stack-managed candidates are grouped so each owning stack is
|
||||
// redeployed at most once per tick.
|
||||
func (s *Service) updateEndpoint(endpoint *portainer.Endpoint, scope string, opts updateOptions) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Swarm note (M4 limitation, mirrors auto-heal): we connect to the endpoint's
|
||||
// primary node only (nodeName ""). Containers scheduled on other Swarm nodes
|
||||
// are not updated here; stacks are redeployed cluster-wide by the swarm engine.
|
||||
clientTimeout := endpointTimeout
|
||||
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to create Docker client")
|
||||
return
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Running containers only: a stopped container has nothing to update now and
|
||||
// would be started by a bare recreate.
|
||||
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect the in-scope, outdated, non-monitor-only containers as candidates.
|
||||
// An in-scope monitor-only container is still status-checked (keeping its badge
|
||||
// cache warm) but never auto-applied. This only covers in-scope containers: in
|
||||
// "labeled" scope a monitor-only container without the enable label is filtered
|
||||
// out below before any status check, so its badge is not refreshed here.
|
||||
var candidates []UpdateCandidate
|
||||
for _, c := range containers {
|
||||
if !InUpdateScope(scope, c.Labels) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the image status. This also refreshes the package-level status
|
||||
// cache that backs the badge, so in-scope monitor-only containers are still
|
||||
// checked even though they are never auto-applied.
|
||||
statusCtx, statusCancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
status, err := s.digestClient.ContainerImageStatus(statusCtx, c.ID, endpoint, "")
|
||||
statusCancel()
|
||||
if err != nil {
|
||||
// Pull / registry-auth / network failure: leave the running container
|
||||
// untouched, never recreate on a failed check.
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: image status check failed, leaving container untouched")
|
||||
continue
|
||||
}
|
||||
|
||||
if status != images.Outdated {
|
||||
continue
|
||||
}
|
||||
|
||||
// Monitor-only: detect-only, never auto-apply (status already cached above).
|
||||
if IsMonitorOnly(c.Labels) {
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: outdated image detected but container is monitor-only, not applying")
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, UpdateCandidate{ID: c.ID, Name: containerName(c.Names), ImageID: c.ImageID, Labels: c.Labels})
|
||||
}
|
||||
|
||||
// Route and de-duplicate: one redeploy per stack per tick.
|
||||
grouped := groupContainersForUpdate(candidates, s.stackLookupForEndpoint(endpoint.ID))
|
||||
|
||||
for _, ext := range grouped.External {
|
||||
log.Debug().Str("container_id", ext.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: outdated externally-managed compose container, detect only")
|
||||
}
|
||||
|
||||
for _, c := range grouped.Standalone {
|
||||
s.updateStandalone(cli, endpoint, c, opts)
|
||||
}
|
||||
|
||||
for _, st := range grouped.Stacks {
|
||||
s.updateStack(endpoint, st)
|
||||
}
|
||||
}
|
||||
|
||||
// stackLookupForEndpoint builds a compose-project-name -> Portainer compose stack
|
||||
// resolver for a single endpoint. Only Docker Compose stacks on this endpoint
|
||||
// match; a same-named swarm/kubernetes stack is treated as external (mirrors
|
||||
// M3's resolveContainerUpdatePath).
|
||||
func (s *Service) stackLookupForEndpoint(endpointID portainer.EndpointID) func(project string) *StackMatch {
|
||||
stacks, err := s.dataStore.Stack().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", int(endpointID)).
|
||||
Msg("auto-update: unable to read stacks, treating compose containers as external")
|
||||
return func(string) *StackMatch { return nil }
|
||||
}
|
||||
|
||||
byName := make(map[string]*StackMatch)
|
||||
for i := range stacks {
|
||||
st := &stacks[i]
|
||||
if st.EndpointID != endpointID || st.Type != portainer.DockerComposeStack {
|
||||
continue
|
||||
}
|
||||
|
||||
byName[st.Name] = &StackMatch{StackID: int(st.ID), IsGit: st.WorkflowID != 0}
|
||||
}
|
||||
|
||||
return func(project string) *StackMatch {
|
||||
return byName[project]
|
||||
}
|
||||
}
|
||||
|
||||
// updateStandalone recreates a standalone container with a re-pull of its image,
|
||||
// then (when rollback is enabled and the container has a healthcheck) holds a
|
||||
// health gate over the new container and rolls back to the previous image if it
|
||||
// fails to become healthy. The old-image cleanup is deliberately ordered AFTER
|
||||
// the health gate, so the rollback target is never removed before the update is
|
||||
// confirmed good.
|
||||
//
|
||||
// Sequence: capture old image id + original ref + healthcheck -> recreate(pull)
|
||||
// -> [health gate] -> on healthy: cleanup (if enabled); on unhealthy: rollback
|
||||
// (never cleanup).
|
||||
func (s *Service) updateStandalone(cli *dockerclient.Client, endpoint *portainer.Endpoint, c UpdateCandidate, opts updateOptions) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Loop-guard safety: the rolled-back map is keyed by endpoint+name (the only
|
||||
// identifier that survives a recreate). An unnamed container cannot be recorded
|
||||
// (recordRolledBack skips it), so with rollback enabled a container that keeps
|
||||
// failing its health gate would update->rollback every tick with NO suppression.
|
||||
// Skip the unnamed case when rollback is on so it cannot enter that
|
||||
// unsuppressable loop; detection/badge refresh already happened upstream and is
|
||||
// unaffected. (With rollback off there is no rollback to loop, so we proceed.)
|
||||
if skipUnnamedForRollback(opts.rollback, c.Name) {
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: skipping unnamed standalone container, rollback is enabled but there is no stable name to key the loop guard")
|
||||
return
|
||||
}
|
||||
|
||||
// Update->rollback loop guard: if this container's update was rolled back
|
||||
// recently and the remote still points at the SAME failed image, skip it until
|
||||
// the cooldown elapses. A genuinely new upstream image (a changed remote digest)
|
||||
// is not blocked.
|
||||
rollbackMapKey := rollbackKey(endpoint.ID, c.Name)
|
||||
if rec, ok := s.getRolledBack(rollbackMapKey); ok && s.shouldSkipRolledBack(rollbackMapKey, rec) {
|
||||
log.Info().Str("container_id", c.ID).Str("container", c.Name).Str("image", rec.ref).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: skipping update, a recent rollback failed on this image and the remote is unchanged (cooldown)")
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the pre-update image identity for a possible rollback. The container
|
||||
// list gives us the old image id; an inspect adds the original reference (re-tag
|
||||
// target), whether a usable healthcheck exists, and the healthcheck start_period
|
||||
// (which must be waited out before deciding). We only health-gate when rollback
|
||||
// is enabled, the container has a healthcheck, we resolved both the old image id
|
||||
// and its reference, and that reference is a proper tag (a digest-pinned or bare
|
||||
// image id cannot be re-tagged, so the gate could never roll back).
|
||||
oldImageID := c.ImageID
|
||||
var originalRef string
|
||||
var startPeriod time.Duration
|
||||
healthGated := false
|
||||
if opts.rollback {
|
||||
// Bound the inspect like every other engine call so a hung/unreachable engine
|
||||
// cannot block the whole sequential tick until shutdown.
|
||||
inspectCtx, inspectCancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
inspect, err := cli.ContainerInspect(inspectCtx, c.ID)
|
||||
inspectCancel()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: unable to inspect container before update, proceeding without a health gate")
|
||||
} else {
|
||||
originalRef = inspect.Config.Image
|
||||
if oldImageID == "" {
|
||||
oldImageID = inspect.Image
|
||||
}
|
||||
if hc := inspect.Config.Healthcheck; hc != nil {
|
||||
startPeriod = hc.StartPeriod
|
||||
}
|
||||
|
||||
switch {
|
||||
case !hasHealthGate(inspect.Config.Healthcheck):
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: container has no healthcheck, updating without a rollback gate")
|
||||
case oldImageID == "" || originalRef == "":
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: unable to resolve previous image identity, updating without a rollback gate")
|
||||
case !isTagReference(originalRef):
|
||||
log.Info().Str("container_id", c.ID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: health gate skipped, image is digest-pinned and cannot be rolled back")
|
||||
default:
|
||||
healthGated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
|
||||
defer cancel()
|
||||
|
||||
newContainer, err := s.containerService.Recreate(ctx, endpoint, c.ID, true, "", "")
|
||||
if err != nil {
|
||||
// Recreate preserves config and rolls back on a create failure; a pull or
|
||||
// create failure leaves the original container running.
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: failed to recreate standalone container")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID,
|
||||
Message: "failed to recreate standalone container", Err: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: recreated standalone container with updated image")
|
||||
newImage := ""
|
||||
if newContainer != nil {
|
||||
newImage = newContainer.Config.Image
|
||||
}
|
||||
|
||||
// Health gate: roll back if the new container does not become healthy in time.
|
||||
// The old image is preserved (not cleaned up) until the gate confirms health,
|
||||
// so the rollback target is still available. The "updated" event is held until
|
||||
// the gate confirms health, so an observer never sees a misleading
|
||||
// "updated" -> "rollback" sequence for the same container; on the rollback path
|
||||
// only EventRollback (or update-failed) is emitted.
|
||||
if healthGated {
|
||||
switch s.healthGate(cli, newContainer.ID, opts.rollbackTimeout, startPeriod) {
|
||||
case gateAborted:
|
||||
// Server shutdown mid-gate: leave the new container in place, do not roll
|
||||
// back and do not emit an event (we never observed a real failure).
|
||||
return
|
||||
case gateRollback:
|
||||
s.rollback(cli, endpoint, newContainer.ID, oldImageID, originalRef, c.Name)
|
||||
return
|
||||
case gateHealthy:
|
||||
// Confirmed healthy: fall through to emit "updated" and clean up.
|
||||
}
|
||||
}
|
||||
|
||||
// Emit "updated" now: either there was no gate (emitted right after recreate,
|
||||
// as before), or the gate confirmed the new container is healthy.
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID,
|
||||
Image: newImage, Message: "updated standalone container",
|
||||
})
|
||||
|
||||
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
|
||||
s.cleanupOldImage(cli, endpoint, oldImageID)
|
||||
}
|
||||
}
|
||||
|
||||
// containerName returns a container's primary name without the leading slash, or
|
||||
// "" when none is reported. The name is stable across a recreate (Recreate
|
||||
// assigns a new container ID but preserves the name), so it keys the rolled-back
|
||||
// loop-guard map.
|
||||
func containerName(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(names[0], "/")
|
||||
}
|
||||
|
||||
// skipUnnamedForRollback reports whether a standalone update must be skipped
|
||||
// because rollback is enabled but the container has no stable name to key the
|
||||
// loop guard. The rolled-back map is keyed by endpoint+name (the only identifier
|
||||
// that survives a recreate); without a name the guard cannot record a failed
|
||||
// target, so a repeatedly-failing update would loop update->rollback every tick
|
||||
// with no suppression. When rollback is off there is nothing to loop, so an
|
||||
// unnamed container is still allowed to update.
|
||||
func skipUnnamedForRollback(rollback bool, name string) bool {
|
||||
return rollback && name == ""
|
||||
}
|
||||
|
||||
// rollbackKey identifies a standalone container in the rolled-back map by its
|
||||
// endpoint and (recreate-stable) name. A recreate assigns a new container ID, so
|
||||
// the ID cannot key state across an update; the name is preserved.
|
||||
func rollbackKey(endpointID portainer.EndpointID, name string) string {
|
||||
return fmt.Sprintf("%d/%s", int(endpointID), name)
|
||||
}
|
||||
|
||||
// resolveRemoteDigest fetches the current remote image digest for a reference. It
|
||||
// tells whether a rolled-back container's upstream target is still the same
|
||||
// failed image (skip) or a new push (retry).
|
||||
func (s *Service) resolveRemoteDigest(ctx context.Context, ref string) (string, error) {
|
||||
img, err := images.ParseImage(images.ParseImageOptions{Name: ref})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dig, err := s.digestClient.RemoteDigest(ctx, img)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dig.String(), nil
|
||||
}
|
||||
|
||||
// recordRolledBack stores the failed target after a successful rollback so the
|
||||
// next poll skips re-pulling the same broken image. The failed remote digest is
|
||||
// resolved now (the registry is reachable, the image was just pulled); if it
|
||||
// cannot be resolved the record is still stored with an empty digest and the
|
||||
// guard skips conservatively until the cooldown elapses.
|
||||
func (s *Service) recordRolledBack(endpoint *portainer.Endpoint, name, ref string) {
|
||||
if name == "" {
|
||||
// Without a stable key we cannot reliably match the container next tick.
|
||||
log.Debug().Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: rolled-back container has no name, loop guard not recorded")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
digest, err := s.resolveRemoteDigest(ctx, ref)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: could not resolve failed remote digest, loop guard will skip conservatively until cooldown")
|
||||
}
|
||||
|
||||
s.setRolledBack(rollbackKey(endpoint.ID, name), rolledBackTarget{ref: ref, digest: digest, at: time.Now()})
|
||||
}
|
||||
|
||||
// shouldSkipRolledBack reports whether a standalone container must be skipped this
|
||||
// tick to avoid the update->rollback loop, clearing the record once the skip no
|
||||
// longer applies (cooldown elapsed or a new upstream image). It resolves the
|
||||
// current remote digest so a genuinely new image is never blocked.
|
||||
func (s *Service) shouldSkipRolledBack(key string, rec rolledBackTarget) bool {
|
||||
now := time.Now()
|
||||
|
||||
// Fast paths that avoid a registry call: cooldown elapsed -> clear & proceed;
|
||||
// no recorded digest -> skip conservatively while the cooldown is open.
|
||||
if now.Sub(rec.at) >= updateRollbackCooldown {
|
||||
s.clearRolledBack(key)
|
||||
return false
|
||||
}
|
||||
if rec.digest == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
currentDigest, err := s.resolveRemoteDigest(ctx, rec.ref)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// Cannot confirm the upstream target changed: stay conservative and skip to
|
||||
// avoid re-entering the loop, until the cooldown elapses.
|
||||
log.Debug().Err(err).Str("image", rec.ref).
|
||||
Msg("auto-update: cannot resolve remote digest for a rolled-back container, skipping until cooldown")
|
||||
return true
|
||||
}
|
||||
|
||||
if decideUpdateSkip(rec, currentDigest, now, updateRollbackCooldown) {
|
||||
return true
|
||||
}
|
||||
|
||||
// New upstream image (changed digest): the failed target is gone, clear the
|
||||
// record and let the update proceed.
|
||||
s.clearRolledBack(key)
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanupOldImage attempts a conservative removal of the previous image after a
|
||||
// standalone update. The removal is NOT forced: Docker refuses to delete an
|
||||
// image that still carries tags or is referenced by any container, so this only
|
||||
// succeeds when the old image has become genuinely dangling (untagged and
|
||||
// unused). It never touches a tagged image still in use.
|
||||
func (s *Service) cleanupOldImage(cli *dockerclient.Client, endpoint *portainer.Endpoint, oldImageID string) {
|
||||
if oldImageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := cli.ImageRemove(ctx, oldImageID, image.RemoveOptions{Force: false, PruneChildren: false}); err != nil {
|
||||
log.Debug().Err(err).Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: old image not removed (still tagged or in use)")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: removed dangling old image after update")
|
||||
}
|
||||
|
||||
// updateStack applies an image update to a Portainer-managed compose stack so its
|
||||
// containers are recreated by the stack engine and stay part of the stack. It is
|
||||
// called at most once per stack per tick.
|
||||
//
|
||||
// - git stacks: detect-only here. A git stack's source of truth is its commit;
|
||||
// this tick's trigger is an image-only update (same compose manifest, newer
|
||||
// upstream digest), which the git redeploy path (RedeployWhenChanged) would
|
||||
// short-circuit without applying — while still doing a real git fetch every
|
||||
// tick. So we skip git stacks: the image update lands on the stack's next git
|
||||
// change or via a manual "Update now", and we do not fetch git every tick.
|
||||
// - file stacks: the deployer is driven directly with forcePullImage=true,
|
||||
// applying the image update immediately.
|
||||
func (s *Service) updateStack(endpoint *portainer.Endpoint, st StackUpdate) {
|
||||
if st.IsGit {
|
||||
// Detect-only: leave git bookkeeping to the git redeploy path. Logged at
|
||||
// debug so it does not repeat at info on every tick (it would otherwise
|
||||
// fire for an unchanged git stack indefinitely).
|
||||
log.Debug().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: outdated git stack image detected, detect only (applied on next git change or manual update)")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, stackRedeployTimeout)
|
||||
defer cancel()
|
||||
|
||||
stack, err := s.dataStore.Stack().Read(portainer.StackID(st.StackID))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: unable to read stack for redeploy")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve registries the same way the established userless/system redeploy does
|
||||
// (RedeployWhenChanged): scope them to the stack author's access on the endpoint
|
||||
// and refresh ECR tokens, so an ECR-backed stack authenticates with fresh
|
||||
// credentials instead of the stale token a raw ReadAll() would pass.
|
||||
registries, err := deployments.ResolveStackRegistries(s.dataStore, stack, endpoint.ID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: unable to resolve registries for stack redeploy")
|
||||
return
|
||||
}
|
||||
|
||||
// prune=false (conservative: do not remove resources the user may rely on),
|
||||
// forcePullImage=true (the whole point), forceRecreate=false.
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = s.stackDeployer.DeployRemoteComposeStack(ctx, stack, endpoint, registries, false, true, false)
|
||||
} else {
|
||||
err = s.stackDeployer.DeployComposeStack(ctx, stack, endpoint, registries, false, true, false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: failed to redeploy compose stack with re-pull")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: redeployed compose stack with updated images")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdated, EndpointID: int(endpoint.ID), StackID: st.StackID,
|
||||
Message: "redeployed compose stack with updated images",
|
||||
})
|
||||
}
|
||||
241
api/containerautomation/labels.go
Normal file
241
api/containerautomation/labels.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package containerautomation
|
||||
|
||||
import "strconv"
|
||||
|
||||
const (
|
||||
// Scope values shared by the auto-heal and auto-update global settings.
|
||||
ScopeLabeled = "labeled"
|
||||
ScopeAll = "all"
|
||||
|
||||
// Primary labels (with community aliases) controlling per-container auto-heal.
|
||||
labelEnable = "io.portainer.autoheal.enable"
|
||||
labelEnableAlias = "autoheal"
|
||||
labelStopTimeout = "io.portainer.autoheal.stop-timeout"
|
||||
labelStopTimeoutAlias = "autoheal.stop.timeout"
|
||||
labelRetries = "io.portainer.autoheal.retries"
|
||||
|
||||
// Primary labels (with watchtower aliases) controlling per-container auto-update.
|
||||
labelUpdateEnable = "io.portainer.update.enable"
|
||||
labelUpdateEnableAlias = "com.centurylinklabs.watchtower.enable"
|
||||
labelUpdateMonitorOnly = "io.portainer.update.monitor-only"
|
||||
labelUpdateMonitorOnlyAlias = "com.centurylinklabs.watchtower.monitor-only"
|
||||
|
||||
// composeProjectLabel identifies the compose project a container belongs to.
|
||||
composeProjectLabel = "com.docker.compose.project"
|
||||
|
||||
// Defaults used when a label is missing or holds an invalid value.
|
||||
defaultStopTimeout = 10
|
||||
defaultRetries = 3
|
||||
)
|
||||
|
||||
// InScope reports whether a container is subject to auto-heal given the global
|
||||
// scope and the container's labels.
|
||||
//
|
||||
// - "all": every container is in scope, unless it explicitly opts out with the
|
||||
// enable label set to false.
|
||||
// - "labeled" (default): only containers with the enable label set to true.
|
||||
func InScope(scope string, labels map[string]string) bool {
|
||||
enabled, present := boolLabel(labels, labelEnable, labelEnableAlias)
|
||||
|
||||
switch scope {
|
||||
case ScopeAll:
|
||||
if present && !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
default: // ScopeLabeled
|
||||
return present && enabled
|
||||
}
|
||||
}
|
||||
|
||||
// boolLabel resolves a boolean label (primary key first, alias second).
|
||||
// It returns the parsed value and whether the label was present at all.
|
||||
// Invalid values are treated as false but still count as "present".
|
||||
func boolLabel(labels map[string]string, key, alias string) (value bool, present bool) {
|
||||
raw, ok := labels[key]
|
||||
if !ok {
|
||||
raw, ok = labels[alias]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// InUpdateScope reports whether a container is subject to auto-update given the
|
||||
// global scope and the container's labels. It mirrors InScope but reads the
|
||||
// update enable label (io.portainer.update.enable / watchtower alias):
|
||||
//
|
||||
// - "all": every container is in scope, unless it explicitly opts out with the
|
||||
// update enable label set to false.
|
||||
// - "labeled" (default): only containers with the update enable label true.
|
||||
func InUpdateScope(scope string, labels map[string]string) bool {
|
||||
enabled, present := boolLabel(labels, labelUpdateEnable, labelUpdateEnableAlias)
|
||||
|
||||
switch scope {
|
||||
case ScopeAll:
|
||||
if present && !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
default: // ScopeLabeled
|
||||
return present && enabled
|
||||
}
|
||||
}
|
||||
|
||||
// IsMonitorOnly reports whether a container is flagged detect-only via the
|
||||
// monitor-only label (io.portainer.update.monitor-only / watchtower alias).
|
||||
// Such containers have their image status resolved (for the badge cache) but are
|
||||
// never auto-applied.
|
||||
func IsMonitorOnly(labels map[string]string) bool {
|
||||
value, present := boolLabel(labels, labelUpdateMonitorOnly, labelUpdateMonitorOnlyAlias)
|
||||
|
||||
return present && value
|
||||
}
|
||||
|
||||
// UpdateKind is the apply path resolved for an outdated container.
|
||||
type UpdateKind string
|
||||
|
||||
const (
|
||||
// UpdateStandalone: recreate-with-pull (no compose project).
|
||||
UpdateStandalone UpdateKind = "standalone"
|
||||
// UpdateStack: redeploy the owning Portainer compose stack with re-pull, so
|
||||
// the container stays part of its stack.
|
||||
UpdateStack UpdateKind = "stack"
|
||||
// UpdateExternal: compose-managed but with no matching Portainer compose
|
||||
// stack record; Portainer must not touch it (would detach it / drift).
|
||||
UpdateExternal UpdateKind = "external"
|
||||
)
|
||||
|
||||
// StackMatch is the Portainer Docker Compose stack a compose project resolves to.
|
||||
type StackMatch struct {
|
||||
StackID int
|
||||
// IsGit routes file vs git redeploy at apply time.
|
||||
IsGit bool
|
||||
}
|
||||
|
||||
// UpdateRouting is the decision returned by resolveContainerUpdateRouting.
|
||||
type UpdateRouting struct {
|
||||
Kind UpdateKind
|
||||
StackID int
|
||||
IsGit bool
|
||||
}
|
||||
|
||||
// resolveContainerUpdateRouting decides how a container's image update must be
|
||||
// applied, given a lookup that resolves a compose project name to a matching
|
||||
// Portainer Docker Compose stack (nil when none exists or it is not a compose
|
||||
// stack). It is the Go equivalent of M3's TS resolveContainerUpdatePath: pure
|
||||
// and side-effect free so it can be unit-tested without Docker or the datastore.
|
||||
//
|
||||
// - No compose project label -> standalone (recreate-with-pull).
|
||||
// - Compose project matching a Portainer compose stack -> stack
|
||||
// (redeploy-with-pull, keeps the container in its stack).
|
||||
// - Compose project with no matching Portainer compose stack -> external
|
||||
// (managed outside Portainer / a same-named stack of another type), left
|
||||
// untouched to avoid detaching it or drifting.
|
||||
func resolveContainerUpdateRouting(labels map[string]string, stackLookup func(project string) *StackMatch) UpdateRouting {
|
||||
project := labels[composeProjectLabel]
|
||||
if project == "" {
|
||||
return UpdateRouting{Kind: UpdateStandalone}
|
||||
}
|
||||
|
||||
match := stackLookup(project)
|
||||
if match == nil {
|
||||
return UpdateRouting{Kind: UpdateExternal}
|
||||
}
|
||||
|
||||
return UpdateRouting{Kind: UpdateStack, StackID: match.StackID, IsGit: match.IsGit}
|
||||
}
|
||||
|
||||
// UpdateCandidate is an outdated, in-scope container considered for auto-update.
|
||||
type UpdateCandidate struct {
|
||||
ID string
|
||||
// Name is the container's primary name (no leading slash). It is stable across
|
||||
// a recreate and keys the update->rollback loop guard.
|
||||
Name string
|
||||
ImageID string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// StackUpdate identifies a Portainer stack to redeploy once.
|
||||
type StackUpdate struct {
|
||||
StackID int
|
||||
IsGit bool
|
||||
}
|
||||
|
||||
// GroupedUpdates partitions candidates into their apply paths, de-duplicating
|
||||
// stack containers so each owning stack is redeployed at most once per tick
|
||||
// (the overlap guard for stack fan-out). Pure and unit-testable, the Go analogue
|
||||
// of M3's groupContainersForUpdate.
|
||||
type GroupedUpdates struct {
|
||||
Standalone []UpdateCandidate
|
||||
External []UpdateCandidate
|
||||
Stacks []StackUpdate
|
||||
}
|
||||
|
||||
// groupContainersForUpdate routes each candidate and collapses stack candidates
|
||||
// so a stack with several outdated containers is redeployed only once.
|
||||
func groupContainersForUpdate(candidates []UpdateCandidate, stackLookup func(project string) *StackMatch) GroupedUpdates {
|
||||
grouped := GroupedUpdates{}
|
||||
seenStacks := make(map[int]bool)
|
||||
|
||||
for _, c := range candidates {
|
||||
routing := resolveContainerUpdateRouting(c.Labels, stackLookup)
|
||||
switch routing.Kind {
|
||||
case UpdateStandalone:
|
||||
grouped.Standalone = append(grouped.Standalone, c)
|
||||
case UpdateExternal:
|
||||
grouped.External = append(grouped.External, c)
|
||||
case UpdateStack:
|
||||
if seenStacks[routing.StackID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seenStacks[routing.StackID] = true
|
||||
grouped.Stacks = append(grouped.Stacks, StackUpdate{StackID: routing.StackID, IsGit: routing.IsGit})
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
// StopTimeout returns the per-container stop timeout (in seconds) from labels,
|
||||
// falling back to the default when missing or invalid.
|
||||
func StopTimeout(labels map[string]string) int {
|
||||
return positiveIntLabel(labels, labelStopTimeout, labelStopTimeoutAlias, defaultStopTimeout)
|
||||
}
|
||||
|
||||
// MaxRetries returns the per-container max restarts per window from labels,
|
||||
// falling back to the default when missing or invalid.
|
||||
func MaxRetries(labels map[string]string) int {
|
||||
return positiveIntLabel(labels, labelRetries, "", defaultRetries)
|
||||
}
|
||||
|
||||
// positiveIntLabel reads an integer label (primary first, optional alias second)
|
||||
// and returns it when strictly positive, otherwise the provided default.
|
||||
func positiveIntLabel(labels map[string]string, key, alias string, fallback int) int {
|
||||
raw, ok := labels[key]
|
||||
if !ok && alias != "" {
|
||||
raw, ok = labels[alias]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
231
api/containerautomation/labels_test.go
Normal file
231
api/containerautomation/labels_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package containerautomation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"labeled: no labels", ScopeLabeled, nil, false},
|
||||
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelEnable: "true"}, true},
|
||||
{"labeled: enable true (alias)", ScopeLabeled, map[string]string{labelEnableAlias: "true"}, true},
|
||||
{"labeled: enable false", ScopeLabeled, map[string]string{labelEnable: "false"}, false},
|
||||
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelEnable: "yepp"}, false},
|
||||
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelEnable: "true", labelEnableAlias: "false"}, true},
|
||||
{"all: no labels", ScopeAll, nil, true},
|
||||
{"all: enable true", ScopeAll, map[string]string{labelEnable: "true"}, true},
|
||||
{"all: explicit opt-out", ScopeAll, map[string]string{labelEnable: "false"}, false},
|
||||
{"all: opt-out via alias", ScopeAll, map[string]string{labelEnableAlias: "0"}, false},
|
||||
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelEnable: "nope"}, false},
|
||||
{"unknown scope falls back to labeled", "weird", map[string]string{labelEnable: "true"}, true},
|
||||
{"unknown scope, no label", "weird", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := InScope(tt.scope, tt.labels); got != tt.want {
|
||||
t.Errorf("InScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want int
|
||||
}{
|
||||
{"default when missing", nil, defaultStopTimeout},
|
||||
{"primary value", map[string]string{labelStopTimeout: "25"}, 25},
|
||||
{"alias value", map[string]string{labelStopTimeoutAlias: "15"}, 15},
|
||||
{"primary wins over alias", map[string]string{labelStopTimeout: "25", labelStopTimeoutAlias: "15"}, 25},
|
||||
{"bad value falls back", map[string]string{labelStopTimeout: "abc"}, defaultStopTimeout},
|
||||
{"zero falls back", map[string]string{labelStopTimeout: "0"}, defaultStopTimeout},
|
||||
{"negative falls back", map[string]string{labelStopTimeout: "-5"}, defaultStopTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := StopTimeout(tt.labels); got != tt.want {
|
||||
t.Errorf("StopTimeout(%v) = %d, want %d", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxRetries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want int
|
||||
}{
|
||||
{"default when missing", nil, defaultRetries},
|
||||
{"explicit value", map[string]string{labelRetries: "5"}, 5},
|
||||
{"bad value falls back", map[string]string{labelRetries: "lots"}, defaultRetries},
|
||||
{"zero falls back", map[string]string{labelRetries: "0"}, defaultRetries},
|
||||
{"no alias for retries", map[string]string{"autoheal.retries": "7"}, defaultRetries},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := MaxRetries(tt.labels); got != tt.want {
|
||||
t.Errorf("MaxRetries(%v) = %d, want %d", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInUpdateScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"labeled: no labels", ScopeLabeled, nil, false},
|
||||
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"labeled: enable true (watchtower alias)", ScopeLabeled, map[string]string{labelUpdateEnableAlias: "true"}, true},
|
||||
{"labeled: enable false", ScopeLabeled, map[string]string{labelUpdateEnable: "false"}, false},
|
||||
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelUpdateEnable: "soon"}, false},
|
||||
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelUpdateEnable: "true", labelUpdateEnableAlias: "false"}, true},
|
||||
{"all: no labels", ScopeAll, nil, true},
|
||||
{"all: enable true", ScopeAll, map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"all: explicit opt-out", ScopeAll, map[string]string{labelUpdateEnable: "false"}, false},
|
||||
{"all: opt-out via watchtower alias", ScopeAll, map[string]string{labelUpdateEnableAlias: "0"}, false},
|
||||
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelUpdateEnable: "nope"}, false},
|
||||
{"unknown scope falls back to labeled", "weird", map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"unknown scope, no label", "weird", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := InUpdateScope(tt.scope, tt.labels); got != tt.want {
|
||||
t.Errorf("InUpdateScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMonitorOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"no labels", nil, false},
|
||||
{"primary true", map[string]string{labelUpdateMonitorOnly: "true"}, true},
|
||||
{"watchtower alias true", map[string]string{labelUpdateMonitorOnlyAlias: "true"}, true},
|
||||
{"primary false", map[string]string{labelUpdateMonitorOnly: "false"}, false},
|
||||
{"bad value", map[string]string{labelUpdateMonitorOnly: "maybe"}, false},
|
||||
{"primary wins over alias", map[string]string{labelUpdateMonitorOnly: "true", labelUpdateMonitorOnlyAlias: "false"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsMonitorOnly(tt.labels); got != tt.want {
|
||||
t.Errorf("IsMonitorOnly(%v) = %v, want %v", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContainerUpdateRouting(t *testing.T) {
|
||||
// stackLookup resolves "my-stack" to compose stack 7 (git) and nothing else,
|
||||
// mirroring how the job builds a per-endpoint compose-stack index.
|
||||
stackLookup := func(project string) *StackMatch {
|
||||
if project == "my-stack" {
|
||||
return &StackMatch{StackID: 7, IsGit: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want UpdateRouting
|
||||
}{
|
||||
{
|
||||
name: "no compose label -> standalone",
|
||||
labels: map[string]string{"foo": "bar"},
|
||||
want: UpdateRouting{Kind: UpdateStandalone},
|
||||
},
|
||||
{
|
||||
name: "empty compose label -> standalone",
|
||||
labels: map[string]string{composeProjectLabel: ""},
|
||||
want: UpdateRouting{Kind: UpdateStandalone},
|
||||
},
|
||||
{
|
||||
name: "compose project matching a portainer compose stack -> stack",
|
||||
labels: map[string]string{composeProjectLabel: "my-stack"},
|
||||
want: UpdateRouting{Kind: UpdateStack, StackID: 7, IsGit: true},
|
||||
},
|
||||
{
|
||||
name: "compose project with no matching stack -> external",
|
||||
labels: map[string]string{composeProjectLabel: "other"},
|
||||
want: UpdateRouting{Kind: UpdateExternal},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveContainerUpdateRouting(tt.labels, stackLookup)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveContainerUpdateRouting(%v) = %+v, want %+v", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupContainersForUpdate(t *testing.T) {
|
||||
// stackLookup: "web" -> compose stack 3 (file), "api" -> compose stack 4 (git).
|
||||
stackLookup := func(project string) *StackMatch {
|
||||
switch project {
|
||||
case "web":
|
||||
return &StackMatch{StackID: 3, IsGit: false}
|
||||
case "api":
|
||||
return &StackMatch{StackID: 4, IsGit: true}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []UpdateCandidate{
|
||||
{ID: "standalone-1"},
|
||||
{ID: "web-a", Labels: map[string]string{composeProjectLabel: "web"}},
|
||||
{ID: "web-b", Labels: map[string]string{composeProjectLabel: "web"}}, // same stack -> deduped
|
||||
{ID: "api-a", Labels: map[string]string{composeProjectLabel: "api"}},
|
||||
{ID: "ext-1", Labels: map[string]string{composeProjectLabel: "unknown"}},
|
||||
}
|
||||
|
||||
grouped := groupContainersForUpdate(candidates, stackLookup)
|
||||
|
||||
if len(grouped.Standalone) != 1 || grouped.Standalone[0].ID != "standalone-1" {
|
||||
t.Errorf("Standalone = %+v, want one entry standalone-1", grouped.Standalone)
|
||||
}
|
||||
|
||||
if len(grouped.External) != 1 || grouped.External[0].ID != "ext-1" {
|
||||
t.Errorf("External = %+v, want one entry ext-1", grouped.External)
|
||||
}
|
||||
|
||||
// One redeploy per stack: web appears twice in input but once in output.
|
||||
if len(grouped.Stacks) != 2 {
|
||||
t.Fatalf("Stacks = %+v, want 2 deduped stacks", grouped.Stacks)
|
||||
}
|
||||
|
||||
got := map[int]bool{}
|
||||
for _, st := range grouped.Stacks {
|
||||
got[st.StackID] = st.IsGit
|
||||
}
|
||||
|
||||
if isGit, ok := got[3]; !ok || isGit {
|
||||
t.Errorf("stack 3 = (%v, present=%v), want present file stack", isGit, ok)
|
||||
}
|
||||
|
||||
if isGit, ok := got[4]; !ok || !isGit {
|
||||
t.Errorf("stack 4 = (%v, present=%v), want present git stack", isGit, ok)
|
||||
}
|
||||
}
|
||||
75
api/containerautomation/notify.go
Normal file
75
api/containerautomation/notify.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package containerautomation
|
||||
|
||||
import "github.com/rs/zerolog/log"
|
||||
|
||||
// EventKind enumerates the container-automation events surfaced to a Notifier.
|
||||
// The set is intentionally small: it is the seam future milestones extend with
|
||||
// real senders (Slack/email/webhook) without touching the daemon call sites.
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
// EventUpdated is emitted after a container/stack image was updated.
|
||||
EventUpdated EventKind = "updated"
|
||||
// EventRollback is emitted after a health-gated rollback to the previous image.
|
||||
EventRollback EventKind = "rollback"
|
||||
// EventUpdateFailed is emitted when an update (or its rollback) could not be applied.
|
||||
EventUpdateFailed EventKind = "update-failed"
|
||||
// EventHealRestarted is emitted after an unhealthy container was restarted.
|
||||
EventHealRestarted EventKind = "heal-restarted"
|
||||
)
|
||||
|
||||
// Event is a structured container-automation notification. Optional fields are
|
||||
// left zero when not applicable to the event (e.g. StackID for a standalone
|
||||
// update, ContainerID for a stack redeploy).
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
EndpointID int
|
||||
ContainerID string
|
||||
StackID int
|
||||
Image string
|
||||
Message string
|
||||
// Err carries the underlying error for failure events; nil otherwise.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Notifier receives container-automation events. CE has no generic notification
|
||||
// subsystem, so the only implementation is logNotifier; this interface is the
|
||||
// seam external senders plug into later.
|
||||
type Notifier interface {
|
||||
Notify(event Event)
|
||||
}
|
||||
|
||||
// logNotifier is the default Notifier: it emits each event as a structured log
|
||||
// line. It never blocks and never errors, so it is safe to call from the daemon
|
||||
// hot path.
|
||||
type logNotifier struct{}
|
||||
|
||||
// Notify logs the event with its kind and context fields. Failure events are
|
||||
// logged at warn (with the error), the rest at info.
|
||||
func (logNotifier) Notify(event Event) {
|
||||
entry := log.Info()
|
||||
if event.Kind == EventUpdateFailed {
|
||||
entry = log.Warn()
|
||||
if event.Err != nil {
|
||||
entry = entry.Err(event.Err)
|
||||
}
|
||||
}
|
||||
|
||||
entry = entry.Str("event", string(event.Kind)).Int("endpoint_id", event.EndpointID)
|
||||
if event.ContainerID != "" {
|
||||
entry = entry.Str("container_id", event.ContainerID)
|
||||
}
|
||||
if event.StackID != 0 {
|
||||
entry = entry.Int("stack_id", event.StackID)
|
||||
}
|
||||
if event.Image != "" {
|
||||
entry = entry.Str("image", event.Image)
|
||||
}
|
||||
|
||||
message := event.Message
|
||||
if message == "" {
|
||||
message = "container automation event"
|
||||
}
|
||||
|
||||
entry.Msg("container automation: " + message)
|
||||
}
|
||||
64
api/containerautomation/notify_test.go
Normal file
64
api/containerautomation/notify_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// recordingNotifier captures emitted events for assertions in tests.
|
||||
type recordingNotifier struct {
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (r *recordingNotifier) Notify(event Event) {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
func TestLogNotifierDoesNotPanic(t *testing.T) {
|
||||
n := logNotifier{}
|
||||
|
||||
// Every event kind, including a failure carrying an error, must log without
|
||||
// panicking and without requiring any optional field.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerID: "abc", Image: "nginx:latest"})
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, StackID: 7})
|
||||
n.Notify(Event{Kind: EventRollback, EndpointID: 2, ContainerID: "def", Image: "nginx:1.0"})
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 3, ContainerID: "ghi"})
|
||||
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4, ContainerID: "jkl", Err: errors.New("boom")})
|
||||
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4}) // failure without an error
|
||||
n.Notify(Event{}) // zero value
|
||||
}
|
||||
|
||||
func TestRecordingNotifierCapturesEvents(t *testing.T) {
|
||||
r := &recordingNotifier{}
|
||||
r.Notify(Event{Kind: EventUpdated, EndpointID: 1})
|
||||
r.Notify(Event{Kind: EventRollback, EndpointID: 1})
|
||||
|
||||
if len(r.events) != 2 {
|
||||
t.Fatalf("captured %d events, want 2", len(r.events))
|
||||
}
|
||||
if r.events[0].Kind != EventUpdated || r.events[1].Kind != EventRollback {
|
||||
t.Errorf("unexpected event kinds: %v, %v", r.events[0].Kind, r.events[1].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationEnabledForEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *portainer.Endpoint
|
||||
want bool
|
||||
}{
|
||||
{name: "nil endpoint is not enabled", endpoint: nil, want: false},
|
||||
{name: "default (zero value) participates", endpoint: &portainer.Endpoint{}, want: true},
|
||||
{name: "explicitly disabled opts out", endpoint: &portainer.Endpoint{ContainerAutomationDisabled: true}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AutomationEnabledForEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Errorf("AutomationEnabledForEndpoint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
387
api/containerautomation/rollback.go
Normal file
387
api/containerautomation/rollback.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.podman.io/image/v5/docker/reference"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRollbackTimeout bounds how long the health gate waits for a freshly
|
||||
// updated standalone container to become healthy before rolling back.
|
||||
defaultRollbackTimeout = 120 * time.Second
|
||||
// rollbackPollInterval is the delay between two health probes of the new
|
||||
// container while the rollback window is open.
|
||||
rollbackPollInterval = 3 * time.Second
|
||||
// rollbackGateBuffer is added to the rollback timeout when deriving the inspect
|
||||
// context deadline, leaving room for the final probe to complete after the
|
||||
// decision deadline elapses.
|
||||
rollbackGateBuffer = 10 * time.Second
|
||||
// startPeriodBuffer is added to a container's healthcheck start_period when it
|
||||
// is longer than the rollback timeout, so the gate waits through the whole
|
||||
// start period (during which Docker reports "starting") plus a small grace
|
||||
// before deciding. Without it a legitimately slow-starting container would be
|
||||
// rolled back while it is still initializing normally.
|
||||
startPeriodBuffer = 15 * time.Second
|
||||
// maxConsecutiveInspectErrors is how many back-to-back inspect failures the
|
||||
// health gate tolerates before declaring the update failed. A single transient
|
||||
// Docker API blip must not trigger a false rollback, so the gate keeps polling
|
||||
// and only gives up once the failures are clearly not transient.
|
||||
maxConsecutiveInspectErrors = 3
|
||||
// updateRollbackCooldown is how long a standalone container whose update was
|
||||
// rolled back is skipped from updating to the SAME failed image again. It
|
||||
// breaks the update->rollback loop: without it a persistently-unhealthy new
|
||||
// image would be re-pulled and rolled back on every poll tick. A genuinely new
|
||||
// upstream image (a changed remote digest) is not blocked; the cooldown only
|
||||
// suppresses the exact target that just failed. It is generous because a broken
|
||||
// upstream image is normally fixed by a new push, which lifts the skip at once.
|
||||
updateRollbackCooldown = 24 * time.Hour
|
||||
)
|
||||
|
||||
// rolledBackTarget records that a standalone container's update to a specific
|
||||
// remote image was rolled back, so the same target is skipped until the cooldown
|
||||
// elapses or the upstream digest changes.
|
||||
type rolledBackTarget struct {
|
||||
// ref is the container's original image reference (the re-tag target), used to
|
||||
// re-resolve the current remote digest on later ticks.
|
||||
ref string
|
||||
// digest is the remote image digest that failed the health gate. A later tick
|
||||
// resolving a DIFFERENT digest (a new upstream push) is allowed through; the
|
||||
// same digest is skipped until the cooldown elapses. Empty when it could not be
|
||||
// resolved at rollback time, in which case the guard skips conservatively.
|
||||
digest string
|
||||
// at is when the rollback happened; the cooldown is measured from it.
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// decideUpdateSkip is the pure core of the update->rollback loop guard: given a
|
||||
// recorded rolled-back target and the freshly-resolved current remote digest, it
|
||||
// reports whether the standalone update must be skipped this tick. The skip holds
|
||||
// only while the cooldown is open AND the remote still points at the same failed
|
||||
// image; once the cooldown elapses the skip is lifted. An unknown recorded digest
|
||||
// is skipped conservatively (we cannot prove the target changed). Mirrors the
|
||||
// decideRestart pattern so it is unit-testable without Docker.
|
||||
func decideUpdateSkip(rec rolledBackTarget, currentDigest string, now time.Time, cooldown time.Duration) bool {
|
||||
if now.Sub(rec.at) >= cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
if rec.digest == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return currentDigest == rec.digest
|
||||
}
|
||||
|
||||
// rollbackOutcome is the decision produced from a single health sample.
|
||||
type rollbackOutcome int
|
||||
|
||||
const (
|
||||
// rollbackContinue: still starting and before the deadline, keep polling.
|
||||
rollbackContinue rollbackOutcome = iota
|
||||
// rollbackHealthy: the new container is healthy, accept the update.
|
||||
rollbackHealthy
|
||||
// rollbackTrigger: the new container failed the health gate, roll back.
|
||||
rollbackTrigger
|
||||
)
|
||||
|
||||
// gateResult is the terminal outcome of healthGate. It is a tri-state because a
|
||||
// shutdown mid-gate must be distinguished from a genuine failure: only a real
|
||||
// unhealthy/not-running/deadline outcome may roll back.
|
||||
type gateResult int
|
||||
|
||||
const (
|
||||
// gateHealthy: the new container became healthy in time, accept the update.
|
||||
gateHealthy gateResult = iota
|
||||
// gateRollback: the new container failed the gate, roll back to the old image.
|
||||
gateRollback
|
||||
// gateAborted: the service base context was cancelled (server shutdown) while
|
||||
// the gate was open. The new container is left running as-is; no rollback and
|
||||
// no failure event, since we never observed an actual failure.
|
||||
gateAborted
|
||||
)
|
||||
|
||||
// imageIDReference matches a content-addressable image id carried verbatim in a
|
||||
// container's Config.Image when it was started from a bare id (e.g.
|
||||
// "sha256:ab12…"). Such an id is not a tag and cannot be re-tagged, so it must
|
||||
// not enable the health gate. A full bare hex id (no algorithm prefix) is
|
||||
// already rejected by reference.ParseNormalizedNamed; this catches the
|
||||
// algorithm-prefixed digest form, which otherwise parses as a bogus tag.
|
||||
var imageIDReference = regexp.MustCompile(`^[a-z0-9]+:[0-9a-f]{64}$`)
|
||||
|
||||
// containerHealth is the minimal health signal the gate polls. It is built from
|
||||
// a container inspect but kept independent of the Docker SDK so the decision
|
||||
// logic can be unit-tested without a Docker engine.
|
||||
type containerHealth struct {
|
||||
// Running reports whether the container is currently running. A container that
|
||||
// has exited within the window is a failed update.
|
||||
Running bool
|
||||
// Status is the Docker health status: "starting", "healthy", "unhealthy" or
|
||||
// "none"/"" when there is no healthcheck.
|
||||
Status string
|
||||
}
|
||||
|
||||
// decideRollback is a pure decision over a single health sample taken at time
|
||||
// `now`, given the rollback `deadline`. It is the testable core of the health
|
||||
// gate: callers feed it successive samples and act on the outcome.
|
||||
//
|
||||
// Rules, in order:
|
||||
// - healthy -> accept the update (rollbackHealthy);
|
||||
// - unhealthy -> roll back immediately (Docker only reports unhealthy after the
|
||||
// configured retries fail, so it is a definitive signal);
|
||||
// - not running (crashed/exited post-start) -> roll back;
|
||||
// - still starting past the deadline -> roll back (never became healthy in time);
|
||||
// - otherwise keep waiting (rollbackContinue).
|
||||
func decideRollback(h containerHealth, now, deadline time.Time) rollbackOutcome {
|
||||
switch h.Status {
|
||||
case string(container.Healthy):
|
||||
return rollbackHealthy
|
||||
case string(container.Unhealthy):
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
if !h.Running {
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
if !now.Before(deadline) {
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
return rollbackContinue
|
||||
}
|
||||
|
||||
// effectiveRollbackDeadline derives the health-gate deadline from the gate start
|
||||
// time, the configured rollback timeout, and the container's healthcheck
|
||||
// start_period. While a container is within its start_period Docker keeps
|
||||
// reporting "starting" (it never reports unhealthy yet), so a start_period
|
||||
// longer than the rollback timeout would otherwise trip a premature rollback
|
||||
// while the container is initializing normally. The deadline is therefore the
|
||||
// later of (start + timeout) and (start + start_period + buffer).
|
||||
func effectiveRollbackDeadline(start time.Time, timeout, startPeriod time.Duration) time.Time {
|
||||
window := timeout
|
||||
if startPeriod > 0 {
|
||||
if d := startPeriod + startPeriodBuffer; d > window {
|
||||
window = d
|
||||
}
|
||||
}
|
||||
|
||||
return start.Add(window)
|
||||
}
|
||||
|
||||
// inspectErrorTolerated reports whether the health gate should keep polling after
|
||||
// `consecutive` back-to-back inspect failures rather than declaring the update
|
||||
// failed. Up to maxConsecutiveInspectErrors transient errors are tolerated; the
|
||||
// counter is reset by the caller on any successful inspect.
|
||||
func inspectErrorTolerated(consecutive int) bool {
|
||||
return consecutive <= maxConsecutiveInspectErrors
|
||||
}
|
||||
|
||||
// hasHealthGate reports whether a container's healthcheck config yields a usable
|
||||
// health signal. A nil config, an empty test, or an explicit {"NONE"} disable all
|
||||
// mean Docker never reports healthy/unhealthy, so there is nothing to gate on.
|
||||
func hasHealthGate(hc *container.HealthConfig) bool {
|
||||
if hc == nil || len(hc.Test) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return hc.Test[0] != "NONE"
|
||||
}
|
||||
|
||||
// isTagReference reports whether ref is a proper tag reference that the health
|
||||
// gate can roll back. Rolling back re-tags the previous image id onto ref via
|
||||
// ImageTag, which Docker rejects for a digest-pinned reference (repo@sha256:…)
|
||||
// with "refusing to create a tag with a digest reference", and which is
|
||||
// meaningless for a bare image id. Such containers are detected here so the gate
|
||||
// is skipped instead of silently no-op'ing.
|
||||
func isTagReference(ref string) bool {
|
||||
if ref == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Algorithm-prefixed image id (e.g. "sha256:<64 hex>"): a bare id, not a tag.
|
||||
if imageIDReference.MatchString(ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
named, err := reference.ParseNormalizedNamed(ref)
|
||||
if err != nil {
|
||||
// Unparseable (e.g. a full bare hex image id): not a usable tag target.
|
||||
return false
|
||||
}
|
||||
|
||||
// A digest-pinned reference (with or without a tag) cannot be re-tagged.
|
||||
if _, ok := named.(reference.Canonical); ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// healthGate polls the new container's health until it becomes healthy, fails, or
|
||||
// the rollback window elapses, returning the terminal gateResult.
|
||||
//
|
||||
// The polling context is derived from the service base context, so a server
|
||||
// shutdown ends the wait. A shutdown is reported as gateAborted (leave the new
|
||||
// container in place, do not roll back): we never observed a real failure, and a
|
||||
// rollback derived from the cancelled context would itself fail and emit a
|
||||
// misleading "rollback failed" event on every shutdown during a gate window.
|
||||
//
|
||||
// Transient inspect failures (a brief Docker API blip) are tolerated: the gate
|
||||
// keeps polling and only declares the update failed after more than
|
||||
// maxConsecutiveInspectErrors consecutive failures, resetting on any success.
|
||||
//
|
||||
// Scheduling note (known limitation): this poll runs inside the sequential update
|
||||
// tick, so N unhealthy standalone containers with rollback enabled can each hold
|
||||
// the tick for up to their rollback window, delaying other containers/endpoints
|
||||
// in the same tick. The overlap guard in update() still prevents ticks from
|
||||
// piling up; this is accepted rather than re-architected (no per-container
|
||||
// goroutine) to keep the update path simple and ordered.
|
||||
func (s *Service) healthGate(cli *dockerclient.Client, containerID string, timeout, startPeriod time.Duration) gateResult {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultRollbackTimeout
|
||||
}
|
||||
|
||||
deadline := effectiveRollbackDeadline(time.Now(), timeout, startPeriod)
|
||||
|
||||
ctx, cancel := context.WithDeadline(s.baseCtx, deadline.Add(rollbackGateBuffer))
|
||||
defer cancel()
|
||||
|
||||
consecutiveErrors := 0
|
||||
for {
|
||||
inspect, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
// Server shutdown cancelled the base context: abort without rolling back.
|
||||
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(s.baseCtx.Err(), context.Canceled) {
|
||||
log.Debug().Str("container_id", containerID).
|
||||
Msg("auto-update: health gate aborted due to shutdown")
|
||||
|
||||
return gateAborted
|
||||
}
|
||||
|
||||
consecutiveErrors++
|
||||
if !inspectErrorTolerated(consecutiveErrors) {
|
||||
// Repeated failures: the container vanished or the engine is
|
||||
// unreachable, treat as a failed update so the rollback can restore
|
||||
// the previous image.
|
||||
log.Warn().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
|
||||
Msg("auto-update: health gate inspect failed repeatedly, treating as unhealthy")
|
||||
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
// Tolerate a transient blip: keep polling until the data resolves or the
|
||||
// deadline passes.
|
||||
log.Debug().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
|
||||
Msg("auto-update: health gate inspect failed, retrying (transient)")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.gateDeadlineResult()
|
||||
case <-time.After(rollbackPollInterval):
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
consecutiveErrors = 0
|
||||
|
||||
h := containerHealth{Running: inspect.State != nil && inspect.State.Running}
|
||||
if inspect.State != nil && inspect.State.Health != nil {
|
||||
h.Status = string(inspect.State.Health.Status)
|
||||
}
|
||||
|
||||
switch decideRollback(h, time.Now(), deadline) {
|
||||
case rollbackHealthy:
|
||||
return gateHealthy
|
||||
case rollbackTrigger:
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.gateDeadlineResult()
|
||||
case <-time.After(rollbackPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gateDeadlineResult maps a context-done gate exit to its outcome: a base-context
|
||||
// cancellation (shutdown) aborts without rolling back, while a plain deadline
|
||||
// (the container never became healthy in time) rolls back.
|
||||
func (s *Service) gateDeadlineResult() gateResult {
|
||||
if errors.Is(s.baseCtx.Err(), context.Canceled) {
|
||||
log.Debug().Msg("auto-update: health gate aborted due to shutdown")
|
||||
|
||||
return gateAborted
|
||||
}
|
||||
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
// rollback restores the previous image after a failed health-gated update. It
|
||||
// re-tags the old image id back onto the container's original reference (which
|
||||
// the new image currently owns), then recreates the new container on that
|
||||
// reference with no pull, so Recreate's full config-preservation + create-failure
|
||||
// rollback is reused while resolving to the old image.
|
||||
//
|
||||
// Side effect: re-tagging moves `originalRef` from the new image to the old one,
|
||||
// leaving the new (unhealthy) image untagged/dangling. It is intentionally left
|
||||
// in place (not pruned) so an operator can inspect why the update failed.
|
||||
//
|
||||
// If any step fails the previous image cannot be safely restored, so the
|
||||
// (unhealthy) new container is left running rather than destroyed, and a loud
|
||||
// failure notification is emitted.
|
||||
func (s *Service) rollback(cli *dockerclient.Client, endpoint *portainer.Endpoint, newContainerID, oldImageID, originalRef, containerName string) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: new container failed the health gate, rolling back to the previous image")
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Re-tag the previous image id back onto the original reference. After the
|
||||
// update the reference points at the new image; this moves it back so Recreate
|
||||
// resolves the old image without a pull.
|
||||
if err := cli.ImageTag(ctx, oldImageID, originalRef); err != nil {
|
||||
log.Error().Err(err).Str("image_id", oldImageID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rollback failed to re-tag the previous image, leaving the unhealthy container in place")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
|
||||
Image: originalRef, Message: "rollback failed: could not re-tag previous image", Err: err,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.containerService.Recreate(ctx, endpoint, newContainerID, false, "", ""); err != nil {
|
||||
log.Error().Err(err).Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rollback recreate failed, leaving the unhealthy container in place")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
|
||||
Image: originalRef, Message: "rollback failed: could not recreate on previous image", Err: err,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rolled back to the previous image after a failed update")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventRollback, EndpointID: endpointID, ContainerID: newContainerID,
|
||||
Image: originalRef, Message: "rolled back to previous image after failed health check",
|
||||
})
|
||||
|
||||
// Record the failed target so the next poll does not immediately re-pull the
|
||||
// same broken image and roll back again (the update->rollback loop). Recorded
|
||||
// only after a SUCCESSFUL rollback; a changed remote digest later lifts the skip.
|
||||
s.recordRolledBack(endpoint, containerName, originalRef)
|
||||
}
|
||||
333
api/containerautomation/rollback_test.go
Normal file
333
api/containerautomation/rollback_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
func TestDecideRollback(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
deadline := now.Add(120 * time.Second)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
health containerHealth
|
||||
at time.Time
|
||||
want rollbackOutcome
|
||||
}{
|
||||
{
|
||||
name: "healthy within the window accepts the update",
|
||||
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackHealthy,
|
||||
},
|
||||
{
|
||||
name: "unhealthy triggers an immediate rollback",
|
||||
health: containerHealth{Running: true, Status: string(container.Unhealthy)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "still starting before the deadline keeps polling",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackContinue,
|
||||
},
|
||||
{
|
||||
name: "still starting past the deadline rolls back",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: now.Add(121 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "starting exactly at the deadline rolls back",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: deadline,
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "exited container rolls back even before the deadline",
|
||||
health: containerHealth{Running: false, Status: string(container.Starting)},
|
||||
at: now.Add(5 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "unhealthy wins over a stopped state",
|
||||
health: containerHealth{Running: false, Status: string(container.Unhealthy)},
|
||||
at: now.Add(5 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "healthy wins even past the deadline",
|
||||
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
||||
at: now.Add(200 * time.Second),
|
||||
want: rollbackHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := decideRollback(tt.health, tt.at, deadline); got != tt.want {
|
||||
t.Errorf("decideRollback() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRollbackDeadline(t *testing.T) {
|
||||
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
timeout := 120 * time.Second
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startPeriod time.Duration
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "no start period uses the timeout",
|
||||
startPeriod: 0,
|
||||
want: start.Add(timeout),
|
||||
},
|
||||
{
|
||||
name: "start period shorter than timeout uses the timeout",
|
||||
startPeriod: 30 * time.Second,
|
||||
want: start.Add(timeout),
|
||||
},
|
||||
{
|
||||
name: "start period longer than timeout extends to start period plus buffer",
|
||||
startPeriod: 300 * time.Second,
|
||||
want: start.Add(300*time.Second + startPeriodBuffer),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := effectiveRollbackDeadline(start, timeout, tt.startPeriod); !got.Equal(tt.want) {
|
||||
t.Errorf("effectiveRollbackDeadline() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideRollbackWithLongStartPeriod proves the F3 fix end to end at the
|
||||
// decision layer: with a start_period longer than the configured rollback
|
||||
// timeout, the start-period-aware deadline keeps a still-starting container
|
||||
// alive while it is within the start period, and only rolls back after it.
|
||||
func TestDecideRollbackWithLongStartPeriod(t *testing.T) {
|
||||
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
timeout := 60 * time.Second
|
||||
startPeriod := 300 * time.Second
|
||||
|
||||
deadline := effectiveRollbackDeadline(start, timeout, startPeriod)
|
||||
|
||||
starting := containerHealth{Running: true, Status: string(container.Starting)}
|
||||
|
||||
// Past the bare timeout but still within the start period: keep waiting.
|
||||
if got := decideRollback(starting, start.Add(120*time.Second), deadline); got != rollbackContinue {
|
||||
t.Errorf("within start_period: decideRollback() = %v, want rollbackContinue", got)
|
||||
}
|
||||
|
||||
// After the start period (plus buffer): roll back.
|
||||
if got := decideRollback(starting, start.Add(330*time.Second), deadline); got != rollbackTrigger {
|
||||
t.Errorf("after start_period: decideRollback() = %v, want rollbackTrigger", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectErrorTolerated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
consecutive int
|
||||
want bool
|
||||
}{
|
||||
{name: "first transient error is tolerated", consecutive: 1, want: true},
|
||||
{name: "second consecutive error is tolerated", consecutive: 2, want: true},
|
||||
{name: "at the threshold is still tolerated", consecutive: maxConsecutiveInspectErrors, want: true},
|
||||
{name: "beyond the threshold is a failure", consecutive: maxConsecutiveInspectErrors + 1, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := inspectErrorTolerated(tt.consecutive); got != tt.want {
|
||||
t.Errorf("inspectErrorTolerated(%d) = %v, want %v", tt.consecutive, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTagReference(t *testing.T) {
|
||||
const digest = "sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{name: "tagged reference is rollbackable", ref: "nginx:1.21", want: true},
|
||||
{name: "untagged reference (implicit latest) is rollbackable", ref: "nginx", want: true},
|
||||
{name: "fully-qualified tagged reference is rollbackable", ref: "registry.example.com/team/app:v2", want: true},
|
||||
{name: "digest-pinned reference cannot be re-tagged", ref: "nginx@" + digest, want: false},
|
||||
{name: "tagged-and-digest-pinned reference cannot be re-tagged", ref: "nginx:1.21@" + digest, want: false},
|
||||
{name: "algorithm-prefixed bare image id cannot be re-tagged", ref: digest, want: false},
|
||||
{name: "full bare hex image id cannot be re-tagged", ref: "02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", want: false},
|
||||
{name: "empty reference is not rollbackable", ref: "", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isTagReference(tt.ref); got != tt.want {
|
||||
t.Errorf("isTagReference(%q) = %v, want %v", tt.ref, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipUnnamedForRollback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rollback bool
|
||||
cName string
|
||||
want bool
|
||||
}{
|
||||
{name: "rollback on, unnamed -> skip (unsuppressable loop otherwise)", rollback: true, cName: "", want: true},
|
||||
{name: "rollback on, named -> proceed (guard can key it)", rollback: true, cName: "web", want: false},
|
||||
{name: "rollback off, unnamed -> proceed (no rollback to loop)", rollback: false, cName: "", want: false},
|
||||
{name: "rollback off, named -> proceed", rollback: false, cName: "web", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := skipUnnamedForRollback(tt.rollback, tt.cName); got != tt.want {
|
||||
t.Errorf("skipUnnamedForRollback(%v, %q) = %v, want %v", tt.rollback, tt.cName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasHealthGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hc *container.HealthConfig
|
||||
want bool
|
||||
}{
|
||||
{name: "nil config has no gate", hc: nil, want: false},
|
||||
{name: "empty test inherits, no usable gate", hc: &container.HealthConfig{Test: nil}, want: false},
|
||||
{name: "explicit NONE disables the gate", hc: &container.HealthConfig{Test: []string{"NONE"}}, want: false},
|
||||
{name: "CMD healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD", "curl", "-f", "localhost"}}, want: true},
|
||||
{name: "CMD-SHELL healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD-SHELL", "exit 0"}}, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasHealthGate(tt.hc); got != tt.want {
|
||||
t.Errorf("hasHealthGate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRollbackTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "valid duration", raw: "90s", want: 90 * time.Second},
|
||||
{name: "empty falls back to default", raw: "", want: defaultRollbackTimeout},
|
||||
{name: "unparseable falls back to default", raw: "nope", want: defaultRollbackTimeout},
|
||||
{name: "zero falls back to default", raw: "0s", want: defaultRollbackTimeout},
|
||||
{name: "negative falls back to default", raw: "-5s", want: defaultRollbackTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseRollbackTimeout(tt.raw); got != tt.want {
|
||||
t.Errorf("parseRollbackTimeout(%q) = %v, want %v", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideUpdateSkip(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
cooldown := 24 * time.Hour
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rec rolledBackTarget
|
||||
currentDigest string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same digest within cooldown is skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "new digest within cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:bbb",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "same digest after cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-25 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown recorded digest is skipped conservatively within cooldown",
|
||||
rec: rolledBackTarget{digest: "", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unknown recorded digest after cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "", at: now.Add(-25 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := decideUpdateSkip(tt.rec, tt.currentDigest, now, cooldown); got != tt.want {
|
||||
t.Errorf("decideUpdateSkip() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneRolledBack locks in the F8 fix: pruneRolledBack must iterate the
|
||||
// rolledBack map and drop only entries whose cooldown has fully elapsed, keeping
|
||||
// fresh ones, so the map cannot grow unbounded. It mirrors TestPruneRetries. The
|
||||
// boundary is inclusive (production uses now.Sub(at) >= updateRollbackCooldown),
|
||||
// so an entry exactly at the cooldown is pruned.
|
||||
func TestPruneRolledBack(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
s := &Service{rolledBack: map[string]rolledBackTarget{
|
||||
// within the cooldown -> retained
|
||||
"fresh": {ref: "img:fresh", digest: "sha256:aaa", at: now.Add(-updateRollbackCooldown / 2)},
|
||||
// exactly at the cooldown boundary -> pruned (>= is inclusive)
|
||||
"edge": {ref: "img:edge", digest: "sha256:bbb", at: now.Add(-updateRollbackCooldown)},
|
||||
// long past the cooldown -> pruned
|
||||
"stale": {ref: "img:stale", digest: "sha256:ccc", at: now.Add(-2 * updateRollbackCooldown)},
|
||||
}}
|
||||
|
||||
s.pruneRolledBack(now)
|
||||
|
||||
if _, ok := s.rolledBack["fresh"]; !ok {
|
||||
t.Error("entry within the rollback cooldown should be retained")
|
||||
}
|
||||
if _, ok := s.rolledBack["edge"]; ok {
|
||||
t.Error("entry exactly at the cooldown boundary should be pruned")
|
||||
}
|
||||
if _, ok := s.rolledBack["stale"]; ok {
|
||||
t.Error("entry past the rollback cooldown should be pruned")
|
||||
}
|
||||
if len(s.rolledBack) != 1 {
|
||||
t.Errorf("rolledBack length = %d, want 1", len(s.rolledBack))
|
||||
}
|
||||
}
|
||||
314
api/containerautomation/service.go
Normal file
314
api/containerautomation/service.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Package containerautomation provides native container automation that runs as
|
||||
// background scheduler jobs. M1 implements auto-heal (restarting Docker
|
||||
// containers whose healthcheck reports "unhealthy", replacing the
|
||||
// willfarrell/autoheal sidecar); M4 adds auto-update (periodically detecting
|
||||
// outdated images and applying updates, replacing the containrrr/watchtower
|
||||
// sidecar).
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultCheckInterval is used when the configured auto-heal interval is empty or unparseable.
|
||||
defaultCheckInterval = 30 * time.Second
|
||||
// defaultPollInterval is used when the configured auto-update interval is empty or unparseable.
|
||||
// It is conservative (hours) to stay within registry rate limits; the image-status cache is
|
||||
// short-lived (keyed by the local imageID), so each poll re-checks the remote digest.
|
||||
defaultPollInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
// Service manages the lifecycle of the auto-heal and auto-update scheduler jobs
|
||||
// and keeps the per-container retry state in memory across ticks.
|
||||
type Service struct {
|
||||
// baseCtx is the application shutdown context. It is the base for every
|
||||
// per-operation timeout context, so a server shutdown cancels in-flight heal
|
||||
// restarts and update redeploys instead of letting them run detached.
|
||||
baseCtx context.Context
|
||||
|
||||
scheduler *scheduler.Scheduler
|
||||
dataStore dataservices.DataStore
|
||||
clientFactory *dockerclient.ClientFactory
|
||||
|
||||
// Dependencies used by the auto-update job (M4).
|
||||
digestClient *images.DigestClient
|
||||
containerService *docker.ContainerService
|
||||
stackDeployer deployments.StackDeployer
|
||||
|
||||
// notifier receives automation events (update/rollback/failure/heal). The
|
||||
// default is logNotifier; the field is the seam external senders plug into.
|
||||
notifier Notifier
|
||||
|
||||
mu sync.Mutex
|
||||
healJobID string
|
||||
updateJobID string
|
||||
|
||||
// running guards against overlapping heal ticks.
|
||||
running atomic.Bool
|
||||
// updateRunning guards against overlapping update ticks.
|
||||
updateRunning atomic.Bool
|
||||
|
||||
retryMu sync.Mutex
|
||||
retries map[string]retryState
|
||||
|
||||
// rolledBackMu guards rolledBack.
|
||||
rolledBackMu sync.Mutex
|
||||
// rolledBack records standalone containers whose update was rolled back, keyed
|
||||
// by endpoint+name, so the auto-update job does not immediately re-pull the
|
||||
// same failed image and roll back again on the next tick (the update->rollback
|
||||
// loop guard, mirroring the auto-heal retries map).
|
||||
//
|
||||
// This state is in-memory only and is NOT persisted: after a Portainer restart
|
||||
// the map is empty, so at most one extra update->rollback cycle per restart is
|
||||
// possible before the guard re-records the failed target. Persisting it would
|
||||
// require a datastore schema (key + digest + timestamp) and is intentionally out
|
||||
// of scope here; the cooldown-bounded single extra cycle is an acceptable
|
||||
// trade-off against that complexity.
|
||||
rolledBack map[string]rolledBackTarget
|
||||
}
|
||||
|
||||
// NewService creates a new container automation service. Call Start to schedule
|
||||
// the jobs according to the persisted settings. baseCtx is the application
|
||||
// shutdown context: it bounds the job operation contexts so a shutdown cancels
|
||||
// any in-flight heal/update. The stackDeployer and containerService are used by
|
||||
// the auto-update job; they may be nil only in tests that do not exercise
|
||||
// auto-update.
|
||||
func NewService(
|
||||
baseCtx context.Context,
|
||||
scheduler *scheduler.Scheduler,
|
||||
dataStore dataservices.DataStore,
|
||||
clientFactory *dockerclient.ClientFactory,
|
||||
containerService *docker.ContainerService,
|
||||
stackDeployer deployments.StackDeployer,
|
||||
) *Service {
|
||||
if baseCtx == nil {
|
||||
baseCtx = context.Background()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
baseCtx: baseCtx,
|
||||
scheduler: scheduler,
|
||||
dataStore: dataStore,
|
||||
clientFactory: clientFactory,
|
||||
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(dataStore), clientFactory),
|
||||
containerService: containerService,
|
||||
stackDeployer: stackDeployer,
|
||||
notifier: logNotifier{},
|
||||
retries: make(map[string]retryState),
|
||||
rolledBack: make(map[string]rolledBackTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// AutomationEnabledForEndpoint reports whether container automation (auto-heal and
|
||||
// auto-update) should run for an environment. It is the per-endpoint opt-out (M5)
|
||||
// layered on top of the global switch: an environment participates unless it has
|
||||
// been explicitly disabled. The zero value (not disabled) preserves the
|
||||
// pre-M5 behavior for every existing environment.
|
||||
func AutomationEnabledForEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint != nil && !endpoint.ContainerAutomationDisabled
|
||||
}
|
||||
|
||||
// Start schedules the enabled jobs according to the persisted settings.
|
||||
func (s *Service) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.start()
|
||||
}
|
||||
|
||||
// Reload re-applies the current settings: it stops the running jobs and starts
|
||||
// fresh ones with the new intervals, or leaves them stopped if disabled. It is
|
||||
// safe to call after a settings update.
|
||||
//
|
||||
// Note: stopping a job unschedules future ticks but does not interrupt a tick
|
||||
// already in progress. An in-flight heal/update pass runs to completion on its
|
||||
// original (pre-reload) context and is only cancelled by a server shutdown (via
|
||||
// baseCtx); the new interval takes effect from the next scheduled tick. The
|
||||
// overlap guards (running/updateRunning) and the per-map mutexes keep this safe
|
||||
// against data races, so this is a deliberate behavioural nuance, not a bug.
|
||||
func (s *Service) Reload() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.stop()
|
||||
s.start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// start (re)schedules the enabled jobs from settings. Caller must hold s.mu.
|
||||
func (s *Service) start() {
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("container automation: unable to read settings, jobs not scheduled")
|
||||
return
|
||||
}
|
||||
|
||||
s.startHeal(settings)
|
||||
s.startUpdate(settings)
|
||||
}
|
||||
|
||||
// startHeal schedules the auto-heal job if enabled. Caller must hold s.mu.
|
||||
func (s *Service) startHeal(settings *portainer.Settings) {
|
||||
if s.healJobID != "" {
|
||||
return
|
||||
}
|
||||
|
||||
autoHeal := settings.ContainerAutomation.AutoHeal
|
||||
if !autoHeal.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(autoHeal.CheckInterval)
|
||||
if err != nil || interval <= 0 {
|
||||
log.Warn().Str("interval", autoHeal.CheckInterval).Dur("default", defaultCheckInterval).
|
||||
Msg("auto-heal: invalid check interval, falling back to default")
|
||||
interval = defaultCheckInterval
|
||||
}
|
||||
|
||||
s.healJobID = s.scheduler.StartJobEvery(interval, s.heal)
|
||||
log.Info().Dur("interval", interval).Msg("auto-heal: job scheduled")
|
||||
}
|
||||
|
||||
// startUpdate schedules the auto-update job if enabled. Caller must hold s.mu.
|
||||
func (s *Service) startUpdate(settings *portainer.Settings) {
|
||||
if s.updateJobID != "" {
|
||||
return
|
||||
}
|
||||
|
||||
autoUpdate := settings.ContainerAutomation.AutoUpdate
|
||||
if !autoUpdate.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(autoUpdate.PollInterval)
|
||||
if err != nil || interval <= 0 {
|
||||
log.Warn().Str("interval", autoUpdate.PollInterval).Dur("default", defaultPollInterval).
|
||||
Msg("auto-update: invalid poll interval, falling back to default")
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
|
||||
s.updateJobID = s.scheduler.StartJobEvery(interval, s.update)
|
||||
log.Info().Dur("interval", interval).Msg("auto-update: job scheduled")
|
||||
}
|
||||
|
||||
// stop cancels the running jobs, if any. Caller must hold s.mu.
|
||||
func (s *Service) stop() {
|
||||
if s.healJobID != "" {
|
||||
if err := s.scheduler.StopJob(s.healJobID); err != nil {
|
||||
log.Warn().Err(err).Msg("auto-heal: could not stop the job")
|
||||
}
|
||||
|
||||
s.healJobID = ""
|
||||
}
|
||||
|
||||
if s.updateJobID != "" {
|
||||
if err := s.scheduler.StopJob(s.updateJobID); err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: could not stop the job")
|
||||
}
|
||||
|
||||
s.updateJobID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// scope returns the configured auto-heal scope, defaulting to "labeled".
|
||||
func (s *Service) scope() string {
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return ScopeLabeled
|
||||
}
|
||||
|
||||
if settings.ContainerAutomation.AutoHeal.Scope == ScopeAll {
|
||||
return ScopeAll
|
||||
}
|
||||
|
||||
return ScopeLabeled
|
||||
}
|
||||
|
||||
// getRetry returns the retry state for a container (zero value if unknown).
|
||||
func (s *Service) getRetry(containerID string) retryState {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
return s.retries[containerID]
|
||||
}
|
||||
|
||||
// setRetry stores the retry state for a container.
|
||||
func (s *Service) setRetry(containerID string, state retryState) {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
s.retries[containerID] = state
|
||||
}
|
||||
|
||||
// getRolledBack returns the rolled-back target for a key and whether it exists.
|
||||
func (s *Service) getRolledBack(key string) (rolledBackTarget, bool) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
rec, ok := s.rolledBack[key]
|
||||
|
||||
return rec, ok
|
||||
}
|
||||
|
||||
// setRolledBack records a rolled-back target for a key.
|
||||
func (s *Service) setRolledBack(key string, rec rolledBackTarget) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
s.rolledBack[key] = rec
|
||||
}
|
||||
|
||||
// clearRolledBack drops the rolled-back record for a key (cooldown elapsed or a
|
||||
// new upstream image lifted the skip).
|
||||
func (s *Service) clearRolledBack(key string) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
delete(s.rolledBack, key)
|
||||
}
|
||||
|
||||
// pruneRolledBack drops rolled-back records whose cooldown has fully elapsed, so
|
||||
// the map cannot grow unbounded. It mirrors pruneRetries.
|
||||
func (s *Service) pruneRolledBack(now time.Time) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
for key, rec := range s.rolledBack {
|
||||
if now.Sub(rec.at) >= updateRollbackCooldown {
|
||||
delete(s.rolledBack, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneRetries drops retry state for containers whose retry window has fully
|
||||
// elapsed since their last restart. A container is kept regardless of whether it
|
||||
// appeared in the current tick: one that briefly leaves the unhealthy filter
|
||||
// (e.g. while "starting" right after a restart) must not lose its accounting, or
|
||||
// the cooldown / max-retries storm guard would be defeated. A container that has
|
||||
// recovered and stayed quiet for longer than the window is cleaned up (fresh
|
||||
// budget next incident, no unbounded growth).
|
||||
func (s *Service) pruneRetries(now time.Time) {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
for id, state := range s.retries {
|
||||
if now.Sub(state.lastRestart) >= retryWindow {
|
||||
delete(s.retries, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,19 @@ import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
// Not allowed in FIPS mode
|
||||
"golang.org/x/crypto/argon2" //nolint:depguard
|
||||
"golang.org/x/crypto/scrypt" //nolint:depguard
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,20 +25,32 @@ const (
|
||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||
|
||||
aesGcmFIPSHeader = "FIPS-AES256-GCM"
|
||||
aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm
|
||||
|
||||
// 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
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
argon2MemoryCost = 12 * 1024
|
||||
argon2TimeCost = 3
|
||||
argon2Threads = 1
|
||||
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
|
||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
if err := aesEncryptGCM(input, output, passphrase); err != nil {
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
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 {
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -40,14 +58,35 @@ func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
|
||||
// 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) {
|
||||
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
|
||||
inputReader := bufio.NewReader(input)
|
||||
header, err := inputReader.Peek(len(aesGcmHeader))
|
||||
header, err := inputReader.Peek(len(aesGcmFIPSHeader))
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
||||
|
||||
_, err = output.Write(ciphertext)
|
||||
if err != nil {
|
||||
if _, err := output.Write(ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce.Increment()
|
||||
if err := nonce.Increment(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -183,7 +223,7 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -197,7 +237,134 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
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
|
||||
@@ -207,11 +374,9 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// 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
|
||||
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
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,3 +393,18 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
@@ -17,201 +28,422 @@ func randBytes(n int) []byte {
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
|
||||
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) {
|
||||
const passphrase = "passphrase"
|
||||
|
||||
tmpdir := t.TempDir()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
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)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
t.Parallel()
|
||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
content := randBytes(500)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
tmpdir := t.TempDir()
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(500)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
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"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
|
||||
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)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
tmpdir := t.TempDir()
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1024 * 50)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(1024 * 50)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(""))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
tmpdir := t.TempDir()
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1034)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(1034)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
_, err = decrypt(encryptedFileReader, []byte("garbage"))
|
||||
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) {
|
||||
t.Parallel()
|
||||
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
|
||||
}
|
||||
|
||||
hash := libcrypto.HashFromBytes([]byte(message))
|
||||
hash := libcrypto.InsecureHashFromBytes([]byte(message))
|
||||
|
||||
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
||||
if err != nil {
|
||||
|
||||
23
api/crypto/ecdsa_test.go
Normal file
23
api/crypto/ecdsa_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
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
|
||||
|
||||
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.
|
||||
type Service struct{}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestService_Hash(t *testing.T) {
|
||||
var s = &Service{}
|
||||
t.Parallel()
|
||||
var s = Service{}
|
||||
|
||||
type args struct {
|
||||
hash string
|
||||
@@ -51,3 +54,12 @@ func TestService_Hash(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Service{}
|
||||
|
||||
hash, err := s.Hash("Passw0rd!")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Nonce struct {
|
||||
@@ -15,7 +16,7 @@ func NewNonce(size int) *Nonce {
|
||||
}
|
||||
|
||||
// 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
|
||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
||||
func NewRandomNonce(size int) (*Nonce, error) {
|
||||
@@ -45,7 +46,7 @@ func (n *Nonce) Value() []byte {
|
||||
|
||||
func (n *Nonce) Increment() error {
|
||||
// Start incrementing from the least significant byte
|
||||
for i := len(n.val) - 1; i >= 0; i-- {
|
||||
for i := range slices.Backward(n.val) {
|
||||
// Increment the current byte
|
||||
n.val[i]++
|
||||
|
||||
|
||||
@@ -4,11 +4,32 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
||||
func CreateTLSConfiguration() *tls.Config {
|
||||
return &tls.Config{
|
||||
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||
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,
|
||||
CipherSuites: []uint16{
|
||||
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_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
},
|
||||
InsecureSkipVerify: insecureSkipVerify, //nolint:forbidigo
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from memory.
|
||||
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := CreateTLSConfiguration()
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Certificates = []tls.Certificate{certificate}
|
||||
}
|
||||
|
||||
if !skipServerVerification {
|
||||
if !skipServerVerification || fipsEnabled {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
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
|
||||
// loaded from disk.
|
||||
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := CreateTLSConfiguration()
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
|
||||
}
|
||||
|
||||
if certPath != "" && keyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if !skipServerVerification && caCertPath != "" {
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
if !tlsConfig.InsecureSkipVerify && config.TLSCACertPath != "" { //nolint:forbidigo
|
||||
caCert, err := os.ReadFile(config.TLSCACertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
return config, nil
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
92
api/crypto/tls_test.go
Normal file
92
api/crypto/tls_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateTLSConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
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) {
|
||||
t.Parallel()
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,6 +23,9 @@ import (
|
||||
const (
|
||||
DatabaseFileName = "portainer.db"
|
||||
EncryptedDatabaseFileName = "portainer.edb"
|
||||
|
||||
txMaxSize = 65536
|
||||
compactedSuffix = ".compacted"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -35,6 +40,9 @@ type DbConnection struct {
|
||||
InitialMmapSize int
|
||||
EncryptionKey []byte
|
||||
isEncrypted bool
|
||||
Compact bool
|
||||
|
||||
gcm cipher.AEAD
|
||||
|
||||
*bolt.DB
|
||||
}
|
||||
@@ -71,8 +79,28 @@ func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
||||
return file.Size(), nil
|
||||
}
|
||||
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) error {
|
||||
connection.isEncrypted = flag
|
||||
|
||||
if !flag || connection.EncryptionKey == nil {
|
||||
connection.gcm = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(connection.EncryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating AES cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating GCM cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
connection.gcm = gcm
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return true if the database is encrypted
|
||||
@@ -96,7 +124,9 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
|
||||
// If we have a loaded encryption key, always set encrypted
|
||||
if connection.EncryptionKey != nil {
|
||||
connection.SetEncrypted(true)
|
||||
if err := connection.SetEncrypted(true); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check for portainer.db
|
||||
@@ -132,13 +162,8 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
func (connection *DbConnection) Open() error {
|
||||
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
||||
|
||||
// Now we open the db
|
||||
databasePath := connection.GetDatabaseFilePath()
|
||||
|
||||
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
||||
Timeout: 1 * time.Second,
|
||||
InitialMmapSize: connection.InitialMmapSize,
|
||||
})
|
||||
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,6 +172,24 @@ func (connection *DbConnection) Open() error {
|
||||
db.MaxBatchDelay = connection.MaxBatchDelay
|
||||
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
|
||||
}
|
||||
|
||||
@@ -244,6 +287,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 {
|
||||
if !connection.isEncrypted {
|
||||
return nil
|
||||
@@ -386,3 +455,48 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package boltdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test the specific scenarios mentioned in NeedsEncryptionMigration
|
||||
|
||||
// i.e.
|
||||
@@ -92,24 +96,42 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
|
||||
if tc.dbname == "both" {
|
||||
// Special case. If portainer.db and portainer.edb exist.
|
||||
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
||||
dbFile1 := filesystem.JoinPaths(connection.Path, DatabaseFileName)
|
||||
f, _ := os.Create(dbFile1)
|
||||
f.Close()
|
||||
defer os.Remove(dbFile1)
|
||||
|
||||
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
||||
err := f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
err := os.Remove(dbFile1)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName)
|
||||
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 != "" {
|
||||
dbFile := path.Join(connection.Path, tc.dbname)
|
||||
dbFile := filesystem.JoinPaths(connection.Path, tc.dbname)
|
||||
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 {
|
||||
connection.EncryptionKey = []byte("secret")
|
||||
connection.EncryptionKey = secretToEncryptionKey("secret")
|
||||
}
|
||||
|
||||
result, err := connection.NeedsEncryptionMigration()
|
||||
@@ -119,3 +141,112 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{EncryptionKey: []byte("bad")}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn.gcm)
|
||||
|
||||
err = conn.SetEncrypted(false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
|
||||
// MarshalObject must return plaintext after encryption is disabled
|
||||
data, err := conn.MarshalObject("hello")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", string(data))
|
||||
}
|
||||
|
||||
func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{
|
||||
Path: t.TempDir(),
|
||||
EncryptionKey: []byte("bad"),
|
||||
}
|
||||
|
||||
result, err := conn.NeedsEncryptionMigration()
|
||||
require.Error(t, err)
|
||||
require.False(t, result)
|
||||
}
|
||||
|
||||
func TestDBCompaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
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 (
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
@@ -37,7 +38,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
||||
if err != nil {
|
||||
return []byte("{}"), err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer logs.CloseAndLogErr(connection)
|
||||
|
||||
backup := make(map[string]any)
|
||||
if metadata {
|
||||
|
||||
@@ -2,10 +2,7 @@ package boltdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/segmentio/encoding/json"
|
||||
@@ -30,29 +27,29 @@ func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if connection.getEncryptionKey() == nil {
|
||||
if connection.gcm == nil {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
return encrypt(buf.Bytes(), connection.getEncryptionKey())
|
||||
return encrypt(buf.Bytes(), connection.gcm), nil
|
||||
}
|
||||
|
||||
// UnmarshalObject decodes an object from binary data
|
||||
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
var err error
|
||||
if connection.getEncryptionKey() != nil {
|
||||
data, err = decrypt(data, connection.getEncryptionKey())
|
||||
if connection.gcm != nil {
|
||||
data, err = decrypt(data, connection.gcm)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
@@ -61,50 +58,23 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// mmm, don't have a KMS .... aes GCM seems the most likely from
|
||||
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
||||
|
||||
func encrypt(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
|
||||
func encrypt(plaintext []byte, gcm cipher.AEAD) []byte {
|
||||
return gcm.Seal(nil, nil, plaintext, nil)
|
||||
}
|
||||
|
||||
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
||||
func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) {
|
||||
if string(encrypted) == "false" {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(encrypted) < gcm.Overhead() {
|
||||
return encrypted, errEncryptedStringTooShort
|
||||
}
|
||||
|
||||
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
|
||||
plaintextByte, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
|
||||
plaintextByte, err := gcm.Open(nil, nil, encrypted, nil)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||
}
|
||||
|
||||
return plaintextByte, err
|
||||
return plaintextByte, nil
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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://kubernetes.github.io/ingress-nginx","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="},"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"
|
||||
)
|
||||
|
||||
@@ -20,9 +27,10 @@ func secretToEncryptionKey(passphrase string) []byte {
|
||||
}
|
||||
|
||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
uuid := uuid.Must(uuid.NewV4())
|
||||
uuid := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
object any
|
||||
@@ -87,13 +95,14 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -128,13 +137,14 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
var object string
|
||||
err := conn.UnmarshalObject(test.object, &object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.expected, object)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -161,17 +171,221 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
var object []byte
|
||||
err = conn.UnmarshalObject(data, &object)
|
||||
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.object, object)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NonceSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 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
|
||||
}
|
||||
|
||||
passphrase := make([]byte, 32)
|
||||
_, err := io.ReadFull(rand.Reader, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
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 = decrypt(enc, gcm)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, plain, dec)
|
||||
|
||||
enc = encrypt(plain, gcm)
|
||||
|
||||
dec, err = decryptOldFn(enc, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, plain, dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt([]byte("false"), gcm)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("false"), result)
|
||||
}
|
||||
|
||||
func TestDecrypt_ShortDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
short := []byte("short")
|
||||
result, err := decrypt(short, gcm)
|
||||
require.ErrorIs(t, err, errEncryptedStringTooShort)
|
||||
require.Equal(t, short, result)
|
||||
}
|
||||
|
||||
func TestDecrypt_CorruptDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 30 bytes passes the length check but fails authentication
|
||||
corrupted := make([]byte, 30)
|
||||
_, err = io.ReadFull(rand.Reader, corrupted)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt(corrupted, gcm)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, corrupted, result)
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused.
|
||||
func BenchmarkEncryptCachedCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call.
|
||||
func BenchmarkEncryptPerCallCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
_ = gcm.Seal(nil, nil, data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use.
|
||||
func BenchmarkEncryptCachedCipherParallel(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
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)
|
||||
}
|
||||
|
||||
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.gcm != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.gcm); 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 {
|
||||
data, err := tx.conn.MarshalObject(object)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,10 +2,12 @@ package boltdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testBucketName = "test-bucket"
|
||||
@@ -17,70 +19,56 @@ type testStruct struct {
|
||||
}
|
||||
|
||||
func TestTxs(t *testing.T) {
|
||||
conn := DbConnection{
|
||||
Path: t.TempDir(),
|
||||
}
|
||||
t.Parallel()
|
||||
conn := DbConnection{Path: t.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Error propagation
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return errors.New("this is an error")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("an error was expected, got nil instead")
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
// Create an object
|
||||
newObj := testStruct{
|
||||
Key: "key",
|
||||
Value: "value",
|
||||
}
|
||||
newObj := testStruct{Key: "key", Value: "value"}
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
err = tx.SetServiceName(testBucketName)
|
||||
if err != nil {
|
||||
if err := tx.SetServiceName(testBucketName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := testStruct{}
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Update an object
|
||||
updatedObj := testStruct{
|
||||
Key: "updated-key",
|
||||
Value: "updated-value",
|
||||
}
|
||||
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
@@ -90,16 +78,12 @@ func TestTxs(t *testing.T) {
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if !dataservices.IsErrObjectNotFound(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.True(t, dataservices.IsErrObjectNotFound(err))
|
||||
|
||||
// Get next identifier
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
@@ -112,15 +96,65 @@ func TestTxs(t *testing.T) {
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to write in a read transaction
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("an error was expected, got nil instead")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func BenchmarkGetAll(b *testing.B) {
|
||||
const endpointBucket = "endpoints"
|
||||
const n = 10000
|
||||
|
||||
conn := DbConnection{Path: b.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
require.NoError(b, err)
|
||||
b.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(b, err)
|
||||
})
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
if err := tx.SetServiceName(endpointBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 1; i <= n; i++ {
|
||||
ep := portainer.Endpoint{
|
||||
ID: portainer.EndpointID(i),
|
||||
Name: "env-" + strconv.Itoa(i),
|
||||
Type: portainer.DockerEnvironment,
|
||||
URL: "tcp://192.168.1." + strconv.Itoa(i%254+1) + ":2375",
|
||||
PublicURL: "https://env-" + strconv.Itoa(i) + ".example.com",
|
||||
GroupID: portainer.EndpointGroupID(i%10 + 1),
|
||||
TagIDs: []portainer.TagID{portainer.TagID(i%5 + 1), portainer.TagID(i%3 + 1)},
|
||||
LastCheckInDate: int64(i) * 1000,
|
||||
EdgeID: "edge-" + strconv.Itoa(i),
|
||||
}
|
||||
|
||||
if err := tx.CreateObjectWithId(endpointBucket, i, &ep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(b, err)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
var collection []portainer.Endpoint
|
||||
|
||||
if err := conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAll(endpointBucket, new(portainer.Endpoint), dataservices.AppendFn(&collection))
|
||||
}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
)
|
||||
|
||||
// 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" {
|
||||
return &boltdb.DbConnection{
|
||||
Path: storePath,
|
||||
EncryptionKey: encryptionKey,
|
||||
Compact: compact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
25
api/database/database_test.go
Normal file
25
api/database/database_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
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) {
|
||||
t.Parallel()
|
||||
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)
|
||||
}
|
||||
131
api/dataservices/allowlist/allowlist.go
Normal file
131
api/dataservices/allowlist/allowlist.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package allowlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
)
|
||||
|
||||
const (
|
||||
BucketName = "allowlist"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
baseService dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return service.baseService.BucketName()
|
||||
}
|
||||
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
baseService: dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]{
|
||||
Bucket: BucketName,
|
||||
Connection: connection,
|
||||
},
|
||||
}
|
||||
|
||||
err = service.populateCache()
|
||||
|
||||
return service, err
|
||||
}
|
||||
|
||||
func (service *Service) populateCache() error {
|
||||
allowListKeys := []portainer.AllowListKey{portainer.AllowListSSRF}
|
||||
cache, err := lru.New(len(allowListKeys))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, k := range allowListKeys {
|
||||
allowList, err := service.baseService.Read(k)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
allowList = &portainer.AllowList{
|
||||
ID: k,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAllowList := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsedAllowList.Mode = allowList.Mode
|
||||
|
||||
cache.Add(k, &parsedAllowList)
|
||||
}
|
||||
|
||||
service.cache = cache
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) *ServiceTx {
|
||||
return &ServiceTx{
|
||||
baseService: service.baseService.Tx(tx),
|
||||
cache: service.cache,
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
|
||||
var result *portainer.AllowList
|
||||
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).Read(id)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) ReadAll() ([]portainer.AllowList, error) {
|
||||
var result []portainer.AllowList
|
||||
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).ReadAll()
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
|
||||
allowListAny, ok := service.cache.Get(id)
|
||||
if ok {
|
||||
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
var result *portainer.ParsedAllowList
|
||||
err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).ReadParsed(id)
|
||||
return err
|
||||
})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (service *Service) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
|
||||
return service.baseService.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).Update(id, allowList)
|
||||
})
|
||||
}
|
||||
89
api/dataservices/allowlist/allowlist_test.go
Normal file
89
api/dataservices/allowlist/allowlist_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package allowlist_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllowListReadEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, expected))
|
||||
|
||||
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
got, err := ds.AllowList().ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []portainer.AllowList{}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllAfterUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &expected))
|
||||
|
||||
got, err := ds.AllowList().ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []portainer.AllowList{expected}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadParsedAfterUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}))
|
||||
|
||||
expected := &portainer.ParsedAllowList{
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Nets: []*net.IPNet{},
|
||||
Hosts: map[string]bool{
|
||||
"example.com": true,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ds.AllowList().ReadParsed(portainer.AllowListSSRF)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
77
api/dataservices/allowlist/tx.go
Normal file
77
api/dataservices/allowlist/tx.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package allowlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
baseService dataservices.BaseDataServiceTx[portainer.AllowList, portainer.AllowListKey]
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func (service *ServiceTx) BucketName() string {
|
||||
return service.baseService.BucketName()
|
||||
}
|
||||
|
||||
func (service *ServiceTx) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
|
||||
allowListAny, ok := service.cache.Get(id)
|
||||
if ok {
|
||||
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
allowList, err := service.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsed.Mode = allowList.Mode
|
||||
service.cache.Add(id, &parsed)
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
|
||||
allowList, err := service.baseService.Read(id)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
allowList = &portainer.AllowList{
|
||||
ID: id,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) ReadAll() ([]portainer.AllowList, error) {
|
||||
allowLists, err := service.baseService.ReadAll()
|
||||
if err != nil && !dataservices.IsErrObjectNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return allowLists, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
|
||||
if err := service.baseService.Update(id, allowList); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsed.Mode = allowList.Mode
|
||||
service.cache.Add(id, &parsed)
|
||||
return nil
|
||||
}
|
||||
92
api/dataservices/allowlist/tx_test.go
Normal file
92
api/dataservices/allowlist/tx_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package allowlist_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 TestAllowListReadTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
var got *portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
|
||||
return err
|
||||
}))
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllEmptyTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
var got []portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().ReadAll()
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, []portainer.AllowList{}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllAfterUpdateTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.AllowList().Update(portainer.AllowListSSRF, &expected)
|
||||
}))
|
||||
|
||||
var got []portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().ReadAll()
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, []portainer.AllowList{expected}, got)
|
||||
}
|
||||
|
||||
func TestAllowListUpdateTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.AllowList().Update(portainer.AllowListSSRF, expected)
|
||||
}))
|
||||
|
||||
var got *portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
@@ -2,13 +2,10 @@ package apikeyrepository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
@@ -40,19 +37,10 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.APIKey{},
|
||||
func(obj any) (any, error) {
|
||||
record, ok := obj.(*portainer.APIKey)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
|
||||
}
|
||||
|
||||
if record.UserID == userID {
|
||||
result = append(result, *record)
|
||||
}
|
||||
|
||||
return &portainer.APIKey{}, nil
|
||||
})
|
||||
dataservices.FilterFn(&result, func(record portainer.APIKey) bool {
|
||||
return record.UserID == userID
|
||||
}),
|
||||
)
|
||||
|
||||
return result, err
|
||||
}
|
||||
@@ -60,27 +48,18 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
||||
// GetAPIKeyByDigest returns the API key for the associated digest.
|
||||
// Note: there is a 1-to-1 mapping of api-key and digest
|
||||
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
|
||||
var k *portainer.APIKey
|
||||
stop := errors.New("ok")
|
||||
var found portainer.APIKey
|
||||
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.APIKey{},
|
||||
func(obj any) (any, error) {
|
||||
key, ok := obj.(*portainer.APIKey)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
|
||||
}
|
||||
if key.Digest == digest {
|
||||
k = key
|
||||
return nil, stop
|
||||
}
|
||||
dataservices.FirstFn(&found, func(key portainer.APIKey) bool {
|
||||
return key.Digest == digest
|
||||
}),
|
||||
)
|
||||
|
||||
return &portainer.APIKey{}, nil
|
||||
})
|
||||
|
||||
if errors.Is(err, stop) {
|
||||
return k, nil
|
||||
if errors.Is(err, dataservices.ErrStop) {
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||
Create(element *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
|
||||
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)
|
||||
|
||||
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
collection, err = service.Tx(tx).ReadAll()
|
||||
collection, err = service.Tx(tx).ReadAll(predicates...)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
92
api/dataservices/base_test.go
Normal file
92
api/dataservices/base_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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) {
|
||||
t.Parallel()
|
||||
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,13 +28,38 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
||||
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)
|
||||
|
||||
if len(predicates) == 0 {
|
||||
return collection, service.Tx.GetAll(
|
||||
service.Bucket,
|
||||
new(T),
|
||||
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),
|
||||
AppendFn(&collection),
|
||||
FilterFn(&collection, filterFn),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,3 +72,13 @@ func (service BaseDataServiceTx[T, I]) Delete(ID I) error {
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user