Compare commits
1413 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d97839cd90 | |||
| 1f8217d99b | |||
| cd83c525c8 | |||
| 88e6654a6c | |||
|
|
10a9ab9001 | ||
|
|
ea961a70c3 | ||
|
|
8a98a8a512 | ||
|
|
2301db6f85 | ||
|
|
aab9e22819 | ||
|
|
403489591e | ||
|
|
8034eaaabb | ||
|
|
44f4557005 | ||
|
|
d92a892ece | ||
|
|
a7487dadd7 | ||
|
|
aa6eef94fd | ||
|
|
f31a1b031e | ||
|
|
2de7e57e08 | ||
|
|
05795ee286 | ||
|
|
f48e9b3ba6 | ||
|
|
4d685c129c | ||
|
|
2d359067f6 | ||
|
|
e25d6b3c91 | ||
|
|
08723cf623 | ||
|
|
c80a40023c | ||
|
|
21d451085f | ||
|
|
02247b852a | ||
|
|
66a7e82c43 | ||
|
|
44b8b91540 | ||
|
|
bda2295f71 | ||
|
|
b17b1f00b3 | ||
|
|
3f52c56d1e | ||
|
|
041524d663 | ||
|
|
a3d46d7597 | ||
|
|
0a0b25d886 | ||
|
|
a22c5a2e65 | ||
|
|
e68940619c | ||
|
|
ba1beb08f4 | ||
|
|
4010e61165 | ||
|
|
5f3ff3d2e2 | ||
|
|
2bccbec3ae | ||
|
|
996ac343ee | ||
|
|
413a52605d | ||
|
|
a980acd936 | ||
|
|
1fa156c7e4 | ||
|
|
b772be7131 | ||
|
|
63351343ba | ||
|
|
01766ff4e2 | ||
|
|
d79d043696 | ||
|
|
7f90c8acb2 | ||
|
|
ea824bde1c | ||
|
|
46a955d1ff | ||
|
|
56ef60060b | ||
|
|
014b4ba5f6 | ||
|
|
9c09ca3f56 | ||
|
|
6bdb7ed9c7 | ||
|
|
8debbe699e | ||
|
|
fb1a38b840 | ||
|
|
225a05e1a5 | ||
|
|
8512d76ce7 | ||
|
|
ed7dcb0081 | ||
|
|
71bd44f9a0 | ||
|
|
aed4dd8766 | ||
|
|
331979b887 | ||
|
|
c25bd51857 | ||
|
|
ae283ef0f5 | ||
|
|
f4eb8e57fb | ||
|
|
b8ab05e367 | ||
|
|
797e0cd250 | ||
|
|
8c6c089a65 | ||
|
|
bcf2bfbf20 | ||
|
|
89f37985bd | ||
|
|
aaf9dea44a | ||
|
|
e1e7d0e85a | ||
|
|
d98522a2b6 | ||
|
|
6576754256 | ||
|
|
69df253e41 | ||
|
|
6877142ba4 | ||
|
|
b3c603644a | ||
|
|
5c93da6fc7 | ||
|
|
543f3fd495 | ||
|
|
2573ccaf19 | ||
|
|
3585d67743 | ||
|
|
458dae9827 | ||
|
|
19f4dbeb4b | ||
|
|
b6c9da470e | ||
|
|
f728f8a923 | ||
|
|
6f212fbb5a | ||
|
|
02a51c0a21 | ||
|
|
933667bdf1 | ||
|
|
3dd167d770 | ||
|
|
16276cc0d6 | ||
|
|
4d98bda282 | ||
|
|
7f75f52aaf | ||
|
|
106c50a51d | ||
|
|
86653c237d | ||
|
|
9a62046607 | ||
|
|
6a6a41bf05 | ||
|
|
151c90b2a9 | ||
|
|
02df78a9ec | ||
|
|
864fce89d2 | ||
|
|
149a9e3cf5 | ||
|
|
a2622dbe3d | ||
|
|
ad90bb4b68 | ||
|
|
16cb2d5b75 | ||
|
|
1178cf6d24 | ||
|
|
458ca52101 | ||
|
|
bfa86327ce | ||
|
|
82cbe791dc | ||
|
|
09adcc47d2 | ||
|
|
441f142089 | ||
|
|
b2865e5a2d | ||
|
|
e6a61d940d | ||
|
|
26fda6e084 | ||
|
|
4beb514232 | ||
|
|
c7e2db2559 | ||
|
|
5f0b036e34 | ||
|
|
c48accfb51 | ||
|
|
c28a98082b | ||
|
|
6c4e705f97 | ||
|
|
37f634fd31 | ||
|
|
36ca627f2e | ||
|
|
98552f9b99 | ||
|
|
39dd708768 | ||
|
|
8a7f760d3c | ||
|
|
a3aedba3f1 | ||
|
|
35f93b6b11 | ||
|
|
69c6c70845 | ||
|
|
bfc5516467 | ||
|
|
54591f946a | ||
|
|
d09e688738 | ||
|
|
aa428e375c | ||
|
|
323554256c | ||
|
|
877749a730 | ||
|
|
dce6af9d24 | ||
|
|
57efbac055 | ||
|
|
77ea273a14 | ||
|
|
a334431107 | ||
|
|
5a569d4ed1 | ||
|
|
dded18c94d | ||
|
|
4bd93294d4 | ||
|
|
6c35ea4fd9 | ||
|
|
da7ef7c2a1 | ||
|
|
11a96bb462 | ||
|
|
7efe25f13d | ||
|
|
d29cba2946 | ||
|
|
583adee780 | ||
|
|
4bb2daca9d | ||
|
|
04801dedd5 | ||
|
|
bbb2cdd9f7 | ||
|
|
0e780482eb | ||
|
|
fa365952f6 | ||
|
|
612bd1e0ce | ||
|
|
086a8f9847 | ||
|
|
fac8be3c56 | ||
|
|
8eda2e40a4 | ||
|
|
d2b39167a8 | ||
|
|
579c5e24fc | ||
|
|
8a44eb421f | ||
|
|
ea0a3f1f12 | ||
|
|
e0dd374a3b | ||
|
|
914808bea0 | ||
|
|
7512133cb7 | ||
|
|
33e0ab7b68 | ||
|
|
b8f8b0f674 | ||
|
|
55c873f360 | ||
|
|
528bb594af | ||
|
|
8f5470ad37 | ||
|
|
ce6502e7b6 | ||
|
|
91e7940947 | ||
|
|
22762480d8 | ||
|
|
8ce7965030 | ||
|
|
fa42b8394e | ||
|
|
022d5ad3e7 | ||
|
|
6d2cc80aa1 | ||
|
|
24360de912 | ||
|
|
b91e041a13 | ||
|
|
f793c00981 | ||
|
|
d78c75f7c0 | ||
|
|
ebbe6ad2b1 | ||
|
|
54130f2380 | ||
|
|
2286f71dd5 | ||
|
|
096d57953c | ||
|
|
059a528adf | ||
|
|
07fadb3461 | ||
|
|
34731a41be | ||
|
|
b73cf0d017 | ||
|
|
86c8e646fa | ||
|
|
ac726862f2 | ||
|
|
1f7fa31b1a | ||
|
|
165b44deec | ||
|
|
25879b92f4 | ||
|
|
776f0641fd | ||
|
|
bd2b7d2b32 | ||
|
|
d114f3646d | ||
|
|
fccf857bce | ||
|
|
cf1a723a88 | ||
|
|
6d9a2f6fca | ||
|
|
76bf87f472 | ||
|
|
d976b1c65a | ||
|
|
d63d70c080 | ||
|
|
69a3adad21 | ||
|
|
03fbeab01b | ||
|
|
13247246fc | ||
|
|
815e7aed04 | ||
|
|
af0ebf6d51 | ||
|
|
2207ff4518 | ||
|
|
43c5b23c83 | ||
|
|
026f1ed16c | ||
|
|
27f425987c | ||
|
|
886bf2a4f3 | ||
|
|
c85eb0e647 | ||
|
|
10c26c53a3 | ||
|
|
a197fea4ba | ||
|
|
023427115f | ||
|
|
8a8e01522d | ||
|
|
f392ec4caa | ||
|
|
159c6af0dd | ||
|
|
0ff1ec4c04 | ||
|
|
987efff021 | ||
|
|
773bb92aa3 | ||
|
|
41f8b03b79 | ||
|
|
555b322ce5 | ||
|
|
432ea9fdad | ||
|
|
0f27403e36 | ||
|
|
e760d04aa6 | ||
|
|
e5603fe6d9 | ||
|
|
0520ff4e05 | ||
|
|
66bb63ea18 | ||
|
|
592226943f | ||
|
|
7953ef9a4f | ||
|
|
92518a9a74 | ||
|
|
f142385313 | ||
|
|
366e88069e | ||
|
|
059ef13b99 | ||
|
|
be5c615201 | ||
|
|
2cbe205186 | ||
|
|
4e5a3025d7 | ||
|
|
b236cb64f9 | ||
|
|
931c2c0ac3 | ||
|
|
3ef94e3b41 | ||
|
|
c7dc688858 | ||
|
|
e049c82d9c | ||
|
|
e331b4380f | ||
|
|
a737f197c7 | ||
|
|
7a31b6a55e | ||
|
|
3f468733cb | ||
|
|
4f883e9707 | ||
|
|
e100e3084c | ||
|
|
5f1224ba5f | ||
|
|
e22d12ae53 | ||
|
|
7a02f8e402 | ||
|
|
79285e896b | ||
|
|
96891ab314 | ||
|
|
8644cc9e07 | ||
|
|
386baae8de | ||
|
|
8819e2a195 | ||
|
|
8b7e126c1c | ||
|
|
ce0c5b2b78 | ||
|
|
9ec950289e | ||
|
|
2e9bb84bae | ||
|
|
73797fe7cd | ||
|
|
2b9617c7f8 | ||
|
|
83e7b4ae10 | ||
|
|
7c81802bb6 | ||
|
|
646a0d761f | ||
|
|
65d762d1cc | ||
|
|
6bff61dd7a | ||
|
|
6c0ed713ac | ||
|
|
805d83e974 | ||
|
|
79f6c54675 | ||
|
|
c3c6555558 | ||
|
|
545cd0ece5 | ||
|
|
aa47089005 | ||
|
|
41379391b6 | ||
|
|
b6cd1c7287 | ||
|
|
5285f39e6b | ||
|
|
0301be8d9e | ||
|
|
82f2c1e1f9 | ||
|
|
b26db065d6 | ||
|
|
6b031f4780 | ||
|
|
dbd1d4d2e8 | ||
|
|
aec904c94e | ||
|
|
a01cb5e6c5 | ||
|
|
51fb504a2e | ||
|
|
194c416f29 | ||
|
|
ec99579451 | ||
|
|
f94fe37f90 | ||
|
|
10d7d8dded | ||
|
|
2e7b2e9281 | ||
|
|
c68ca8195b | ||
|
|
d4c1ab636f | ||
|
|
20f115fdac | ||
|
|
86c528e510 | ||
|
|
29cc7e1a71 | ||
|
|
9e02dd8c7e | ||
|
|
cb8c3b0002 | ||
|
|
bc716490af | ||
|
|
660e24bff5 | ||
|
|
65dd9d4998 | ||
|
|
b36d06e5cd | ||
|
|
a9ecd52de8 | ||
|
|
a6fa98926c | ||
|
|
e99d975589 | ||
|
|
9e20ddebeb | ||
|
|
8167d32c75 | ||
|
|
bfc833cdc5 | ||
|
|
265867ea2d | ||
|
|
2c50058ed2 | ||
|
|
b93b2c20b0 | ||
|
|
e5e31fe871 | ||
|
|
bcddcef2b9 | ||
|
|
ebc7758c1f | ||
|
|
6980693cc5 | ||
|
|
78dfe797f0 | ||
|
|
1de9938fd4 | ||
|
|
c377159121 | ||
|
|
fa2a5b6469 | ||
|
|
ed98625ae9 | ||
|
|
87d973b894 | ||
|
|
30ea23384a | ||
|
|
e27d70f254 | ||
|
|
996609293e | ||
|
|
b032f2a540 | ||
|
|
0e6951213b | ||
|
|
55bb0b9107 | ||
|
|
1b0c31121a | ||
|
|
34939ad5f4 | ||
|
|
57524e90f1 | ||
|
|
7ae2bb3c10 | ||
|
|
e81664ec7b | ||
|
|
82dea3fdda | ||
|
|
a1926edb94 | ||
|
|
c137b22fa9 | ||
|
|
765b95080b | ||
|
|
28c639e48b | ||
|
|
b1ea5424b9 | ||
|
|
6e4a3b5127 | ||
|
|
b7f0b37c10 | ||
|
|
53e26e56b1 | ||
|
|
7e1a84b400 | ||
|
|
5b2d6a3e00 | ||
|
|
678777deef | ||
|
|
a9b53cb6b5 | ||
|
|
2be7a6f1a5 | ||
|
|
4a8d6556c7 | ||
|
|
a157d24741 | ||
|
|
5f37fae2dc | ||
|
|
51e2962718 | ||
|
|
9211ded55c | ||
|
|
5ca5acd76a | ||
|
|
6c9cc6915e | ||
|
|
13ed94f5b7 | ||
|
|
3c96c9447c | ||
|
|
5456a30e14 | ||
|
|
2e8fbad641 | ||
|
|
a978a5ecc1 | ||
|
|
9f94da307d | ||
|
|
09f7f052b4 | ||
|
|
c2f91f63df | ||
|
|
87d8366311 | ||
|
|
6e6ebbc981 | ||
|
|
d23b354cbc | ||
|
|
6620cc1d18 | ||
|
|
d2f668c880 | ||
|
|
ff48b5b0f6 | ||
|
|
c1892b6398 | ||
|
|
6e59f129c1 | ||
|
|
73b26d318a | ||
|
|
fc1240060e | ||
|
|
2a37f3f1c3 | ||
|
|
923033af13 | ||
|
|
f0090d8ac7 | ||
|
|
ec05ed930f | ||
|
|
39d9bc5e5f | ||
|
|
4b2eb56a54 | ||
|
|
062e57b1c7 | ||
|
|
7ef85d2a14 | ||
|
|
cbdb42093b | ||
|
|
e069333f58 | ||
|
|
960b552ba9 | ||
|
|
5faf1bbc54 | ||
|
|
4453e1a834 | ||
|
|
e489be545d | ||
|
|
65ae238f17 | ||
|
|
91be87f293 | ||
|
|
5e3cb5468c | ||
|
|
90a12ccd80 | ||
|
|
01e4828ab6 | ||
|
|
f20ecb7460 | ||
|
|
4d059468af | ||
|
|
d51f20b1db | ||
|
|
c8edaf8368 | ||
|
|
22a9c49672 | ||
|
|
fab9a2a100 | ||
|
|
5edc3d1041 | ||
|
|
3b9752a2bb | ||
|
|
c17d65711b | ||
|
|
57716748c6 | ||
|
|
b842a66be2 | ||
|
|
45fae19e5b | ||
|
|
7ccc32744a | ||
|
|
190079b7f3 | ||
|
|
51d04bb0d1 | ||
|
|
4e4bbe190d | ||
|
|
e777149ea4 | ||
|
|
53d6ee0011 | ||
|
|
619c9f304e | ||
|
|
4c3865308b | ||
|
|
7eb547faa5 | ||
|
|
21f71e5cdc | ||
|
|
aac55508db | ||
|
|
91df5a38bd | ||
|
|
aaf05691c0 | ||
|
|
5e985452a4 | ||
|
|
1a5cda1a93 | ||
|
|
6e1377c2b0 | ||
|
|
9650eb8a46 | ||
|
|
df2524586e | ||
|
|
31626146c2 | ||
|
|
ed79677232 | ||
|
|
e824c3a2e2 | ||
|
|
eb1becd4b8 | ||
|
|
8cb450ffe0 | ||
|
|
42cacd47c2 | ||
|
|
f1b67559b1 | ||
|
|
96a453c015 | ||
|
|
22ae656783 | ||
|
|
f5027efaf8 | ||
|
|
44d9d4f453 | ||
|
|
310335e6ab | ||
|
|
51e0bc0e53 | ||
|
|
d871b38c8d | ||
|
|
2fbe500d36 | ||
|
|
3fe4f4fb7f | ||
|
|
2aef5fdde5 | ||
|
|
af531f2894 | ||
|
|
42b029af47 | ||
|
|
c9e4c0e5f3 | ||
|
|
8826f7c6e9 | ||
|
|
5b6fb295b5 | ||
|
|
7f29fd815a | ||
|
|
de988a1bfb | ||
|
|
af3cdda11b | ||
|
|
d37ecf1fa3 | ||
|
|
dfb4cff2c7 | ||
|
|
4c3cfd3dd7 | ||
|
|
decd9ae90b | ||
|
|
3c0258cbb5 | ||
|
|
652bfb1642 | ||
|
|
fe19a1d47b | ||
|
|
8032f11e05 | ||
|
|
01032808ea | ||
|
|
5cf8d53087 | ||
|
|
2595721f45 | ||
|
|
ab012fec87 | ||
|
|
9d1817c718 | ||
|
|
dea4525cec | ||
|
|
4880b091a2 | ||
|
|
422e17bc27 | ||
|
|
e21cc1ef63 | ||
|
|
bb7c40a384 | ||
|
|
c106da46f8 | ||
|
|
9e69bf2256 | ||
|
|
b6e32cf62f | ||
|
|
fea7b3f0f9 | ||
|
|
2f6d410258 | ||
|
|
8c93cd18c8 | ||
|
|
3469dfa14e | ||
|
|
efa324d16a | ||
|
|
92b7df3da7 | ||
|
|
5a3d0bba9e | ||
|
|
cade5051a8 | ||
|
|
ae25738fac | ||
|
|
d90516fdc7 | ||
|
|
91528a6af7 | ||
|
|
1f85564111 | ||
|
|
059ed2d9ea | ||
|
|
1412f10703 | ||
|
|
6245f71556 | ||
|
|
b57273c8f5 | ||
|
|
91b962803e | ||
|
|
7511355f06 | ||
|
|
c628296f27 | ||
|
|
d58552be82 | ||
|
|
5e76fb3468 | ||
|
|
61e2c93a40 | ||
|
|
cc67f37a28 | ||
|
|
60652b4056 | ||
|
|
e57a12dac9 | ||
|
|
01769bfae8 | ||
|
|
d43ad2168c | ||
|
|
57e1a030db | ||
|
|
c7af23a6eb | ||
|
|
e6ea5a4d3d | ||
|
|
cc2cc48332 | ||
|
|
ff13bafc55 | ||
|
|
868a1f9239 | ||
|
|
2110aed6c7 | ||
|
|
f147e45da3 | ||
|
|
230f450bc2 | ||
|
|
e97623bbaf | ||
|
|
c4c0ca0b43 | ||
|
|
ba102368d3 | ||
|
|
f3706ee3ea | ||
|
|
7065a7e228 | ||
|
|
d56e6c6683 | ||
|
|
c455fb07f2 | ||
|
|
3f6addab2a | ||
|
|
53de9c1c27 | ||
|
|
62ecfe75b6 | ||
|
|
dc1bf28529 | ||
|
|
f8c98d141c | ||
|
|
fcdd2ea974 | ||
|
|
579f1c6bd9 | ||
|
|
d9b97cc465 | ||
|
|
caeedc62a5 | ||
|
|
b6a4f494cd | ||
|
|
835a9d2068 | ||
|
|
639b83c26c | ||
|
|
e037d25097 | ||
|
|
187e1df52c | ||
|
|
a93cee66bb | ||
|
|
467ffbe7cc | ||
|
|
4712ab6bf1 | ||
|
|
fd931b3e37 | ||
|
|
d93829cf07 | ||
|
|
46eb63a952 | ||
|
|
776ccb8b21 | ||
|
|
ef94958cd5 | ||
|
|
9150081892 | ||
|
|
62310a5a09 | ||
|
|
e54faaf56d | ||
|
|
43f1298653 | ||
|
|
57a2a56e32 | ||
|
|
aa0c46539c | ||
|
|
ef43d7c615 | ||
|
|
7a21d64333 | ||
|
|
11062e4d6a | ||
|
|
e3adb49c50 | ||
|
|
95e754c06b | ||
|
|
a6a1df7556 | ||
|
|
41032137ea | ||
|
|
d736bb3435 | ||
|
|
ce51735d7a | ||
|
|
f81731e2d9 | ||
|
|
d92f9305dc | ||
|
|
975364553b | ||
|
|
4b5ffd768f | ||
|
|
11006f4ef3 | ||
|
|
8bc36fb69a | ||
|
|
7458ddfaf4 | ||
|
|
21ca8102fa | ||
|
|
1082b31367 | ||
|
|
e5e28d16a5 | ||
|
|
ee24670e39 | ||
|
|
e4e7917eb3 | ||
|
|
f6c2a77ef2 | ||
|
|
e33d3b0048 | ||
|
|
74dc687c1d | ||
|
|
ea79e3de41 | ||
|
|
4df32aa3df | ||
|
|
50aec4962b | ||
|
|
54fead51c6 | ||
|
|
0e99df94b7 | ||
|
|
e0facd1ee5 | ||
|
|
d92e4a8aff | ||
|
|
a8bfb47292 | ||
|
|
790d1776e7 | ||
|
|
ed911869e1 | ||
|
|
1a77a34726 | ||
|
|
fc518884f9 | ||
|
|
b03a6b9004 | ||
|
|
591770bf63 | ||
|
|
84eca8312d | ||
|
|
606d8701ed | ||
|
|
cf74c364fb | ||
|
|
58966f5a8a | ||
|
|
dc354827a3 | ||
|
|
324188e239 | ||
|
|
c3adea9750 | ||
|
|
07c56a4032 | ||
|
|
594fd4ffa3 | ||
|
|
d8376ed890 | ||
|
|
d89433fe3d | ||
|
|
ef243fbf2f | ||
|
|
c42fa5f412 | ||
|
|
3b289cfc24 | ||
|
|
017db5edae | ||
|
|
b0c54edc78 | ||
|
|
4b2554db86 | ||
|
|
d4566ea157 | ||
|
|
4b5c96dc22 | ||
|
|
672c6a9ded | ||
|
|
d544eb76a8 | ||
|
|
febc7a49b6 | ||
|
|
61b7859f12 | ||
|
|
81c1530fce | ||
|
|
ec38ef216e | ||
|
|
5e7adf690c | ||
|
|
5304d71a96 | ||
|
|
a2e7fca1ba | ||
|
|
ae5411b096 | ||
|
|
0f8ceee2cd | ||
|
|
6657ce2520 | ||
|
|
2ca53dd660 | ||
|
|
03802d2f7d | ||
|
|
eb76c80193 | ||
|
|
18b78d6299 | ||
|
|
0d8856b0ce | ||
|
|
e9bb483fb8 | ||
|
|
44d6d5784c | ||
|
|
4553d1750c | ||
|
|
22f27b8bd8 | ||
|
|
593a495381 | ||
|
|
90ce417f01 | ||
|
|
2f624404fd | ||
|
|
291a501295 | ||
|
|
56a6960bf6 | ||
|
|
a7c4e92822 | ||
|
|
0c6fe20cfb | ||
|
|
f99698eb76 | ||
|
|
5e0a9ffa53 | ||
|
|
bc545cd76f | ||
|
|
79e54641f4 | ||
|
|
ce4598b0af | ||
|
|
c5cae8a568 | ||
|
|
5f0f24d596 | ||
|
|
df3d88caa1 | ||
|
|
c358c37324 | ||
|
|
724a00e9c1 | ||
|
|
2103956b4f | ||
|
|
853c5a22a4 | ||
|
|
a25c37e83c | ||
|
|
ec23c70ab3 | ||
|
|
141b614c36 | ||
|
|
57a16ab0f7 | ||
|
|
4485482c1f | ||
|
|
c16757adf1 | ||
|
|
360fd52355 | ||
|
|
f36f685aab | ||
|
|
182e1be469 | ||
|
|
98afefd092 | ||
|
|
24ebffd3ff | ||
|
|
f5fa27a358 | ||
|
|
773e52f02e | ||
|
|
c26768fe90 | ||
|
|
21fcd3b3dd | ||
|
|
0956a75aca | ||
|
|
bfde430c64 | ||
|
|
78a021bc14 | ||
|
|
d2b2ed2d66 | ||
|
|
16d6000e9a | ||
|
|
078c67a6ee | ||
|
|
0fe2aa1cd9 | ||
|
|
0c98833f8d | ||
|
|
3322bc0cda | ||
|
|
1a0ca2c6ae | ||
|
|
2de6a8e3aa | ||
|
|
82d61eaf05 | ||
|
|
c482708a26 | ||
|
|
b2f90e32b0 | ||
|
|
02aa47db9e | ||
|
|
c76b40fc00 | ||
|
|
0ffde9b022 | ||
|
|
e565d8cfa3 | ||
|
|
756db53bab | ||
|
|
351abeb3b4 | ||
|
|
7f00abfad8 | ||
|
|
7a470ff949 | ||
|
|
3b61c0cd55 | ||
|
|
8b8f57896d | ||
|
|
5068b6e059 | ||
|
|
127b710d62 | ||
|
|
da3d315438 | ||
|
|
1f4b00376b | ||
|
|
e94231487c | ||
|
|
813c9a18d8 | ||
|
|
39fe8cd25e | ||
|
|
fd5b21b7be | ||
|
|
093ca00002 | ||
|
|
1bd2c06f15 | ||
|
|
8bd6d17778 | ||
|
|
45235a854f | ||
|
|
481c97f26c | ||
|
|
fc7b3c5c43 | ||
|
|
f81f7540ef | ||
|
|
40abc4a106 | ||
|
|
d798d91d59 | ||
|
|
0c1dc1e2f6 | ||
|
|
ab0f270c64 | ||
|
|
a3c7e09958 | ||
|
|
28c6da4522 | ||
|
|
63a3f64d12 | ||
|
|
599e808843 | ||
|
|
a2b862a830 | ||
|
|
4d3a674f12 | ||
|
|
f2a296e887 | ||
|
|
6d0622c12e | ||
|
|
c8c5490513 | ||
|
|
19156ca8f6 | ||
|
|
23edaa4f80 | ||
|
|
fe7427ebb9 | ||
|
|
e7c2609a6a | ||
|
|
23bd1ecde2 | ||
|
|
b6b329d4b5 | ||
|
|
51b627de2a | ||
|
|
67717d9a14 | ||
|
|
3ad0efb610 | ||
|
|
c0aa38abea | ||
|
|
acc4a36237 | ||
|
|
376c3ab8f3 | ||
|
|
478b378cd6 | ||
|
|
981b8c8893 | ||
|
|
ec225a40cd | ||
|
|
211fbcb468 | ||
|
|
3181cff5d6 | ||
|
|
7bbe71ab61 | ||
|
|
f78a6206d3 | ||
|
|
414bb1b243 | ||
|
|
2aea0f766a | ||
|
|
32e94ab212 | ||
|
|
289299f574 | ||
|
|
4effa85acb | ||
|
|
f684988ae2 | ||
|
|
d2df2b0eeb | ||
|
|
441bf3e1a7 | ||
|
|
9f9d26fb61 | ||
|
|
d6d05d4e56 | ||
|
|
fc2883b754 | ||
|
|
9ae1554d79 | ||
|
|
e879afcd30 | ||
|
|
f3daf48fef | ||
|
|
b51f608660 | ||
|
|
364adb2acf | ||
|
|
bd467ec0ad | ||
|
|
96b56f20a7 | ||
|
|
3d11b7686d | ||
|
|
74f175fe7f | ||
|
|
6abb05802f | ||
|
|
6764ffdd93 | ||
|
|
9c3b242b12 | ||
|
|
ba7ef95f06 | ||
|
|
4bbdd71062 | ||
|
|
f1c4dec079 | ||
|
|
42f2894acc | ||
|
|
5c155d6c62 | ||
|
|
8c57d16e48 | ||
|
|
bc78c7964a | ||
|
|
eb6b7f0609 | ||
|
|
b7ebd96ec7 | ||
|
|
06f19bc1da | ||
|
|
b6cb81468b | ||
|
|
b6ccb2fa98 | ||
|
|
1a239d7ab7 | ||
|
|
8d2c320f7a | ||
|
|
0539eb2ac5 | ||
|
|
ce370d624a | ||
|
|
e95c7aba65 | ||
|
|
e6d1f00006 | ||
|
|
2e9189a6e2 | ||
|
|
e71161acde | ||
|
|
8b25ad4dbe | ||
|
|
2d6c284491 | ||
|
|
73d7678465 | ||
|
|
eef897b208 | ||
|
|
ab8000b05e | ||
|
|
3f8942c62d | ||
|
|
1dcd52ba08 | ||
|
|
92da209c98 | ||
|
|
1c9e2c8b90 | ||
|
|
393399bc0f | ||
|
|
bd27c853b1 | ||
|
|
3ee0773e61 | ||
|
|
22e7c2d863 | ||
|
|
d0a5194266 | ||
|
|
114db8e32c | ||
|
|
4ed05ffe9f | ||
|
|
11027392cf | ||
|
|
e00cb13dec | ||
|
|
674dcf6006 | ||
|
|
f823f12503 | ||
|
|
646206bd8a | ||
|
|
cbaa7fb49a | ||
|
|
0207edafaa | ||
|
|
9419713cf3 | ||
|
|
b7905c0a84 | ||
|
|
1e38a8b085 | ||
|
|
81d6e16054 | ||
|
|
2b594e229d | ||
|
|
6f6aad9a9b | ||
|
|
065f429a53 | ||
|
|
8d098545de | ||
|
|
95e700e85d | ||
|
|
e29fd4cac7 | ||
|
|
b2784e3104 | ||
|
|
073d205547 | ||
|
|
daa102e5cc | ||
|
|
554522b044 | ||
|
|
5b7fb3fb22 | ||
|
|
d8ea1eb236 | ||
|
|
1f1adbac3d | ||
|
|
33b4484fcd | ||
|
|
28bc9e893c | ||
|
|
1cd3b6d763 | ||
|
|
ab84d306ad | ||
|
|
943f347327 | ||
|
|
606fd4bde1 | ||
|
|
75baa9dc3b | ||
|
|
c28143e1f9 | ||
|
|
bfe9d9f671 | ||
|
|
de558842bb | ||
|
|
32b8839b68 | ||
|
|
1b88ccb803 | ||
|
|
6a47a64a2c | ||
|
|
7a2e7a8a9e | ||
|
|
41da84cbb5 | ||
|
|
e3667a54fe | ||
|
|
f81e2d6843 | ||
|
|
bc65473880 | ||
|
|
02e21de560 | ||
|
|
89a74e2a74 | ||
|
|
e8f2d13c43 | ||
|
|
2164524d62 | ||
|
|
596c0899f5 | ||
|
|
4028d0c1ad | ||
|
|
4510ea8edd | ||
|
|
05b9701179 | ||
|
|
54d3c0b84c | ||
|
|
2969fb1f4b | ||
|
|
a54e2029af | ||
|
|
4dd6cc3986 | ||
|
|
97fb7ba580 | ||
|
|
2e6ebbac55 | ||
|
|
85c04b92cd | ||
|
|
d70ed0dbbf | ||
|
|
a34ad7e648 | ||
|
|
5b30187a52 | ||
|
|
d5753c760d | ||
|
|
06249a9225 | ||
|
|
67bb697055 | ||
|
|
b59e157409 | ||
|
|
aeebe9f1d5 | ||
|
|
f70639d09e | ||
|
|
c282c7df54 | ||
|
|
cf233000b5 | ||
|
|
e83ef12e1b | ||
|
|
409d161ed1 | ||
|
|
f6f191d9e0 | ||
|
|
7e25783091 | ||
|
|
79833c689f | ||
|
|
ea8ae90674 | ||
|
|
fd250664e3 | ||
|
|
21a2eb0d83 | ||
|
|
f4c69c8b84 | ||
|
|
bc22ccbaef | ||
|
|
05f0f37a60 | ||
|
|
799b296ed8 | ||
|
|
d0a51b569b | ||
|
|
a69bd3578e | ||
|
|
691dafd7e7 | ||
|
|
972bded7a2 | ||
|
|
f3490ecaf7 | ||
|
|
5f0cee8ce1 | ||
|
|
27dd7dc3f4 | ||
|
|
29756ec8f3 | ||
|
|
67aa596008 | ||
|
|
8b8dfdb042 | ||
|
|
4f1ccbd47a | ||
|
|
84b6e863ef | ||
|
|
12c0f8eb8e | ||
|
|
68e74447c3 | ||
|
|
1441baa591 | ||
|
|
5b3714bfa0 | ||
|
|
633ec30a1c | ||
|
|
4444ced98a | ||
|
|
1ef04e7b8d | ||
|
|
1566965d67 | ||
|
|
fe754290e3 | ||
|
|
1d5336f842 | ||
|
|
b3317222b6 | ||
|
|
ce157e637c | ||
|
|
74b3669738 | ||
|
|
1d92791718 | ||
|
|
0921d4e865 | ||
|
|
96bec04f10 | ||
|
|
3540a7ebc2 | ||
|
|
eabde7927b | ||
|
|
f6121d6377 | ||
|
|
76d766bcd1 | ||
|
|
a2c20c6cb7 | ||
|
|
49b7141194 | ||
|
|
082dbde25c | ||
|
|
ab1deb20a5 | ||
|
|
865c4556e0 | ||
|
|
280420f3fa | ||
|
|
d72ce8baa1 | ||
|
|
3676e99039 | ||
|
|
7a4d3e467b | ||
|
|
fef9bd3448 | ||
|
|
5878bd0f57 | ||
|
|
ee47eb4434 | ||
|
|
f20898d2cf | ||
|
|
8c7c1741d8 | ||
|
|
d81cabd4b9 | ||
|
|
9abdd7f050 | ||
|
|
23ea3aa7f6 | ||
|
|
8985b7c0e5 | ||
|
|
16fb7f34c0 | ||
|
|
3513e6a296 | ||
|
|
76210f6ef6 | ||
|
|
658b6fdbf8 | ||
|
|
f1bc70aee7 | ||
|
|
13f3136717 | ||
|
|
c08e2b605c | ||
|
|
4ec20afab8 | ||
|
|
390686e9a3 | ||
|
|
903bcfa9ef | ||
|
|
9e5ca4d0a6 | ||
|
|
b5fe49c062 | ||
|
|
6fd5a6c9ab | ||
|
|
00a9889594 | ||
|
|
a7de9c7437 | ||
|
|
2345193b16 | ||
|
|
579a3f7734 | ||
|
|
8fb0252e00 | ||
|
|
c0aff7b82e | ||
|
|
5a351d33df | ||
|
|
5247542d48 | ||
|
|
78c4be4dc0 | ||
|
|
99d7ac2a9b | ||
|
|
1df5832dc5 | ||
|
|
cbf2df6053 | ||
|
|
416f715ecc | ||
|
|
d1a86efe94 | ||
|
|
09125b9f80 | ||
|
|
08d4c3e848 | ||
|
|
34fa095973 | ||
|
|
5a18d6c28f | ||
|
|
17228c4316 | ||
|
|
734c956a07 | ||
|
|
90319208f8 | ||
|
|
c5bbb00251 | ||
|
|
e668ca33b1 | ||
|
|
7ea25ed42d | ||
|
|
0529da81bc | ||
|
|
41bac518a3 | ||
|
|
6fb785d8d7 | ||
|
|
0ef41579a3 | ||
|
|
f083ea85c9 | ||
|
|
1d45a5f2c7 | ||
|
|
6b4cd9973b | ||
|
|
ad6aeb1969 | ||
|
|
e35ab25fed | ||
|
|
bfa5723fda | ||
|
|
bb20108d52 | ||
|
|
70c44da5a6 | ||
|
|
195bb3ab82 | ||
|
|
f9d0f7c498 | ||
|
|
ee8f710fd9 | ||
|
|
968a5a8c16 | ||
|
|
f84de6fb2b | ||
|
|
7aa863a21c | ||
|
|
b957248c0b | ||
|
|
7ff2d2b8ef | ||
|
|
0d1b0ce6e6 | ||
|
|
12e67002fe | ||
|
|
44f5484ade | ||
|
|
3eed918462 | ||
|
|
eaef7fb879 | ||
|
|
bb13ab9aa8 | ||
|
|
cca822b06a | ||
|
|
c84eea711b | ||
|
|
8aa0b157b6 | ||
|
|
9a8d188978 | ||
|
|
68f49c6c6c | ||
|
|
547b9fd922 | ||
|
|
ea64a0555d | ||
|
|
490d716d3b | ||
|
|
475a39af46 | ||
|
|
8974ceab7e | ||
|
|
84b53075a0 | ||
|
|
e132806073 | ||
|
|
24bde1c462 | ||
|
|
c733c32e31 | ||
|
|
1714571533 | ||
|
|
0c0d4c0cdb | ||
|
|
7ea62b593c | ||
|
|
c9298d0078 | ||
|
|
19cddfbddd | ||
|
|
baaf0c58b2 | ||
|
|
83c485d0d0 | ||
|
|
e63bb86b81 | ||
|
|
74cca3f919 | ||
|
|
410994b2a3 | ||
|
|
78987a6f82 | ||
|
|
a9af533ee6 | ||
|
|
8227afc82b | ||
|
|
cf60524ed9 | ||
|
|
c2c15d54e4 | ||
|
|
379379115f | ||
|
|
f9514484e9 | ||
|
|
75fd2e2883 | ||
|
|
d890923f0e | ||
|
|
f93a6e2817 | ||
|
|
f31d7fe160 | ||
|
|
37296c2586 | ||
|
|
c10ecbc6b2 | ||
|
|
da234e6dcc | ||
|
|
300b62f1aa | ||
|
|
282186e2e0 | ||
|
|
93835861c4 | ||
|
|
7868afa641 | ||
|
|
480d2bedd4 | ||
|
|
de31c2cff1 | ||
|
|
0cd9ec8998 | ||
|
|
3f59bd2bae | ||
|
|
8de4217002 | ||
|
|
9150617dec | ||
|
|
5e65159a3d | ||
|
|
d20651be76 | ||
|
|
3475a421c7 | ||
|
|
7404967d25 | ||
|
|
2e3ea8d2c8 | ||
|
|
e2bd3da707 | ||
|
|
80082bf688 | ||
|
|
52253c1afb | ||
|
|
c721325e5d | ||
|
|
2ee208a335 | ||
|
|
61c66784dd | ||
|
|
4920c29312 | ||
|
|
3256d7984e | ||
|
|
62cc0aef3d | ||
|
|
73c17d4258 | ||
|
|
06ef9ae6fe | ||
|
|
665bd249ea | ||
|
|
7108d50451 | ||
|
|
1ec96731a8 | ||
|
|
0ac969ffde | ||
|
|
7951ad80fd | ||
|
|
d2ef16b698 | ||
|
|
3a14671ab0 | ||
|
|
0c557c5d72 | ||
|
|
b8ac81e9dd | ||
|
|
0ef1451bcc | ||
|
|
a99a1e9c5a | ||
|
|
65eea13a0b | ||
|
|
20cdc7d0ff | ||
|
|
eb7fe39890 | ||
|
|
3be0bb5266 | ||
|
|
17c788feae | ||
|
|
c82cbd5b5b | ||
|
|
3b382dde40 | ||
|
|
48c5a8102b | ||
|
|
f9acfb70a1 | ||
|
|
2e264d8df8 | ||
|
|
8a2b23e057 | ||
|
|
c95e436655 | ||
|
|
f09a90bf80 | ||
|
|
9f5df5e527 | ||
|
|
6ae0daebe1 | ||
|
|
862508658b | ||
|
|
1a46fd83ed | ||
|
|
0c27b15897 | ||
|
|
c4a90aeef8 | ||
|
|
bd8f62b024 | ||
|
|
df05a7364e | ||
|
|
26ae039f33 | ||
|
|
1af7304b2c | ||
|
|
8802c40909 | ||
|
|
a51aed8cbb | ||
|
|
4303366baf | ||
|
|
a9a356b96d | ||
|
|
b647b2c3f6 | ||
|
|
232423446a | ||
|
|
0e52836daf | ||
|
|
cbcc52c438 | ||
|
|
26a3ec75a6 | ||
|
|
6bbffa6439 | ||
|
|
139b89b5f9 | ||
|
|
266865c070 | ||
|
|
50611146fb | ||
|
|
789847e4f9 | ||
|
|
994c213420 | ||
|
|
ad2470a735 | ||
|
|
4eb45d4c4b | ||
|
|
65f74c5c50 | ||
|
|
faaee889d5 | ||
|
|
b2d6b07d90 | ||
|
|
f5835d4e56 | ||
|
|
5ed5bc15b3 | ||
|
|
4229505f5d | ||
|
|
21760b18c2 | ||
|
|
bdc0480934 | ||
|
|
93509fd223 | ||
|
|
5e66a1942e | ||
|
|
6dff1f2f88 | ||
|
|
f96734321d | ||
|
|
a141772b11 | ||
|
|
0e23c350c8 | ||
|
|
3ecbd94d96 | ||
|
|
acfb866a8b | ||
|
|
160016b5fa | ||
|
|
aa283f5cd3 | ||
|
|
3f9e6abd6c | ||
|
|
343f172304 | ||
|
|
ddb45c943b | ||
|
|
70fdafa41d | ||
|
|
fa0f04c84f | ||
|
|
b77b9c3714 | ||
|
|
559bfa89ce | ||
|
|
e784133c16 | ||
|
|
ee4a5c458f | ||
|
|
1d3cf75364 | ||
|
|
60bee1fc2f | ||
|
|
e937e114a5 | ||
|
|
9210fafdc1 | ||
|
|
133e8a4475 | ||
|
|
707067856b | ||
|
|
dd198c2dd3 | ||
|
|
338a9481a7 | ||
|
|
0f90e95266 | ||
|
|
16c81e63ff | ||
|
|
ee17cec6cb | ||
|
|
acfb62962e | ||
|
|
d9dcf75671 | ||
|
|
fd8ea7a65a | ||
|
|
69b692b962 | ||
|
|
eab73d227f | ||
|
|
c93106acb2 | ||
|
|
fbdf6e6952 | ||
|
|
cf202bedb5 | ||
|
|
442a8a95ae | ||
|
|
2f37fc728b | ||
|
|
c2d85d1d76 | ||
|
|
f1db8a4f90 | ||
|
|
7853d7ea3f | ||
|
|
13092e269b | ||
|
|
952e823339 | ||
|
|
457e581b65 | ||
|
|
ac3046915e | ||
|
|
ac92a02474 | ||
|
|
6d023b011e | ||
|
|
12268f9242 | ||
|
|
e29e1ef2d6 | ||
|
|
e643c2625e | ||
|
|
05a76afe33 | ||
|
|
fef399d057 | ||
|
|
5c50dc73c3 | ||
|
|
d8e074417a | ||
|
|
2ce929a747 | ||
|
|
fb937040ab | ||
|
|
bd3f8422d6 | ||
|
|
c795435c2d | ||
|
|
893974c744 | ||
|
|
7ae8a0d895 | ||
|
|
d1cae38197 | ||
|
|
391acea9e2 | ||
|
|
195e84f0fd | ||
|
|
fa98d68724 | ||
|
|
86fdd6428d | ||
|
|
fa4c3c59cb | ||
|
|
530fa17534 | ||
|
|
f15abae92e | ||
|
|
cd42d0301a | ||
|
|
14dac1d57a | ||
|
|
3e1f255c1d | ||
|
|
4a63f2672b | ||
|
|
7db83c02da | ||
|
|
29d21f7796 | ||
|
|
1206c3348a | ||
|
|
a3b855e82f | ||
|
|
3cd1cb60b6 | ||
|
|
30dc143809 | ||
|
|
02c64bbcbf | ||
|
|
6bf905c84b | ||
|
|
19cb4b82ef | ||
|
|
0f29955368 | ||
|
|
be1a537618 | ||
|
|
7daeb6ea59 | ||
|
|
2cda44747c | ||
|
|
0d8e2101cb | ||
|
|
0f5e9b0a60 | ||
|
|
28665466fa | ||
|
|
035108c9f1 | ||
|
|
7987e67c0a | ||
|
|
2d5b34cfc2 | ||
|
|
a55d257be1 | ||
|
|
3aab6f634f | ||
|
|
3eca82a113 | ||
|
|
078494e07c | ||
|
|
501e2e4d5f | ||
|
|
9cb63f0bd6 | ||
|
|
b60851b818 | ||
|
|
0fe34092d1 | ||
|
|
49cce55058 | ||
|
|
e3c2db48e9 | ||
|
|
889a41562e | ||
|
|
b4f337a5c3 | ||
|
|
2202cd3cba | ||
|
|
532500b3f5 | ||
|
|
1ae5c19aa4 | ||
|
|
d752f1e2b9 | ||
|
|
88978fc12b | ||
|
|
4b00c2754d | ||
|
|
3dbfe0ed90 | ||
|
|
c6c6bd2f8e | ||
|
|
5635645200 | ||
|
|
4119f3b80d | ||
|
|
4282e53b56 | ||
|
|
e8f6b1bb3d | ||
|
|
222377d1d5 | ||
|
|
249e1a5851 | ||
|
|
d36a3f80c7 | ||
|
|
76c46ef07a | ||
|
|
728caae607 | ||
|
|
bd0c8047f1 | ||
|
|
4e7bcf23b6 | ||
|
|
2e09dfac74 | ||
|
|
8d723eb376 | ||
|
|
80d5153dc3 | ||
|
|
3e71b12c01 | ||
|
|
0fa58c16bd | ||
|
|
95240475c3 | ||
|
|
7f17e0a9f4 | ||
|
|
148d159f73 | ||
|
|
ea85122b05 | ||
|
|
35affcf3e7 | ||
|
|
4d237924ac | ||
|
|
0049033470 | ||
|
|
1755104d21 | ||
|
|
8745cd2d9f | ||
|
|
6bdb714a0f | ||
|
|
1cf168c165 | ||
|
|
e37c3699ff | ||
|
|
82f979b8b9 | ||
|
|
922820f56e | ||
|
|
f4dcbab826 | ||
|
|
0e18c4dd43 | ||
|
|
63b3391483 | ||
|
|
f84aa60a04 | ||
|
|
555cdcebbb | ||
|
|
509effef99 | ||
|
|
ed64e6193c | ||
|
|
2df2727f1a | ||
|
|
72c3bf2220 | ||
|
|
02d5cbebae | ||
|
|
b9851b2453 | ||
|
|
09c4c76744 | ||
|
|
18bc8ff929 | ||
|
|
dea1ed5769 | ||
|
|
a09b46b898 | ||
|
|
c9b0a309da | ||
|
|
7ff4970948 | ||
|
|
862d198040 | ||
|
|
8611e2a3d2 | ||
|
|
9253d2102c | ||
|
|
e26a741cb7 | ||
|
|
7ee0450e69 | ||
|
|
3bb919f266 | ||
|
|
5e7ec329bd | ||
|
|
479dc5b094 | ||
|
|
249ebdb066 | ||
|
|
25134089ee | ||
|
|
3d2d690bb1 | ||
|
|
e1ffc92e86 | ||
|
|
1a3ec6c470 | ||
|
|
41dabfd633 | ||
|
|
e3ad34295b | ||
|
|
96f6ad0f43 | ||
|
|
99b5da6086 | ||
|
|
e60c46b79f | ||
|
|
caba9f7645 | ||
|
|
9ce7657839 | ||
|
|
cf841846fd | ||
|
|
3dada9d51a | ||
|
|
c62b7b5a64 | ||
|
|
32094d3dd7 | ||
|
|
cc19e1e341 | ||
|
|
8f6d32db71 | ||
|
|
f9befb34a3 | ||
|
|
c64bbd5c93 | ||
|
|
965f86d04e | ||
|
|
ec337ae5cf | ||
|
|
f5fac4b8b9 | ||
|
|
ed7e10bf8f | ||
|
|
8084ccfb58 | ||
|
|
33561d42d3 | ||
|
|
85fde9ee94 | ||
|
|
afb034b246 | ||
|
|
e7515df4b1 | ||
|
|
66ae15cfc5 | ||
|
|
8484985124 | ||
|
|
fd1281245c | ||
|
|
b05d2fc784 | ||
|
|
c571efd481 | ||
|
|
d2ef0e27c7 | ||
|
|
f28cd9e692 | ||
|
|
fbecf70cc2 | ||
|
|
6e5f955205 | ||
|
|
199c125522 | ||
|
|
6df3e39a46 | ||
|
|
4955c2449c | ||
|
|
2d11060363 | ||
|
|
1534808d0e | ||
|
|
ff9f2a8c94 | ||
|
|
72682352a6 | ||
|
|
0f6340f811 | ||
|
|
e5680477f9 | ||
|
|
0a945dc2be | ||
|
|
12ac7300b9 | ||
|
|
a3af4d491b | ||
|
|
63d10ccd96 | ||
|
|
10296e05c7 | ||
|
|
4009035011 | ||
|
|
762d3708fc | ||
|
|
af56d03fab | ||
|
|
4bbe7bebd7 | ||
|
|
ebe7dfdaf3 | ||
|
|
cdb2374333 | ||
|
|
47376f8009 | ||
|
|
8c26abe6f9 | ||
|
|
0094b96051 | ||
|
|
bdb0c35ba3 | ||
|
|
313692014a | ||
|
|
33f5b5416c | ||
|
|
12ac03a755 | ||
|
|
b8589c4e9b | ||
|
|
7183c74cd0 | ||
|
|
e67e06809c | ||
|
|
f5c763d5ab | ||
|
|
673c7e4262 | ||
|
|
eb7e10e86b | ||
|
|
c236395c01 | ||
|
|
c44aa2a118 | ||
|
|
5ad4949607 | ||
|
|
2818130313 | ||
|
|
a846112993 | ||
|
|
ff5f860cee | ||
|
|
7f5ced76f6 | ||
|
|
770966a7d0 | ||
|
|
33f8290eb3 | ||
|
|
fbab49c68d | ||
|
|
113ebda3ff | ||
|
|
d145281fc4 | ||
|
|
55eac0c495 | ||
|
|
abc0924ff3 | ||
|
|
ee902e63ef | ||
|
|
708966b96e | ||
|
|
f2140006d5 | ||
|
|
94401575cc | ||
|
|
3cb43ad041 | ||
|
|
b3c24a3df0 | ||
|
|
588d3acce2 | ||
|
|
b1989f071b | ||
|
|
49adae832e | ||
|
|
62608be10d | ||
|
|
062aa4b34b | ||
|
|
11d99cf330 | ||
|
|
c2456db060 | ||
|
|
fa888ff84c | ||
|
|
b61c3f2f3f | ||
|
|
070acb297a | ||
|
|
08fd9db73c | ||
|
|
af3aae13f5 | ||
|
|
813f5086f3 | ||
|
|
96e1dc7c16 | ||
|
|
3ce10d920c | ||
|
|
619277e6b6 | ||
|
|
d57ea72cf1 | ||
|
|
0c55709a16 | ||
|
|
4be7ea8519 | ||
|
|
73d6539b8c | ||
|
|
855b9536e5 | ||
|
|
a667a3ed71 | ||
|
|
622d0e27c7 | ||
|
|
36c11232c1 | ||
|
|
5bffacf271 | ||
|
|
57490858a1 | ||
|
|
db677404ae | ||
|
|
4d29891e59 | ||
|
|
02b27d9b70 | ||
|
|
eb25b2a615 | ||
|
|
9c3a288946 | ||
|
|
4e66f2c8be | ||
|
|
3797a9b42e | ||
|
|
d956aff028 | ||
|
|
f6be37b2a2 | ||
|
|
f89c97a105 | ||
|
|
940b4385bf | ||
|
|
3734c54d8f | ||
|
|
3f72737720 | ||
|
|
40f5589ff8 | ||
|
|
ca1182402b | ||
|
|
f830665a46 | ||
|
|
a36aeb18f3 | ||
|
|
2896e88c1b | ||
|
|
ff392444a4 | ||
|
|
f26a1a3f0c | ||
|
|
b23fbbcc94 | ||
|
|
fb7d541d83 | ||
|
|
fddd0f2982 | ||
|
|
4634ef752b | ||
|
|
2c27f29e64 | ||
|
|
a8b6f2843d | ||
|
|
4249f78120 | ||
|
|
a669f9a63a | ||
|
|
f6d3cdeecb | ||
|
|
6e74807de9 | ||
|
|
c29a448085 | ||
|
|
d2c440bf4f | ||
|
|
c9c2a3d905 | ||
|
|
2ebaaf0e8c | ||
|
|
957282b8bc |
1012 changed files with 208205 additions and 1436 deletions
46
.forgejo/cascading-forgejo
Executable file
46
.forgejo/cascading-forgejo
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
forgejo=$1
|
||||
forgejo_pr=$2
|
||||
runner=$3
|
||||
runner_pr_or_ref=$4
|
||||
|
||||
#
|
||||
# Get information from the runner
|
||||
#
|
||||
cd $runner
|
||||
#
|
||||
# code.forgejo.org/forgejo/runner/vN may be
|
||||
# upgraded to code.forgejo.org/forgejo/runner/vN+1
|
||||
#
|
||||
module=$(cat go.mod | head -1 | cut -d' ' -f2)
|
||||
test "$module"
|
||||
sha=$(git -C $runner show --no-patch --format=%H)
|
||||
test "$sha"
|
||||
|
||||
#
|
||||
# Update Forgejo to use the runner at $runner_pr_or_ref
|
||||
#
|
||||
cd $forgejo
|
||||
#
|
||||
# Update the runner major version if needed
|
||||
#
|
||||
find * -name '*.go' -o -name 'go.mod' | xargs sed -i -E -e "s|code.forgejo.org/forgejo/runner/v[0-9]+|$module|"
|
||||
#
|
||||
# If it is a pull request, change the module to reference the forked repository so
|
||||
# go mod tidy can find the SHA from a known branch or tag
|
||||
#
|
||||
if test -f "$runner_pr_or_ref"; then
|
||||
repository=$(jq --raw-output .head.repo.full_name <$runner_pr_or_ref)
|
||||
test "$repository"
|
||||
module=$(echo $module | sed -e "s|code.forgejo.org/forgejo/runner|code.forgejo.org/$repository|")
|
||||
fi
|
||||
#
|
||||
# add a "replace code.forgejo.org/forgejo/runner/v?? $sha" line to the forgejo go.mod
|
||||
# so that it uses the branch or pull request from which the cascade is run.
|
||||
#
|
||||
sed -i -e "\|replace $module|d" go.mod
|
||||
echo "replace $module => $module $sha" >>go.mod
|
||||
go mod tidy
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
setup_forgejo=$1
|
||||
setup_forgejo_pr=$2
|
||||
runner=$3
|
||||
runner_pr=$4
|
||||
|
||||
url=$(jq --raw-output .head.repo.html_url < $runner_pr)
|
||||
test "$url" != null
|
||||
branch=$(jq --raw-output .head.ref < $runner_pr)
|
||||
test "$branch" != null
|
||||
cd $setup_forgejo
|
||||
./utils/upgrade-runner.sh $url @$branch
|
||||
date > last-upgrade
|
||||
24
.forgejo/cascading-setup-forgejo
Executable file
24
.forgejo/cascading-setup-forgejo
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
setup_forgejo=$1
|
||||
setup_forgejo_pr=$2
|
||||
runner=$3
|
||||
runner_pr_or_ref=$4
|
||||
|
||||
if test -f "$runner_pr_or_ref"; then
|
||||
url=$(jq --raw-output .head.repo.html_url <$runner_pr_or_ref)
|
||||
test "$url" != null
|
||||
branch=$(jq --raw-output .head.ref <$runner_pr_or_ref)
|
||||
else
|
||||
url=https://code.forgejo.org/forgejo/runner
|
||||
branch=${runner_pr_or_ref#refs/heads/}
|
||||
fi
|
||||
test "$url"
|
||||
test "$branch"
|
||||
test "$branch" != null
|
||||
cd $setup_forgejo
|
||||
./utils/upgrade-runner.sh $url @$branch
|
||||
rm -f .forgejo/workflows/integration*.yml
|
||||
date >last-upgrade
|
||||
|
|
@ -70,8 +70,10 @@ body:
|
|||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
It's really important to provide pertinent logs. You must give us `DEBUG` level logs.
|
||||
Please read https://forgejo.org/docs/latest/admin/logging-documentation/.
|
||||
It's really important to provide pertinent logs. For Forgejo
|
||||
[use `DEBUG` level logs](https://forgejo.org/docs/next/admin/logging-documentation/)
|
||||
and for the Forgejo runner
|
||||
[set `log.level=debug` and `log.job_level=debug`](https://forgejo.org/docs/next/admin/runner-installation/#configuration).
|
||||
|
||||
Please copy and paste your logs here, with any sensitive information (e.g. API keys) removed/hidden.
|
||||
You can wrap your logs in `<details>...</details>` tags so it doesn't take up too much space in the issue.
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
name: 💡 Feature Request
|
||||
description: Got an idea for an improvement to the Forgejo Runner? Suggest it here!
|
||||
description: Got an idea ready to be implemented? Suggest it here!
|
||||
title: "feat: "
|
||||
labels: ["Kind/Enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
- Please speak English, as this is the language all maintainers can speak and write.
|
||||
- Be as clear and concise as possible. A very verbose request is harder to interpret in a concrete way.
|
||||
- New ideas must be [proposed in an issue tracker dedicated to feature requests](https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues/new?template=.forgejo%2fissue_template%2ffeature-request.yaml).
|
||||
- Ideas that have been prepared there and are ready to be implemented can be proposed here.
|
||||
- Be civil, and follow the [Forgejo Code of Conduct](https://codeberg.org/forgejo/code-of-conduct).
|
||||
- Please make sure you are using the latest release of the runner and take a moment to [check that your feature hasn't already been suggested](https://code.forgejo.org/forgejo/runner/issues?q=&type=all).
|
||||
- type: textarea
|
||||
id: needs-benefits
|
||||
id: url
|
||||
attributes:
|
||||
label: Needs and benefits
|
||||
description: As concisely as possible, describe the benefits your feature request will provide or the problems it will try to solve.
|
||||
label: Feature request URL
|
||||
description: The URL to the [feature request](https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues?labels=990) in the `ready` stage
|
||||
placeholder: |
|
||||
https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues/???
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
id: Description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: As concisely as possible, describe the feature you would like to see added or the changes you would like to see made to Forgejo.
|
||||
label: Copy/paste of the description of the feature request
|
||||
validations:
|
||||
required: true
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ expectedLabels = {
|
|||
"org.opencontainers.image.source": "https://code.forgejo.org/forgejo/runner",
|
||||
"org.opencontainers.image.version": "1.2.3",
|
||||
"org.opencontainers.image.vendor": "Forgejo",
|
||||
"org.opencontainers.image.licenses": "MIT",
|
||||
"org.opencontainers.image.licenses": "GPL-3.0-or-later",
|
||||
"org.opencontainers.image.title": "Forgejo Runner",
|
||||
"org.opencontainers.image.description": "A runner for Forgejo Actions.",
|
||||
}
|
||||
|
|
|
|||
9
.forgejo/testdata/var.yml
vendored
Normal file
9
.forgejo/testdata/var.yml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
on: push
|
||||
jobs:
|
||||
var:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: code.forgejo.org/oci/debian:bookworm
|
||||
steps:
|
||||
- run: echo "VAR -> ${{ vars.MY_VAR }}"
|
||||
27
.forgejo/workflows/build-ipcei.yml
Normal file
27
.forgejo/workflows/build-ipcei.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ">=1.25.1"
|
||||
- name: Test code
|
||||
run: make test
|
||||
- name: Run GoReleaser
|
||||
uses: https://github.com/goreleaser/goreleaser-action@v6
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
with:
|
||||
args: release --clean
|
||||
|
|
@ -16,19 +16,24 @@ on:
|
|||
- .forgejo/workflows/build-release.yml
|
||||
- .forgejo/workflows/build-release-integration.yml
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
env:
|
||||
FORGEJO_VERSION: 11.0.7 # renovate: datasource=docker depName=code.forgejo.org/forgejo/forgejo
|
||||
|
||||
jobs:
|
||||
release-simulation:
|
||||
runs-on: lxc-bookworm
|
||||
if: github.repository_owner != 'forgejo-integration' && github.repository_owner != 'forgejo-release'
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- id: forgejo
|
||||
uses: https://data.forgejo.org/actions/setup-forgejo@v2.0.6
|
||||
uses: https://data.forgejo.org/actions/setup-forgejo@v3.0.4
|
||||
with:
|
||||
user: root
|
||||
password: admin1234
|
||||
image-version: 1.20
|
||||
image-version: ${{ env.FORGEJO_VERSION }}
|
||||
lxc-ip-prefix: 10.0.9
|
||||
|
||||
- name: publish
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: lxc-bookworm
|
||||
# root is used for testing, allow it
|
||||
if: secrets.ROLE == 'forgejo-integration' || github.repository_owner == 'root'
|
||||
if: vars.ROLE == 'forgejo-integration' || forge.repository_owner == 'root'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Increase the verbosity when there are no secrets
|
||||
id: verbose
|
||||
|
|
@ -31,74 +33,61 @@ jobs:
|
|||
else
|
||||
value=false
|
||||
fi
|
||||
echo "value=$value" >> "$GITHUB_OUTPUT"
|
||||
echo "value=$value" >> "$FORGEJO_OUTPUT"
|
||||
|
||||
- name: Sanitize the name of the repository
|
||||
id: repository
|
||||
run: |
|
||||
echo "value=${GITHUB_REPOSITORY##*/}" >> "$GITHUB_OUTPUT"
|
||||
echo "value=${FORGEJO_REPOSITORY##*/}" >> "$FORGEJO_OUTPUT"
|
||||
|
||||
- name: create test TOKEN
|
||||
id: token
|
||||
if: ${{ secrets.TOKEN == '' }}
|
||||
run: |
|
||||
apt-get -qq install -y jq
|
||||
url="${{ env.GITHUB_SERVER_URL }}"
|
||||
url="${{ env.FORGEJO_SERVER_URL }}"
|
||||
hostport=${url##http*://}
|
||||
hostport=${hostport%%/}
|
||||
doer=root
|
||||
api=http://$doer:admin1234@$hostport/api/v1/users/$doer/tokens
|
||||
curl -sS -X DELETE $api/release
|
||||
token=$(curl -sS -X POST -H 'Content-Type: application/json' --data-raw '{"name": "release", "scopes": ["all"]}' $api | jq --raw-output .sha1)
|
||||
echo "value=${token}" >> "$GITHUB_OUTPUT"
|
||||
echo "value=${token}" >> "$FORGEJO_OUTPUT"
|
||||
|
||||
- name: version from ref_name
|
||||
id: tag-version
|
||||
run: |
|
||||
version=${GITHUB_REF_NAME##*v}
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
anchor=${{ steps.tag-version.outputs.value }}
|
||||
anchor=${anchor//./-}
|
||||
cat >> "$GITHUB_OUTPUT" <<EOF
|
||||
value<<ENDVAR
|
||||
See https://code.forgejo.org/forgejo/runner/src/branch/main/RELEASE-NOTES.md#$anchor
|
||||
ENDVAR
|
||||
EOF
|
||||
version=${FORGEJO_REF_NAME##*v}
|
||||
echo "value=$version" >> "$FORGEJO_OUTPUT"
|
||||
|
||||
- name: build without TOKEN
|
||||
if: ${{ secrets.TOKEN == '' }}
|
||||
uses: https://code.forgejo.org/forgejo/forgejo-build-publish/build@v5.3.1
|
||||
uses: https://data.forgejo.org/forgejo/forgejo-build-publish/build@v5.4.1
|
||||
with:
|
||||
forgejo: "${{ env.GITHUB_SERVER_URL }}"
|
||||
owner: "${{ env.GITHUB_REPOSITORY_OWNER }}"
|
||||
forgejo: "${{ env.FORGEJO_SERVER_URL }}"
|
||||
owner: "${{ env.FORGEJO_REPOSITORY_OWNER }}"
|
||||
repository: "${{ steps.repository.outputs.value }}"
|
||||
doer: root
|
||||
sha: "${{ github.sha }}"
|
||||
sha: "${{ forge.sha }}"
|
||||
release-version: "${{ steps.tag-version.outputs.value }}"
|
||||
token: ${{ steps.token.outputs.value }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
release-notes: "${{ steps.release-notes.outputs.value }}"
|
||||
binary-name: forgejo-runner
|
||||
binary-path: /bin/forgejo-runner
|
||||
verbose: ${{ steps.verbose.outputs.value }}
|
||||
|
||||
- name: build with TOKEN
|
||||
if: ${{ secrets.TOKEN != '' }}
|
||||
uses: https://code.forgejo.org/forgejo/forgejo-build-publish/build@v5.3.1
|
||||
uses: https://data.forgejo.org/forgejo/forgejo-build-publish/build@v5.4.1
|
||||
with:
|
||||
forgejo: "${{ env.GITHUB_SERVER_URL }}"
|
||||
owner: "${{ env.GITHUB_REPOSITORY_OWNER }}"
|
||||
forgejo: "${{ env.FORGEJO_SERVER_URL }}"
|
||||
owner: "${{ env.FORGEJO_REPOSITORY_OWNER }}"
|
||||
repository: "${{ steps.repository.outputs.value }}"
|
||||
doer: "${{ secrets.DOER }}"
|
||||
sha: "${{ github.sha }}"
|
||||
sha: "${{ forge.sha }}"
|
||||
release-version: "${{ steps.tag-version.outputs.value }}"
|
||||
token: "${{ secrets.TOKEN }}"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
release-notes: "${{ steps.release-notes.outputs.value }}"
|
||||
binary-name: forgejo-runner
|
||||
binary-path: /bin/forgejo-runner
|
||||
verbose: ${{ steps.verbose.outputs.value }}
|
||||
|
|
|
|||
110
.forgejo/workflows/cascade-forgejo.yml
Normal file
110
.forgejo/workflows/cascade-forgejo.yml
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Copyright 2025 The Forgejo Authors
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# FORGEJO_CASCADING_PR_ORIGIN_TOKEN is a token from the https://code.forgejo.org/cascading-pr user
|
||||
# with scope write:issue read:repository read:user
|
||||
# FORGEJO_CASCADING_PR_DESTINATION_TOKEN is a token from the https://codeberg.org/forgejo-cascading-pr user
|
||||
# with scope write:issue write:repository read:user
|
||||
#
|
||||
# To modify this workflow:
|
||||
#
|
||||
# - push it to the wip-cascade branch on the repository
|
||||
# otherwise it will not have access to the secrets required to push
|
||||
# the cascading PR
|
||||
#
|
||||
# - once it works, open a pull request for the sake of keeping track
|
||||
# of the change even if the PR won't run it because it will use
|
||||
# whatever is in the default branch instead
|
||||
#
|
||||
# - after it is merged, double check it works by setting the
|
||||
# label on a pull request (any pull request will do)
|
||||
#
|
||||
name: cascade
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'wip-cascade'
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
- labeled
|
||||
- closed
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
jobs:
|
||||
debug:
|
||||
if: >
|
||||
vars.DEBUG == 'yes'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: data.forgejo.org/oci/node:22-bookworm
|
||||
steps:
|
||||
- name: event
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge.event.pull_request.labels.*.name) }}
|
||||
EOF
|
||||
cat <<'EOF'
|
||||
push => ${{ forge.event_name == 'push' && ( forge.ref_name == 'main' || forge.ref_name == 'wip-cascade') }}
|
||||
pull_request_target synchornized => ${{ ( forge.event.action == 'synchronized' && contains(forge.event.pull_request.labels.*.name, 'run-forgejo-tests') ) }}
|
||||
pull_request_target label_updated => ${{ ( forge.event.action == 'label_updated' && forge.event.label.name == 'run-forgejo-tests' ) }}
|
||||
contains => ${{ contains(forge.event.pull_request.labels.*.name, 'run-forgejo-tests') }}
|
||||
contains boolean => ${{ contains(forge.event.pull_request.labels.*.name, 'run-forgejo-tests') == true }}
|
||||
EOF
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge) }}
|
||||
EOF
|
||||
|
||||
forgejo:
|
||||
#
|
||||
# Always run when a commit is pushed to the main or wip-cascade branch
|
||||
# If this is a pull request, run
|
||||
# - when the `run-forgejo-tests` label is set (label_updated) (but not if another label is set or if a label is removed)
|
||||
# - when a new commit is pushed to the pull request (synchronized) if the `run-forgejo-tests` is already present
|
||||
# - when the pull request is closed, which also happens when it is merged, so that the Forgejo pull request is closed
|
||||
#
|
||||
if: >
|
||||
vars.ROLE == 'forgejo-coding' && (
|
||||
(
|
||||
forge.event_name == 'push' && ( forge.ref_name == 'main' || forge.ref_name == 'wip-cascade')
|
||||
) || (
|
||||
forge.event_name == 'pull_request_target' && (
|
||||
forge.event.action == 'closed' ||
|
||||
( forge.event.action == 'synchronized' && contains(forge.event.pull_request.labels.*.name, 'run-forgejo-tests') ) ||
|
||||
( forge.event.action == 'label_updated' && forge.event.label.name == 'run-forgejo-tests' )
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
runs-on: docker
|
||||
container:
|
||||
image: data.forgejo.org/oci/node:22-bookworm
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
show-progress: 'false'
|
||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: https://data.forgejo.org/actions/cascading-pr@v2.3.0
|
||||
with:
|
||||
origin-url: ${{ forge.server_url }}
|
||||
origin-repo: ${{ forge.repository }}
|
||||
origin-token: ${{ secrets.FORGEJO_CASCADING_PR_ORIGIN_TOKEN }}
|
||||
origin-pr: ${{ forge.event.pull_request.number }}
|
||||
origin-ref: ${{ forge.event_name == 'push' && forge.event.ref || '' }}
|
||||
destination-url: https://codeberg.org
|
||||
destination-fork-repo: forgejo-cascading-pr/forgejo
|
||||
destination-repo: forgejo/forgejo
|
||||
destination-branch: forgejo
|
||||
destination-token: ${{ secrets.FORGEJO_CASCADING_PR_DESTINATION_TOKEN }}
|
||||
prefix: runner
|
||||
close: true
|
||||
verbose: ${{ vars.VERBOSE == 'yes' }}
|
||||
debug: ${{ vars.DEBUG == 'yes' }}
|
||||
wait-iteration: 120
|
||||
update: .forgejo/cascading-forgejo
|
||||
|
|
@ -1,28 +1,99 @@
|
|||
# Copyright 2025 The Forgejo Authors
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# CASCADING_PR_ORIGIN is a token from the https://code.forgejo.org/cascading-pr user
|
||||
# with scope write:issue read:repository read:user
|
||||
# CASCADING_PR_DESTINATION is a token from the https://code.forgejo.org/cascading-pr user
|
||||
# with scope write:issue write:repository read:user
|
||||
#
|
||||
# To modify this workflow:
|
||||
#
|
||||
# - push it to the wip-cascade branch on the repository
|
||||
# otherwise it will not have access to the secrets required to push
|
||||
# the cascading PR
|
||||
#
|
||||
# - once it works, open a pull request for the sake of keeping track
|
||||
# of the change even if the PR won't run it because it will use
|
||||
# whatever is in the default branch instead
|
||||
#
|
||||
# - after it is merged, double check it works by setting the
|
||||
# label on a pull request (any pull request will do)
|
||||
#
|
||||
name: cascade
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'wip-cascade'
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- labeled
|
||||
- closed
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
jobs:
|
||||
cascade:
|
||||
debug:
|
||||
if: >
|
||||
vars.DEBUG == 'yes'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'code.forgejo.org/oci/node:20-bookworm'
|
||||
if: >
|
||||
! contains(github.event.pull_request.title, '[skip cascade]')
|
||||
image: data.forgejo.org/oci/node:22-bookworm
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/cascading-pr@v2.2.0
|
||||
- name: event
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge.event.pull_request.labels.*.name) }}
|
||||
EOF
|
||||
cat <<'EOF'
|
||||
push => ${{ forge.event_name == 'push' && ( forge.ref_name == 'wip-cascade') }}
|
||||
pull_request_target synchornized => ${{ ( forge.event.action == 'synchronized' && contains(forge.event.pull_request.labels.*.name, 'run-end-to-end-tests') ) }}
|
||||
pull_request_target label_updated => ${{ ( forge.event.action == 'label_updated' && forge.event.label.name == 'run-end-to-end-tests' ) }}
|
||||
contains => ${{ contains(forge.event.pull_request.labels.*.name, 'run-end-to-end-tests') }}
|
||||
contains boolean => ${{ contains(forge.event.pull_request.labels.*.name, 'run-end-to-end-tests') == true }}
|
||||
EOF
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge) }}
|
||||
EOF
|
||||
|
||||
end-to-end:
|
||||
#
|
||||
# Always run when a commit is pushed to the wip-cascade branch
|
||||
# If this is a pull request, run
|
||||
# - when the `run-end-to-end-tests` label is set (label_updated) (but not if another label is set or if a label is removed)
|
||||
# - when a new commit is pushed to the pull request (synchronized) if the `run-end-to-end-tests` is already present
|
||||
# - when the pull request is closed, which also happens when it is merged, so that the setup-forgejo & end-to-end pull requests are closed
|
||||
#
|
||||
if: >
|
||||
vars.ROLE == 'forgejo-coding' && (
|
||||
(
|
||||
forge.event_name == 'push' && ( forge.ref_name == 'wip-cascade' )
|
||||
) || (
|
||||
forge.event_name == 'pull_request_target' && (
|
||||
forge.event.action == 'closed' ||
|
||||
( forge.event.action == 'synchronized' && contains(forge.event.pull_request.labels.*.name, 'run-end-to-end-tests') ) ||
|
||||
( forge.event.action == 'label_updated' && forge.event.label.name == 'run-end-to-end-tests' )
|
||||
)
|
||||
)
|
||||
)
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'code.forgejo.org/oci/node:22-bookworm'
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/cascading-pr@v2.3.0
|
||||
with:
|
||||
origin-url: ${{ env.GITHUB_SERVER_URL }}
|
||||
origin-repo: forgejo/runner
|
||||
origin-url: ${{ forge.server_url }}
|
||||
origin-repo: ${{ forge.repository }}
|
||||
origin-token: ${{ secrets.CASCADING_PR_ORIGIN }}
|
||||
origin-pr: ${{ github.event.pull_request.number }}
|
||||
destination-url: ${{ env.GITHUB_SERVER_URL }}
|
||||
origin-pr: ${{ forge.event.pull_request.number }}
|
||||
origin-ref: ${{ forge.event_name == 'push' && forge.event.ref || '' }}
|
||||
destination-url: ${{ forge.server_url }}
|
||||
destination-repo: actions/setup-forgejo
|
||||
destination-fork-repo: cascading-pr/setup-forgejo
|
||||
destination-branch: main
|
||||
destination-token: ${{ secrets.CASCADING_PR_DESTINATION }}
|
||||
close-merge: true
|
||||
update: .forgejo/cascading-pr-setup-forgejo
|
||||
close: true
|
||||
verbose: ${{ vars.VERBOSE == 'yes' }}
|
||||
debug: ${{ vars.DEBUG == 'yes' }}
|
||||
update: .forgejo/cascading-setup-forgejo
|
||||
|
|
|
|||
85
.forgejo/workflows/docker-build-push-action-in-lxc.yml
Normal file
85
.forgejo/workflows/docker-build-push-action-in-lxc.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#
|
||||
# Example that requires a Forgejo runner with an [LXC backend](https://forgejo.org/docs/latest/admin/actions/runner-installation/#setting-up-the-container-environment).
|
||||
#
|
||||
# - Start a Forgejo instance to be used as a container registry
|
||||
# - Build a container image using the [docker/build-push-action](https://code.forgejo.org/docker/build-push-action) action
|
||||
# - Push the image to the Forgejo instance
|
||||
# - Retrieve the image
|
||||
#
|
||||
# Runs of this workflow can be seen in [the Forgejo runner](https://code.forgejo.org/forgejo/runner/actions?workflow=docker-build-push-action-in-lxc.yml) logs.
|
||||
#
|
||||
name: example
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
paths:
|
||||
- examples/docker-build-push-action/**
|
||||
- .forgejo/workflows/docker-build-push-action-in-lxc.yml
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
env:
|
||||
FORGEJO_VERSION: 11.0.7 # renovate: datasource=docker depName=code.forgejo.org/forgejo/forgejo
|
||||
FORGEJO_USER: root
|
||||
FORGEJO_PASSWORD: admin1234
|
||||
|
||||
jobs:
|
||||
docker-build-push-action-in-lxc:
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-bookworm
|
||||
|
||||
steps:
|
||||
|
||||
- name: install Forgejo so it can be used as a container registry
|
||||
id: registry
|
||||
uses: https://data.forgejo.org/actions/setup-forgejo@v3.0.4
|
||||
with:
|
||||
user: ${{ env.FORGEJO_USER }}
|
||||
password: ${{ env.FORGEJO_PASSWORD }}
|
||||
binary: https://code.forgejo.org/forgejo/forgejo/releases/download/v${{ env.FORGEJO_VERSION }}/forgejo-${{ env.FORGEJO_VERSION }}-linux-amd64
|
||||
lxc-ip-prefix: 10.0.9
|
||||
|
||||
- name: enable insecure / http uploads to the Forgejo registry
|
||||
run: |-
|
||||
set -x
|
||||
# the docker daemon was implicitly installed when Forgejo was
|
||||
# installed in the previous step. But it will refuse to connect
|
||||
# to an insecure / http registry by default and must be told
|
||||
# otherwise
|
||||
cat > /etc/docker/daemon.json <<EOF
|
||||
{
|
||||
"insecure-registries" : ["${{ steps.registry.outputs.host-port }}"]
|
||||
}
|
||||
EOF
|
||||
systemctl restart docker
|
||||
|
||||
- uses: https://data.forgejo.org/docker/setup-qemu-action@v3
|
||||
- uses: https://data.forgejo.org/docker/setup-buildx-action@v2
|
||||
with:
|
||||
# insecure / http connections to the registry are allowed
|
||||
config-inline: |
|
||||
[registry."${{ steps.registry.outputs.host-port }}"]
|
||||
http = true
|
||||
|
||||
- name: login to the Forgejo registry
|
||||
uses: https://data.forgejo.org/docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host-port }}
|
||||
username: ${{ env.FORGEJO_USER }}
|
||||
password: ${{ env.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: build and push to the Forgejo registry
|
||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
||||
with:
|
||||
context: examples/docker-build-push-action
|
||||
push: true
|
||||
tags: ${{ steps.registry.outputs.host-port }}/root/testimage:latest
|
||||
cache-from: type=gha,scope=docker-build-push-action-in-lxc
|
||||
cache-to: type=gha,scope=docker-build-push-action-in-lxc
|
||||
|
||||
- name: verify the image can be read from the Forgejo registry
|
||||
run: |
|
||||
set -x
|
||||
docker pull ${{ steps.registry.outputs.host-port }}/root/testimage:latest
|
||||
|
|
@ -8,12 +8,14 @@ on:
|
|||
- examples/docker-compose/**
|
||||
- .forgejo/workflows/example-docker-compose.yml
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
jobs:
|
||||
example-docker-compose:
|
||||
if: github.repository_owner != 'forgejo-integration' && github.repository_owner != 'forgejo-experimental' && github.repository_owner != 'forgejo-release'
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Install docker
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -8,15 +8,18 @@ on:
|
|||
- examples/lxc-systemd/**
|
||||
- .forgejo/workflows/example-lxc-systemd.yml
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
env:
|
||||
SERIAL: "30"
|
||||
LIFETIME: "60"
|
||||
SYSTEMD_OPTIONS: "--no-pager --full"
|
||||
USE_VERSION: 11.0.7 # renovate: datasource=docker depName=code.forgejo.org/forgejo/forgejo
|
||||
|
||||
jobs:
|
||||
example-lxc-systemd:
|
||||
if: github.repository_owner != 'forgejo-integration' && github.repository_owner != 'forgejo-experimental' && github.repository_owner != 'forgejo-release'
|
||||
runs-on: lxc-bookworm
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-trixie
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
|
|
@ -43,7 +46,7 @@ jobs:
|
|||
done
|
||||
|
||||
cd examples/lxc-systemd
|
||||
VERBOSE=true ./forgejo-runner-service.sh upgrade 1.2.3 $(pwd)/forgejo-runner-service.sh
|
||||
VERBOSE=true ./forgejo-runner-service.sh upgrade file://$(pwd)/forgejo-runner-service.sh
|
||||
|
||||
for script in $scripts; do
|
||||
! grep --quiet something $bin/$script
|
||||
|
|
@ -51,11 +54,11 @@ jobs:
|
|||
done
|
||||
|
||||
- id: forgejo
|
||||
uses: https://data.forgejo.org/actions/setup-forgejo@v2.0.7
|
||||
uses: https://data.forgejo.org/actions/setup-forgejo@v3.0.4
|
||||
with:
|
||||
user: root
|
||||
password: admin1234
|
||||
binary: https://code.forgejo.org/forgejo/forgejo/releases/download/v7.0.12/forgejo-7.0.12-linux-amd64
|
||||
binary: https://code.forgejo.org/forgejo/forgejo/releases/download/v${{ env.USE_VERSION }}/forgejo-${{ env.USE_VERSION }}-linux-amd64
|
||||
# must be the same as LXC_IPV4_PREFIX in examples/lxc-systemd/forgejo-runner-service.sh
|
||||
lxc-ip-prefix: 10.105.7
|
||||
|
||||
|
|
@ -121,8 +124,8 @@ jobs:
|
|||
started_running=/etc/forgejo-runner/$serial/started-running
|
||||
killed_gracefully=/etc/forgejo-runner/$serial/killed-gracefully
|
||||
stopped_gracefully=/etc/forgejo-runner/$serial/stopped-gracefully
|
||||
retry --delay 5 --times 20 cp -a $started_running /tmp/first-run
|
||||
retry --delay 1 --times 30 grep --quiet 'Starting runner daemon' /var/log/forgejo-runner/$serial.log
|
||||
retry --delay 10 --times 20 cp -a $started_running /tmp/first-run
|
||||
retry --delay 2 --times 30 grep --quiet 'Starting runner daemon' /var/log/forgejo-runner/$serial.log
|
||||
systemctl stop forgejo-runner@$serial
|
||||
! systemctl $all status forgejo-runner@$serial
|
||||
ls -l /etc/forgejo-runner/$serial
|
||||
|
|
@ -134,7 +137,7 @@ jobs:
|
|||
! test -f $killed_gracefully
|
||||
! test -f $stopped_gracefully
|
||||
lifetime=${{ env.LIFETIME }}
|
||||
# give it time to restart at least once
|
||||
: give it time to restart at least once
|
||||
ls -l /etc/forgejo-runner/$serial
|
||||
sleep $lifetime ; sleep $lifetime
|
||||
ls -l /etc/forgejo-runner/$serial
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
# vars.FROM_OWNER: forgejo-integration
|
||||
# vars.TO_OWNER: forgejo
|
||||
# vars.DOER: release-team
|
||||
# vars.ROLE: forgejo-release
|
||||
# secrets.TOKEN: <generated from code.forgejo.org/release-team>
|
||||
# secrets.GPG_PRIVATE_KEY: <XYZ>
|
||||
# secrets.GPG_PASSPHRASE: <ABC>
|
||||
|
|
@ -19,24 +20,35 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: lxc-bookworm
|
||||
if: vars.DOER != '' && vars.FORGEJO != '' && vars.TO_OWNER != '' && vars.FROM_OWNER != '' && secrets.TOKEN != ''
|
||||
if: vars.ROLE == 'forgejo-release'
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: copy & sign
|
||||
uses: https://data.forgejo.org/forgejo/forgejo-build-publish/publish@v5.3.1
|
||||
uses: https://data.forgejo.org/forgejo/forgejo-build-publish/publish@v5.4.1
|
||||
with:
|
||||
from-forgejo: ${{ vars.FORGEJO }}
|
||||
to-forgejo: ${{ vars.FORGEJO }}
|
||||
from-owner: ${{ vars.FROM_OWNER }}
|
||||
to-owner: ${{ vars.TO_OWNER }}
|
||||
repo: "runner"
|
||||
release-notes: "See https://code.forgejo.org/forgejo/runner/src/branch/main/RELEASE-NOTES.md#{ANCHOR}"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
sha: ${{ github.sha }}
|
||||
release-notes: |
|
||||
- [User guide](https://forgejo.org/docs/next/user/actions/overview/)
|
||||
- [Administrator guide](https://forgejo.org/docs/next/admin/actions/)
|
||||
- [Container images](https://code.forgejo.org/forgejo/-/packages/container/runner/versions)
|
||||
|
||||
Release Notes
|
||||
|
||||
---
|
||||
|
||||
release-notes-assistant: true
|
||||
ref-name: ${{ forge.ref_name }}
|
||||
sha: ${{ forge.sha }}
|
||||
container-suffixes: " "
|
||||
from-token: ${{ secrets.TOKEN }}
|
||||
to-doer: ${{ vars.DOER }}
|
||||
|
|
|
|||
43
.forgejo/workflows/release-notes-assistant.yml
Normal file
43
.forgejo/workflows/release-notes-assistant.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#
|
||||
# secrets.RELEASE_NOTES_ASSISTANT_TOKEN issued by https://code.forgejo.org/release-notes-assistant-bot with write:issue, write:repository, write:organization and member of the https://code.forgejo.org/org/forgejo/teams/release-notes-assistant team that further tune the access
|
||||
#
|
||||
name: issue-labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- labeled
|
||||
|
||||
env:
|
||||
RNA_VERSION: v1.4.1 # renovate: datasource=forgejo-releases depName=forgejo/release-notes-assistant
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
if: vars.ROLE == 'forgejo-coding' && !contains(forge.event.pull_request.labels.*.name, 'Kind/DependencyUpdate')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'data.forgejo.org/oci/ci:1'
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: event
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge.event.pull_request.labels.*.name) }}
|
||||
EOF
|
||||
cat <<'EOF'
|
||||
${{ toJSON(forge.event) }}
|
||||
EOF
|
||||
|
||||
- name: install release-notes-assistant
|
||||
run: |
|
||||
set -x
|
||||
wget -O /usr/local/bin/rna https://code.forgejo.org/forgejo/release-notes-assistant/releases/download/${{ env.RNA_VERSION}}/release-notes-assistant
|
||||
chmod +x /usr/local/bin/rna
|
||||
|
||||
- name: release-notes-assistant preview
|
||||
run: |
|
||||
rna --verbose --storage pr --storage-location ${{ forge.event.pull_request.number }} --forgejo-url $FORGEJO_SERVER_URL --repository $FORGEJO_REPOSITORY --token ${{ secrets.RELEASE_NOTES_ASSISTANT_TOKEN }} preview ${{ forge.event.pull_request.number }}
|
||||
|
|
@ -5,42 +5,43 @@ on:
|
|||
- 'main'
|
||||
pull_request:
|
||||
|
||||
enable-email-notifications: true
|
||||
|
||||
env:
|
||||
FORGEJO_HOST_PORT: 'forgejo:3000'
|
||||
FORGEJO_ADMIN_USER: 'root'
|
||||
FORGEJO_ADMIN_PASSWORD: 'admin1234'
|
||||
FORGEJO_RUNNER_SECRET: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
FORGEJO_SCRIPT: |
|
||||
/bin/s6-svscan /etc/s6 & sleep 10 ; su -c "forgejo admin user create --admin --username $FORGEJO_ADMIN_USER --password $FORGEJO_ADMIN_PASSWORD --email root@example.com" git && su -c "forgejo forgejo-cli actions register --labels docker --name therunner --secret $FORGEJO_RUNNER_SECRET" git && sleep infinity
|
||||
GOPROXY: https://goproxy.io,direct
|
||||
/usr/bin/s6-svscan /etc/s6 & sleep 10 ; su -c "forgejo admin user create --admin --username $FORGEJO_ADMIN_USER --password $FORGEJO_ADMIN_PASSWORD --email root@example.com" git && su -c "forgejo forgejo-cli actions register --labels docker --name therunner --secret $FORGEJO_RUNNER_SECRET" git && sleep infinity
|
||||
|
||||
jobs:
|
||||
build-and-tests:
|
||||
name: build and test
|
||||
if: github.repository_owner != 'forgejo-integration' && github.repository_owner != 'forgejo-experimental' && github.repository_owner != 'forgejo-release'
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'code.forgejo.org/oci/ci:1'
|
||||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:9
|
||||
image: code.forgejo.org/forgejo/forgejo:11
|
||||
env:
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
FORGEJO__log__LEVEL: "debug"
|
||||
FORGEJO__actions__ENABLED: "true"
|
||||
FORGEJO_ADMIN_USER: ${{ env.FORGEJO_ADMIN_USER }}
|
||||
FORGEJO_ADMIN_PASSWORD: ${{ env.FORGEJO_ADMIN_PASSWORD }}
|
||||
FORGEJO_RUNNER_SECRET: ${{ env.FORGEJO_RUNNER_SECRET }}
|
||||
FORGEJO_ADMIN_USER: root
|
||||
FORGEJO_ADMIN_PASSWORD: admin1234
|
||||
FORGEJO_RUNNER_SECRET: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
cmd:
|
||||
- 'bash'
|
||||
- '-c'
|
||||
- ${{ env.FORGEJO_SCRIPT }}
|
||||
- '/usr/bin/s6-svscan /etc/s6 & sleep 10 ; su -c "forgejo admin user create --admin --username $FORGEJO_ADMIN_USER --password $FORGEJO_ADMIN_PASSWORD --email root@example.com" git && su -c "forgejo forgejo-cli actions register --labels docker --name therunner --secret $FORGEJO_RUNNER_SECRET" git && sleep infinity'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
|
@ -49,8 +50,8 @@ jobs:
|
|||
set -ex
|
||||
toolchain=$(grep -oP '(?<=toolchain ).+' go.mod)
|
||||
version=$(go version | cut -d' ' -f3)
|
||||
if [ "$toolchain" != "$version" ]; then
|
||||
echo "go version mismatch: $toolchain <> $version"
|
||||
if dpkg --compare-versions ${version#go} lt ${toolchain#go}; then
|
||||
echo "go version too low: $toolchain >= $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ jobs:
|
|||
|
||||
- run: make build
|
||||
|
||||
- uses: https://code.forgejo.org/actions/upload-artifact@v3
|
||||
- uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||
with:
|
||||
name: forgejo-runner
|
||||
path: forgejo-runner
|
||||
|
|
@ -72,19 +73,20 @@ jobs:
|
|||
- run: make FORGEJO_URL=http://$FORGEJO_HOST_PORT test
|
||||
|
||||
runner-exec-tests:
|
||||
needs: [build-and-tests]
|
||||
name: runner exec tests
|
||||
if: github.repository_owner != 'forgejo-integration' && github.repository_owner != 'forgejo-experimental' && github.repository_owner != 'forgejo-release'
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-bookworm
|
||||
|
||||
needs: [build-and-tests]
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://code.forgejo.org/actions/download-artifact@v3
|
||||
- uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||
with:
|
||||
name: forgejo-runner
|
||||
|
||||
- run: chmod +x forgejo-runner
|
||||
|
||||
- name: install docker
|
||||
run: |
|
||||
mkdir /etc/docker
|
||||
|
|
@ -111,10 +113,162 @@ jobs:
|
|||
- name: forgejo-runner exec --enable-ipv6
|
||||
run: |
|
||||
set -x
|
||||
chmod +x forgejo-runner
|
||||
./forgejo-runner exec --enable-ipv6 --workflows .forgejo/testdata/ipv6.yml
|
||||
if ./forgejo-runner exec --workflows .forgejo/testdata/ipv6.yml >& /tmp/out ; then
|
||||
cat /tmp/out
|
||||
echo "IPv6 not enabled, should fail"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: forgejo-runner exec --var
|
||||
run: |
|
||||
set -x
|
||||
./forgejo-runner exec --var MY_VAR=testvariable --workflows .forgejo/testdata/var.yml |& tee /tmp/var.out
|
||||
grep --quiet 'Success - Main echo "VAR -> testvariable"' /tmp/var.out
|
||||
|
||||
integration-tests:
|
||||
name: integration tests
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-bookworm
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: apt install docker.io
|
||||
run: |
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get -q install -qq -y docker.io
|
||||
|
||||
- run: apt-get -q install -qq -y gcc # required for `-race`
|
||||
- name: integration test
|
||||
run: |
|
||||
go test -race ./act/container
|
||||
go test -race -timeout 30m ./act/runner/...
|
||||
|
||||
runner-integration-tests:
|
||||
name: runner integration tests
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: lxc-bookworm
|
||||
needs: [build-and-tests]
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: install docker
|
||||
run: |
|
||||
mkdir /etc/docker
|
||||
cat > /etc/docker/daemon.json <<EOF
|
||||
{
|
||||
"ipv6": true,
|
||||
"experimental": true,
|
||||
"ip6tables": true,
|
||||
"fixed-cidr-v6": "fd05:d0ca:1::/64",
|
||||
"default-address-pools": [
|
||||
{
|
||||
"base": "172.19.0.0/16",
|
||||
"size": 24
|
||||
},
|
||||
{
|
||||
"base": "fd05:d0ca:2::/104",
|
||||
"size": 112
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
apt --quiet install --yes -qq docker.io make
|
||||
|
||||
- name: install LXC
|
||||
run: |
|
||||
act/runner/lxc-helpers.sh lxc_prepare_environment
|
||||
act/runner/lxc-helpers.sh lxc_install_lxc_inside 10.39.28 fdb1
|
||||
|
||||
- run: apt-get -q install -qq -y gcc # required for `-race`
|
||||
|
||||
- run: make integration-test
|
||||
|
||||
validate-mocks:
|
||||
name: validate mocks
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'code.forgejo.org/oci/ci:1'
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: generate mocks
|
||||
run: |
|
||||
set -ex
|
||||
make deps-tools
|
||||
make generate
|
||||
make fmt
|
||||
|
||||
- name: validate mocks
|
||||
run: |
|
||||
git diff --ignore-matching-lines='Code generated by mockery.*DO NOT EDIT' --quiet || {
|
||||
echo "[ERROR] Please apply the changes mockery suggests:"
|
||||
git diff --color=always
|
||||
exit 1
|
||||
}
|
||||
|
||||
validate-pre-commit:
|
||||
name: validate pre-commit-hooks file
|
||||
if: vars.ROLE == 'forgejo-coding'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: 'code.forgejo.org/oci/ci:1'
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: install pre-commit
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
PIP_ROOT_USER_ACTION: ignore
|
||||
PIP_BREAK_SYSTEM_PACKAGES: 1
|
||||
PIP_PROGRESS_BAR: off
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get -q install -qq -y python3-pip
|
||||
python3 -m pip install 'pre-commit>=3.2.0'
|
||||
|
||||
- name: validate .pre-commit-hooks.yaml
|
||||
run: pre-commit validate-manifest .pre-commit-hooks.yaml
|
||||
|
||||
# Will fail due to `act/runner/testdata/local-action-fails-schema-validation/action/action.yml`
|
||||
- name: check pre-commit hook against local action files (should fail)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pre-commit try-repo --all-files --verbose . forgejo-runner-validate
|
||||
|
||||
- name: check that a bad workflow file doesn’t validate (should fail)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p test-repo
|
||||
cd test-repo
|
||||
git config set advice.defaultBranchName false
|
||||
git init --quiet
|
||||
mkdir -p .forgejo/workflows
|
||||
cp ../act/runner/testdata/local-action-fails-schema-validation/action/action.yml ./
|
||||
touch .forgejo/workflows/bad-workflow.yml
|
||||
cat > .pre-commit-config.yaml <<EOF
|
||||
repos:
|
||||
- repo: ..
|
||||
rev: ${{ forge.sha }}
|
||||
hooks:
|
||||
- id: forgejo-runner-validate
|
||||
EOF
|
||||
git add .
|
||||
pre-commit run --all-files --verbose forgejo-runner-validate
|
||||
|
|
|
|||
50
.github/workflows/build-release.yml
vendored
50
.github/workflows/build-release.yml
vendored
|
|
@ -1,50 +0,0 @@
|
|||
# This workflow will build a Windows binary for each architecture and upload it as an artifact.
|
||||
# If the push is a tag, it will create a release with the binaries attached.
|
||||
# This build is currently supported on https://github.com/Crown0815/forgejo-runner-windows
|
||||
|
||||
name: Build release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: 'v*'
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{matrix.architecture}}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: ['386', amd64, arm, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build for ${{matrix.architecture}}
|
||||
run: |
|
||||
env GOOS=windows GOARCH=${{matrix.architecture}} \
|
||||
go build \
|
||||
-ldflags "-s -w -X gitea.com/gitea/act_runner/internal/pkg/ver.version=${{ github.ref_name }}" \
|
||||
-o forgejo-runner-windows-${{matrix.architecture}}.exe
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: forgejo-runner-windows-${{matrix.architecture}}
|
||||
path: forgejo-runner-windows-${{matrix.architecture}}.exe
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: .
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: forgejo-runner-windows-*/forgejo-runner-windows-*.exe
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
fail_on_unmatched_files: true
|
||||
body: See [original release notes](https://code.forgejo.org/forgejo/runner/releases/tag/${{ github.ref_name }}).
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,7 +4,6 @@ forgejo-runner
|
|||
.env
|
||||
.runner
|
||||
coverage.txt
|
||||
/gitea-vet
|
||||
/config.yaml
|
||||
|
||||
# MS VSCode
|
||||
|
|
|
|||
318
.golangci.yml
318
.golangci.yml
|
|
@ -1,165 +1,167 @@
|
|||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- gosimple
|
||||
- typecheck
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- dupl
|
||||
#- gocyclo # The cyclomatic complexety of a lot of functions is too high, we should refactor those another time.
|
||||
- gofmt
|
||||
- misspell
|
||||
- gocritic
|
||||
- bidichk
|
||||
- ineffassign
|
||||
- revive
|
||||
- gofumpt
|
||||
- depguard
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocritic
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- unconvert
|
||||
- wastedassign
|
||||
- nolintlint
|
||||
- stylecheck
|
||||
enable-all: false
|
||||
disable-all: true
|
||||
fast: false
|
||||
|
||||
run:
|
||||
go: 1.18
|
||||
timeout: 10m
|
||||
skip-dirs:
|
||||
- node_modules
|
||||
- public
|
||||
- web_src
|
||||
|
||||
linters-settings:
|
||||
stylecheck:
|
||||
checks: ["all", "-ST1005", "-ST1003"]
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- ifElseChain
|
||||
- singleCaseSwitch # Every time this occurred in the code, there was no other way.
|
||||
revive:
|
||||
ignore-generated-header: false
|
||||
severity: warning
|
||||
confidence: 0.8
|
||||
errorCode: 1
|
||||
warningCode: 1
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
- usetesting
|
||||
- wastedassign
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: io/ioutil
|
||||
desc: use os or io instead
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- ifElseChain
|
||||
- singleCaseSwitch
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
revive:
|
||||
confidence: 0.8
|
||||
severity: warning
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: dot-imports
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: var-naming
|
||||
arguments: [[], [], [{ skipPackageNameChecks: true }]]
|
||||
- name: var-declaration
|
||||
- name: package-comments
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
- name: errorf
|
||||
- name: duplicated-imports
|
||||
- name: modifies-value-receiver
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -ST1003
|
||||
- -ST1005
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: dot-imports
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: var-naming
|
||||
- name: var-declaration
|
||||
- name: package-comments
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
- name: errorf
|
||||
- name: duplicated-imports
|
||||
- name: modifies-value-receiver
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
lang-version: "1.18"
|
||||
depguard:
|
||||
# TODO: use depguard to replace import checks in gitea-vet
|
||||
list-type: denylist
|
||||
# Check the list against standard lib.
|
||||
include-go-root: true
|
||||
packages-with-error-message:
|
||||
- github.com/unknwon/com: "use gitea's util and replacements"
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
# Exclude some linters from running on tests files.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- dupl
|
||||
- gosec
|
||||
- unparam
|
||||
- staticcheck
|
||||
- path: models/migrations/v
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- dupl
|
||||
- gosec
|
||||
- linters:
|
||||
- dupl
|
||||
text: "webhook"
|
||||
- linters:
|
||||
- gocritic
|
||||
text: "`ID' should not be capitalized"
|
||||
- path: modules/templates/helper.go
|
||||
linters:
|
||||
- gocritic
|
||||
- linters:
|
||||
- unused
|
||||
text: "swagger"
|
||||
- path: contrib/pr/checkout.go
|
||||
linters:
|
||||
- errcheck
|
||||
- path: models/issue.go
|
||||
linters:
|
||||
- errcheck
|
||||
- path: models/migrations/
|
||||
linters:
|
||||
- errcheck
|
||||
- path: modules/log/
|
||||
linters:
|
||||
- errcheck
|
||||
- path: routers/api/v1/repo/issue_subscription.go
|
||||
linters:
|
||||
- dupl
|
||||
- path: routers/repo/view.go
|
||||
linters:
|
||||
- dupl
|
||||
- path: models/migrations/
|
||||
linters:
|
||||
- unused
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "argument x is overwritten before first use"
|
||||
- path: modules/httplib/httplib.go
|
||||
linters:
|
||||
- staticcheck
|
||||
# Enabling this would require refactoring the methods and how they are called.
|
||||
- path: models/issue_comment_list.go
|
||||
linters:
|
||||
- dupl
|
||||
- linters:
|
||||
- misspell
|
||||
text: '`Unknwon` is a misspelling of `Unknown`'
|
||||
- path: models/update.go
|
||||
linters:
|
||||
- unused
|
||||
- path: cmd/dump.go
|
||||
linters:
|
||||
- dupl
|
||||
- text: "commentFormatting: put a space between `//` and comment text"
|
||||
linters:
|
||||
- gocritic
|
||||
- text: "exitAfterDefer:"
|
||||
linters:
|
||||
- gocritic
|
||||
- path: modules/graceful/manager_windows.go
|
||||
linters:
|
||||
- staticcheck
|
||||
text: "svc.IsAnInteractiveSession is deprecated: Use IsWindowsService instead."
|
||||
- path: models/user/openid.go
|
||||
linters:
|
||||
- golint
|
||||
- linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
- staticcheck
|
||||
- unparam
|
||||
path: _test\.go
|
||||
- linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
path: models/migrations/v
|
||||
- linters:
|
||||
- dupl
|
||||
text: webhook
|
||||
- linters:
|
||||
- gocritic
|
||||
text: '`ID'' should not be capitalized'
|
||||
- linters:
|
||||
- gocritic
|
||||
path: modules/templates/helper.go
|
||||
- linters:
|
||||
- unused
|
||||
text: swagger
|
||||
- linters:
|
||||
- errcheck
|
||||
path: contrib/pr/checkout.go
|
||||
- linters:
|
||||
- errcheck
|
||||
path: models/issue.go
|
||||
- linters:
|
||||
- errcheck
|
||||
path: models/migrations/
|
||||
- linters:
|
||||
- errcheck
|
||||
path: modules/log/
|
||||
- linters:
|
||||
- dupl
|
||||
path: routers/api/v1/repo/issue_subscription.go
|
||||
- linters:
|
||||
- dupl
|
||||
path: routers/repo/view.go
|
||||
- linters:
|
||||
- unused
|
||||
path: models/migrations/
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: argument x is overwritten before first use
|
||||
- linters:
|
||||
- staticcheck
|
||||
path: modules/httplib/httplib.go
|
||||
- linters:
|
||||
- dupl
|
||||
path: models/issue_comment_list.go
|
||||
- linters:
|
||||
- misspell
|
||||
text: '`Unknwon` is a misspelling of `Unknown`'
|
||||
- linters:
|
||||
- unused
|
||||
path: models/update.go
|
||||
- linters:
|
||||
- dupl
|
||||
path: cmd/dump.go
|
||||
- linters:
|
||||
- gocritic
|
||||
text: 'commentFormatting: put a space between `//` and comment text'
|
||||
- linters:
|
||||
- gocritic
|
||||
text: 'exitAfterDefer:'
|
||||
- linters:
|
||||
- staticcheck
|
||||
path: modules/graceful/manager_windows.go
|
||||
text: 'svc.IsAnInteractiveSession is deprecated: Use IsWindowsService instead.'
|
||||
- linters:
|
||||
- golint
|
||||
path: models/user/openid.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- gofumpt
|
||||
settings:
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
|
|
|||
60
.goreleaser.yaml
Normal file
60
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- formats: [binary]
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
|
||||
changelog:
|
||||
abbrev: 10
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
format: "{{.SHA}}: {{.Message}}"
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
|
||||
order: 0
|
||||
- title: "Bug fixes"
|
||||
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: "Chores"
|
||||
regexp: '^.*?chore(\([[:word:]]+\))??!?:.+$'
|
||||
order: 2
|
||||
- title: Others
|
||||
order: 999
|
||||
sort: asc
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: DevFW-CICD
|
||||
name: runner
|
||||
|
||||
force_token: gitea
|
||||
gitea_urls:
|
||||
api: https://edp.buildth.ing/api/v1
|
||||
download: https://edp.buildth.ing
|
||||
# set to true if you use a self-signed certificate
|
||||
skip_tls_verify: false
|
||||
13
.pre-commit-hooks.yaml
Normal file
13
.pre-commit-hooks.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
- id: forgejo-runner-validate
|
||||
name: Validate Forgejo Actions files
|
||||
description: This hook validates Forgejo Actions action and workflow files.
|
||||
language: golang
|
||||
entry: runner validate
|
||||
args: ['--directory', '.']
|
||||
pass_filenames: false
|
||||
files: (?:(?:^|/)action|^\.(?:forgejo|github|gitea)/workflows/[^/\n]+)\.ya?ml$
|
||||
types: [yaml]
|
||||
# 3.2.0 is when the pre-* `stages` used here were added.
|
||||
# Old names (without the pre- prefix) are deprecated since 4.0.0.
|
||||
minimum_pre_commit_version: '3.2.0'
|
||||
stages: [pre-commit, pre-merge-commit, pre-push, manual]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
FROM --platform=$BUILDPLATFORM code.forgejo.org/oci/xx AS xx
|
||||
FROM --platform=$BUILDPLATFORM data.forgejo.org/oci/xx AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM code.forgejo.org/oci/golang:1.23-alpine3.20 AS build-env
|
||||
FROM --platform=$BUILDPLATFORM data.forgejo.org/oci/golang:1.25-alpine3.22 AS build-env
|
||||
|
||||
#
|
||||
# Transparently cross compile for the target platform
|
||||
|
|
@ -19,7 +19,7 @@ WORKDIR /srv
|
|||
|
||||
RUN make clean && make build
|
||||
|
||||
FROM code.forgejo.org/oci/alpine:3.20
|
||||
FROM data.forgejo.org/oci/alpine:3.22
|
||||
ARG RELEASE_VERSION
|
||||
RUN apk add --no-cache git bash
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ LABEL maintainer="contact@forgejo.org" \
|
|||
org.opencontainers.image.source="https://code.forgejo.org/forgejo/runner" \
|
||||
org.opencontainers.image.version="${RELEASE_VERSION}" \
|
||||
org.opencontainers.image.vendor="Forgejo" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.licenses="GPL-3.0-or-later" \
|
||||
org.opencontainers.image.title="Forgejo Runner" \
|
||||
org.opencontainers.image.description="A runner for Forgejo Actions."
|
||||
|
||||
|
|
|
|||
688
LICENSE
688
LICENSE
|
|
@ -1,20 +1,674 @@
|
|||
Copyright (c) 2023 The Forgejo Authors
|
||||
Copyright (c) 2022 The Gitea Authors
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
Preamble
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
|
|||
86
Makefile
86
Makefile
|
|
@ -3,12 +3,9 @@ EXECUTABLE := forgejo-runner
|
|||
GOFMT ?= gofumpt -l
|
||||
DIST := dist
|
||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
GO ?= $(shell go env GOROOT)/bin/go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
||||
XGO_VERSION := go-1.21.x
|
||||
GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
|
|
@ -16,6 +13,9 @@ WINDOWS_ARCHS ?= windows/amd64
|
|||
GO_FMT_FILES := $(shell find . -type f -name "*.go" ! -name "generated.*")
|
||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||
|
||||
MOCKERY_PACKAGE ?= github.com/vektra/mockery/v2@v2.53.5 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 # renovate: datasource=go
|
||||
|
||||
DOCKER_IMAGE ?= gitea/act_runner
|
||||
DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
|
|
@ -46,7 +46,7 @@ STORED_VERSION_FILE := VERSION
|
|||
|
||||
ifneq ($(DRONE_TAG),)
|
||||
VERSION ?= $(subst v,,$(DRONE_TAG))
|
||||
RELASE_VERSION ?= $(VERSION)
|
||||
RELEASE_VERSION ?= $(VERSION)
|
||||
else
|
||||
ifneq ($(DRONE_BRANCH),)
|
||||
VERSION ?= $(subst release/v,,$(DRONE_BRANCH))
|
||||
|
|
@ -56,20 +56,27 @@ else
|
|||
|
||||
STORED_VERSION=$(shell cat $(STORED_VERSION_FILE) 2>/dev/null)
|
||||
ifneq ($(STORED_VERSION),)
|
||||
RELASE_VERSION ?= $(STORED_VERSION)
|
||||
RELEASE_VERSION ?= $(STORED_VERSION)
|
||||
else
|
||||
RELASE_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
|
||||
RELEASE_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
|
||||
endif
|
||||
endif
|
||||
|
||||
GO_PACKAGES_TO_VET ?= $(filter-out gitea.com/gitea/act_runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
|
||||
|
||||
GO_PACKAGES_TO_VET ?= $(filter-out code.forgejo.org/forgejo/runner/v11/internal/pkg/client/mocks code.forgejo.org/forgejo/runner/v11/act/artifactcache/mock_caches.go,$(shell $(GO) list ./...))
|
||||
|
||||
TAGS ?=
|
||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||
LDFLAGS ?= -X "code.forgejo.org/forgejo/runner/v11/internal/pkg/ver.version=v$(RELEASE_VERSION)"
|
||||
|
||||
all: build
|
||||
|
||||
.PHONY: lint-check
|
||||
lint-check:
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run $(GOLANGCI_LINT_ARGS)
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run $(GOLANGCI_LINT_ARGS) --fix
|
||||
|
||||
fmt:
|
||||
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GO) install mvdan.cc/gofumpt@latest; \
|
||||
|
|
@ -98,67 +105,38 @@ fmt-check:
|
|||
exit 1; \
|
||||
fi;
|
||||
|
||||
test: fmt-check
|
||||
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
test: lint-check fmt-check
|
||||
$(GO) test -v -race -short -cover -coverprofile coverage.txt ./internal/...
|
||||
$(GO) test -race -short ./act/container
|
||||
$(GO) test -race ./act/artifactcache/... ./act/workflowpattern/... ./act/filecollector/... ./act/common/... ./act/jobparser ./act/model ./act/exprparser ./act/schema
|
||||
|
||||
integration-test:
|
||||
@$(GO) test -race -v ./internal/app/run/...
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
@echo "Running go vet..."
|
||||
@$(GO) vet $(GO_PACKAGES_TO_VET)
|
||||
|
||||
.PHONY: generate
|
||||
generate:
|
||||
$(GO) generate ./...
|
||||
|
||||
install: $(GOFILES)
|
||||
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
|
||||
|
||||
build: go-check $(EXECUTABLE)
|
||||
|
||||
$(EXECUTABLE): $(GOFILES)
|
||||
$(EXECUTABLE): $(GOFILES) act/schema/action_schema.json act/schema/workflow_schema.json
|
||||
$(GO) build -v -tags 'netgo osusergo $(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@
|
||||
|
||||
.PHONY: deps-backend
|
||||
deps-backend:
|
||||
$(GO) mod download
|
||||
$(GO) install $(GXZ_PAGAGE)
|
||||
$(GO) install $(XGO_PACKAGE)
|
||||
|
||||
.PHONY: release
|
||||
release: release-windows release-linux release-darwin release-copy release-compress release-check
|
||||
.PHONY: deps-tools
|
||||
deps-tools:
|
||||
$(GO) install $(MOCKERY_PACKAGE)
|
||||
|
||||
$(DIST_DIRS):
|
||||
mkdir -p $(DIST_DIRS)
|
||||
|
||||
.PHONY: release-windows
|
||||
release-windows: | $(DIST_DIRS)
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||
ifeq ($(CI),true)
|
||||
cp -r /build/* $(DIST)/binaries/
|
||||
endif
|
||||
|
||||
.PHONY: release-linux
|
||||
release-linux: | $(DIST_DIRS)
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||
ifeq ($(CI),true)
|
||||
cp -r /build/* $(DIST)/binaries/
|
||||
endif
|
||||
|
||||
.PHONY: release-darwin
|
||||
release-darwin: | $(DIST_DIRS)
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '$(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||
ifeq ($(CI),true)
|
||||
cp -r /build/* $(DIST)/binaries/
|
||||
endif
|
||||
|
||||
.PHONY: release-copy
|
||||
release-copy: | $(DIST_DIRS)
|
||||
cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done;
|
||||
|
||||
.PHONY: release-check
|
||||
release-check: | $(DIST_DIRS)
|
||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;
|
||||
|
||||
.PHONY: release-compress
|
||||
release-compress: | $(DIST_DIRS)
|
||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PAGAGE) -k -9 $${file}; done;
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
if ! docker buildx version >/dev/null 2>&1; then \
|
||||
|
|
|
|||
133
README.md
133
README.md
|
|
@ -1,94 +1,73 @@
|
|||
# Forgejo Runner
|
||||
|
||||
**WARNING:** this is [alpha release quality](https://en.wikipedia.org/wiki/Software_release_life_cycle#Alpha) code and should not be considered secure enough to deploy in production.
|
||||
A daemon that connects to a Forgejo instance and runs jobs for continuous integration. The [installation and usage instructions](https://forgejo.org/docs/next/admin/actions/) are part of the Forgejo documentation.
|
||||
|
||||
A daemon that connects to a Forgejo instance and runs jobs for continous integration. The [installation and usage instructions](https://forgejo.org/docs/next/admin/actions/) are part of the Forgejo documentation.
|
||||
# Reporting security-related issues
|
||||
|
||||
# Reporting bugs
|
||||
Sensitive security-related issues should be reported to [security@forgejo.org](mailto:security@forgejo.org) using [encryption](https://keyoxide.org/security@forgejo.org).
|
||||
|
||||
When filing a bug in [the issue tracker](https://code.forgejo.org/forgejo/runner/issues), it is very helpful to propose a pull request [in the end-to-end tests](https://code.forgejo.org/forgejo/end-to-end/src/branch/main/actions) repository that adds a reproducer. It will fail the CI and unambiguously demonstrate that the problem exists. In most cases it is enough to add a workflow ([see the echo example](https://code.forgejo.org/forgejo/end-to-end/src/branch/main/actions/example-echo)). For more complicated cases it is also possible to add a runner config file as well as shell scripts to setup and teardown the test case ([see the service example](https://code.forgejo.org/forgejo/end-to-end/src/branch/main/actions/example-service)).
|
||||
## License
|
||||
|
||||
The Forgejo runner is distributed under the terms of the [GPL version 3.0](LICENSE) or any later version.
|
||||
|
||||
# Architectures & OS
|
||||
|
||||
The Forgejo runner is supported and tested on `amd64` and `arm64` ([binaries](https://code.forgejo.org/forgejo/runner/releases) and [containers](https://code.forgejo.org/forgejo/-/packages/container/runner/versions)) on Operating Systems based on the Linux kernel.
|
||||
|
||||
Work may be in progress for other architectures and you can browse the corresponding issues to figure out how they make progress. If you are interested in helping them move forward, open an issue. The most challenging part is to setup and maintain a native runner long term. Once it is supported by Forgejo, the runner is expected to be available 24/7 which can be challenging. Otherwise debugging any architecture specific problem won't be possible.
|
||||
|
||||
- [linux-s390x](https://code.forgejo.org/forgejo/runner/issues?labels=969)
|
||||
- [linux-powerpc64le](https://code.forgejo.org/forgejo/runner/issues?labels=968)
|
||||
- [linux-riscv64](https://code.forgejo.org/forgejo/runner/issues?labels=970)
|
||||
- [Windows](https://code.forgejo.org/forgejo/runner/issues?labels=365)
|
||||
|
||||
# Hacking
|
||||
|
||||
The Forgejo runner depends on [a fork of ACT](https://code.forgejo.org/forgejo/act) and is a dependency of the [setup-forgejo action](https://code.forgejo.org/actions/setup-forgejo). See [the full dependency graph](https://code.forgejo.org/actions/cascading-pr/#forgejo-dependencies) for a global view.
|
||||
The Forgejo runner is a dependency of the [setup-forgejo action](https://code.forgejo.org/actions/setup-forgejo). See [the full dependency graph](https://code.forgejo.org/actions/cascading-pr/#forgejo-dependencies) for a global view.
|
||||
|
||||
## Local debug
|
||||
## Building
|
||||
|
||||
The repositories are checked out in the same directory:
|
||||
- Install [Go](https://go.dev/doc/install) and `make(1)`
|
||||
- `make build`
|
||||
|
||||
- **runner**: [Forgejo runner](https://code.forgejo.org/forgejo/runner)
|
||||
- **act**: [ACT](https://code.forgejo.org/forgejo/act)
|
||||
- **setup-forgejo**: [setup-forgejo](https://code.forgejo.org/actions/setup-forgejo)
|
||||
## Linting
|
||||
|
||||
### Install dependencies
|
||||
- `make lint-check`
|
||||
- `make lint` # will fix some lint errors
|
||||
|
||||
The dependencies are installed manually or with:
|
||||
## Testing
|
||||
|
||||
```shell
|
||||
setup-forgejo/forgejo-dependencies.sh
|
||||
The [workflow](.forgejo/workflows/test.yml) that runs in the CI uses similar commands.
|
||||
|
||||
### Without a Forgejo instance
|
||||
|
||||
- Install [Docker](https://docs.docker.com/engine/install/)
|
||||
- `make test integration-test`
|
||||
|
||||
The `TestRunner_RunEvent` test suite contains most integration tests
|
||||
with real-world workflows and is time-consuming to run. During
|
||||
development, it is helpful to run a specific test through a targeted
|
||||
command such as this:
|
||||
|
||||
- `go test -count=1 -run='TestRunner_RunEvent$/local-action-dockerfile$' ./act/runner`
|
||||
|
||||
### With a Forgejo instance
|
||||
|
||||
- Run a Forgejo instance locally (for instance at http://0.0.0.0:8080) and create as shared secret
|
||||
```sh
|
||||
export FORGEJO_RUNNER_SECRET='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
export FORGEJO_URL=http://0.0.0.0:8080
|
||||
forgejo forgejo-cli actions register --labels docker --name therunner --secret $FORGEJO_RUNNER_SECRET
|
||||
```
|
||||
- `make test integration-test` # which will run addional tests because FORGEJO_URL is set
|
||||
|
||||
### Build the Forgejo runner with the local ACT
|
||||
### end-to-end
|
||||
|
||||
The Forgejo runner is rebuilt with the ACT directory by changing the `runner/go.mod` file to:
|
||||
|
||||
```
|
||||
replace github.com/nektos/act => ../act
|
||||
```
|
||||
|
||||
Running:
|
||||
|
||||
```
|
||||
cd runner ; go mod tidy
|
||||
```
|
||||
|
||||
Building:
|
||||
|
||||
```shell
|
||||
cd runner ; rm -f forgejo-runner ; make forgejo-runner
|
||||
```
|
||||
|
||||
### Launch Forgejo and the runner
|
||||
|
||||
A Forgejo instance is launched with:
|
||||
|
||||
```shell
|
||||
cd setup-forgejo
|
||||
./forgejo.sh setup
|
||||
firefox $(cat forgejo-url)
|
||||
```
|
||||
|
||||
The user is `root` with password `admin1234`. The runner is registered with:
|
||||
|
||||
```
|
||||
cd setup-forgejo
|
||||
docker exec --user 1000 forgejo forgejo actions generate-runner-token > forgejo-runner-token
|
||||
../runner/forgejo-runner register --no-interactive --instance "$(cat forgejo-url)" --name runner --token $(cat forgejo-runner-token) --labels docker:docker://node:20-bullseye,self-hosted:host://-self-hosted,lxc:lxc://debian:bullseye
|
||||
```
|
||||
|
||||
And launched with:
|
||||
|
||||
```shell
|
||||
cd setup-forgejo ; ../runner/forgejo-runner --config runner-config.yml daemon
|
||||
```
|
||||
|
||||
Note that the `runner-config.yml` is required in that particular case
|
||||
to configure the network in `bridge` mode, otherwise the runner will
|
||||
create a network that cannot reach the forgejo instance.
|
||||
|
||||
### Try a sample workflow
|
||||
|
||||
From the Forgejo web interface, create a repository and add the
|
||||
following to `.forgejo/workflows/try.yaml`. It will launch the job and
|
||||
the result can be observed from the `actions` tab.
|
||||
|
||||
```yaml
|
||||
on: [push]
|
||||
jobs:
|
||||
ls:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: |
|
||||
ls ${{ github.workspace }}
|
||||
```
|
||||
- Follow the instructions from the end-to-end tests to [run actions tests locally](https://code.forgejo.org/forgejo/end-to-end#running-from-locally-built-binary).
|
||||
- `./end-to-end.sh actions_teardown` # stop the Forgejo and runner daemons running in the end-to-end environment
|
||||
- `( cd ~/clone-of-the-runner-repo ; make build ; cp forgejo-runner /tmp/forgejo-end-to-end/forgejo-runner )` # install the runner built from sources
|
||||
- `./end-to-end.sh actions_setup 13.0` # start Forgejo v13.0 and the runner daemon in the end-to-end environment
|
||||
- `./end-to-end.sh actions_verify_example echo` # run the [echo workflow](https://code.forgejo.org/forgejo/end-to-end/src/branch/main/actions/example-echo/.forgejo/workflows/test.yml)
|
||||
- `xdg-open http://127.0.0.1:3000/root/example-echo/actions/runs/1` # see the logs workflow
|
||||
- `less /tmp/forgejo-end-to-end/forgejo-runner.log` # analyze the runner logs
|
||||
- `less /tmp/forgejo-end-to-end/forgejo-work-path/log/forgejo.log` # analyze the Forgejo logs
|
||||
|
|
|
|||
|
|
@ -1,5 +1,60 @@
|
|||
# Release Notes
|
||||
|
||||
As of [Forgejo runner v9.0.0](https://code.forgejo.org/forgejo/runner/releases/tag/v9.0.0) the release notes are published in the release itself and this file will no longer be updated.
|
||||
|
||||
## 8.0.1
|
||||
|
||||
* [tolerate strings for fail-fast, max-parallel, timeout-minutes, cancel-timeout-minutes](https://code.forgejo.org/forgejo/act/pulls/203).
|
||||
|
||||
## 8.0.0
|
||||
|
||||
* Breaking change: workflows files go through a [schema validation](https://code.forgejo.org/forgejo/act/pulls/170) and will not run if they do not pass. Some existing workflows may have syntax errors that did not prevent them from running with versions 7.0.0 and below but they will no longer work with versions 8.0.0 and above.
|
||||
Existing workflows can be verified and fixed before upgrading by using `forgejo-runner exec --workflows path-to-the-workflow`. For instance in a workflow where `ruins-on` was typed by mistake instead of `runs-on`:
|
||||
```sh
|
||||
$ forgejo-runner exec --event unknown --workflows ../forgejo/.forgejo/workflows/build-release.yml
|
||||
Error: workflow is not valid. 'build-release.yml': Line: 32 Column 5: Failed to match job-factory: Line: 32 Column 5: Unknown Property ruins-on
|
||||
Line: 32 Column 5: Failed to match workflow-job: Line: 32 Column 5: Unknown Property ruins-on
|
||||
Line: 35 Column 5: Unknown Property steps
|
||||
Forgejo Actions YAML Schema validation error
|
||||
```
|
||||
If the error is not immediately obvious, please file an issue with a copy of the failed workflow and revert to using version 7.0.0 until it is resolved.
|
||||
* Breaking change: the logic assigning labels was updated and refactored:
|
||||
- in the absence of a label or a label, [default to `docker://node:22-bookworm` instead of `docker://node:20-bullseye` or `host`](https://code.forgejo.org/forgejo/runner/issues/134).
|
||||
- if the `lxc` scheme is set with no argument, it defaults to `lxc://debian:bookworm` instead of `lxc://debian:bullseye`.
|
||||
- the `host` schema cannot have any argument, it can no longer be `host://-self-hosted`
|
||||
* Breaking change: [bash fallback to sh if it is not available](https://code.forgejo.org/forgejo/runner/issues/150). It will use `bash` instead of `sh` when a container image is explicitly specified in the step. If a workflow depens on that behavior, it will need to be modified to explictly set the shell to `sh`.
|
||||
* Breaking change: [sanitize network aliases to be valid DNS names](https://code.forgejo.org/forgejo/act/pulls/190). It is breaking for workflows with services that rely on host names (derived from the service name or the job name) that do not match `[^A-Z0-9-]+`. They will be sanitized and a message displayed in the logs showing the sanitized name. The service can either be renamed to match the constraint so it can be used as is. Or the sanitized name can be used. For instance of a PostgreSQL service runs as `data.base` it will be sanitized as `data_base`.
|
||||
* [secrets that contain multiple lines are masked from the output](https://code.forgejo.org/forgejo/runner/pulls/661).
|
||||
* [sum256 the container name so derivations do not overflow](https://code.forgejo.org/forgejo/act/pulls/191).
|
||||
|
||||
## 7.0.0
|
||||
|
||||
* Breaking change: [forgejo-runner exec --forgejo-instance replaces --gitea-instance](https://code.forgejo.org/forgejo/runner/pulls/652).
|
||||
* Breaking change: [forge.FORGEJO_* can be used instead of github.GITHUB_*](https://code.forgejo.org/forgejo/act/pulls/171), e.g. `forge.FORGEJO_REPOSITORY` is the same as `github.GITHUB_REPOSITORY`. The `GITHUB_*` environment variables are preserved indefinitely for backward compatibiliy with existing workflows and actions. A workflow that previously set preset `FORGEJO_*` variables in any context, they will be overridden by this naming change. For instance if `secrets.FORGEJO_TOKEN` was set, it will be set to the automatic token and instead of the value from the secrets of the repository. The same is true for `forge.FORGEJO_REPOSITORY` etc.
|
||||
* [fix a v6.4.0 regression that fail a job when if: false](https://code.forgejo.org/forgejo/runner/issues/660).
|
||||
* [support for forgejo-runner exec --var](https://code.forgejo.org/forgejo/runner/pulls/645).
|
||||
* [do not force WORKING_DIR in service containers](https://code.forgejo.org/forgejo/runner/issues/304).
|
||||
* [remove the local action cache if the remote has changed](https://code.forgejo.org/forgejo/act/pulls/142), e.g. when [DEFAULT_ACTIONS_URL](https://forgejo.org/docs/next/admin/config-cheat-sheet/#actions-actions) is modified in the forgejo configuration.
|
||||
|
||||
## 6.4.0
|
||||
|
||||
**Do not use, it [contains a regression](https://code.forgejo.org/forgejo/runner/issues/660) fixed in 7.0.0.**
|
||||
|
||||
* [Update code.forgejo.org/forgejo/act](https://code.forgejo.org/forgejo/runner/pulls/571) to v1.26.0. This brings [several security updates](https://code.forgejo.org/forgejo/act/compare/v1.25.1...v1.26.0), as well as [offline action caching](https://code.forgejo.org/forgejo/act/commit/613090ecd71f75e6200ded4c9d5424b26a792755).
|
||||
* [Remove unused x-runner-version header](https://code.forgejo.org/forgejo/runner/pulls/496).
|
||||
* [Upgrade lxc-systemd using a URL instead of a version](https://code.forgejo.org/forgejo/runner/pulls/520).
|
||||
* [Correctly use HTTP proxy if insecure is true](https://code.forgejo.org/forgejo/runner/pulls/535).
|
||||
* [Update golang.org/x/crypto](https://code.forgejo.org/forgejo/runner/pulls/562) to a version that is not susceptible to DOS attack.
|
||||
* [Update golang.org/x/net](https://code.forgejo.org/forgejo/runner/pulls/563) to a version with several security fixes.
|
||||
|
||||
## 6.3.1
|
||||
|
||||
* [Fixed an issue which caused data races and timeouts](https://code.forgejo.org/forgejo/act/pulls/109) in certain cases, which would [cause cache storing and retrieval to fail](https://code.forgejo.org/forgejo/runner/issues/509).
|
||||
|
||||
## 6.3.0
|
||||
|
||||
* [Caches are now correctly scoped to repositories](https://code.forgejo.org/forgejo/runner/pulls/503). Require authentication for cache requests, and set up cache proxy to provide authentication transparently and automatically.
|
||||
|
||||
## 6.2.2
|
||||
|
||||
* LXC systemd service unit example script [learned how to upgrade](https://code.forgejo.org/forgejo/runner/pulls/475).
|
||||
|
|
|
|||
324
act/artifactcache/caches.go
Normal file
324
act/artifactcache/caches.go
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
package artifactcache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/timshannon/bolthold"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
//go:generate mockery --inpackage --name caches
|
||||
type caches interface {
|
||||
getDB() *bolthold.Store
|
||||
validateMac(rundata RunData) (string, error)
|
||||
readCache(id uint64, repo string) (*Cache, error)
|
||||
useCache(id uint64) error
|
||||
setgcAt(at time.Time)
|
||||
gcCache()
|
||||
close()
|
||||
|
||||
serve(w http.ResponseWriter, r *http.Request, id uint64)
|
||||
commit(id uint64, size int64) (int64, error)
|
||||
exist(id uint64) (bool, error)
|
||||
write(id, offset uint64, reader io.Reader) error
|
||||
}
|
||||
|
||||
type cachesImpl struct {
|
||||
dir string
|
||||
storage *Storage
|
||||
logger logrus.FieldLogger
|
||||
secret string
|
||||
|
||||
db *bolthold.Store
|
||||
|
||||
gcing atomic.Bool
|
||||
gcAt time.Time
|
||||
}
|
||||
|
||||
func newCaches(dir, secret string, logger logrus.FieldLogger) (caches, error) {
|
||||
c := &cachesImpl{
|
||||
secret: secret,
|
||||
}
|
||||
|
||||
c.logger = logger
|
||||
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.dir = dir
|
||||
|
||||
storage, err := NewStorage(filepath.Join(dir, "cache"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.storage = storage
|
||||
|
||||
file := filepath.Join(c.dir, "bolt.db")
|
||||
db, err := bolthold.Open(file, 0o644, &bolthold.Options{
|
||||
Encoder: json.Marshal,
|
||||
Decoder: json.Unmarshal,
|
||||
Options: &bbolt.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
||||
FreelistType: bbolt.DefaultOptions.FreelistType,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Open(%s): %w", file, err)
|
||||
}
|
||||
c.db = db
|
||||
|
||||
c.gcCache()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *cachesImpl) close() {
|
||||
if c.db != nil {
|
||||
c.db.Close()
|
||||
c.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cachesImpl) getDB() *bolthold.Store {
|
||||
return c.db
|
||||
}
|
||||
|
||||
var findCacheWithIsolationKeyFallback = func(db *bolthold.Store, repo string, keys []string, version, writeIsolationKey string) (*Cache, error) {
|
||||
cache, err := findCache(db, repo, keys, version, writeIsolationKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If read was scoped to WriteIsolationKey and didn't find anything, we can fallback to the non-isolated cache read
|
||||
if cache == nil && writeIsolationKey != "" {
|
||||
cache, err = findCache(db, repo, keys, version, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
func findCache(db *bolthold.Store, repo string, keys []string, version, writeIsolationKey string) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
for _, prefix := range keys {
|
||||
// if a key in the list matches exactly, don't return partial matches
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||
And("Key").Eq(prefix).
|
||||
And("Version").Eq(version).
|
||||
And("WriteIsolationKey").Eq(writeIsolationKey).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache entry equal to %s: %w", prefix, err)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
prefixPattern := fmt.Sprintf("^%s", regexp.QuoteMeta(prefix))
|
||||
re, err := regexp.Compile(prefixPattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||
And("Key").RegExp(re).
|
||||
And("Version").Eq(version).
|
||||
And("WriteIsolationKey").Eq(writeIsolationKey).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err != nil {
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("find cache entry starting with %s: %w", prefix, err)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||
return fmt.Errorf("insert cache: %w", err)
|
||||
}
|
||||
// write back id to db
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
return fmt.Errorf("write back id to db: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cachesImpl) readCache(id uint64, repo string) (*Cache, error) {
|
||||
db := c.getDB()
|
||||
cache := &Cache{}
|
||||
if err := db.Get(id, cache); err != nil {
|
||||
return nil, fmt.Errorf("readCache: Get(%v): %w", id, err)
|
||||
}
|
||||
if cache.Repo != repo {
|
||||
return nil, fmt.Errorf("readCache: Get(%v): cache.Repo %s != repo %s", id, cache.Repo, repo)
|
||||
}
|
||||
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *cachesImpl) useCache(id uint64) error {
|
||||
db := c.getDB()
|
||||
cache := &Cache{}
|
||||
if err := db.Get(id, cache); err != nil {
|
||||
return fmt.Errorf("useCache: Get(%v): %w", id, err)
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
return fmt.Errorf("useCache: Update(%v): %v", cache.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cachesImpl) serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||
c.storage.Serve(w, r, id)
|
||||
}
|
||||
|
||||
func (c *cachesImpl) commit(id uint64, size int64) (int64, error) {
|
||||
return c.storage.Commit(id, size)
|
||||
}
|
||||
|
||||
func (c *cachesImpl) exist(id uint64) (bool, error) {
|
||||
return c.storage.Exist(id)
|
||||
}
|
||||
|
||||
func (c *cachesImpl) write(id, offset uint64, reader io.Reader) error {
|
||||
return c.storage.Write(id, offset, reader)
|
||||
}
|
||||
|
||||
const (
|
||||
keepUsed = 30 * 24 * time.Hour
|
||||
keepUnused = 7 * 24 * time.Hour
|
||||
keepTemp = 5 * time.Minute
|
||||
keepOld = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (c *cachesImpl) setgcAt(at time.Time) {
|
||||
c.gcAt = at
|
||||
}
|
||||
|
||||
func (c *cachesImpl) gcCache() {
|
||||
if c.gcing.Load() {
|
||||
return
|
||||
}
|
||||
if !c.gcing.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer c.gcing.Store(false)
|
||||
|
||||
if time.Since(c.gcAt) < time.Hour {
|
||||
c.logger.Debugf("skip gc: %v", c.gcAt.String())
|
||||
return
|
||||
}
|
||||
c.gcAt = time.Now()
|
||||
c.logger.Debugf("gc: %v", c.gcAt.String())
|
||||
|
||||
db := c.getDB()
|
||||
|
||||
// Remove the caches which are not completed for a while, they are most likely to be broken.
|
||||
var caches []*Cache
|
||||
if err := db.Find(&caches, bolthold.
|
||||
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
|
||||
And("Complete").Eq(false),
|
||||
); err != nil {
|
||||
fatal(c.logger, fmt.Errorf("gc caches not completed: %v", err))
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
c.storage.Remove(cache.ID)
|
||||
if err := db.Delete(cache.ID, cache); err != nil {
|
||||
c.logger.Errorf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
c.logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the old caches which have not been used recently.
|
||||
caches = caches[:0]
|
||||
if err := db.Find(&caches, bolthold.
|
||||
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
|
||||
); err != nil {
|
||||
fatal(c.logger, fmt.Errorf("gc caches old not used: %v", err))
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
c.storage.Remove(cache.ID)
|
||||
if err := db.Delete(cache.ID, cache); err != nil {
|
||||
c.logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
c.logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the old caches which are too old.
|
||||
caches = caches[:0]
|
||||
if err := db.Find(&caches, bolthold.
|
||||
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
|
||||
); err != nil {
|
||||
fatal(c.logger, fmt.Errorf("gc caches too old: %v", err))
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
c.storage.Remove(cache.ID)
|
||||
if err := db.Delete(cache.ID, cache); err != nil {
|
||||
c.logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
c.logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the old caches with the same key and version, keep the latest one.
|
||||
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
||||
if results, err := db.FindAggregate(
|
||||
&Cache{},
|
||||
bolthold.Where("Complete").Eq(true),
|
||||
"Key", "Version",
|
||||
); err != nil {
|
||||
fatal(c.logger, fmt.Errorf("gc aggregate caches: %v", err))
|
||||
} else {
|
||||
for _, result := range results {
|
||||
if result.Count() <= 1 {
|
||||
continue
|
||||
}
|
||||
result.Sort("CreatedAt")
|
||||
caches = caches[:0]
|
||||
result.Reduction(&caches)
|
||||
for _, cache := range caches[:len(caches)-1] {
|
||||
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
|
||||
// Keep it since it has been used recently, even if it's old.
|
||||
// Or it could break downloading in process.
|
||||
continue
|
||||
}
|
||||
c.storage.Remove(cache.ID)
|
||||
if err := db.Delete(cache.ID, cache); err != nil {
|
||||
c.logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
c.logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
act/artifactcache/caches_test.go
Normal file
53
act/artifactcache/caches_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package artifactcache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/timshannon/bolthold"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCacheReadWrite(t *testing.T) {
|
||||
caches, err := newCaches(t.TempDir(), "secret", logrus.New())
|
||||
require.NoError(t, err)
|
||||
defer caches.close()
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
found, err := caches.readCache(456, "repo")
|
||||
assert.Nil(t, found)
|
||||
assert.ErrorIs(t, err, bolthold.ErrNotFound)
|
||||
})
|
||||
|
||||
repo := "repository"
|
||||
cache := &Cache{
|
||||
Repo: repo,
|
||||
Key: "key",
|
||||
Version: "version",
|
||||
Size: 444,
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
cache.CreatedAt = now
|
||||
cache.UsedAt = now
|
||||
cache.Repo = repo
|
||||
|
||||
t.Run("Insert", func(t *testing.T) {
|
||||
db := caches.getDB()
|
||||
assert.NoError(t, insertCache(db, cache))
|
||||
})
|
||||
|
||||
t.Run("Found", func(t *testing.T) {
|
||||
found, err := caches.readCache(cache.ID, cache.Repo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cache.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("InvalidRepo", func(t *testing.T) {
|
||||
invalidRepo := "INVALID REPO"
|
||||
found, err := caches.readCache(cache.ID, invalidRepo)
|
||||
assert.Nil(t, found)
|
||||
assert.ErrorContains(t, err, invalidRepo)
|
||||
})
|
||||
}
|
||||
8
act/artifactcache/doc.go
Normal file
8
act/artifactcache/doc.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Package artifactcache provides a cache handler for the runner.
|
||||
//
|
||||
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||
//
|
||||
// TODO: Authorization
|
||||
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
||||
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
package artifactcache
|
||||
459
act/artifactcache/handler.go
Normal file
459
act/artifactcache/handler.go
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
package artifactcache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/timshannon/bolthold"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
const (
|
||||
urlBase = "/_apis/artifactcache"
|
||||
)
|
||||
|
||||
var fatal = func(logger logrus.FieldLogger, err error) {
|
||||
logger.Errorf("unrecoverable error in the cache: %v", err)
|
||||
if err := suicide(); err != nil {
|
||||
logger.Errorf("unrecoverable error in the cache: failed to send the TERM signal to shutdown the daemon %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
ExternalURL() string
|
||||
Close() error
|
||||
isClosed() bool
|
||||
getCaches() caches
|
||||
setCaches(caches caches)
|
||||
find(w http.ResponseWriter, r *http.Request, params httprouter.Params)
|
||||
reserve(w http.ResponseWriter, r *http.Request, params httprouter.Params)
|
||||
upload(w http.ResponseWriter, r *http.Request, params httprouter.Params)
|
||||
commit(w http.ResponseWriter, r *http.Request, params httprouter.Params)
|
||||
get(w http.ResponseWriter, r *http.Request, params httprouter.Params)
|
||||
clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
|
||||
middleware(handler httprouter.Handle) httprouter.Handle
|
||||
responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
caches caches
|
||||
router *httprouter.Router
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
|
||||
outboundIP string
|
||||
}
|
||||
|
||||
func StartHandler(dir, outboundIP string, port uint16, secret string, logger logrus.FieldLogger) (Handler, error) {
|
||||
h := &handler{}
|
||||
|
||||
if logger == nil {
|
||||
discard := logrus.New()
|
||||
discard.Out = io.Discard
|
||||
logger = discard
|
||||
}
|
||||
logger = logger.WithField("module", "artifactcache")
|
||||
h.logger = logger
|
||||
|
||||
caches, err := newCaches(dir, secret, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.caches = caches
|
||||
|
||||
if outboundIP != "" {
|
||||
h.outboundIP = outboundIP
|
||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||
return nil, fmt.Errorf("unable to determine outbound IP address")
|
||||
} else {
|
||||
h.outboundIP = ip.String()
|
||||
}
|
||||
|
||||
router := httprouter.New()
|
||||
router.GET(urlBase+"/cache", h.middleware(h.find))
|
||||
router.POST(urlBase+"/caches", h.middleware(h.reserve))
|
||||
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
|
||||
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
|
||||
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
|
||||
router.POST(urlBase+"/clean", h.middleware(h.clean))
|
||||
|
||||
h.router = router
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := &http.Server{
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
}
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
||||
logger.Errorf("http serve: %v", err)
|
||||
}
|
||||
}()
|
||||
h.listener = listener
|
||||
h.server = server
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *handler) ExternalURL() string {
|
||||
port := strconv.Itoa(h.listener.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
// TODO: make the external url configurable if necessary
|
||||
return fmt.Sprintf("http://%s", net.JoinHostPort(h.outboundIP, port))
|
||||
}
|
||||
|
||||
func (h *handler) Close() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
var retErr error
|
||||
if h.caches != nil {
|
||||
h.caches.close()
|
||||
h.caches = nil
|
||||
}
|
||||
if h.server != nil {
|
||||
err := h.server.Close()
|
||||
if err != nil {
|
||||
retErr = err
|
||||
}
|
||||
h.server = nil
|
||||
}
|
||||
if h.listener != nil {
|
||||
err := h.listener.Close()
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
retErr = err
|
||||
}
|
||||
h.listener = nil
|
||||
}
|
||||
return retErr
|
||||
}
|
||||
|
||||
func (h *handler) isClosed() bool {
|
||||
return h.listener == nil && h.server == nil
|
||||
}
|
||||
|
||||
func (h *handler) getCaches() caches {
|
||||
return h.caches
|
||||
}
|
||||
|
||||
func (h *handler) setCaches(caches caches) {
|
||||
if h.caches != nil {
|
||||
h.caches.close()
|
||||
}
|
||||
h.caches = caches
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/cache
|
||||
func (h *handler) find(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
repo, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
|
||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||
// cache keys are case insensitive
|
||||
for i, key := range keys {
|
||||
keys[i] = strings.ToLower(key)
|
||||
}
|
||||
version := r.URL.Query().Get("version")
|
||||
|
||||
db := h.caches.getDB()
|
||||
|
||||
cache, err := findCacheWithIsolationKeyFallback(db, repo, keys, version, rundata.WriteIsolationKey)
|
||||
if err != nil {
|
||||
h.responseFatalJSON(w, r, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.caches.exist(cache.ID); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
archiveLocation := fmt.Sprintf("%s/%s%s/artifacts/%d", r.Header.Get("Forgejo-Cache-Host"), r.Header.Get("Forgejo-Cache-RunId"), urlBase, cache.ID)
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": archiveLocation,
|
||||
"cacheKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *handler) reserve(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
repo, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
|
||||
api := &Request{}
|
||||
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
// cache keys are case insensitive
|
||||
api.Key = strings.ToLower(api.Key)
|
||||
|
||||
cache := api.ToCache()
|
||||
db := h.caches.getDB()
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache.CreatedAt = now
|
||||
cache.UsedAt = now
|
||||
cache.Repo = repo
|
||||
cache.WriteIsolationKey = rundata.WriteIsolationKey
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"cacheId": cache.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH /_apis/artifactcache/caches/:id
|
||||
func (h *handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
repo, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache, err := h.caches.readCache(id, repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
h.responseJSON(w, r, 404, fmt.Errorf("cache %d: not reserved", id))
|
||||
return
|
||||
}
|
||||
h.responseFatalJSON(w, r, fmt.Errorf("cache Get: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.WriteIsolationKey != rundata.WriteIsolationKey {
|
||||
h.responseJSON(w, r, 403, fmt.Errorf("cache authorized for write isolation %q, but attempting to operate on %q", rundata.WriteIsolationKey, cache.WriteIsolationKey))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
}
|
||||
start, _, err := parseContentRange(r.Header.Get("Content-Range"))
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, fmt.Errorf("cache parseContentRange(%s): %w", r.Header.Get("Content-Range"), err))
|
||||
return
|
||||
}
|
||||
if err := h.caches.write(cache.ID, start, r.Body); err != nil {
|
||||
h.responseJSON(w, r, 500, fmt.Errorf("cache storage.Write: %w", err))
|
||||
return
|
||||
}
|
||||
if err := h.caches.useCache(id); err != nil {
|
||||
h.responseJSON(w, r, 500, fmt.Errorf("cache useCache: %w", err))
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches/:id
|
||||
func (h *handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
repo, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache, err := h.caches.readCache(id, repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
h.responseJSON(w, r, 404, fmt.Errorf("cache %d: not reserved", id))
|
||||
return
|
||||
}
|
||||
h.responseFatalJSON(w, r, fmt.Errorf("cache Get: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.WriteIsolationKey != rundata.WriteIsolationKey {
|
||||
h.responseJSON(w, r, 403, fmt.Errorf("cache authorized for write isolation %q, but attempting to operate on %q", rundata.WriteIsolationKey, cache.WriteIsolationKey))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
}
|
||||
|
||||
size, err := h.caches.commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, fmt.Errorf("commit(%v): %w", cache.ID, err))
|
||||
return
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = size
|
||||
|
||||
db := h.caches.getDB()
|
||||
|
||||
cache.Complete = true
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
func (h *handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
repo, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache, err := h.caches.readCache(id, repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
h.responseJSON(w, r, 404, fmt.Errorf("cache %d: not reserved", id))
|
||||
return
|
||||
}
|
||||
h.responseFatalJSON(w, r, fmt.Errorf("cache Get: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// reads permitted against caches w/ the same isolation key, or no isolation key
|
||||
if cache.WriteIsolationKey != rundata.WriteIsolationKey && cache.WriteIsolationKey != "" {
|
||||
h.responseJSON(w, r, 403, fmt.Errorf("cache authorized for write isolation %q, but attempting to operate on %q", rundata.WriteIsolationKey, cache.WriteIsolationKey))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.caches.useCache(id); err != nil {
|
||||
h.responseJSON(w, r, 500, fmt.Errorf("cache useCache: %w", err))
|
||||
return
|
||||
}
|
||||
h.caches.serve(w, r, id)
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/clean
|
||||
func (h *handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
rundata := runDataFromHeaders(r)
|
||||
_, err := h.caches.validateMac(rundata)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 403, err)
|
||||
return
|
||||
}
|
||||
// TODO: don't support force deleting cache entries
|
||||
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
func (h *handler) middleware(handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.RequestURI)
|
||||
handler(w, r, params)
|
||||
go h.caches.gcCache()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) responseFatalJSON(w http.ResponseWriter, r *http.Request, err error) {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
fatal(h.logger, err)
|
||||
}
|
||||
|
||||
func (h *handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
var data []byte
|
||||
if len(v) == 0 || v[0] == nil {
|
||||
data, _ = json.Marshal(struct{}{})
|
||||
} else if err, ok := v[0].(error); ok {
|
||||
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
|
||||
data, _ = json.Marshal(map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
data, _ = json.Marshal(v[0])
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func parseContentRange(s string) (uint64, uint64, error) {
|
||||
// support the format like "bytes 11-22/*" only
|
||||
s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/")
|
||||
s1, s2, _ := strings.Cut(s, "-")
|
||||
|
||||
start, err := strconv.ParseUint(s1, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
|
||||
}
|
||||
stop, err := strconv.ParseUint(s2, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
|
||||
}
|
||||
return start, stop, nil
|
||||
}
|
||||
|
||||
type RunData struct {
|
||||
RepositoryFullName string
|
||||
RunNumber string
|
||||
Timestamp string
|
||||
RepositoryMAC string
|
||||
WriteIsolationKey string
|
||||
}
|
||||
|
||||
func runDataFromHeaders(r *http.Request) RunData {
|
||||
return RunData{
|
||||
RepositoryFullName: r.Header.Get("Forgejo-Cache-Repo"),
|
||||
RunNumber: r.Header.Get("Forgejo-Cache-RunNumber"),
|
||||
Timestamp: r.Header.Get("Forgejo-Cache-Timestamp"),
|
||||
RepositoryMAC: r.Header.Get("Forgejo-Cache-MAC"),
|
||||
WriteIsolationKey: r.Header.Get("Forgejo-Cache-WriteIsolationKey"),
|
||||
}
|
||||
}
|
||||
1205
act/artifactcache/handler_test.go
Normal file
1205
act/artifactcache/handler_test.go
Normal file
File diff suppressed because it is too large
Load diff
51
act/artifactcache/mac.go
Normal file
51
act/artifactcache/mac.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrValidation = errors.New("validation error")
|
||||
|
||||
func (c *cachesImpl) validateMac(rundata RunData) (string, error) {
|
||||
// TODO: allow configurable max age
|
||||
if !validateAge(rundata.Timestamp) {
|
||||
return "", ErrValidation
|
||||
}
|
||||
|
||||
expectedMAC := ComputeMac(c.secret, rundata.RepositoryFullName, rundata.RunNumber, rundata.Timestamp, rundata.WriteIsolationKey)
|
||||
if hmac.Equal([]byte(expectedMAC), []byte(rundata.RepositoryMAC)) {
|
||||
return rundata.RepositoryFullName, nil
|
||||
}
|
||||
return "", ErrValidation
|
||||
}
|
||||
|
||||
func validateAge(ts string) bool {
|
||||
tsInt, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if tsInt > time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ComputeMac(secret, repo, run, ts, writeIsolationKey string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(repo))
|
||||
mac.Write([]byte(">"))
|
||||
mac.Write([]byte(run))
|
||||
mac.Write([]byte(">"))
|
||||
mac.Write([]byte(ts))
|
||||
mac.Write([]byte(">"))
|
||||
mac.Write([]byte(writeIsolationKey))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
82
act/artifactcache/mac_test.go
Normal file
82
act/artifactcache/mac_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package artifactcache
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMac(t *testing.T) {
|
||||
cache := &cachesImpl{
|
||||
secret: "secret for testing",
|
||||
}
|
||||
|
||||
t.Run("validate correct mac", func(t *testing.T) {
|
||||
name := "org/reponame"
|
||||
run := "1"
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
mac := ComputeMac(cache.secret, name, run, ts, "")
|
||||
rundata := RunData{
|
||||
RepositoryFullName: name,
|
||||
RunNumber: run,
|
||||
Timestamp: ts,
|
||||
RepositoryMAC: mac,
|
||||
}
|
||||
|
||||
repoName, err := cache.validateMac(rundata)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, name, repoName)
|
||||
})
|
||||
|
||||
t.Run("validate incorrect timestamp", func(t *testing.T) {
|
||||
name := "org/reponame"
|
||||
run := "1"
|
||||
ts := "9223372036854775807" // This should last us for a while...
|
||||
|
||||
mac := ComputeMac(cache.secret, name, run, ts, "")
|
||||
rundata := RunData{
|
||||
RepositoryFullName: name,
|
||||
RunNumber: run,
|
||||
Timestamp: ts,
|
||||
RepositoryMAC: mac,
|
||||
}
|
||||
|
||||
_, err := cache.validateMac(rundata)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("validate incorrect mac", func(t *testing.T) {
|
||||
name := "org/reponame"
|
||||
run := "1"
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
rundata := RunData{
|
||||
RepositoryFullName: name,
|
||||
RunNumber: run,
|
||||
Timestamp: ts,
|
||||
RepositoryMAC: "this is not the right mac :D",
|
||||
}
|
||||
|
||||
repoName, err := cache.validateMac(rundata)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "", repoName)
|
||||
})
|
||||
|
||||
t.Run("compute correct mac", func(t *testing.T) {
|
||||
secret := "this is my cool secret string :3"
|
||||
name := "org/reponame"
|
||||
run := "42"
|
||||
ts := "1337"
|
||||
|
||||
mac := ComputeMac(secret, name, run, ts, "")
|
||||
expectedMac := "4754474b21329e8beadd2b4054aa4be803965d66e710fa1fee091334ed804f29" // * Precomputed, anytime the ComputeMac function changes this needs to be recalculated
|
||||
require.Equal(t, expectedMac, mac)
|
||||
|
||||
mac = ComputeMac(secret, name, run, ts, "refs/pull/12/head")
|
||||
expectedMac = "9ca8f4cb5e1b083ee8cd215215bc00f379b28511d3ef7930bf054767de34766d" // * Precomputed, anytime the ComputeMac function changes this needs to be recalculated
|
||||
require.Equal(t, expectedMac, mac)
|
||||
})
|
||||
}
|
||||
225
act/artifactcache/mock_caches.go
Normal file
225
act/artifactcache/mock_caches.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
http "net/http"
|
||||
|
||||
bolthold "github.com/timshannon/bolthold"
|
||||
|
||||
io "io"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
time "time"
|
||||
)
|
||||
|
||||
// mockCaches is an autogenerated mock type for the caches type
|
||||
type mockCaches struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// close provides a mock function with no fields
|
||||
func (_m *mockCaches) close() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// commit provides a mock function with given fields: id, size
|
||||
func (_m *mockCaches) commit(id uint64, size int64) (int64, error) {
|
||||
ret := _m.Called(id, size)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for commit")
|
||||
}
|
||||
|
||||
var r0 int64
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(uint64, int64) (int64, error)); ok {
|
||||
return rf(id, size)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(uint64, int64) int64); ok {
|
||||
r0 = rf(id, size)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(uint64, int64) error); ok {
|
||||
r1 = rf(id, size)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// exist provides a mock function with given fields: id
|
||||
func (_m *mockCaches) exist(id uint64) (bool, error) {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for exist")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(uint64) (bool, error)); ok {
|
||||
return rf(id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(uint64) bool); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(uint64) error); ok {
|
||||
r1 = rf(id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// gcCache provides a mock function with no fields
|
||||
func (_m *mockCaches) gcCache() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// getDB provides a mock function with no fields
|
||||
func (_m *mockCaches) getDB() *bolthold.Store {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for getDB")
|
||||
}
|
||||
|
||||
var r0 *bolthold.Store
|
||||
if rf, ok := ret.Get(0).(func() *bolthold.Store); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*bolthold.Store)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// readCache provides a mock function with given fields: id, repo
|
||||
func (_m *mockCaches) readCache(id uint64, repo string) (*Cache, error) {
|
||||
ret := _m.Called(id, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for readCache")
|
||||
}
|
||||
|
||||
var r0 *Cache
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(uint64, string) (*Cache, error)); ok {
|
||||
return rf(id, repo)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(uint64, string) *Cache); ok {
|
||||
r0 = rf(id, repo)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*Cache)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(uint64, string) error); ok {
|
||||
r1 = rf(id, repo)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// serve provides a mock function with given fields: w, r, id
|
||||
func (_m *mockCaches) serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||
_m.Called(w, r, id)
|
||||
}
|
||||
|
||||
// setgcAt provides a mock function with given fields: at
|
||||
func (_m *mockCaches) setgcAt(at time.Time) {
|
||||
_m.Called(at)
|
||||
}
|
||||
|
||||
// useCache provides a mock function with given fields: id
|
||||
func (_m *mockCaches) useCache(id uint64) error {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for useCache")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(uint64) error); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// validateMac provides a mock function with given fields: rundata
|
||||
func (_m *mockCaches) validateMac(rundata RunData) (string, error) {
|
||||
ret := _m.Called(rundata)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for validateMac")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(RunData) (string, error)); ok {
|
||||
return rf(rundata)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(RunData) string); ok {
|
||||
r0 = rf(rundata)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(RunData) error); ok {
|
||||
r1 = rf(rundata)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// write provides a mock function with given fields: id, offset, reader
|
||||
func (_m *mockCaches) write(id uint64, offset uint64, reader io.Reader) error {
|
||||
ret := _m.Called(id, offset, reader)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for write")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(uint64, uint64, io.Reader) error); ok {
|
||||
r0 = rf(id, offset, reader)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// newMockCaches creates a new instance of mockCaches. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func newMockCaches(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
},
|
||||
) *mockCaches {
|
||||
mock := &mockCaches{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
36
act/artifactcache/model.go
Normal file
36
act/artifactcache/model.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package artifactcache
|
||||
|
||||
type Request struct {
|
||||
Key string `json:"key" `
|
||||
Version string `json:"version"`
|
||||
Size int64 `json:"cacheSize"`
|
||||
}
|
||||
|
||||
func (c *Request) ToCache() *Cache {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ret := &Cache{
|
||||
Key: c.Key,
|
||||
Version: c.Version,
|
||||
Size: c.Size,
|
||||
}
|
||||
if c.Size == 0 {
|
||||
// So the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
// It doesn't send cache size. Set it to -1 to indicate that.
|
||||
ret.Size = -1
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
Size int64 `json:"cacheSize"`
|
||||
Complete bool `json:"complete"`
|
||||
UsedAt int64 `json:"usedAt"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
WriteIsolationKey string `json:"writeIsolationKey"`
|
||||
}
|
||||
130
act/artifactcache/storage.go
Normal file
130
act/artifactcache/storage.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package artifactcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func NewStorage(rootDir string) (*Storage, error) {
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Storage{
|
||||
rootDir: rootDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Exist(id uint64) (bool, error) {
|
||||
name := s.filename(id)
|
||||
if _, err := os.Stat(name); os.IsNotExist(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id, offset uint64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
}()
|
||||
|
||||
name := s.filename(id)
|
||||
tempNames, err := s.tempNames(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var written int64
|
||||
for _, v := range tempNames {
|
||||
f, err := os.Open(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(file, f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||
name := s.filename(id)
|
||||
http.ServeFile(w, r, name)
|
||||
}
|
||||
|
||||
func (s *Storage) Remove(id uint64) {
|
||||
_ = os.Remove(s.filename(id))
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
}
|
||||
|
||||
func (s *Storage) filename(id uint64) string {
|
||||
return filepath.Join(s.rootDir, fmt.Sprintf("%02x", id%0xff), fmt.Sprint(id))
|
||||
}
|
||||
|
||||
func (s *Storage) tempDir(id uint64) string {
|
||||
return filepath.Join(s.rootDir, "tmp", fmt.Sprint(id))
|
||||
}
|
||||
|
||||
func (s *Storage) tempName(id, offset uint64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
30
act/artifactcache/testdata/example/example.yaml
vendored
Normal file
30
act/artifactcache/testdata/example/example.yaml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Copied from https://github.com/actions/cache#example-cache-workflow
|
||||
name: Caching Primes
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- run: env
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Primes
|
||||
id: cache-primes
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: prime-numbers
|
||||
key: ${{ runner.os }}-primes-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-primes
|
||||
${{ runner.os }}
|
||||
|
||||
- name: Generate Prime Numbers
|
||||
if: steps.cache-primes.outputs.cache-hit != 'true'
|
||||
run: cat /proc/sys/kernel/random/uuid > prime-numbers
|
||||
|
||||
- name: Use Prime Numbers
|
||||
run: cat prime-numbers
|
||||
9
act/artifactcache/utils.go
Normal file
9
act/artifactcache/utils.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !windows
|
||||
|
||||
package artifactcache
|
||||
|
||||
import "syscall"
|
||||
|
||||
func suicide() error {
|
||||
return syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
}
|
||||
14
act/artifactcache/utils_windows.go
Normal file
14
act/artifactcache/utils_windows.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows
|
||||
|
||||
package artifactcache
|
||||
|
||||
import "syscall"
|
||||
|
||||
func suicide() error {
|
||||
handle, err := syscall.GetCurrentProcess()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return syscall.TerminateProcess(handle, uint32(syscall.SIGTERM))
|
||||
}
|
||||
212
act/cacheproxy/handler.go
Normal file
212
act/cacheproxy/handler.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cacheproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/artifactcache"
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
const (
|
||||
urlBase = "/_apis/artifactcache"
|
||||
)
|
||||
|
||||
var urlRegex = regexp.MustCompile(`/(\w+)(/_apis/artifactcache/.+)`)
|
||||
|
||||
type Handler struct {
|
||||
router *httprouter.Router
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
|
||||
outboundIP string
|
||||
|
||||
cacheServerHost string
|
||||
cacheProxyHostOverride string
|
||||
|
||||
cacheSecret string
|
||||
|
||||
runs sync.Map
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRunData(fullName, runNumber, timestamp, writeIsolationKey string) artifactcache.RunData {
|
||||
mac := artifactcache.ComputeMac(h.cacheSecret, fullName, runNumber, timestamp, writeIsolationKey)
|
||||
return artifactcache.RunData{
|
||||
RepositoryFullName: fullName,
|
||||
RunNumber: runNumber,
|
||||
Timestamp: timestamp,
|
||||
RepositoryMAC: mac,
|
||||
WriteIsolationKey: writeIsolationKey,
|
||||
}
|
||||
}
|
||||
|
||||
func StartHandler(targetHost, outboundIP string, port uint16, cacheProxyHostOverride, cacheSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
||||
h := &Handler{}
|
||||
|
||||
if logger == nil {
|
||||
discard := logrus.New()
|
||||
discard.Out = io.Discard
|
||||
logger = discard
|
||||
}
|
||||
logger = logger.WithField("module", "cacheproxy")
|
||||
h.logger = logger
|
||||
|
||||
h.cacheSecret = cacheSecret
|
||||
|
||||
if outboundIP != "" {
|
||||
h.outboundIP = outboundIP
|
||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||
return nil, fmt.Errorf("unable to determine outbound IP address")
|
||||
} else {
|
||||
h.outboundIP = ip.String()
|
||||
}
|
||||
|
||||
h.cacheServerHost = targetHost
|
||||
h.cacheProxyHostOverride = cacheProxyHostOverride
|
||||
|
||||
proxy, err := h.newReverseProxy(targetHost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to set up proxy to target host: %v", err)
|
||||
}
|
||||
|
||||
router := httprouter.New()
|
||||
router.HandlerFunc("GET", "/:runId"+urlBase+"/cache", proxyRequestHandler(proxy))
|
||||
router.HandlerFunc("POST", "/:runId"+urlBase+"/caches", proxyRequestHandler(proxy))
|
||||
router.HandlerFunc("PATCH", "/:runId"+urlBase+"/caches/:id", proxyRequestHandler(proxy))
|
||||
router.HandlerFunc("POST", "/:runId"+urlBase+"/caches/:id", proxyRequestHandler(proxy))
|
||||
router.HandlerFunc("GET", "/:runId"+urlBase+"/artifacts/:id", proxyRequestHandler(proxy))
|
||||
router.HandlerFunc("POST", "/:runId"+urlBase+"/clean", proxyRequestHandler(proxy))
|
||||
|
||||
h.router = router
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := &http.Server{
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
}
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
||||
logger.Errorf("http serve: %v", err)
|
||||
}
|
||||
}()
|
||||
h.listener = listener
|
||||
h.server = server
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func proxyRequestHandler(proxy *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
|
||||
return proxy.ServeHTTP
|
||||
}
|
||||
|
||||
func (h *Handler) newReverseProxy(targetHost string) (*httputil.ReverseProxy, error) {
|
||||
targetURL, err := url.Parse(targetHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
matches := urlRegex.FindStringSubmatch(r.In.URL.Path)
|
||||
id := matches[1]
|
||||
data, ok := h.runs.Load(id)
|
||||
if !ok {
|
||||
// The ID doesn't exist.
|
||||
h.logger.Warn(fmt.Sprintf("Tried starting a cache proxy with id %s, which does not exist.", id))
|
||||
return
|
||||
}
|
||||
runData := data.(artifactcache.RunData)
|
||||
uri := matches[2]
|
||||
|
||||
r.SetURL(targetURL)
|
||||
r.Out.URL.Path = uri
|
||||
h.logger.Debugf("proxy req %s %q to %q", r.In.Method, r.In.URL, r.Out.URL)
|
||||
|
||||
r.Out.Header.Set("Forgejo-Cache-Repo", runData.RepositoryFullName)
|
||||
r.Out.Header.Set("Forgejo-Cache-RunNumber", runData.RunNumber)
|
||||
r.Out.Header.Set("Forgejo-Cache-RunId", id)
|
||||
r.Out.Header.Set("Forgejo-Cache-Timestamp", runData.Timestamp)
|
||||
r.Out.Header.Set("Forgejo-Cache-MAC", runData.RepositoryMAC)
|
||||
r.Out.Header.Set("Forgejo-Cache-Host", h.ExternalURL())
|
||||
if runData.WriteIsolationKey != "" {
|
||||
r.Out.Header.Set("Forgejo-Cache-WriteIsolationKey", runData.WriteIsolationKey)
|
||||
}
|
||||
},
|
||||
ModifyResponse: func(r *http.Response) error {
|
||||
h.logger.Debugf("proxy resp %s w/ %d bytes", r.Status, r.ContentLength)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
if h.cacheProxyHostOverride != "" {
|
||||
return h.cacheProxyHostOverride
|
||||
}
|
||||
return fmt.Sprintf("http://%s", net.JoinHostPort(h.outboundIP, strconv.Itoa(h.listener.Addr().(*net.TCPAddr).Port)))
|
||||
}
|
||||
|
||||
// Informs the proxy of a workflow run that can make cache requests.
|
||||
// The RunData contains the information about the repository.
|
||||
// The function returns the 32-bit random key which the run will use to identify itself.
|
||||
func (h *Handler) AddRun(data artifactcache.RunData) (string, error) {
|
||||
for range 3 {
|
||||
key := common.MustRandName(4)
|
||||
_, loaded := h.runs.LoadOrStore(key, data)
|
||||
if !loaded {
|
||||
// The key was unique and added successfully
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Repeated collisions in generating run id")
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveRun(runID string) error {
|
||||
_, existed := h.runs.LoadAndDelete(runID)
|
||||
if !existed {
|
||||
return errors.New("The run id was not known to the proxy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) Close() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
var retErr error
|
||||
if h.server != nil {
|
||||
err := h.server.Close()
|
||||
if err != nil {
|
||||
retErr = err
|
||||
}
|
||||
h.server = nil
|
||||
}
|
||||
if h.listener != nil {
|
||||
err := h.listener.Close()
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
retErr = err
|
||||
}
|
||||
h.listener = nil
|
||||
}
|
||||
return retErr
|
||||
}
|
||||
54
act/common/cartesian.go
Normal file
54
act/common/cartesian.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package common
|
||||
|
||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||
listNames := make([]string, 0)
|
||||
lists := make([][]any, 0)
|
||||
for k, v := range mapOfLists {
|
||||
listNames = append(listNames, k)
|
||||
lists = append(lists, v)
|
||||
}
|
||||
|
||||
listCart := cartN(lists...)
|
||||
|
||||
rtn := make([]map[string]any, 0)
|
||||
for _, list := range listCart {
|
||||
vMap := make(map[string]any)
|
||||
for i, v := range list {
|
||||
vMap[listNames[i]] = v
|
||||
}
|
||||
rtn = append(rtn, vMap)
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func cartN(a ...[]any) [][]any {
|
||||
c := 1
|
||||
for _, a := range a {
|
||||
c *= len(a)
|
||||
}
|
||||
if c == 0 || len(a) == 0 {
|
||||
return nil
|
||||
}
|
||||
p := make([][]any, c)
|
||||
b := make([]any, c*len(a))
|
||||
n := make([]int, len(a))
|
||||
s := 0
|
||||
for i := range p {
|
||||
e := s + len(a)
|
||||
pi := b[s:e]
|
||||
p[i] = pi
|
||||
s = e
|
||||
for j, n := range n {
|
||||
pi[j] = a[j][n]
|
||||
}
|
||||
for j := len(n) - 1; j >= 0; j-- {
|
||||
n[j]++
|
||||
if n[j] < len(a[j]) {
|
||||
break
|
||||
}
|
||||
n[j] = 0
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
39
act/common/cartesian_test.go
Normal file
39
act/common/cartesian_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCartesianProduct(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
input := map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {"a", "b", "c"},
|
||||
"baz": {false, true},
|
||||
}
|
||||
|
||||
output := CartesianProduct(input)
|
||||
assert.Len(output, 24)
|
||||
|
||||
for _, v := range output {
|
||||
assert.Len(v, 3)
|
||||
|
||||
assert.Contains(v, "foo")
|
||||
assert.Contains(v, "bar")
|
||||
assert.Contains(v, "baz")
|
||||
}
|
||||
|
||||
input = map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {},
|
||||
"baz": {false, true},
|
||||
}
|
||||
output = CartesianProduct(input)
|
||||
assert.Len(output, 0)
|
||||
|
||||
input = map[string][]any{}
|
||||
output = CartesianProduct(input)
|
||||
assert.Len(output, 0)
|
||||
}
|
||||
142
act/common/draw.go
Normal file
142
act/common/draw.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Style is a specific style
|
||||
type Style int
|
||||
|
||||
// Styles
|
||||
const (
|
||||
StyleDoubleLine = iota
|
||||
StyleSingleLine
|
||||
StyleDashedLine
|
||||
StyleNoLine
|
||||
)
|
||||
|
||||
// NewPen creates a new pen
|
||||
func NewPen(style Style, color int) *Pen {
|
||||
bgcolor := 49
|
||||
if os.Getenv("CLICOLOR") == "0" {
|
||||
color = 0
|
||||
bgcolor = 0
|
||||
}
|
||||
return &Pen{
|
||||
style: style,
|
||||
color: color,
|
||||
bgcolor: bgcolor,
|
||||
}
|
||||
}
|
||||
|
||||
type styleDef struct {
|
||||
cornerTL string
|
||||
cornerTR string
|
||||
cornerBL string
|
||||
cornerBR string
|
||||
lineH string
|
||||
lineV string
|
||||
}
|
||||
|
||||
var styleDefs = []styleDef{
|
||||
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
|
||||
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
|
||||
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
|
||||
{" ", " ", " ", " ", " ", " "},
|
||||
}
|
||||
|
||||
// Pen struct
|
||||
type Pen struct {
|
||||
style Style
|
||||
color int
|
||||
bgcolor int
|
||||
}
|
||||
|
||||
// Drawing struct
|
||||
type Drawing struct {
|
||||
buf *strings.Builder
|
||||
width int
|
||||
}
|
||||
|
||||
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
// DrawArrow between boxes
|
||||
func (p *Pen) DrawArrow() *Drawing {
|
||||
drawing := &Drawing{
|
||||
buf: new(strings.Builder),
|
||||
width: 1,
|
||||
}
|
||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
|
||||
fmt.Fprintf(drawing.buf, "\u2b07")
|
||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
|
||||
return drawing
|
||||
}
|
||||
|
||||
// DrawBoxes to draw boxes
|
||||
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
|
||||
width := 0
|
||||
for _, l := range labels {
|
||||
width += len(l) + 2 + 2 + 1
|
||||
}
|
||||
drawing := &Drawing{
|
||||
buf: new(strings.Builder),
|
||||
width: width,
|
||||
}
|
||||
p.drawTopBars(drawing.buf, labels...)
|
||||
p.drawLabels(drawing.buf, labels...)
|
||||
p.drawBottomBars(drawing.buf, labels...)
|
||||
|
||||
return drawing
|
||||
}
|
||||
|
||||
// Draw to writer
|
||||
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
|
||||
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
|
||||
for l := range strings.SplitSeq(d.buf.String(), "\n") {
|
||||
if len(l) > 0 {
|
||||
padding := strings.Repeat(" ", padSize)
|
||||
fmt.Fprintf(writer, "%s%s\n", padding, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetWidth of drawing
|
||||
func (d *Drawing) GetWidth() int {
|
||||
return d.width
|
||||
}
|
||||
25
act/common/dryrun.go
Normal file
25
act/common/dryrun.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type dryrunContextKey string
|
||||
|
||||
const dryrunContextKeyVal = dryrunContextKey("dryrun")
|
||||
|
||||
// Dryrun returns true if the current context is dryrun
|
||||
func Dryrun(ctx context.Context) bool {
|
||||
val := ctx.Value(dryrunContextKeyVal)
|
||||
if val != nil {
|
||||
if dryrun, ok := val.(bool); ok {
|
||||
return dryrun
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithDryrun adds a value to the context for dryrun
|
||||
func WithDryrun(ctx context.Context, dryrun bool) context.Context {
|
||||
return context.WithValue(ctx, dryrunContextKeyVal, dryrun)
|
||||
}
|
||||
196
act/common/executor.go
Normal file
196
act/common/executor.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Warning that implements `error` but safe to ignore
|
||||
type Warning struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error the contract for error
|
||||
func (w Warning) Error() string {
|
||||
return w.Message
|
||||
}
|
||||
|
||||
// Warningf create a warning
|
||||
func Warningf(format string, args ...any) Warning {
|
||||
w := Warning{
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Executor define contract for the steps of a workflow
|
||||
type Executor func(ctx context.Context) error
|
||||
|
||||
// Conditional define contract for the conditional predicate
|
||||
type Conditional func(ctx context.Context) bool
|
||||
|
||||
// NewInfoExecutor is an executor that logs messages
|
||||
func NewInfoExecutor(format string, args ...any) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := Logger(ctx)
|
||||
logger.Infof(format, args...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewDebugExecutor is an executor that logs messages
|
||||
func NewDebugExecutor(format string, args ...any) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := Logger(ctx)
|
||||
logger.Debugf(format, args...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewPipelineExecutor creates a new executor from a series of other executors
|
||||
func NewPipelineExecutor(executors ...Executor) Executor {
|
||||
if len(executors) == 0 {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var rtn Executor
|
||||
for _, executor := range executors {
|
||||
if rtn == nil {
|
||||
rtn = executor
|
||||
} else {
|
||||
rtn = rtn.Then(executor)
|
||||
}
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
// NewConditionalExecutor creates a new executor based on conditions
|
||||
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if conditional(ctx) {
|
||||
if trueExecutor != nil {
|
||||
return trueExecutor(ctx)
|
||||
}
|
||||
} else {
|
||||
if falseExecutor != nil {
|
||||
return falseExecutor(ctx)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorExecutor creates a new executor that always errors out
|
||||
func NewErrorExecutor(err error) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// NewParallelExecutor creates a new executor from a parallel of other executors
|
||||
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
work := make(chan Executor, len(executors))
|
||||
errs := make(chan error, len(executors))
|
||||
|
||||
if 1 > parallel {
|
||||
log.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
||||
parallel = 1
|
||||
}
|
||||
|
||||
for i := 0; i < parallel; i++ {
|
||||
go func(work <-chan Executor, errs chan<- error) {
|
||||
for executor := range work {
|
||||
errs <- executor(ctx)
|
||||
}
|
||||
}(work, errs)
|
||||
}
|
||||
|
||||
for i := range executors {
|
||||
work <- executors[i]
|
||||
}
|
||||
close(work)
|
||||
|
||||
// Executor waits all executors to cleanup these resources.
|
||||
var firstErr error
|
||||
for range executors {
|
||||
err := <-errs
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
}
|
||||
|
||||
// Then runs another executor if this executor succeeds
|
||||
func (e Executor) Then(then Executor) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := e(ctx)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case Warning:
|
||||
Logger(ctx).Warning(err.Error())
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return then(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// If only runs this executor if conditional is true
|
||||
func (e Executor) If(conditional Conditional) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if conditional(ctx) {
|
||||
return e(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IfNot only runs this executor if conditional is true
|
||||
func (e Executor) IfNot(conditional Conditional) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if !conditional(ctx) {
|
||||
return e(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IfBool only runs this executor if conditional is true
|
||||
func (e Executor) IfBool(conditional bool) Executor {
|
||||
return e.If(func(ctx context.Context) bool {
|
||||
return conditional
|
||||
})
|
||||
}
|
||||
|
||||
// Finally adds an executor to run after other executor
|
||||
func (e Executor) Finally(finally Executor) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := e(ctx)
|
||||
err2 := finally(ctx)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Not return an inverted conditional
|
||||
func (c Conditional) Not() Conditional {
|
||||
return func(ctx context.Context) bool {
|
||||
return !c(ctx)
|
||||
}
|
||||
}
|
||||
164
act/common/executor_test.go
Normal file
164
act/common/executor_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWorkflow(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// empty
|
||||
emptyWorkflow := NewPipelineExecutor()
|
||||
assert.Nil(emptyWorkflow(ctx))
|
||||
|
||||
// error case
|
||||
errorWorkflow := NewErrorExecutor(fmt.Errorf("test error"))
|
||||
assert.NotNil(errorWorkflow(ctx))
|
||||
|
||||
// multiple success case
|
||||
runcount := 0
|
||||
successWorkflow := NewPipelineExecutor(
|
||||
func(ctx context.Context) error {
|
||||
runcount++
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context) error {
|
||||
runcount++
|
||||
return nil
|
||||
})
|
||||
assert.Nil(successWorkflow(ctx))
|
||||
assert.Equal(2, runcount)
|
||||
}
|
||||
|
||||
func TestNewConditionalExecutor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
trueCount := 0
|
||||
falseCount := 0
|
||||
|
||||
err := NewConditionalExecutor(func(ctx context.Context) bool {
|
||||
return false
|
||||
}, func(ctx context.Context) error {
|
||||
trueCount++
|
||||
return nil
|
||||
}, func(ctx context.Context) error {
|
||||
falseCount++
|
||||
return nil
|
||||
})(ctx)
|
||||
|
||||
assert.Nil(err)
|
||||
assert.Equal(0, trueCount)
|
||||
assert.Equal(1, falseCount)
|
||||
|
||||
err = NewConditionalExecutor(func(ctx context.Context) bool {
|
||||
return true
|
||||
}, func(ctx context.Context) error {
|
||||
trueCount++
|
||||
return nil
|
||||
}, func(ctx context.Context) error {
|
||||
falseCount++
|
||||
return nil
|
||||
})(ctx)
|
||||
|
||||
assert.Nil(err)
|
||||
assert.Equal(1, trueCount)
|
||||
assert.Equal(1, falseCount)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
var count atomic.Int32
|
||||
var activeCount atomic.Int32
|
||||
var maxCount atomic.Int32
|
||||
|
||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
|
||||
currentActive := activeCount.Add(1)
|
||||
|
||||
// maxCount = max(maxCount, currentActive) -- but concurrent-safe by using CompareAndSwap.
|
||||
for {
|
||||
currentMax := maxCount.Load()
|
||||
if currentActive <= currentMax {
|
||||
break
|
||||
}
|
||||
if maxCount.CompareAndSwap(currentMax, currentActive) {
|
||||
break
|
||||
}
|
||||
// If CompareAndSwap failed, retry due to concurrent update by another goroutine.
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
activeCount.Add(-1)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||
assert.Nil(err)
|
||||
|
||||
// Reset to test running the executor with 0 parallelism
|
||||
count.Store(0)
|
||||
activeCount.Store(0)
|
||||
maxCount.Store(0)
|
||||
|
||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||
assert.Nil(errSingle)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
var count atomic.Int32
|
||||
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
return fmt.Errorf("fake error")
|
||||
})
|
||||
err := NewParallelExecutor(1, errorWorkflow)(ctx)
|
||||
assert.Equal(int32(1), count.Load())
|
||||
assert.ErrorIs(context.Canceled, err)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
errExpected := fmt.Errorf("fake error")
|
||||
|
||||
var count atomic.Int32
|
||||
successWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
return nil
|
||||
})
|
||||
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
return errExpected
|
||||
})
|
||||
err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx)
|
||||
assert.Equal(int32(3), count.Load())
|
||||
assert.Error(errExpected, err)
|
||||
}
|
||||
73
act/common/file.go
Normal file
73
act/common/file.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CopyFile copy file
|
||||
func CopyFile(source, dest string) (err error) {
|
||||
sourcefile, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer sourcefile.Close()
|
||||
|
||||
destfile, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer destfile.Close()
|
||||
|
||||
_, err = io.Copy(destfile, sourcefile)
|
||||
if err == nil {
|
||||
sourceinfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
_ = os.Chmod(dest, sourceinfo.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CopyDir recursive copy of directory
|
||||
func CopyDir(source, dest string) (err error) {
|
||||
// get properties of source dir
|
||||
sourceinfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create dest dir
|
||||
|
||||
err = os.MkdirAll(dest, sourceinfo.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objects, err := os.ReadDir(source)
|
||||
|
||||
for _, obj := range objects {
|
||||
sourcefilepointer := source + "/" + obj.Name()
|
||||
|
||||
destinationfilepointer := dest + "/" + obj.Name()
|
||||
|
||||
if obj.IsDir() {
|
||||
// create sub-directories - recursively
|
||||
err = CopyDir(sourcefilepointer, destinationfilepointer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
} else {
|
||||
// perform copy
|
||||
err = CopyFile(sourcefilepointer, destinationfilepointer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
427
act/common/git/git.go
Normal file
427
act/common/git/git.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
var (
|
||||
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||
|
||||
cloneLock sync.Mutex
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
err error
|
||||
commit string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *Error) Commit() string {
|
||||
return e.commit
|
||||
}
|
||||
|
||||
// FindGitRevision get the current git revision
|
||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
gitDir, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
head, err := gitDir.Reference(plumbing.HEAD, true)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if head.Hash().IsZero() {
|
||||
return "", "", fmt.Errorf("HEAD sha1 could not be resolved")
|
||||
}
|
||||
|
||||
hash := head.Hash().String()
|
||||
|
||||
logger.Debugf("Found revision: %s", hash)
|
||||
return hash[:7], strings.TrimSpace(hash), nil
|
||||
}
|
||||
|
||||
// FindGitRef get the current git ref
|
||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
logger.Debugf("Loading revision from git directory")
|
||||
_, ref, err := FindGitRevision(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Debugf("HEAD points to '%s'", ref)
|
||||
|
||||
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
||||
refTag := ""
|
||||
refBranch := ""
|
||||
repo, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iter, err := repo.References()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// find the reference that matches the revision's has
|
||||
err = iter.ForEach(func(r *plumbing.Reference) error {
|
||||
/* tags and branches will have the same hash
|
||||
* when a user checks out a tag, it is not mentioned explicitly
|
||||
* in the go-git package, we must identify the revision
|
||||
* then check if any tag matches that revision,
|
||||
* if so then we checked out a tag
|
||||
* else we look for branches and if matches,
|
||||
* it means we checked out a branch
|
||||
*
|
||||
* If a branches matches first we must continue and check all tags (all references)
|
||||
* in case we match with a tag later in the interation
|
||||
*/
|
||||
if r.Hash().String() == ref {
|
||||
if r.Name().IsTag() {
|
||||
refTag = r.Name().String()
|
||||
}
|
||||
if r.Name().IsBranch() {
|
||||
refBranch = r.Name().String()
|
||||
}
|
||||
}
|
||||
|
||||
// we found what we where looking for
|
||||
if refTag != "" && refBranch != "" {
|
||||
return storer.ErrStop
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// order matters here see above comment.
|
||||
if refTag != "" {
|
||||
return refTag, nil
|
||||
}
|
||||
if refBranch != "" {
|
||||
return refBranch, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
||||
}
|
||||
|
||||
// FindGithubRepo get the repo
|
||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||
if remoteName == "" {
|
||||
remoteName = "origin"
|
||||
}
|
||||
|
||||
url, err := findGitRemoteURL(ctx, file, remoteName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, slug, err := findGitSlug(url, githubInstance)
|
||||
return slug, err
|
||||
}
|
||||
|
||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||
repo, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
remote, err := repo.Remote(remoteName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(remote.Config().URLs) < 1 {
|
||||
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
||||
}
|
||||
|
||||
return remote.Config().URLs[0], nil
|
||||
}
|
||||
|
||||
func findGitSlug(url, githubInstance string) (string, string, error) {
|
||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "CodeCommit", matches[2], nil
|
||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "CodeCommit", matches[2], nil
|
||||
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||
} else if githubInstance != "github.com" {
|
||||
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||
// Examples:
|
||||
// - `code.forgejo.org/forgejo/act`
|
||||
// - `code.forgejo.org:22/forgejo/act`
|
||||
// - `code.forgejo.org:forgejo/act`
|
||||
// - `code.forgejo.org:/forgejo/act`
|
||||
gheSSHRegex := regexp.MustCompile(fmt.Sprintf(`%s(?::\d+/|:|/|:/)([^/].+)/(.+?)(?:.git)?$`, githubInstance))
|
||||
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||
}
|
||||
}
|
||||
return "", url, nil
|
||||
}
|
||||
|
||||
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||
type NewGitCloneExecutorInput struct {
|
||||
URL string
|
||||
Ref string
|
||||
Dir string
|
||||
Token string
|
||||
OfflineMode bool
|
||||
|
||||
// For Gitea
|
||||
InsecureSkipTLS bool
|
||||
}
|
||||
|
||||
// CloneIfRequired ...
|
||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
||||
// If the remote URL has changed, remove the directory and clone again.
|
||||
if r, err := git.PlainOpen(input.Dir); err == nil {
|
||||
if remote, err := r.Remote("origin"); err == nil {
|
||||
if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] != input.URL {
|
||||
_ = os.RemoveAll(input.Dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r, err := git.PlainOpen(input.Dir)
|
||||
if err != nil {
|
||||
var progressWriter io.Writer
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
if entry, ok := logger.(*log.Entry); ok {
|
||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||
progressWriter = os.Stdout
|
||||
}
|
||||
}
|
||||
|
||||
cloneOptions := git.CloneOptions{
|
||||
URL: input.URL,
|
||||
Progress: progressWriter,
|
||||
|
||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||
}
|
||||
if input.Token != "" {
|
||||
cloneOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
}
|
||||
|
||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
||||
pullOptions.Force = true
|
||||
|
||||
if token != "" {
|
||||
auth := &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: token,
|
||||
}
|
||||
fetchOptions.Auth = auth
|
||||
pullOptions.Auth = auth
|
||||
}
|
||||
|
||||
return fetchOptions, pullOptions
|
||||
}
|
||||
|
||||
// NewGitCloneExecutor creates an executor to clone git repos
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Infof(" \u2601\ufe0f git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
cloneLock.Lock()
|
||||
defer cloneLock.Unlock()
|
||||
|
||||
refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", input.Ref))
|
||||
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isOfflineMode := input.OfflineMode
|
||||
|
||||
// fetch latest changes
|
||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||
|
||||
if input.InsecureSkipTLS { // For Gitea
|
||||
fetchOptions.InsecureSkipTLS = true
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
err = r.Fetch(&fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var hash *plumbing.Hash
|
||||
rev := plumbing.Revision(input.Ref)
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
}
|
||||
|
||||
if hash.String() != input.Ref && len(input.Ref) >= 4 && strings.HasPrefix(hash.String(), input.Ref) {
|
||||
return &Error{
|
||||
err: ErrShortRef,
|
||||
commit: hash.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we need to know if it's a tag or a branch
|
||||
// And the easiest way to do it is duck typing
|
||||
//
|
||||
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
||||
// it was a sha
|
||||
refType := "tag"
|
||||
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
||||
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
||||
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||
refType = "sha"
|
||||
rev = plumbing.Revision(input.Ref)
|
||||
} else {
|
||||
refType = "branch"
|
||||
rev = plumbing.Revision(rName)
|
||||
}
|
||||
}
|
||||
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
return err
|
||||
}
|
||||
|
||||
var w *git.Worktree
|
||||
if w, err = r.Worktree(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
||||
// using a branch or tag ref, not a sha
|
||||
//
|
||||
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
||||
// we try and pull down any changes
|
||||
if hash.String() != input.Ref && refType == "branch" {
|
||||
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
||||
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||
if err = w.Checkout(&git.CheckoutOptions{
|
||||
Branch: sourceRef,
|
||||
Force: true,
|
||||
}); err != nil {
|
||||
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !isOfflineMode {
|
||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||
}
|
||||
}
|
||||
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
||||
|
||||
if hash.String() != input.Ref && refType == "branch" {
|
||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = w.Checkout(&git.CheckoutOptions{
|
||||
Hash: *hash,
|
||||
Force: true,
|
||||
}); err != nil {
|
||||
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err = w.Reset(&git.ResetOptions{
|
||||
Mode: git.HardReset,
|
||||
Commit: *hash,
|
||||
}); err != nil {
|
||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debugf("Checked out %s", input.Ref)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
280
act/common/git/git_test.go
Normal file
280
act/common/git/git_test.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
func TestFindGitSlug(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
slugTests := []struct {
|
||||
url string // input
|
||||
provider string // expected result
|
||||
slug string // expected result
|
||||
}{
|
||||
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
|
||||
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
|
||||
{"git@github.com:nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"git@github.com:nektos/act", "GitHub", "nektos/act"},
|
||||
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||
{"git+ssh://git@github.com/owner/repo.git", "GitHub", "owner/repo"},
|
||||
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
|
||||
{"ssh://git@example.com/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
||||
{"ssh://git@example.com:2222/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
||||
{"ssh://git@example.com:forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
||||
{"ssh://git@example.com:/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
||||
}
|
||||
|
||||
for _, tt := range slugTests {
|
||||
instance := "example.com"
|
||||
if tt.provider == "GitHub" {
|
||||
instance = "github.com"
|
||||
}
|
||||
|
||||
provider, slug, err := findGitSlug(tt.url, instance)
|
||||
|
||||
assert.NoError(err)
|
||||
assert.Equal(tt.provider, provider)
|
||||
assert.Equal(tt.slug, slug)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanGitHooks(dir string) error {
|
||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||
files, err := os.ReadDir(hooksDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
relName := filepath.Join(hooksDir, f.Name())
|
||||
if err := os.Remove(relName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
basedir := t.TempDir()
|
||||
gitConfig()
|
||||
err := gitCmd("init", basedir)
|
||||
assert.NoError(err)
|
||||
err = cleanGitHooks(basedir)
|
||||
assert.NoError(err)
|
||||
|
||||
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
||||
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
||||
assert.NoError(err)
|
||||
|
||||
u, err := findGitRemoteURL(t.Context(), basedir, "origin")
|
||||
assert.NoError(err)
|
||||
assert.Equal(remoteURL, u)
|
||||
|
||||
remoteURL = "git@github.com/AwesomeOwner/MyAwesomeRepo.git"
|
||||
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
||||
assert.NoError(err)
|
||||
u, err = findGitRemoteURL(t.Context(), basedir, "upstream")
|
||||
assert.NoError(err)
|
||||
assert.Equal(remoteURL, u)
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
gitConfig()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Prepare func(t *testing.T, dir string)
|
||||
Assert func(t *testing.T, ref string, err error)
|
||||
}{
|
||||
"new_repo": {
|
||||
Prepare: func(t *testing.T, dir string) {},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.Error(t, err)
|
||||
},
|
||||
},
|
||||
"new_repo_with_commit": {
|
||||
Prepare: func(t *testing.T, dir string) {
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||
},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/heads/master", ref)
|
||||
},
|
||||
},
|
||||
"current_head_is_tag": {
|
||||
Prepare: func(t *testing.T, dir string) {
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg"))
|
||||
require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3"))
|
||||
require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3"))
|
||||
},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/tags/v1.2.3", ref)
|
||||
},
|
||||
},
|
||||
"current_head_is_same_as_tag": {
|
||||
Prepare: func(t *testing.T, dir string) {
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release"))
|
||||
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||
},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/tags/v1.4.2", ref)
|
||||
},
|
||||
},
|
||||
"current_head_is_not_tag": {
|
||||
Prepare: func(t *testing.T, dir string) {
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2"))
|
||||
},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/heads/master", ref)
|
||||
},
|
||||
},
|
||||
"current_head_is_another_branch": {
|
||||
Prepare: func(t *testing.T, dir string) {
|
||||
require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch"))
|
||||
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||
},
|
||||
Assert: func(t *testing.T, ref string, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/heads/mybranch", ref)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := filepath.Join(basedir, name)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
||||
require.NoError(t, gitCmd("-C", dir, "config", "user.name", "user@example.com"))
|
||||
require.NoError(t, gitCmd("-C", dir, "config", "user.email", "user@example.com"))
|
||||
require.NoError(t, cleanGitHooks(dir))
|
||||
tt.Prepare(t, dir)
|
||||
ref, err := FindGitRef(t.Context(), dir)
|
||||
tt.Assert(t, ref, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutor(t *testing.T) {
|
||||
for name, tt := range map[string]struct {
|
||||
Err error
|
||||
URL, Ref string
|
||||
}{
|
||||
"tag": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "v2",
|
||||
},
|
||||
"branch": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/anchore/scan-action",
|
||||
Ref: "act-fails",
|
||||
},
|
||||
"sha": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||
},
|
||||
"short-sha": {
|
||||
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac90", // v2
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: tt.URL,
|
||||
Ref: tt.Ref,
|
||||
Dir: t.TempDir(),
|
||||
})
|
||||
|
||||
err := clone(t.Context())
|
||||
if tt.Err != nil {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.Err, err)
|
||||
} else {
|
||||
assert.Empty(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func gitConfig() {
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
var err error
|
||||
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gitCmd(args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok {
|
||||
return fmt.Errorf("Exit error %d", waitStatus.ExitStatus())
|
||||
}
|
||||
return exitError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCloneIfRequired(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("clone", func(t *testing.T) {
|
||||
repo, err := CloneIfRequired(ctx, "refs/heads/main", NewGitCloneExecutorInput{
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Dir: tempDir,
|
||||
}, common.Logger(ctx))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, repo)
|
||||
})
|
||||
|
||||
t.Run("clone different remote", func(t *testing.T) {
|
||||
repo, err := CloneIfRequired(ctx, "refs/heads/main", NewGitCloneExecutorInput{
|
||||
URL: "https://github.com/actions/setup-go",
|
||||
Dir: tempDir,
|
||||
}, common.Logger(ctx))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo)
|
||||
|
||||
remote, err := repo.Remote("origin")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remote.Config().URLs, 1)
|
||||
assert.Equal(t, "https://github.com/actions/setup-go", remote.Config().URLs[0])
|
||||
})
|
||||
}
|
||||
30
act/common/job_error.go
Normal file
30
act/common/job_error.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type jobErrorContextKey string
|
||||
|
||||
const jobErrorContextKeyVal = jobErrorContextKey("job.error")
|
||||
|
||||
// JobError returns the job error for current context if any
|
||||
func JobError(ctx context.Context) error {
|
||||
val := ctx.Value(jobErrorContextKeyVal)
|
||||
if val != nil {
|
||||
if container, ok := val.(map[string]error); ok {
|
||||
return container["error"]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetJobError(ctx context.Context, err error) {
|
||||
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
||||
}
|
||||
|
||||
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||
func WithJobErrorContainer(ctx context.Context) context.Context {
|
||||
container := map[string]error{}
|
||||
return context.WithValue(ctx, jobErrorContextKeyVal, container)
|
||||
}
|
||||
50
act/common/line_writer.go
Normal file
50
act/common/line_writer.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
// LineHandler is a callback function for handling a line
|
||||
type LineHandler func(line string) bool
|
||||
|
||||
type lineWriter struct {
|
||||
buffer bytes.Buffer
|
||||
handlers []LineHandler
|
||||
}
|
||||
|
||||
// NewLineWriter creates a new instance of a line writer
|
||||
func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||
w := new(lineWriter)
|
||||
w.handlers = handlers
|
||||
return w
|
||||
}
|
||||
|
||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
pBuf := bytes.NewBuffer(p)
|
||||
written := 0
|
||||
for {
|
||||
line, err := pBuf.ReadString('\n')
|
||||
w, _ := lw.buffer.WriteString(line)
|
||||
written += w
|
||||
if err == nil {
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
} else if err == io.EOF {
|
||||
break
|
||||
} else {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (lw *lineWriter) handleLine(line string) {
|
||||
for _, h := range lw.handlers {
|
||||
ok := h(line)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
37
act/common/line_writer_test.go
Normal file
37
act/common/line_writer_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLineWriter(t *testing.T) {
|
||||
lines := make([]string, 0)
|
||||
lineHandler := func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
}
|
||||
|
||||
lineWriter := NewLineWriter(lineHandler)
|
||||
|
||||
assert := assert.New(t)
|
||||
write := func(s string) {
|
||||
n, err := lineWriter.Write([]byte(s))
|
||||
assert.NoError(err)
|
||||
assert.Equal(len(s), n, s)
|
||||
}
|
||||
|
||||
write("hello")
|
||||
write(" ")
|
||||
write("world!!\nextra")
|
||||
write(" line\n and another\nlast")
|
||||
write(" line\n")
|
||||
write("no newline here...")
|
||||
|
||||
assert.Len(lines, 4)
|
||||
assert.Equal("hello world!!\n", lines[0])
|
||||
assert.Equal("extra line\n", lines[1])
|
||||
assert.Equal(" and another\n", lines[2])
|
||||
assert.Equal("last line\n", lines[3])
|
||||
}
|
||||
48
act/common/logger.go
Normal file
48
act/common/logger.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type loggerContextKey string
|
||||
|
||||
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
||||
|
||||
// Logger returns the appropriate logger for current context
|
||||
func Logger(ctx context.Context) logrus.FieldLogger {
|
||||
val := ctx.Value(loggerContextKeyVal)
|
||||
if val != nil {
|
||||
if logger, ok := val.(logrus.FieldLogger); ok {
|
||||
return logger
|
||||
}
|
||||
}
|
||||
return logrus.StandardLogger()
|
||||
}
|
||||
|
||||
// WithLogger adds a value to the context for the logger
|
||||
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
|
||||
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
||||
}
|
||||
|
||||
type loggerHookKey string
|
||||
|
||||
const loggerHookKeyVal = loggerHookKey("logrus.Hook")
|
||||
|
||||
// LoggerHook returns the appropriate logger hook for current context
|
||||
// the hook affects job logger, not global logger
|
||||
func LoggerHook(ctx context.Context) logrus.Hook {
|
||||
val := ctx.Value(loggerHookKeyVal)
|
||||
if val != nil {
|
||||
if hook, ok := val.(logrus.Hook); ok {
|
||||
return hook
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithLoggerHook adds a value to the context for the logger hook
|
||||
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
||||
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
||||
}
|
||||
75
act/common/outbound_ip.go
Normal file
75
act/common/outbound_ip.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetOutboundIP returns an outbound IP address of this machine.
|
||||
// It tries to access the internet and returns the local IP address of the connection.
|
||||
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
||||
// It returns nil if no IP address is found.
|
||||
func GetOutboundIP() net.IP {
|
||||
// See https://stackoverflow.com/a/37382208
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
||||
}
|
||||
|
||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||
if ifs, err := net.Interfaces(); err == nil {
|
||||
type IP struct {
|
||||
net.IP
|
||||
net.Interface
|
||||
}
|
||||
var ips []IP
|
||||
for _, i := range ifs {
|
||||
if addrs, err := i.Addrs(); err == nil {
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip.IsGlobalUnicast() {
|
||||
ips = append(ips, IP{ip, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) > 1 {
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
ifi := ips[i].Interface
|
||||
ifj := ips[j].Interface
|
||||
|
||||
// ethernet is preferred
|
||||
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
ipi := ips[i].IP
|
||||
ipj := ips[j].IP
|
||||
|
||||
// IPv4 is preferred
|
||||
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
// en0 is preferred to en1
|
||||
if ifi.Name != ifj.Name {
|
||||
return ifi.Name < ifj.Name
|
||||
}
|
||||
|
||||
// fallback
|
||||
return ipi.String() < ipj.String()
|
||||
})
|
||||
return ips[0].IP
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
25
act/common/randname.go
Normal file
25
act/common/randname.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2025 The Forgejo Authors
|
||||
// SPDX-License-Identifier: MIT
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func randName(size int) (string, error) {
|
||||
randBytes := make([]byte, size)
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(randBytes), nil
|
||||
}
|
||||
|
||||
func MustRandName(size int) string {
|
||||
name, err := randName(size)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("RandName(%d): %v", size, err))
|
||||
}
|
||||
return name
|
||||
}
|
||||
13
act/common/sha256.go
Normal file
13
act/common/sha256.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright 2025 The Forgejo Authors
|
||||
// SPDX-License-Identifier: MIT
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func Sha256(content string) string {
|
||||
hashBytes := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(hashBytes[:])
|
||||
}
|
||||
13
act/common/sha256_test.go
Normal file
13
act/common/sha256_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright 2025 The Forgejo Authors
|
||||
// SPDX-License-Identifier: MIT
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSha256(t *testing.T) {
|
||||
assert.Equal(t, "3fc9b689459d738f8c88a3a48aa9e33542016b7a4052e001aaa536fca74813cb", Sha256("something"))
|
||||
}
|
||||
191
act/container/DOCKER_LICENSE
Normal file
191
act/container/DOCKER_LICENSE
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2013-2017 Docker, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
83
act/container/container_types.go
Normal file
83
act/container/container_types.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// NewContainerInput the input for the New function
|
||||
type NewContainerInput struct {
|
||||
Image string
|
||||
Username string
|
||||
Password string
|
||||
Entrypoint []string
|
||||
Cmd []string
|
||||
WorkingDir string
|
||||
Env []string
|
||||
ToolCache string
|
||||
Binds []string
|
||||
Mounts map[string]string
|
||||
Name string
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
NetworkMode string
|
||||
Privileged bool
|
||||
UsernsMode string
|
||||
Platform string
|
||||
NetworkAliases []string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
|
||||
ConfigOptions string
|
||||
JobOptions string
|
||||
|
||||
ValidVolumes []string
|
||||
}
|
||||
|
||||
// FileEntry is a file to copy to a container
|
||||
type FileEntry struct {
|
||||
Name string
|
||||
Mode int64
|
||||
Body string
|
||||
}
|
||||
|
||||
// Container for managing docker run containers
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
ConnectToNetwork(name string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Pull(forcePull bool) common.Executor
|
||||
Start(attach bool) common.Executor
|
||||
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||
UpdateFromEnv(srcPath string, env *map[string]string) common.Executor
|
||||
UpdateFromImageEnv(env *map[string]string) common.Executor
|
||||
Remove() common.Executor
|
||||
Close() common.Executor
|
||||
ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer)
|
||||
IsHealthy(ctx context.Context) (time.Duration, error)
|
||||
}
|
||||
|
||||
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
|
||||
type NewDockerBuildExecutorInput struct {
|
||||
ContextDir string
|
||||
Dockerfile string
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
type NewDockerPullExecutorInput struct {
|
||||
Image string
|
||||
ForcePull bool
|
||||
Platform string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
61
act/container/docker_auth.go
Normal file
61
act/container/docker_auth.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
)
|
||||
|
||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||
logger := common.Logger(ctx)
|
||||
config, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
|
||||
if !config.ContainsAuth() {
|
||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||
}
|
||||
|
||||
hostName := "index.docker.io"
|
||||
index := strings.IndexRune(image, '/')
|
||||
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
|
||||
hostName = image[:index]
|
||||
}
|
||||
|
||||
authConfig, err := config.GetAuthConfig(hostName)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
|
||||
return registry.AuthConfig(authConfig), nil
|
||||
}
|
||||
|
||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
logger := common.Logger(ctx)
|
||||
config, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !config.ContainsAuth() {
|
||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||
}
|
||||
|
||||
creds, _ := config.GetAllCredentials()
|
||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||
for k, v := range creds {
|
||||
authConfigs[k] = registry.AuthConfig(v)
|
||||
}
|
||||
|
||||
return authConfigs
|
||||
}
|
||||
117
act/container/docker_build.go
Normal file
117
act/container/docker_build.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/docker/docker/api/types/build"
|
||||
|
||||
"github.com/moby/go-archive"
|
||||
"github.com/moby/patternmatcher"
|
||||
"github.com/moby/patternmatcher/ignorefile"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
if input.Platform != "" {
|
||||
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
|
||||
} else {
|
||||
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
|
||||
}
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||
|
||||
tags := []string{input.ImageTag}
|
||||
options := build.ImageBuildOptions{
|
||||
Tags: tags,
|
||||
Remove: true,
|
||||
Platform: input.Platform,
|
||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||
Dockerfile: input.Dockerfile,
|
||||
}
|
||||
var buildContext io.ReadCloser
|
||||
if input.BuildContext != nil {
|
||||
buildContext = io.NopCloser(input.BuildContext)
|
||||
} else {
|
||||
buildContext, err = createBuildContext(ctx, input.ContextDir, input.Dockerfile)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer buildContext.Close()
|
||||
|
||||
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
|
||||
resp, err := cli.ImageBuild(ctx, buildContext, options)
|
||||
|
||||
err = logDockerResponse(logger, resp.Body, err != nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||
|
||||
// And canonicalize dockerfile name to a platform-independent one
|
||||
relDockerfile = filepath.ToSlash(relDockerfile)
|
||||
|
||||
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var excludes []string
|
||||
if err == nil {
|
||||
excludes, err = ignorefile.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If .dockerignore mentions .dockerignore or the Dockerfile
|
||||
// then make sure we send both files over to the daemon
|
||||
// because Dockerfile is, obviously, needed no matter what, and
|
||||
// .dockerignore is needed to know if either one needs to be
|
||||
// removed. The daemon will remove them for us, if needed, after it
|
||||
// parses the Dockerfile. Ignore errors here, as they will have been
|
||||
// caught by validateContextDirectory above.
|
||||
includes := []string{"."}
|
||||
keepThem1, _ := patternmatcher.Matches(".dockerignore", excludes)
|
||||
keepThem2, _ := patternmatcher.Matches(relDockerfile, excludes)
|
||||
if keepThem1 || keepThem2 {
|
||||
includes = append(includes, ".dockerignore", relDockerfile)
|
||||
}
|
||||
|
||||
compression := archive.Uncompressed
|
||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||
Compression: compression,
|
||||
ExcludePatterns: excludes,
|
||||
IncludeFiles: includes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildCtx, nil
|
||||
}
|
||||
1083
act/container/docker_cli.go
Normal file
1083
act/container/docker_cli.go
Normal file
File diff suppressed because it is too large
Load diff
975
act/container/docker_cli_test.go
Normal file
975
act/container/docker_cli_test.go
Normal file
|
|
@ -0,0 +1,975 @@
|
|||
// This file is exact copy of https://github.com/docker/cli/blob/9ac8584acfd501c3f4da0e845e3a40ed15c85041/cli/command/container/opts_test.go with:
|
||||
// * appended with license information
|
||||
// * commented out case 'invalid-mixed-network-types' in test TestParseNetworkConfig
|
||||
//
|
||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||
// See DOCKER_LICENSE for the full license text.
|
||||
//
|
||||
|
||||
//nolint:unparam,gocritic
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
networktypes "github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/spf13/pflag"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
"gotest.tools/v3/skip"
|
||||
)
|
||||
|
||||
func TestValidateAttach(t *testing.T) {
|
||||
valid := []string{
|
||||
"stdin",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"STDIN",
|
||||
"STDOUT",
|
||||
"STDERR",
|
||||
}
|
||||
if _, err := validateAttach("invalid"); err == nil {
|
||||
t.Fatal("Expected error with [valid streams are STDIN, STDOUT and STDERR], got nothing")
|
||||
}
|
||||
|
||||
for _, attach := range valid {
|
||||
value, err := validateAttach(attach)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != strings.ToLower(attach) {
|
||||
t.Fatalf("Expected [%v], got [%v]", attach, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRun(args []string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig, error) {
|
||||
flags, copts := setupRunFlags()
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
// TODO: fix tests to accept ContainerConfig
|
||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return containerConfig.Config, containerConfig.HostConfig, containerConfig.NetworkingConfig, err
|
||||
}
|
||||
|
||||
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||
flags := pflag.NewFlagSet("run", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
flags.Usage = nil
|
||||
copts := addFlags(flags)
|
||||
return flags, copts
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) {
|
||||
t.Helper()
|
||||
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||
assert.NilError(t, err)
|
||||
return config, hostConfig
|
||||
}
|
||||
|
||||
func TestParseRunLinks(t *testing.T) {
|
||||
if _, hostConfig := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
||||
}
|
||||
if _, hostConfig := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
||||
}
|
||||
if _, hostConfig := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunAttach(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected container.Config
|
||||
}{
|
||||
{
|
||||
input: "",
|
||||
expected: container.Config{
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "-i",
|
||||
expected: container.Config{
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "-a stdin",
|
||||
expected: container.Config{
|
||||
AttachStdin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "-a stdin -a stdout",
|
||||
expected: container.Config{
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "-a stdin -a stdout -a stderr",
|
||||
expected: container.Config{
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
config, _ := mustParse(t, tc.input)
|
||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunWithInvalidArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
args []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
args: []string{"-a", "ubuntu", "bash"},
|
||||
error: `invalid argument "ubuntu" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "invalid", "ubuntu", "bash"},
|
||||
error: `invalid argument "invalid" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "invalid", "-a", "stdout", "ubuntu", "bash"},
|
||||
error: `invalid argument "invalid" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "stdout", "-a", "stderr", "-z", "ubuntu", "bash"},
|
||||
error: `unknown shorthand flag: 'z' in -z`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "stdin", "-z", "ubuntu", "bash"},
|
||||
error: `unknown shorthand flag: 'z' in -z`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "stdout", "-z", "ubuntu", "bash"},
|
||||
error: `unknown shorthand flag: 'z' in -z`,
|
||||
},
|
||||
{
|
||||
args: []string{"-a", "stderr", "-z", "ubuntu", "bash"},
|
||||
error: `unknown shorthand flag: 'z' in -z`,
|
||||
},
|
||||
{
|
||||
args: []string{"-z", "--rm", "ubuntu", "bash"},
|
||||
error: `unknown shorthand flag: 'z' in -z`,
|
||||
},
|
||||
}
|
||||
flags, _ := setupRunFlags()
|
||||
for _, tc := range tests {
|
||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||
assert.Error(t, flags.Parse(tc.args), tc.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithVolumes(t *testing.T) {
|
||||
// A single volume
|
||||
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
||||
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
||||
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
|
||||
}
|
||||
|
||||
// Two volumes
|
||||
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
||||
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
||||
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
|
||||
} else if _, exists := config.Volumes[arr[1]]; !exists {
|
||||
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[1], config.Volumes)
|
||||
}
|
||||
|
||||
// A single bind mount
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
|
||||
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
|
||||
}
|
||||
|
||||
// Two bind mounts.
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
|
||||
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||
}
|
||||
|
||||
// Two bind mounts, first read-only, second read-write.
|
||||
// TODO Windows: The Windows version uses read-write as that's the only mode it supports. Can change this post TP4
|
||||
arr, tryit = setupPlatformVolume(
|
||||
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
|
||||
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
|
||||
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||
}
|
||||
|
||||
// Similar to previous test but with alternate modes which are only supported by Linux
|
||||
if runtime.GOOS != "windows" {
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
|
||||
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||
}
|
||||
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
|
||||
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
// One bind mount and one volume
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
|
||||
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
|
||||
} else if _, exists := config.Volumes[arr[1]]; !exists {
|
||||
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
|
||||
}
|
||||
|
||||
// Root to non-c: drive letter (Windows specific)
|
||||
if runtime.GOOS == "windows" {
|
||||
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
|
||||
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupPlatformVolume takes two arrays of volume specs - a Unix style
|
||||
// spec and a Windows style spec. Depending on the platform being unit tested,
|
||||
// it returns one of them, along with a volume string that would be passed
|
||||
// on the docker CLI (e.g. -v /bar -v /foo).
|
||||
func setupPlatformVolume(u, w []string) ([]string, string) {
|
||||
var a []string
|
||||
if runtime.GOOS == "windows" {
|
||||
a = w
|
||||
} else {
|
||||
a = u
|
||||
}
|
||||
s := ""
|
||||
for _, v := range a {
|
||||
s = s + "-v " + v + " "
|
||||
}
|
||||
return a, s
|
||||
}
|
||||
|
||||
// check if (a == c && b == d) || (a == d && b == c)
|
||||
// because maps are randomized
|
||||
func compareRandomizedStrings(a, b, c, d string) error {
|
||||
if a == c && b == d {
|
||||
return nil
|
||||
}
|
||||
if a == d && b == c {
|
||||
return nil
|
||||
}
|
||||
return errors.New("strings don't match")
|
||||
}
|
||||
|
||||
// Simple parse with MacAddress validation
|
||||
func TestParseWithMacAddress(t *testing.T) {
|
||||
invalidMacAddress := "--mac-address=invalidMacAddress"
|
||||
validMacAddress := "--mac-address=92:d0:c6:0a:29:33"
|
||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||
}
|
||||
config, _ := mustParse(t, validMacAddress)
|
||||
if config.MacAddress != "92:d0:c6:0a:29:33" {
|
||||
t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as container-wide MacAddress, got '%v'",
|
||||
config.MacAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||
flags, _ := setupRunFlags()
|
||||
args := []string{"--memory=invalid", "img", "cmd"}
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||
|
||||
_, hostconfig := mustParse(t, "--memory=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
||||
}
|
||||
|
||||
func TestParseWithMemorySwap(t *testing.T) {
|
||||
flags, _ := setupRunFlags()
|
||||
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||
|
||||
_, hostconfig := mustParse(t, "--memory-swap=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
||||
|
||||
_, hostconfig = mustParse(t, "--memory-swap=-1")
|
||||
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
||||
}
|
||||
|
||||
func TestParseHostname(t *testing.T) {
|
||||
validHostnames := map[string]string{
|
||||
"hostname": "hostname",
|
||||
"host-name": "host-name",
|
||||
"hostname123": "hostname123",
|
||||
"123hostname": "123hostname",
|
||||
"hostname-of-63-bytes-long-should-be-valid-and-without-any-error": "hostname-of-63-bytes-long-should-be-valid-and-without-any-error",
|
||||
}
|
||||
hostnameWithDomain := "--hostname=hostname.domainname"
|
||||
hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
|
||||
for hostname, expectedHostname := range validHostnames {
|
||||
if config, _ := mustParse(t, fmt.Sprintf("--hostname=%s", hostname)); config.Hostname != expectedHostname {
|
||||
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
|
||||
}
|
||||
}
|
||||
if config, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
|
||||
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
|
||||
}
|
||||
if config, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
|
||||
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostnameDomainname(t *testing.T) {
|
||||
validDomainnames := map[string]string{
|
||||
"domainname": "domainname",
|
||||
"domain-name": "domain-name",
|
||||
"domainname123": "domainname123",
|
||||
"123domainname": "123domainname",
|
||||
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
|
||||
}
|
||||
for domainname, expectedDomainname := range validDomainnames {
|
||||
if config, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
|
||||
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
|
||||
}
|
||||
}
|
||||
if config, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
|
||||
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
|
||||
}
|
||||
if config, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
|
||||
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithExpose(t *testing.T) {
|
||||
invalids := map[string]string{
|
||||
":": "invalid port format for --expose: :",
|
||||
"8080:9090": "invalid port format for --expose: 8080:9090",
|
||||
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
|
||||
}
|
||||
valids := map[string][]nat.Port{
|
||||
"8080/ncp": {"8080/ncp"},
|
||||
"8080-8080/udp": {"8080/udp"},
|
||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
||||
}
|
||||
for expose, expectedError := range invalids {
|
||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
||||
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
|
||||
}
|
||||
}
|
||||
for expose, exposedPorts := range valids {
|
||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.ExposedPorts) != len(exposedPorts) {
|
||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
||||
}
|
||||
for _, port := range exposedPorts {
|
||||
if _, ok := config.ExposedPorts[port]; !ok {
|
||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Merge with actual published port
|
||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.ExposedPorts) != 2 {
|
||||
t.Fatalf("Expected 2 exposed ports, got %v", config.ExposedPorts)
|
||||
}
|
||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
||||
for _, port := range ports {
|
||||
if _, ok := config.ExposedPorts[port]; !ok {
|
||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDevice(t *testing.T) {
|
||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||
valids := map[string]container.DeviceMapping{
|
||||
"/dev/snd": {
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rwm",
|
||||
},
|
||||
"/dev/snd:rw": {
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rw",
|
||||
},
|
||||
"/dev/snd:/something": {
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/something",
|
||||
CgroupPermissions: "rwm",
|
||||
},
|
||||
"/dev/snd:/something:rw": {
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/something",
|
||||
CgroupPermissions: "rw",
|
||||
},
|
||||
}
|
||||
for device, deviceMapping := range valids {
|
||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--device=%v", device), "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hostconfig.Devices) != 1 {
|
||||
t.Fatalf("Expected 1 devices, got %v", hostconfig.Devices)
|
||||
}
|
||||
if hostconfig.Devices[0] != deviceMapping {
|
||||
t.Fatalf("Expected %v, got %v", deviceMapping, hostconfig.Devices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []string
|
||||
expected map[string]*networktypes.EndpointSettings
|
||||
expectedCfg container.HostConfig
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "single-network-legacy",
|
||||
flags: []string{"--network", "net1"},
|
||||
expected: map[string]*networktypes.EndpointSettings{},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "single-network-advanced",
|
||||
flags: []string{"--network", "name=net1"},
|
||||
expected: map[string]*networktypes.EndpointSettings{},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "single-network-legacy-with-options",
|
||||
flags: []string{
|
||||
"--ip", "172.20.88.22",
|
||||
"--ip6", "2001:db8::8822",
|
||||
"--link", "foo:bar",
|
||||
"--link", "bar:baz",
|
||||
"--link-local-ip", "169.254.2.2",
|
||||
"--link-local-ip", "fe80::169:254:2:2",
|
||||
"--network", "name=net1",
|
||||
"--network-alias", "web1",
|
||||
"--network-alias", "web2",
|
||||
},
|
||||
expected: map[string]*networktypes.EndpointSettings{
|
||||
"net1": {
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||
},
|
||||
Links: []string{"foo:bar", "bar:baz"},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
},
|
||||
},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "multiple-network-advanced-mixed",
|
||||
flags: []string{
|
||||
"--ip", "172.20.88.22",
|
||||
"--ip6", "2001:db8::8822",
|
||||
"--link", "foo:bar",
|
||||
"--link", "bar:baz",
|
||||
"--link-local-ip", "169.254.2.2",
|
||||
"--link-local-ip", "fe80::169:254:2:2",
|
||||
"--network", "name=net1,driver-opt=field1=value1",
|
||||
"--network-alias", "web1",
|
||||
"--network-alias", "web2",
|
||||
"--network", "net2",
|
||||
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
||||
},
|
||||
expected: map[string]*networktypes.EndpointSettings{
|
||||
"net1": {
|
||||
DriverOpts: map[string]string{"field1": "value1"},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||
},
|
||||
Links: []string{"foo:bar", "bar:baz"},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
},
|
||||
"net2": {},
|
||||
"net3": {
|
||||
DriverOpts: map[string]string{"field3": "value3"},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
},
|
||||
Aliases: []string{"web3"},
|
||||
},
|
||||
},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "single-network-advanced-with-options",
|
||||
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822"},
|
||||
expected: map[string]*networktypes.EndpointSettings{
|
||||
"net1": {
|
||||
DriverOpts: map[string]string{
|
||||
"field1": "value1",
|
||||
"field2": "value2",
|
||||
},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
},
|
||||
},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "multiple-networks",
|
||||
flags: []string{"--network", "net1", "--network", "name=net2"},
|
||||
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
||||
},
|
||||
{
|
||||
name: "conflict-network",
|
||||
flags: []string{"--network", "duplicate", "--network", "name=duplicate"},
|
||||
expectedErr: `network "duplicate" is specified multiple times`,
|
||||
},
|
||||
{
|
||||
name: "conflict-options-alias",
|
||||
flags: []string{"--network", "name=net1,alias=web1", "--network-alias", "web1"},
|
||||
expectedErr: `conflicting options: cannot specify both --network-alias and per-network alias`,
|
||||
},
|
||||
{
|
||||
name: "conflict-options-ip",
|
||||
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip", "172.20.88.22"},
|
||||
expectedErr: `conflicting options: cannot specify both --ip and per-network IPv4 address`,
|
||||
},
|
||||
{
|
||||
name: "conflict-options-ip6",
|
||||
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
||||
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
||||
},
|
||||
// case is skipped as it fails w/o any change
|
||||
//
|
||||
//{
|
||||
// name: "invalid-mixed-network-types",
|
||||
// flags: []string{"--network", "name=host", "--network", "net1"},
|
||||
// expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
||||
//},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
||||
|
||||
if tc.expectedErr != "" {
|
||||
assert.Error(t, err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModes(t *testing.T) {
|
||||
// pid ko
|
||||
flags, copts := setupRunFlags()
|
||||
args := []string{"--pid=container:", "img", "cmd"}
|
||||
assert.NilError(t, flags.Parse(args))
|
||||
_, err := parse(flags, copts, runtime.GOOS)
|
||||
assert.ErrorContains(t, err, "--pid: invalid PID mode")
|
||||
|
||||
// pid ok
|
||||
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
if !hostconfig.PidMode.Valid() {
|
||||
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
||||
}
|
||||
|
||||
// uts ko
|
||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled
|
||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||
|
||||
// uts ok
|
||||
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
if !hostconfig.UTSMode.Valid() {
|
||||
t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFlagsParseShmSize(t *testing.T) {
|
||||
// shm-size ko
|
||||
flags, _ := setupRunFlags()
|
||||
args := []string{"--shm-size=a128m", "img", "cmd"}
|
||||
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, expectedErr)
|
||||
|
||||
// shm-size ok
|
||||
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
if hostconfig.ShmSize != 134217728 {
|
||||
t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRestartPolicy(t *testing.T) {
|
||||
invalids := map[string]string{
|
||||
"always:2:3": "invalid restart policy format: maximum retry count must be an integer",
|
||||
"on-failure:invalid": "invalid restart policy format: maximum retry count must be an integer",
|
||||
}
|
||||
valids := map[string]container.RestartPolicy{
|
||||
"": {},
|
||||
"always": {
|
||||
Name: "always",
|
||||
MaximumRetryCount: 0,
|
||||
},
|
||||
"on-failure:1": {
|
||||
Name: "on-failure",
|
||||
MaximumRetryCount: 1,
|
||||
},
|
||||
}
|
||||
for restart, expectedError := range invalids {
|
||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--restart=%s", restart), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
||||
t.Fatalf("Expected an error with message '%v' for %v, got %v", expectedError, restart, err)
|
||||
}
|
||||
}
|
||||
for restart, expected := range valids {
|
||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--restart=%v", restart), "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hostconfig.RestartPolicy != expected {
|
||||
t.Fatalf("Expected %v, got %v", expected, hostconfig.RestartPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||
expected := "Conflicting options: --restart and --rm"
|
||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled
|
||||
if err == nil || err.Error() != expected {
|
||||
t.Fatalf("Expected error %v, but got none", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHealth(t *testing.T) {
|
||||
checkOk := func(args ...string) *container.HealthConfig {
|
||||
config, _, _, err := parseRun(args)
|
||||
if err != nil {
|
||||
t.Fatalf("%#v: %v", args, err)
|
||||
}
|
||||
return config.Healthcheck
|
||||
}
|
||||
checkError := func(expected string, args ...string) {
|
||||
config, _, _, err := parseRun(args)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, but got %#v", config)
|
||||
}
|
||||
if err.Error() != expected {
|
||||
t.Fatalf("Expected %#v, got %#v", expected, err)
|
||||
}
|
||||
}
|
||||
health := checkOk("--no-healthcheck", "img", "cmd")
|
||||
if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" {
|
||||
t.Fatalf("--no-healthcheck failed: %#v", health)
|
||||
}
|
||||
|
||||
health = checkOk("--health-cmd=/check.sh -q", "img", "cmd")
|
||||
if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh -q" {
|
||||
t.Fatalf("--health-cmd: got %#v", health.Test)
|
||||
}
|
||||
if health.Timeout != 0 {
|
||||
t.Fatalf("--health-cmd: timeout = %s", health.Timeout)
|
||||
}
|
||||
|
||||
checkError("--no-healthcheck conflicts with --health-* options",
|
||||
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
||||
|
||||
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "img", "cmd")
|
||||
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second {
|
||||
t.Fatalf("--health-*: got %#v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoggingOpts(t *testing.T) {
|
||||
// logging opts ko
|
||||
if _, _, _, err := parseRun([]string{"--log-driver=none", "--log-opt=anything", "img", "cmd"}); err == nil || err.Error() != "invalid logging opts for driver none" {
|
||||
t.Fatalf("Expected an error with message 'invalid logging opts for driver none', got %v", err)
|
||||
}
|
||||
// logging opts ok
|
||||
_, hostconfig, _, err := parseRun([]string{"--log-driver=syslog", "--log-opt=something", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hostconfig.LogConfig.Type != "syslog" || len(hostconfig.LogConfig.Config) != 1 {
|
||||
t.Fatalf("Expected a 'syslog' LogConfig with one config, got %v", hostconfig.RestartPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvfileVariables(t *testing.T) {
|
||||
e := "open nonexistent: no such file or directory"
|
||||
if runtime.GOOS == "windows" {
|
||||
e = "open nonexistent: The system cannot find the file specified."
|
||||
}
|
||||
// env ko
|
||||
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||
}
|
||||
// env ok
|
||||
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Env) != 1 || config.Env[0] != "ENV1=value1" {
|
||||
t.Fatalf("Expected a config with [ENV1=value1], got %v", config.Env)
|
||||
}
|
||||
config, _, _, err = parseRun([]string{"--env-file=testdata/valid.env", "--env=ENV2=value2", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Env) != 2 || config.Env[0] != "ENV1=value1" || config.Env[1] != "ENV2=value2" {
|
||||
t.Fatalf("Expected a config with [ENV1=value1 ENV2=value2], got %v", config.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
||||
// UTF8 with BOM
|
||||
config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := []string{"FOO=BAR", "HELLO=" + string([]byte{0xe6, 0x82, 0xa8, 0xe5, 0xa5, 0xbd}), "BAR=FOO"}
|
||||
if len(config.Env) != len(env) {
|
||||
t.Fatalf("Expected a config with %d env variables, got %v: %v", len(env), len(config.Env), config.Env)
|
||||
}
|
||||
for i, v := range env {
|
||||
if config.Env[i] != v {
|
||||
t.Fatalf("Expected a config with [%s], got %v", v, []byte(config.Env[i]))
|
||||
}
|
||||
}
|
||||
|
||||
// UTF16 with BOM
|
||||
e := "invalid utf8 bytes at line"
|
||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||
t.Fatalf("Expected an error with message '%s', got '%v'", e, err)
|
||||
}
|
||||
// UTF16BE with BOM
|
||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16be.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelfileVariables(t *testing.T) {
|
||||
e := "open nonexistent: no such file or directory"
|
||||
if runtime.GOOS == "windows" {
|
||||
e = "open nonexistent: The system cannot find the file specified."
|
||||
}
|
||||
// label ko
|
||||
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||
}
|
||||
// label ok
|
||||
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Labels) != 1 || config.Labels["LABEL1"] != "value1" {
|
||||
t.Fatalf("Expected a config with [LABEL1:value1], got %v", config.Labels)
|
||||
}
|
||||
config, _, _, err = parseRun([]string{"--label-file=testdata/valid.label", "--label=LABEL2=value2", "img", "cmd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Labels) != 2 || config.Labels["LABEL1"] != "value1" || config.Labels["LABEL2"] != "value2" {
|
||||
t.Fatalf("Expected a config with [LABEL1:value1 LABEL2:value2], got %v", config.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryPoint(t *testing.T) {
|
||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Entrypoint) != 1 && config.Entrypoint[0] != "anything" {
|
||||
t.Fatalf("Expected entrypoint 'anything', got %v", config.Entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevice(t *testing.T) {
|
||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||
valid := []string{
|
||||
"/home",
|
||||
"/home:/home",
|
||||
"/home:/something/else",
|
||||
"/with space",
|
||||
"/home:/with space",
|
||||
"relative:/absolute-path",
|
||||
"hostPath:/containerPath:r",
|
||||
"/hostPath:/containerPath:rw",
|
||||
"/hostPath:/containerPath:mrw",
|
||||
}
|
||||
invalid := map[string]string{
|
||||
"": "bad format for path: ",
|
||||
"./": "./ is not an absolute path",
|
||||
"../": "../ is not an absolute path",
|
||||
"/:../": "../ is not an absolute path",
|
||||
"/:path": "path is not an absolute path",
|
||||
":": "bad format for path: :",
|
||||
"/tmp:": " is not an absolute path",
|
||||
":test": "bad format for path: :test",
|
||||
":/test": "bad format for path: :/test",
|
||||
"tmp:": " is not an absolute path",
|
||||
":test:": "bad format for path: :test:",
|
||||
"::": "bad format for path: ::",
|
||||
":::": "bad format for path: :::",
|
||||
"/tmp:::": "bad format for path: /tmp:::",
|
||||
":/tmp::": "bad format for path: :/tmp::",
|
||||
"path:ro": "ro is not an absolute path",
|
||||
"path:rr": "rr is not an absolute path",
|
||||
"a:/b:ro": "bad mode specified: ro",
|
||||
"a:/b:rr": "bad mode specified: rr",
|
||||
}
|
||||
|
||||
for _, path := range valid {
|
||||
if _, err := validateDevice(path, runtime.GOOS); err != nil {
|
||||
t.Fatalf("ValidateDevice(`%q`) should succeed: error %q", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
for path, expectedError := range invalid {
|
||||
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
||||
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
||||
} else {
|
||||
if err.Error() != expectedError {
|
||||
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSystemPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
in, out, masked, readonly []string
|
||||
}{
|
||||
{
|
||||
doc: "not set",
|
||||
in: []string{},
|
||||
out: []string{},
|
||||
},
|
||||
{
|
||||
doc: "not set, preserve other options",
|
||||
in: []string{
|
||||
"seccomp=unconfined",
|
||||
"apparmor=unconfined",
|
||||
"label=user:USER",
|
||||
"foo=bar",
|
||||
},
|
||||
out: []string{
|
||||
"seccomp=unconfined",
|
||||
"apparmor=unconfined",
|
||||
"label=user:USER",
|
||||
"foo=bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "unconfined",
|
||||
in: []string{"systempaths=unconfined"},
|
||||
out: []string{},
|
||||
masked: []string{},
|
||||
readonly: []string{},
|
||||
},
|
||||
{
|
||||
doc: "unconfined and other options",
|
||||
in: []string{"foo=bar", "bar=baz", "systempaths=unconfined"},
|
||||
out: []string{"foo=bar", "bar=baz"},
|
||||
masked: []string{},
|
||||
readonly: []string{},
|
||||
},
|
||||
{
|
||||
doc: "unknown option",
|
||||
in: []string{"foo=bar", "systempaths=unknown", "bar=baz"},
|
||||
out: []string{"foo=bar", "systempaths=unknown", "bar=baz"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
||||
assert.DeepEqual(t, securityOpts, tc.out)
|
||||
assert.DeepEqual(t, maskedPaths, tc.masked)
|
||||
assert.DeepEqual(t, readonlyPaths, tc.readonly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToStandardNotation(t *testing.T) {
|
||||
valid := map[string][]string{
|
||||
"20:10/tcp": {"target=10,published=20"},
|
||||
"40:30": {"40:30"},
|
||||
"20:20 80:4444": {"20:20", "80:4444"},
|
||||
"1500:2500/tcp 1400:1300": {"target=2500,published=1500", "1400:1300"},
|
||||
"1500:200/tcp 90:80/tcp": {"published=1500,target=200", "target=80,published=90"},
|
||||
}
|
||||
|
||||
invalid := [][]string{
|
||||
{"published=1500,target:444"},
|
||||
{"published=1500,444"},
|
||||
{"published=1500,target,444"},
|
||||
}
|
||||
|
||||
for key, ports := range valid {
|
||||
convertedPorts, err := convertToStandardNotation(ports)
|
||||
if err != nil {
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
assert.DeepEqual(t, strings.Split(key, " "), convertedPorts)
|
||||
}
|
||||
|
||||
for _, ports := range invalid {
|
||||
if _, err := convertToStandardNotation(ports); err == nil {
|
||||
t.Fatalf("ConvertToStandardNotation(`%q`) should have failed conversion", ports)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
act/container/docker_images.go
Normal file
66
act/container/docker_images.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
imagePlatform := fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture)
|
||||
|
||||
if platform == "" || platform == "any" || imagePlatform == platform {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
logger.Infof("image found but platform does not match: %s (image) != %s (platform)\n", imagePlatform, platform)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveImage removes image from local store, the function is used to run different
|
||||
// container image architectures
|
||||
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, image.RemoveOptions{
|
||||
Force: force,
|
||||
PruneChildren: pruneChildren,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
66
act/container/docker_images_test.go
Normal file
66
act/container/docker_images_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
ctx := t.Context()
|
||||
// to help make this test reliable and not flaky, we need to have
|
||||
// an image that will exist, and onew that won't exist
|
||||
|
||||
// Test if image exists with specific tag
|
||||
invalidImageTag, err := ImageExistsLocally(ctx, "code.forgejo.org/oci/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, false, invalidImageTag)
|
||||
|
||||
// Test if image exists with specific architecture (image platform)
|
||||
invalidImagePlatform, err := ImageExistsLocally(ctx, "code.forgejo.org/oci/alpine:latest", "windows/amd64")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, false, invalidImagePlatform)
|
||||
|
||||
// pull an image
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
assert.Nil(t, err)
|
||||
cli.NegotiateAPIVersion(t.Context())
|
||||
|
||||
// Chose alpine latest because it's so small
|
||||
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||
readerDefault, err := cli.ImagePull(ctx, "code.forgejo.org/oci/alpine:latest", image.PullOptions{
|
||||
Platform: "linux/amd64",
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
defer readerDefault.Close()
|
||||
_, err = io.ReadAll(readerDefault)
|
||||
assert.Nil(t, err)
|
||||
|
||||
imageDefaultArchExists, err := ImageExistsLocally(ctx, "code.forgejo.org/oci/alpine:latest", "linux/amd64")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, true, imageDefaultArchExists)
|
||||
|
||||
// Validate if another architecture platform can be pulled
|
||||
readerArm64, err := cli.ImagePull(ctx, "code.forgejo.org/oci/alpine:latest", image.PullOptions{
|
||||
Platform: "linux/arm64",
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
defer readerArm64.Close()
|
||||
_, err = io.ReadAll(readerArm64)
|
||||
assert.Nil(t, err)
|
||||
|
||||
imageArm64Exists, err := ImageExistsLocally(ctx, "code.forgejo.org/oci/alpine:latest", "linux/arm64")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, true, imageArm64Exists)
|
||||
}
|
||||
83
act/container/docker_logger.go
Normal file
83
act/container/docker_logger.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type dockerMessage struct {
|
||||
ID string `json:"id"`
|
||||
Stream string `json:"stream"`
|
||||
Error string `json:"error"`
|
||||
ErrorDetail struct {
|
||||
Message string
|
||||
}
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
const logPrefix = " \U0001F433 "
|
||||
|
||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
||||
if dockerResponse == nil {
|
||||
return nil
|
||||
}
|
||||
defer dockerResponse.Close()
|
||||
|
||||
scanner := bufio.NewScanner(dockerResponse)
|
||||
msg := dockerMessage{}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
msg.ID = ""
|
||||
msg.Stream = ""
|
||||
msg.Error = ""
|
||||
msg.ErrorDetail.Message = ""
|
||||
msg.Status = ""
|
||||
msg.Progress = ""
|
||||
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Error != "" {
|
||||
writeLog(logger, isError, "%s", msg.Error)
|
||||
return errors.New(msg.Error)
|
||||
}
|
||||
|
||||
if msg.ErrorDetail.Message != "" {
|
||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||
return errors.New(msg.Error)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
if msg.Progress != "" {
|
||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||
} else {
|
||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||
}
|
||||
} else if msg.Stream != "" {
|
||||
writeLog(logger, isError, "%s", msg.Stream)
|
||||
} else {
|
||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeLog(logger logrus.FieldLogger, isError bool, format string, args ...any) {
|
||||
if isError {
|
||||
logger.Errorf(format, args...)
|
||||
} else {
|
||||
logger.Debugf(format, args...)
|
||||
}
|
||||
}
|
||||
75
act/container/docker_network.go
Normal file
75
act/container/docker_network.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
)
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, config *network.CreateOptions) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, network.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, network := range networks {
|
||||
if network.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cli.NetworkCreate(ctx, name, *config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Make shure that all network of the specified name are removed
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||
networks, err := cli.NetworkList(ctx, network.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, net := range networks {
|
||||
if net.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, net.ID, network.InspectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Containers) == 0 {
|
||||
if err = cli.NetworkRemove(ctx, net.ID); err != nil {
|
||||
common.Logger(ctx).Debugf("%v", err)
|
||||
}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
126
act/container/docker_pull.go
Normal file
126
act/container/docker_pull.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
// NewDockerPullExecutor function to create a run executor for the container
|
||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("%sdocker pull %v", logPrefix, input.Image)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
pull := input.ForcePull
|
||||
if !pull {
|
||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||
logger.Debugf("Image exists? %v", imageExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||
}
|
||||
|
||||
if !imageExists {
|
||||
pull = true
|
||||
}
|
||||
}
|
||||
|
||||
if !pull {
|
||||
return nil
|
||||
}
|
||||
|
||||
imageRef := cleanImage(ctx, input.Image)
|
||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
imagePullOptions, err := getImagePullOptions(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (image.PullOptions, error) {
|
||||
imagePullOptions := image.PullOptions{
|
||||
Platform: input.Platform,
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
if input.Username != "" && input.Password != "" {
|
||||
logger.Debugf("using authentication for docker pull")
|
||||
|
||||
authConfig := registry.AuthConfig{
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
}
|
||||
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
|
||||
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
} else {
|
||||
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
if authConfig.Username == "" && authConfig.Password == "" {
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
|
||||
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
}
|
||||
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, image string) string {
|
||||
ref, err := reference.ParseAnyReference(image)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Error(err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return ref.String()
|
||||
}
|
||||
60
act/container/docker_pull_test.go
Normal file
60
act/container/docker_pull_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
func TestCleanImage(t *testing.T) {
|
||||
tables := []struct {
|
||||
imageIn string
|
||||
imageOut string
|
||||
}{
|
||||
{"myhost.com/foo/bar", "myhost.com/foo/bar"},
|
||||
{"localhost:8000/canonical/ubuntu", "localhost:8000/canonical/ubuntu"},
|
||||
{"localhost/canonical/ubuntu:latest", "localhost/canonical/ubuntu:latest"},
|
||||
{"localhost:8000/canonical/ubuntu:latest", "localhost:8000/canonical/ubuntu:latest"},
|
||||
{"ubuntu", "docker.io/library/ubuntu"},
|
||||
{"ubuntu:18.04", "docker.io/library/ubuntu:18.04"},
|
||||
{"cibuilds/hugo:0.53", "docker.io/cibuilds/hugo:0.53"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
imageOut := cleanImage(t.Context(), table.imageIn)
|
||||
assert.Equal(t, table.imageOut, imageOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
config.SetDir("/non-existent/docker")
|
||||
|
||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||
assert.Nil(t, err, "Failed to create ImagePullOptions")
|
||||
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set")
|
||||
|
||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||
Image: "",
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
assert.Nil(t, err, "Failed to create ImagePullOptions")
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||
|
||||
config.SetDir("testdata/docker-pull-options")
|
||||
|
||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||
Image: "nektos/act",
|
||||
})
|
||||
assert.Nil(t, err, "Failed to create ImagePullOptions")
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||
}
|
||||
1092
act/container/docker_run.go
Normal file
1092
act/container/docker_run.go
Normal file
File diff suppressed because it is too large
Load diff
464
act/container/docker_run_test.go
Normal file
464
act/container/docker_run_test.go
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
ctx := t.Context()
|
||||
client, err := GetDockerClient(ctx)
|
||||
assert.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||
ContextDir: "testdata",
|
||||
ImageTag: "envmergetest",
|
||||
})
|
||||
|
||||
err = dockerBuild(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
cr := &containerReference{
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "envmergetest",
|
||||
},
|
||||
}
|
||||
env := map[string]string{
|
||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
||||
"RANDOM_VAR": "WITH_VALUE",
|
||||
"ANOTHER_VAR": "",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
}
|
||||
|
||||
envExecutor := cr.extractFromImageEnv(&env)
|
||||
err = envExecutor(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
||||
"RANDOM_VAR": "WITH_VALUE",
|
||||
"ANOTHER_VAR": "",
|
||||
"SOME_RANDOM_VAR": "",
|
||||
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
}, env)
|
||||
}
|
||||
|
||||
type mockDockerClient struct {
|
||||
client.APIClient
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts container.ExecOptions) (container.ExecCreateResponse, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(container.ExecCreateResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts container.ExecAttachOptions) (types.HijackedResponse, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) {
|
||||
args := m.Called(ctx, execID)
|
||||
return args.Get(0).(container.ExecInspect), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options container.CopyToContainerOptions) error {
|
||||
args := m.Called(ctx, id, path, content, options)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
|
||||
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
type mockConn struct {
|
||||
net.Conn
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockConn) Write(b []byte) (n int, err error) {
|
||||
args := m.Called(b)
|
||||
return args.Int(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConn) Close() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDockerExecAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
conn := &mockConn{}
|
||||
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("container.ExecOptions")).Return(container.ExecCreateResponse{ID: "id"}, nil)
|
||||
// container.ExecStartOptions should be container.ExecAttachOptions but fails
|
||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("container.ExecStartOptions")).Return(types.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(endlessReader{}),
|
||||
}, nil)
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
channel := make(chan error)
|
||||
|
||||
go func() {
|
||||
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
err := <-channel
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerExecFailure(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("container.ExecOptions")).Return(container.ExecCreateResponse{ID: "id"}, nil)
|
||||
// container.ExecStartOptions should be container.ExecAttachOptions but fails
|
||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("container.ExecStartOptions")).Return(types.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||
}, nil)
|
||||
client.On("ContainerExecInspect", ctx, "id").Return(container.ExecInspect{
|
||||
ExitCode: 1,
|
||||
}, nil)
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||
assert.Error(t, err, "exit with `FAILURE`: 1")
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStream(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
merr := fmt.Errorf("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr)
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
merr := fmt.Errorf("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr)
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// Type assert containerReference implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &containerReference{}
|
||||
|
||||
func TestCheckVolumes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
}{
|
||||
{
|
||||
desc: "match all volumes",
|
||||
validVolumes: []string{"**"},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no volumes can be matched",
|
||||
validVolumes: []string{},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{},
|
||||
},
|
||||
{
|
||||
desc: "only allowed volumes can be matched",
|
||||
validVolumes: []string{
|
||||
"shared_volume",
|
||||
"/home/test/data",
|
||||
"/etc/conf.d/*.json",
|
||||
},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
ValidVolumes: tc.validVolumes,
|
||||
},
|
||||
}
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeJobOptions(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
options string
|
||||
config *container.Config
|
||||
hostConfig *container.HostConfig
|
||||
}{
|
||||
{
|
||||
name: "Ok",
|
||||
options: `--volume /frob:/nitz --volume somevolume --tmpfs /tmp:exec,noatime --hostname alternatehost --health-cmd "healthz one" --health-interval 10s --health-timeout 5s --health-retries 3 --health-start-period 30s`,
|
||||
config: &container.Config{
|
||||
Volumes: map[string]struct{}{"somevolume": {}},
|
||||
Hostname: "alternatehost",
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"CMD-SHELL", "healthz one"},
|
||||
Interval: 10 * time.Second,
|
||||
Timeout: 5 * time.Second,
|
||||
StartPeriod: 30 * time.Second,
|
||||
Retries: 3,
|
||||
},
|
||||
},
|
||||
hostConfig: &container.HostConfig{
|
||||
Binds: []string{"/frob:/nitz"},
|
||||
Tmpfs: map[string]string{"/tmp": "exec,noatime"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DisableHealthCheck",
|
||||
options: `--no-healthcheck`,
|
||||
config: &container.Config{
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
},
|
||||
},
|
||||
hostConfig: &container.HostConfig{},
|
||||
},
|
||||
{
|
||||
name: "Ignore",
|
||||
options: "--pid=host --device=/dev/sda",
|
||||
config: &container.Config{},
|
||||
hostConfig: &container.HostConfig{},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
JobOptions: testCase.options,
|
||||
},
|
||||
}
|
||||
config, hostConfig, err := cr.mergeJobOptions(t.Context(), &container.Config{}, &container.HostConfig{})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, testCase.config, config)
|
||||
assert.EqualValues(t, testCase.hostConfig, hostConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRun_isHealthy(t *testing.T) {
|
||||
cr := containerReference{
|
||||
id: "containerid",
|
||||
input: &NewContainerInput{
|
||||
NetworkAliases: []string{"servicename"},
|
||||
},
|
||||
}
|
||||
ctx := t.Context()
|
||||
makeInspectResponse := func(interval time.Duration, status container.HealthStatus, test []string) container.InspectResponse {
|
||||
return container.InspectResponse{
|
||||
Config: &container.Config{
|
||||
Image: "example.com/some/image",
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Interval: interval,
|
||||
Test: test,
|
||||
},
|
||||
},
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Health: &container.Health{
|
||||
Status: status,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("IncompleteResponseOrNoHealthCheck", func(t *testing.T) {
|
||||
wait, err := cr.isHealthy(ctx, container.InspectResponse{})
|
||||
assert.Zero(t, wait)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// --no-healthcheck translates into a NONE test command
|
||||
resp := makeInspectResponse(0, container.NoHealthcheck, []string{"NONE"})
|
||||
wait, err = cr.isHealthy(ctx, resp)
|
||||
assert.Zero(t, wait)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("StartingUndefinedIntervalIsNotZero", func(t *testing.T) {
|
||||
resp := makeInspectResponse(0, container.Starting, nil)
|
||||
wait, err := cr.isHealthy(ctx, resp)
|
||||
assert.NotZero(t, wait)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("StartingWithInterval", func(t *testing.T) {
|
||||
expectedWait := time.Duration(42)
|
||||
resp := makeInspectResponse(expectedWait, container.Starting, nil)
|
||||
actualWait, err := cr.isHealthy(ctx, resp)
|
||||
assert.Equal(t, expectedWait, actualWait)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Unhealthy", func(t *testing.T) {
|
||||
resp := makeInspectResponse(0, container.Unhealthy, nil)
|
||||
wait, err := cr.isHealthy(ctx, resp)
|
||||
assert.Zero(t, wait)
|
||||
assert.ErrorContains(t, err, "is not healthy")
|
||||
})
|
||||
|
||||
t.Run("Healthy", func(t *testing.T) {
|
||||
resp := makeInspectResponse(0, container.Healthy, nil)
|
||||
wait, err := cr.isHealthy(ctx, resp)
|
||||
assert.Zero(t, wait)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("UnknownStatus", func(t *testing.T) {
|
||||
resp := makeInspectResponse(0, container.NoHealthcheck, nil)
|
||||
wait, err := cr.isHealthy(ctx, resp)
|
||||
assert.Zero(t, wait)
|
||||
assert.ErrorContains(t, err, "unexpected")
|
||||
})
|
||||
}
|
||||
70
act/container/docker_stub.go
Normal file
70
act/container/docker_stub.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//go:build WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
func ImageExistsLocally(ctx context.Context, imageName string, platform string) (bool, error) {
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
// RemoveImage removes image from local store, the function is used to run different
|
||||
// container image architectures
|
||||
func RemoveImage(ctx context.Context, imageName string, force bool, pruneChildren bool) (bool, error) {
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return errors.New("Unsupported Operation")
|
||||
}
|
||||
}
|
||||
|
||||
// NewDockerPullExecutor function to create a run executor for the container
|
||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return errors.New("Unsupported Operation")
|
||||
}
|
||||
}
|
||||
|
||||
// NewContainer creates a reference to a container
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunnerArch(ctx context.Context) string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
||||
return system.Info{}, nil
|
||||
}
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, config *network.CreateOptions) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
57
act/container/docker_volume.go
Normal file
57
act/container/docker_volume.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
)
|
||||
|
||||
func NewDockerVolumesRemoveExecutor(volumeNames []string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vol := range list.Volumes {
|
||||
if slices.Contains(volumeNames, vol.Name) {
|
||||
if err := removeExecutor(vol.Name)(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeExecutor(volume string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
force := false
|
||||
return cli.VolumeRemove(ctx, volume, force)
|
||||
}
|
||||
}
|
||||
18
act/container/executions_environment.go
Normal file
18
act/container/executions_environment.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package container
|
||||
|
||||
import "context"
|
||||
|
||||
type ExecutionsEnvironment interface {
|
||||
Container
|
||||
ToContainerPath(string) string
|
||||
GetName() string
|
||||
GetRoot() string
|
||||
GetLXC() bool
|
||||
GetActPath() string
|
||||
GetPathVariableName() string
|
||||
DefaultPathVariable() string
|
||||
JoinPathVariable(...string) string
|
||||
GetRunnerContext(ctx context.Context) map[string]any
|
||||
// On windows PATH and Path are the same key
|
||||
IsEnvironmentCaseInsensitive() bool
|
||||
}
|
||||
511
act/container/host_environment.go
Normal file
511
act/container/host_environment.go
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"golang.org/x/term"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
"code.forgejo.org/forgejo/runner/v11/act/filecollector"
|
||||
"code.forgejo.org/forgejo/runner/v11/act/lookpath"
|
||||
)
|
||||
|
||||
type HostEnvironment struct {
|
||||
Name string
|
||||
Path string
|
||||
TmpDir string
|
||||
ToolCache string
|
||||
Workdir string
|
||||
ActPath string
|
||||
Root string
|
||||
StdOut io.Writer
|
||||
LXC bool
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Close() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for _, f := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(destPath, f.Name)), 0o777); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destPath, f.Name), []byte(f.Body), fs.FileMode(f.Mode)); err != nil { //nolint:gosec
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||
if err := os.RemoveAll(destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(tarStream)
|
||||
cp := &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
}
|
||||
for {
|
||||
ti, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if ti.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("CopyTarStream has been cancelled")
|
||||
}
|
||||
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||
var ignorer gitignore.Matcher
|
||||
if useGitIgnore {
|
||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||
if err != nil {
|
||||
logger.Debugf("Error loading .gitignore: %v", err)
|
||||
}
|
||||
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
},
|
||||
}
|
||||
return filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
defer tw.Close()
|
||||
srcPath = filepath.Clean(srcPath)
|
||||
fi, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
}
|
||||
if fi.IsDir() {
|
||||
srcPrefix := srcPath
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: tc,
|
||||
}
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var f io.ReadCloser
|
||||
var linkname string
|
||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||
linkname, err = os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
f, err = os.Open(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Start(_ bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type ptyWriter struct {
|
||||
Out io.Writer
|
||||
AutoStop atomic.Bool
|
||||
dirtyLine bool
|
||||
}
|
||||
|
||||
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' {
|
||||
_, _ = w.Out.Write([]byte("\n"))
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, io.EOF
|
||||
}
|
||||
w.dirtyLine = strings.LastIndex(string(buf), "\n") < len(buf)-1
|
||||
return w.Out.Write(buf)
|
||||
}
|
||||
|
||||
type localEnv struct {
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (l *localEnv) Getenv(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
for k, v := range l.env {
|
||||
if strings.EqualFold(name, k) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return l.env[name]
|
||||
}
|
||||
|
||||
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||
if err != nil {
|
||||
err := "Cannot find: " + fmt.Sprint(cmd) + " in PATH"
|
||||
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||
return "", fmt.Errorf("%v: %w", err, _err)
|
||||
}
|
||||
return "", errors.New(err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
ppty, tty, err := openPty()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if term.IsTerminal(int(tty.Fd())) {
|
||||
_, err := term.MakeRaw(int(tty.Fd()))
|
||||
if err != nil {
|
||||
ppty.Close()
|
||||
tty.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
||||
return ppty, tty, nil
|
||||
}
|
||||
|
||||
func writeKeepAlive(ppty io.Writer) {
|
||||
c := 1
|
||||
var err error
|
||||
for c == 1 && err == nil {
|
||||
c, err = ppty.Write([]byte{4})
|
||||
<-time.After(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) {
|
||||
defer func() {
|
||||
finishLog()
|
||||
}()
|
||||
if _, err := io.Copy(writer, ppty); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvListFromMap(env map[string]string) []string {
|
||||
envList := make([]string, 0)
|
||||
for k, v := range env {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return envList
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) exec(ctx context.Context, commandparam []string, cmdline string, env map[string]string, user, workdir string) error {
|
||||
envList := getEnvListFromMap(env)
|
||||
var wd string
|
||||
if workdir != "" {
|
||||
if filepath.IsAbs(workdir) {
|
||||
wd = workdir
|
||||
} else {
|
||||
wd = filepath.Join(e.Path, workdir)
|
||||
}
|
||||
} else {
|
||||
wd = e.Path
|
||||
}
|
||||
|
||||
if _, err := os.Stat(wd); err != nil {
|
||||
common.Logger(ctx).Debugf("Failed to stat working directory %s %v\n", wd, err.Error())
|
||||
}
|
||||
|
||||
command := make([]string, len(commandparam))
|
||||
copy(command, commandparam)
|
||||
|
||||
if e.GetLXC() {
|
||||
if user == "root" {
|
||||
command = append([]string{"/usr/bin/sudo"}, command...)
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("lxc-attach --name %v %v", e.Name, command)
|
||||
command = append([]string{"/usr/bin/sudo", "--preserve-env", "--preserve-env=PATH", "/usr/bin/lxc-attach", "--keep-env", "--name", e.Name, "--"}, command...)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, f)
|
||||
cmd.Path = f
|
||||
cmd.Args = command
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = e.StdOut
|
||||
cmd.Env = envList
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
defer func() {
|
||||
if ppty != nil {
|
||||
ppty.Close()
|
||||
}
|
||||
if tty != nil {
|
||||
tty.Close()
|
||||
}
|
||||
}()
|
||||
if true /* allocate Terminal */ {
|
||||
var err error
|
||||
ppty, tty, err = setupPty(cmd, cmdline)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||
}
|
||||
}
|
||||
writer := &ptyWriter{Out: e.StdOut}
|
||||
logctx, finishLog := context.WithCancel(context.Background())
|
||||
if ppty != nil {
|
||||
go copyPtyOutput(writer, ppty, finishLog)
|
||||
} else {
|
||||
finishLog()
|
||||
}
|
||||
if ppty != nil {
|
||||
go writeKeepAlive(ppty)
|
||||
}
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("RUN %w", err)
|
||||
}
|
||||
if tty != nil {
|
||||
writer.AutoStop.Store(true)
|
||||
if _, err := tty.Write([]byte("\x04")); err != nil {
|
||||
common.Logger(ctx).Debug("Failed to write EOT")
|
||||
}
|
||||
}
|
||||
<-logctx.Done()
|
||||
|
||||
if ppty != nil {
|
||||
ppty.Close()
|
||||
ppty = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor {
|
||||
return e.ExecWithCmdLine(command, "", env, user, workdir)
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("this step has been cancelled: ctx: %w, exec: %w", ctx.Err(), err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if e.GetLXC() {
|
||||
// there may be files owned by root: removal
|
||||
// is the responsibility of the LXC backend
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(e.Root)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ToContainerPath(path string) string {
|
||||
if bp, err := filepath.Rel(e.Workdir, path); err != nil {
|
||||
return filepath.Join(e.Path, bp)
|
||||
} else if filepath.Clean(e.Workdir) == filepath.Clean(path) {
|
||||
return e.Path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetLXC() bool {
|
||||
return e.LXC
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetName() string {
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetRoot() string {
|
||||
return e.Root
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetActPath() string {
|
||||
actPath := e.ActPath
|
||||
if runtime.GOOS == "windows" {
|
||||
actPath = strings.ReplaceAll(actPath, "\\", "/")
|
||||
}
|
||||
return actPath
|
||||
}
|
||||
|
||||
func (*HostEnvironment) GetPathVariableName() string {
|
||||
switch runtime.GOOS {
|
||||
case "plan9":
|
||||
return "path"
|
||||
case "windows":
|
||||
return "Path" // Actually we need a case insensitive map
|
||||
}
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) DefaultPathVariable() string {
|
||||
v, _ := os.LookupEnv(e.GetPathVariableName())
|
||||
return v
|
||||
}
|
||||
|
||||
func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
||||
return strings.Join(paths, string(filepath.ListSeparator))
|
||||
}
|
||||
|
||||
// Reference for Arch values for runner.arch
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||
func goArchToActionArch(arch string) string {
|
||||
archMapper := map[string]string{
|
||||
"amd64": "X64",
|
||||
"x86_64": "X64",
|
||||
"386": "X86",
|
||||
"aarch64": "ARM64",
|
||||
}
|
||||
if arch, ok := archMapper[arch]; ok {
|
||||
return arch
|
||||
}
|
||||
return arch
|
||||
}
|
||||
|
||||
func goOsToActionOs(os string) string {
|
||||
osMapper := map[string]string{
|
||||
"linux": "Linux",
|
||||
"windows": "Windows",
|
||||
"darwin": "macOS",
|
||||
}
|
||||
if os, ok := osMapper[os]; ok {
|
||||
return os
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": goOsToActionOs(runtime.GOOS),
|
||||
"arch": goArchToActionArch(runtime.GOARCH),
|
||||
"temp": e.TmpDir,
|
||||
"tool_cache": e.ToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) IsHealthy(ctx context.Context) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
|
||||
org := e.StdOut
|
||||
e.StdOut = stdout
|
||||
return org, org
|
||||
}
|
||||
|
||||
func (*HostEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
66
act/container/host_environment_test.go
Normal file
66
act/container/host_environment_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||
|
||||
func TestCopyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := t.Context()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: os.Stdout,
|
||||
Workdir: path.Join("testdata", "scratch"),
|
||||
}
|
||||
_ = os.MkdirAll(e.Path, 0o700)
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetContainerArchive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := t.Context()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: os.Stdout,
|
||||
Workdir: path.Join("testdata", "scratch"),
|
||||
}
|
||||
_ = os.MkdirAll(e.Path, 0o700)
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
expectedContent := []byte("sdde/7sh")
|
||||
err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600)
|
||||
assert.NoError(t, err)
|
||||
archive, err := e.GetContainerArchive(ctx, e.Path)
|
||||
assert.NoError(t, err)
|
||||
defer archive.Close()
|
||||
reader := tar.NewReader(archive)
|
||||
h, err := reader.Next()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "action.yml", h.Name)
|
||||
content, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedContent, content)
|
||||
_, err = reader.Next()
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
90
act/container/linux_container_environment_extensions.go
Normal file
90
act/container/linux_container_environment_extensions.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type LinuxContainerEnvironmentExtensions struct {
|
||||
toolCache string
|
||||
}
|
||||
|
||||
// Resolves the equivalent host path inside the container
|
||||
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
|
||||
// For use in docker volumes and binds
|
||||
func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string {
|
||||
if runtime.GOOS == "windows" && strings.Contains(path, "/") {
|
||||
log.Error("You cannot specify linux style local paths (/mnt/etc) on Windows as it does not understand them.")
|
||||
return ""
|
||||
}
|
||||
|
||||
abspath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Test if the path is a windows path
|
||||
windowsPathRegex := regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`)
|
||||
windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath)
|
||||
|
||||
// Return as-is if no match
|
||||
if windowsPathComponents == nil {
|
||||
return abspath
|
||||
}
|
||||
|
||||
// Convert to WSL2-compatible path if it is a windows path
|
||||
// NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows
|
||||
// based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work.
|
||||
driveLetter := strings.ToLower(windowsPathComponents[1])
|
||||
translatedPath := strings.ReplaceAll(windowsPathComponents[2], `\`, `/`)
|
||||
// Should make something like /mnt/c/Users/person/My Folder/MyActProject
|
||||
result := strings.Join([]string{"/mnt", driveLetter, translatedPath}, `/`)
|
||||
return result
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetName() string {
|
||||
return "NAME"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetLXC() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetRoot() string {
|
||||
return "/var/run"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetActPath() string {
|
||||
return "/var/run/act"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetPathVariableName() string {
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) DefaultPathVariable() string {
|
||||
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) string {
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
func (l *LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "Linux",
|
||||
"arch": RunnerArch(ctx),
|
||||
"temp": "/tmp",
|
||||
"tool_cache": l.toolCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) IsEnvironmentCaseInsensitive() bool {
|
||||
return false
|
||||
}
|
||||
66
act/container/linux_container_environment_extensions_test.go
Normal file
66
act/container/linux_container_environment_extensions_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContainerPath(t *testing.T) {
|
||||
type containerPathJob struct {
|
||||
destinationPath string
|
||||
sourcePath string
|
||||
workDir string
|
||||
}
|
||||
|
||||
linuxcontainerext := &LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
rootDrive := os.Getenv("SystemDrive")
|
||||
rootDriveLetter := strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "")
|
||||
for _, v := range []containerPathJob{
|
||||
{"/mnt/c/Users/act/go/src/github.com/nektos/act", "C:\\Users\\act\\go\\src\\github.com\\nektos\\act\\", ""},
|
||||
{"/mnt/f/work/dir", `F:\work\dir`, ""},
|
||||
{"/mnt/c/windows/to/unix", "windows\\to\\unix", fmt.Sprintf("%s\\", rootDrive)},
|
||||
{fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", fmt.Sprintf("%s\\", rootDrive)},
|
||||
} {
|
||||
if v.workDir != "" {
|
||||
t.Chdir(v.workDir)
|
||||
}
|
||||
|
||||
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
|
||||
}
|
||||
|
||||
t.Chdir(cwd)
|
||||
} else {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
for _, v := range []containerPathJob{
|
||||
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
|
||||
{"/home/act", `/home/act/`, ""},
|
||||
{cwd, ".", ""},
|
||||
} {
|
||||
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type typeAssertMockContainer struct {
|
||||
Container
|
||||
LinuxContainerEnvironmentExtensions
|
||||
}
|
||||
|
||||
// Type assert Container + LinuxContainerEnvironmentExtensions implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &typeAssertMockContainer{}
|
||||
60
act/container/parse_env_file.go
Normal file
60
act/container/parse_env_file.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/common"
|
||||
)
|
||||
|
||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||
localEnv := *env
|
||||
return func(ctx context.Context) error {
|
||||
envTar, err := e.GetContainerArchive(ctx, srcPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer envTar.Close()
|
||||
reader := tar.NewReader(envTar)
|
||||
_, err = reader.Next()
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
s := bufio.NewScanner(reader)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
singleLineEnv := strings.Index(line, "=")
|
||||
multiLineEnv := strings.Index(line, "<<")
|
||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||
} else if multiLineEnv != -1 {
|
||||
multiLineEnvContent := ""
|
||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||
delimiterFound := false
|
||||
for s.Scan() {
|
||||
content := s.Text()
|
||||
if content == multiLineEnvDelimiter {
|
||||
delimiterFound = true
|
||||
break
|
||||
}
|
||||
if multiLineEnvContent != "" {
|
||||
multiLineEnvContent += "\n"
|
||||
}
|
||||
multiLineEnvContent += content
|
||||
}
|
||||
if !delimiterFound {
|
||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||
}
|
||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
||||
} else {
|
||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||
}
|
||||
}
|
||||
env = &localEnv
|
||||
return nil
|
||||
}
|
||||
}
|
||||
5
act/container/testdata/Dockerfile
vendored
Normal file
5
act/container/testdata/Dockerfile
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FROM scratch
|
||||
ENV PATH="/this/path/does/not/exists/anywhere:/this/either"
|
||||
ENV SOME_RANDOM_VAR=""
|
||||
ENV ANOTHER_ONE="BUT_I_HAVE_VALUE"
|
||||
ENV CONFLICT_VAR="I_EXIST_ONLY_HERE"
|
||||
7
act/container/testdata/docker-pull-options/config.json
vendored
Normal file
7
act/container/testdata/docker-pull-options/config.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"auths": {
|
||||
"https://index.docker.io/v1/": {
|
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQK"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
act/container/testdata/scratch/test.txt
vendored
Normal file
1
act/container/testdata/scratch/test.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
testfile
|
||||
BIN
act/container/testdata/utf16.env
vendored
Normal file
BIN
act/container/testdata/utf16.env
vendored
Normal file
Binary file not shown.
BIN
act/container/testdata/utf16be.env
vendored
Normal file
BIN
act/container/testdata/utf16be.env
vendored
Normal file
Binary file not shown.
3
act/container/testdata/utf8.env
vendored
Normal file
3
act/container/testdata/utf8.env
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
FOO=BAR
|
||||
HELLO=您好
|
||||
BAR=FOO
|
||||
1
act/container/testdata/valid.env
vendored
Normal file
1
act/container/testdata/valid.env
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
ENV1=value1
|
||||
1
act/container/testdata/valid.label
vendored
Normal file
1
act/container/testdata/valid.label
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
LABEL1=value1
|
||||
26
act/container/util.go
Normal file
26
act/container/util.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//go:build (!windows && !plan9 && !openbsd) || (!windows && !plan9 && !mips64)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func getSysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
|
||||
if tty {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
}
|
||||
}
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return pty.Open()
|
||||
}
|
||||
17
act/container/util_openbsd_mips64.go
Normal file
17
act/container/util_openbsd_mips64.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
17
act/container/util_plan9.go
Normal file
17
act/container/util_plan9.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Rfork: syscall.RFNOTEG,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
15
act/container/util_windows.go
Normal file
15
act/container/util_windows.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
295
act/exprparser/functions.go
Normal file
295
act/exprparser/functions.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package exprparser
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
||||
switch search.Kind() {
|
||||
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
||||
return strings.Contains(
|
||||
strings.ToLower(impl.coerceToString(search).String()),
|
||||
strings.ToLower(impl.coerceToString(item).String()),
|
||||
), nil
|
||||
|
||||
case reflect.Slice:
|
||||
for i := 0; i < search.Len(); i++ {
|
||||
arrayItem := search.Index(i).Elem()
|
||||
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isEqual, ok := result.(bool); ok && isEqual {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) {
|
||||
return strings.HasPrefix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) {
|
||||
return strings.HasSuffix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
const (
|
||||
passThrough = iota
|
||||
bracketOpen
|
||||
bracketClose
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
||||
input := impl.coerceToString(str).String()
|
||||
output := ""
|
||||
replacementIndex := ""
|
||||
|
||||
state := passThrough
|
||||
for _, character := range input {
|
||||
switch state {
|
||||
case passThrough: // normal buffer output
|
||||
switch character {
|
||||
case '{':
|
||||
state = bracketOpen
|
||||
|
||||
case '}':
|
||||
state = bracketClose
|
||||
|
||||
default:
|
||||
output += string(character)
|
||||
}
|
||||
|
||||
case bracketOpen: // found {
|
||||
switch character {
|
||||
case '{':
|
||||
output += "{"
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
case '}':
|
||||
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
||||
}
|
||||
|
||||
replacementIndex = ""
|
||||
|
||||
if len(replaceValue) <= int(index) {
|
||||
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
||||
}
|
||||
|
||||
output += impl.coerceToString(replaceValue[index]).String()
|
||||
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
replacementIndex += string(character)
|
||||
}
|
||||
|
||||
case bracketClose: // found }
|
||||
switch character {
|
||||
case '}':
|
||||
output += "}"
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
panic("Invalid format parser state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state != passThrough {
|
||||
switch state {
|
||||
case bracketOpen:
|
||||
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
||||
|
||||
case bracketClose:
|
||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||
}
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) {
|
||||
separator := impl.coerceToString(sep).String()
|
||||
switch array.Kind() {
|
||||
case reflect.Slice:
|
||||
var items []string
|
||||
for i := 0; i < array.Len(); i++ {
|
||||
items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
|
||||
}
|
||||
|
||||
return strings.Join(items, separator), nil
|
||||
default:
|
||||
return strings.Join([]string{impl.coerceToString(array).String()}, separator), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
||||
if value.Kind() == reflect.Invalid {
|
||||
return "null", nil
|
||||
}
|
||||
|
||||
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
||||
}
|
||||
|
||||
return string(json), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
||||
if value.Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
||||
}
|
||||
|
||||
var data any
|
||||
|
||||
err := json.Unmarshal([]byte(value.String()), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
const cwdPrefix = "." + string(filepath.Separator)
|
||||
const excludeCwdPrefix = "!" + cwdPrefix
|
||||
for _, path := range paths {
|
||||
if path.Kind() == reflect.String {
|
||||
cleanPath := path.String()
|
||||
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
||||
cleanPath = cleanPath[len(cwdPrefix):]
|
||||
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
||||
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
||||
}
|
||||
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
||||
} else {
|
||||
return "", fmt.Errorf("Non-string path passed to hashFiles")
|
||||
}
|
||||
}
|
||||
|
||||
matcher := gitignore.NewMatcher(ps)
|
||||
|
||||
var files []string
|
||||
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
||||
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
||||
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("Unable to Close file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
||||
needs := job.Needs()
|
||||
|
||||
for _, need := range needs {
|
||||
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
||||
needs = append(needs, parentNeeds...)
|
||||
}
|
||||
|
||||
return needs
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) always() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobSuccess() (bool, error) {
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result != "success" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepSuccess() (bool, error) {
|
||||
return impl.env.Job.Status == "success", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobFailure() (bool, error) {
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result == "failure" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepFailure() (bool, error) {
|
||||
return impl.env.Job.Status == "failure", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) cancelled() (bool, error) {
|
||||
return impl.env.Job.Status == "cancelled", nil
|
||||
}
|
||||
299
act/exprparser/functions_test.go
Normal file
299
act/exprparser/functions_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package exprparser
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFunctionContains(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"contains('search', 'item') }}", false, "contains-str-str"},
|
||||
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
||||
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
||||
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
||||
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
||||
{`contains(null, '') }}`, true, "contains-null-str"},
|
||||
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
||||
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
||||
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
||||
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
||||
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
||||
{`contains(fromJSON('[{ "first": { "result": "success" }},{ "second": { "result": "success" }}]').first.result, 'success') }}`, true, "multiple-contains-item"},
|
||||
{`contains(fromJSON('[{ "result": "success" },{ "result": "failure" }]').*.result, 'failure') }}`, true, "multiple-contains-dereferenced-failure-item"},
|
||||
{`contains(fromJSON('[{ "result": "failure" },{ "result": "success" }]').*.result, 'success') }}`, true, "multiple-contains-dereferenced-success-item"},
|
||||
{`contains(fromJSON('[{ "result": "failure" },{ "result": "success" }]').*.result, 'notthere') }}`, false, "multiple-contains-dereferenced-missing-item"},
|
||||
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'val1') }}`, true, "multiple-contains-dereferenced-output-item"},
|
||||
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'val2') }}`, true, "multiple-contains-dereferenced-output-item-2"},
|
||||
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'missing') }}`, false, "multiple-contains-dereferenced-output-misssing-item"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("contains('one')", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionStartsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
||||
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
||||
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
||||
{"startsWith(123, 's') }}", false, "startswith-string"},
|
||||
{"startsWith(123, '12') }}", true, "startswith-string"},
|
||||
{"startsWith('123', 12) }}", true, "startswith-string"},
|
||||
{"startsWith(null, '42') }}", false, "startswith-string"},
|
||||
{"startsWith('null', null) }}", true, "startswith-string"},
|
||||
{"startsWith('null', '') }}", true, "startswith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("startsWith('one')", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionEndsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
||||
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
||||
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
||||
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
||||
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
||||
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
||||
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
||||
{"endsWith('null', null) }}", true, "endsWith-string"},
|
||||
{"endsWith('null', '') }}", true, "endsWith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("endsWith('one')", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionJoin(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
||||
{"join('string', ',')", "string", "join-str"},
|
||||
{"join(1, ',')", "1", "join-number"},
|
||||
{"join(null, ',')", "", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("join()", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionToJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
||||
{"toJSON(null)", "null", "toJSON-null"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Env: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("tojson()", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionFromJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
||||
"foo": "bar",
|
||||
}, "fromJSON"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("fromjson()", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFunctionHashFiles(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
||||
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
||||
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
||||
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
||||
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
||||
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
||||
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workdir, err := filepath.Abs("testdata")
|
||||
assert.Nil(t, err)
|
||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionFormat(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
error any
|
||||
name string
|
||||
}{
|
||||
{"format('text')", "text", nil, "format-plain-string"},
|
||||
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
||||
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
||||
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
||||
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
||||
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
||||
{"format(true)", "true", nil, "format-with-primitive-args"},
|
||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
||||
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
||||
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
||||
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
||||
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
||||
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
if tt.error != nil {
|
||||
assert.Equal(t, tt.error, err.Error())
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewInterpeter(env, Config{}).Evaluate("format()", DefaultStatusCheckNone)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMapContains(t *testing.T) {
|
||||
env := &EvaluationEnvironment{
|
||||
Needs: map[string]Needs{
|
||||
"first-job": {
|
||||
Outputs: map[string]string{},
|
||||
Result: "success",
|
||||
},
|
||||
"second-job": {
|
||||
Outputs: map[string]string{},
|
||||
Result: "failure",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate("contains(needs.*.result, 'failure')", DefaultStatusCheckNone)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, true, output)
|
||||
}
|
||||
704
act/exprparser/interpreter.go
Normal file
704
act/exprparser/interpreter.go
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
package exprparser
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
type EvaluationEnvironment struct {
|
||||
Github *model.GithubContext
|
||||
Env map[string]string
|
||||
Job *model.JobContext
|
||||
Jobs *map[string]*model.WorkflowCallResult
|
||||
Steps map[string]*model.StepResult
|
||||
Runner map[string]any
|
||||
Secrets map[string]string
|
||||
Vars map[string]string
|
||||
Strategy map[string]any
|
||||
Matrix map[string]any
|
||||
Needs map[string]Needs
|
||||
Inputs map[string]any
|
||||
HashFiles func([]reflect.Value) (any, error)
|
||||
}
|
||||
|
||||
type Needs struct {
|
||||
Outputs map[string]string `json:"outputs"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Run *model.Run
|
||||
WorkingDir string
|
||||
Context string
|
||||
}
|
||||
|
||||
type DefaultStatusCheck int
|
||||
|
||||
const (
|
||||
DefaultStatusCheckNone DefaultStatusCheck = iota
|
||||
DefaultStatusCheckSuccess
|
||||
DefaultStatusCheckAlways
|
||||
DefaultStatusCheckCanceled
|
||||
DefaultStatusCheckFailure
|
||||
)
|
||||
|
||||
func (dsc DefaultStatusCheck) String() string {
|
||||
switch dsc {
|
||||
case DefaultStatusCheckSuccess:
|
||||
return "success"
|
||||
case DefaultStatusCheckAlways:
|
||||
return "always"
|
||||
case DefaultStatusCheckCanceled:
|
||||
return "cancelled"
|
||||
case DefaultStatusCheckFailure:
|
||||
return "failure"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Interpreter interface {
|
||||
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
||||
}
|
||||
|
||||
type interperterImpl struct {
|
||||
env *EvaluationEnvironment
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
||||
return &interperterImpl{
|
||||
env: env,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
||||
input = strings.TrimPrefix(input, "${{")
|
||||
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
||||
input = "success()"
|
||||
}
|
||||
parser := actionlint.NewExprParser()
|
||||
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
||||
}
|
||||
|
||||
if defaultStatusCheck != DefaultStatusCheckNone {
|
||||
hasStatusCheckFunction := false
|
||||
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
||||
switch strings.ToLower(funcCallNode.Callee) {
|
||||
case "success", "always", "cancelled", "failure":
|
||||
hasStatusCheckFunction = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if !hasStatusCheckFunction {
|
||||
exprNode = &actionlint.LogicalOpNode{
|
||||
Kind: actionlint.LogicalOpNodeKindAnd,
|
||||
Left: &actionlint.FuncCallNode{
|
||||
Callee: defaultStatusCheck.String(),
|
||||
Args: []actionlint.ExprNode{},
|
||||
},
|
||||
Right: exprNode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err2 := impl.evaluateNode(exprNode)
|
||||
|
||||
return result, err2
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
||||
switch node := exprNode.(type) {
|
||||
case *actionlint.VariableNode:
|
||||
return impl.evaluateVariable(node)
|
||||
case *actionlint.BoolNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.NullNode:
|
||||
return nil, nil
|
||||
case *actionlint.IntNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.FloatNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.StringNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.IndexAccessNode:
|
||||
return impl.evaluateIndexAccess(node)
|
||||
case *actionlint.ObjectDerefNode:
|
||||
return impl.evaluateObjectDeref(node)
|
||||
case *actionlint.ArrayDerefNode:
|
||||
return impl.evaluateArrayDeref(node)
|
||||
case *actionlint.NotOpNode:
|
||||
return impl.evaluateNot(node)
|
||||
case *actionlint.CompareOpNode:
|
||||
return impl.evaluateCompare(node)
|
||||
case *actionlint.LogicalOpNode:
|
||||
return impl.evaluateLogicalCompare(node)
|
||||
case *actionlint.FuncCallNode:
|
||||
return impl.evaluateFuncCall(node)
|
||||
default:
|
||||
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
||||
switch strings.ToLower(variableNode.Name) {
|
||||
case "github":
|
||||
return impl.env.Github, nil
|
||||
case "gitea": // compatible with Gitea
|
||||
return impl.env.Github, nil
|
||||
case "forge":
|
||||
return impl.env.Github, nil
|
||||
case "forgejo":
|
||||
return impl.env.Github, nil
|
||||
case "env":
|
||||
return impl.env.Env, nil
|
||||
case "job":
|
||||
return impl.env.Job, nil
|
||||
case "jobs":
|
||||
if impl.env.Jobs == nil {
|
||||
return nil, fmt.Errorf("Unavailable context: jobs")
|
||||
}
|
||||
return impl.env.Jobs, nil
|
||||
case "steps":
|
||||
return impl.env.Steps, nil
|
||||
case "runner":
|
||||
return impl.env.Runner, nil
|
||||
case "secrets":
|
||||
return impl.env.Secrets, nil
|
||||
case "vars":
|
||||
return impl.env.Vars, nil
|
||||
case "strategy":
|
||||
return impl.env.Strategy, nil
|
||||
case "matrix":
|
||||
return impl.env.Matrix, nil
|
||||
case "needs":
|
||||
return impl.env.Needs, nil
|
||||
case "inputs":
|
||||
return impl.env.Inputs, nil
|
||||
case "infinity":
|
||||
return math.Inf(1), nil
|
||||
case "nan":
|
||||
return math.NaN(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
||||
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
|
||||
right, err := impl.evaluateNode(indexAccessNode.Index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
switch rightValue.Kind() {
|
||||
case reflect.String:
|
||||
return impl.getPropertyValue(leftValue, rightValue.String())
|
||||
|
||||
case reflect.Int:
|
||||
switch leftValue.Kind() {
|
||||
case reflect.Slice:
|
||||
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
||||
return nil, nil
|
||||
}
|
||||
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
||||
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, receiverIsDeref := objectDerefNode.Receiver.(*actionlint.ArrayDerefNode)
|
||||
if receiverIsDeref {
|
||||
return impl.getPropertyValueDereferenced(reflect.ValueOf(left), objectDerefNode.Property)
|
||||
}
|
||||
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
||||
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
||||
switch left.Kind() {
|
||||
case reflect.Ptr:
|
||||
return impl.getPropertyValue(left.Elem(), property)
|
||||
|
||||
case reflect.Struct:
|
||||
leftType := left.Type()
|
||||
for i := 0; i < leftType.NumField(); i++ {
|
||||
jsonName := leftType.Field(i).Tag.Get("json")
|
||||
if jsonName == property {
|
||||
property = leftType.Field(i).Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
||||
return strings.EqualFold(name, property)
|
||||
})
|
||||
|
||||
if fieldValue.Kind() == reflect.Invalid {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
i := fieldValue.Interface()
|
||||
// The type stepStatus int is an integer, but should be treated as string
|
||||
if m, ok := i.(encoding.TextMarshaler); ok {
|
||||
text, err := m.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(text), nil
|
||||
}
|
||||
return i, nil
|
||||
|
||||
case reflect.Map:
|
||||
iter := left.MapRange()
|
||||
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
|
||||
switch key.Kind() {
|
||||
case reflect.String:
|
||||
if strings.EqualFold(key.String(), property) {
|
||||
return impl.getMapValue(iter.Value())
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
||||
case reflect.Slice:
|
||||
var values []any
|
||||
|
||||
for i := 0; i < left.Len(); i++ {
|
||||
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getPropertyValueDereferenced(left reflect.Value, property string) (value any, err error) {
|
||||
switch left.Kind() {
|
||||
case reflect.Map:
|
||||
iter := left.MapRange()
|
||||
|
||||
var values []any
|
||||
for iter.Next() {
|
||||
value, err := impl.getPropertyValue(iter.Value(), property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
case reflect.Ptr, reflect.Struct, reflect.Slice:
|
||||
return impl.getPropertyValue(left, property)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
||||
if value.Kind() == reflect.Ptr {
|
||||
return impl.getMapValue(value.Elem())
|
||||
}
|
||||
|
||||
return value.Interface(), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
||||
operand, err := impl.evaluateNode(notNode.Operand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return !IsTruthy(operand), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
||||
left, err := impl.evaluateNode(compareNode.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
right, err := impl.evaluateNode(compareNode.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
||||
if leftValue.Kind() != rightValue.Kind() {
|
||||
if !impl.isNumber(leftValue) {
|
||||
leftValue = impl.coerceToNumber(leftValue)
|
||||
}
|
||||
if !impl.isNumber(rightValue) {
|
||||
rightValue = impl.coerceToNumber(rightValue)
|
||||
}
|
||||
}
|
||||
|
||||
switch leftValue.Kind() {
|
||||
case reflect.Bool:
|
||||
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
||||
case reflect.String:
|
||||
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
||||
|
||||
case reflect.Int:
|
||||
if rightValue.Kind() == reflect.Float64 {
|
||||
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
||||
}
|
||||
|
||||
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
||||
|
||||
case reflect.Float64:
|
||||
if rightValue.Kind() == reflect.Int {
|
||||
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
||||
}
|
||||
|
||||
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
||||
|
||||
case reflect.Invalid:
|
||||
if rightValue.Kind() == reflect.Invalid {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// not possible situation - params are converted to the same type in code above
|
||||
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return reflect.ValueOf(0)
|
||||
|
||||
case reflect.Bool:
|
||||
switch value.Bool() {
|
||||
case true:
|
||||
return reflect.ValueOf(1)
|
||||
case false:
|
||||
return reflect.ValueOf(0)
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
if value.String() == "" {
|
||||
return reflect.ValueOf(0)
|
||||
}
|
||||
|
||||
// try to parse the string as a number
|
||||
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return reflect.ValueOf(math.NaN())
|
||||
}
|
||||
|
||||
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return reflect.ValueOf(math.NaN())
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) coerceToString(value reflect.Value) reflect.Value {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return reflect.ValueOf("")
|
||||
|
||||
case reflect.Bool:
|
||||
switch value.Bool() {
|
||||
case true:
|
||||
return reflect.ValueOf("true")
|
||||
case false:
|
||||
return reflect.ValueOf("false")
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
return value
|
||||
|
||||
case reflect.Int:
|
||||
return reflect.ValueOf(fmt.Sprint(value))
|
||||
|
||||
case reflect.Float64:
|
||||
if math.IsInf(value.Float(), 1) {
|
||||
return reflect.ValueOf("Infinity")
|
||||
} else if math.IsInf(value.Float(), -1) {
|
||||
return reflect.ValueOf("-Infinity")
|
||||
}
|
||||
return reflect.ValueOf(fmt.Sprintf("%.15G", value.Float()))
|
||||
|
||||
case reflect.Slice:
|
||||
return reflect.ValueOf("Array")
|
||||
|
||||
case reflect.Map:
|
||||
return reflect.ValueOf("Object")
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||
switch kind {
|
||||
case actionlint.CompareOpNodeKindLess:
|
||||
return left < right, nil
|
||||
case actionlint.CompareOpNodeKindLessEq:
|
||||
return left <= right, nil
|
||||
case actionlint.CompareOpNodeKindGreater:
|
||||
return left > right, nil
|
||||
case actionlint.CompareOpNodeKindGreaterEq:
|
||||
return left >= right, nil
|
||||
case actionlint.CompareOpNodeKindEq:
|
||||
return left == right, nil
|
||||
case actionlint.CompareOpNodeKindNotEq:
|
||||
return left != right, nil
|
||||
default:
|
||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||
switch kind {
|
||||
case actionlint.CompareOpNodeKindLess:
|
||||
return left < right, nil
|
||||
case actionlint.CompareOpNodeKindLessEq:
|
||||
return left <= right, nil
|
||||
case actionlint.CompareOpNodeKindGreater:
|
||||
return left > right, nil
|
||||
case actionlint.CompareOpNodeKindGreaterEq:
|
||||
return left >= right, nil
|
||||
case actionlint.CompareOpNodeKindEq:
|
||||
return left == right, nil
|
||||
case actionlint.CompareOpNodeKindNotEq:
|
||||
return left != right, nil
|
||||
default:
|
||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func IsTruthy(input any) bool {
|
||||
value := reflect.ValueOf(input)
|
||||
switch value.Kind() {
|
||||
case reflect.Bool:
|
||||
return value.Bool()
|
||||
|
||||
case reflect.String:
|
||||
return value.String() != ""
|
||||
|
||||
case reflect.Int:
|
||||
return value.Int() != 0
|
||||
|
||||
case reflect.Float64:
|
||||
if math.IsNaN(value.Float()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value.Float() != 0
|
||||
|
||||
case reflect.Map, reflect.Slice:
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
||||
switch value.Kind() {
|
||||
case reflect.Int, reflect.Float64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return nil
|
||||
|
||||
case reflect.Float64:
|
||||
if value.Float() == 0 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return value.Interface()
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
||||
left, err := impl.evaluateNode(compareNode.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
|
||||
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
||||
return impl.getSafeValue(leftValue), nil
|
||||
}
|
||||
|
||||
right, err := impl.evaluateNode(compareNode.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
switch compareNode.Kind {
|
||||
case actionlint.LogicalOpNodeKindAnd:
|
||||
return impl.getSafeValue(rightValue), nil
|
||||
case actionlint.LogicalOpNodeKindOr:
|
||||
return impl.getSafeValue(rightValue), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
||||
args := make([]reflect.Value, 0)
|
||||
|
||||
for _, arg := range funcCallNode.Args {
|
||||
value, err := impl.evaluateNode(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args = append(args, reflect.ValueOf(value))
|
||||
}
|
||||
|
||||
argCountCheck := func(argCount int) error {
|
||||
if len(args) != argCount {
|
||||
return fmt.Errorf("'%s' expected %d arguments but got %d instead", funcCallNode.Callee, argCount, len(args))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
argAtLeastCheck := func(atLeast int) error {
|
||||
if len(args) < atLeast {
|
||||
return fmt.Errorf("'%s' expected at least %d arguments but got %d instead", funcCallNode.Callee, atLeast, len(args))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(funcCallNode.Callee) {
|
||||
case "contains":
|
||||
if err := argCountCheck(2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.contains(args[0], args[1])
|
||||
case "startswith":
|
||||
if err := argCountCheck(2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.startsWith(args[0], args[1])
|
||||
case "endswith":
|
||||
if err := argCountCheck(2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.endsWith(args[0], args[1])
|
||||
case "format":
|
||||
if err := argAtLeastCheck(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.format(args[0], args[1:]...)
|
||||
case "join":
|
||||
if err := argAtLeastCheck(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return impl.join(args[0], reflect.ValueOf(","))
|
||||
}
|
||||
return impl.join(args[0], args[1])
|
||||
case "tojson":
|
||||
if err := argCountCheck(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.toJSON(args[0])
|
||||
case "fromjson":
|
||||
if err := argCountCheck(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return impl.fromJSON(args[0])
|
||||
case "hashfiles":
|
||||
if impl.env.HashFiles != nil {
|
||||
return impl.env.HashFiles(args)
|
||||
}
|
||||
return impl.hashFiles(args...)
|
||||
case "always":
|
||||
return impl.always()
|
||||
case "success":
|
||||
if impl.config.Context == "job" {
|
||||
return impl.jobSuccess()
|
||||
}
|
||||
if impl.config.Context == "step" {
|
||||
return impl.stepSuccess()
|
||||
}
|
||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||
case "failure":
|
||||
if impl.config.Context == "job" {
|
||||
return impl.jobFailure()
|
||||
}
|
||||
if impl.config.Context == "step" {
|
||||
return impl.stepFailure()
|
||||
}
|
||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||
case "cancelled":
|
||||
return impl.cancelled()
|
||||
default:
|
||||
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
||||
}
|
||||
}
|
||||
635
act/exprparser/interpreter_test.go
Normal file
635
act/exprparser/interpreter_test.go
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
package exprparser
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v11/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLiterals(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"true", true, "true"},
|
||||
{"false", false, "false"},
|
||||
{"null", nil, "null"},
|
||||
{"123", 123, "integer"},
|
||||
{"-9.7", -9.7, "float"},
|
||||
{"0xff", 255, "hex"},
|
||||
{"-2.99e-2", -2.99e-2, "exponential"},
|
||||
{"'foo'", "foo", "string"},
|
||||
{"'it''s foo'", "it's foo", "string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperators(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
error string
|
||||
}{
|
||||
{"(false || (false || true))", true, "logical-grouping", ""},
|
||||
{"github.action", "push", "property-dereference", ""},
|
||||
{"github['action']", "push", "property-index", ""},
|
||||
{"github.action[0]", nil, "string-index", ""},
|
||||
{"github.action['0']", nil, "string-index", ""},
|
||||
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
||||
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
||||
// Disabled weird things are happening
|
||||
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
||||
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
||||
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
||||
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
||||
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
||||
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
||||
{"!true", false, "not", ""},
|
||||
{"1 < 2", true, "less-than", ""},
|
||||
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
||||
{"1 > 2", false, "greater-than", ""},
|
||||
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
||||
{`'a' == 'a'`, true, "equal", ""},
|
||||
{`'a' != 'a'`, false, "not-equal", ""},
|
||||
{`true && false`, false, "and", ""},
|
||||
{`true || false`, true, "or", ""},
|
||||
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
||||
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
||||
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
Event: map[string]any{
|
||||
"commits": []any{
|
||||
map[string]any{
|
||||
"author": map[string]any{
|
||||
"username": "someone",
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"author": map[string]any{
|
||||
"username": "someone-else",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
if tt.error != "" {
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, tt.error, err.Error())
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorsCompare(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"!null", true, "not-null"},
|
||||
{"!-10", false, "not-neg-num"},
|
||||
{"!0", true, "not-zero"},
|
||||
{"!3.14", false, "not-pos-float"},
|
||||
{"!''", true, "not-empty-str"},
|
||||
{"!'abc'", false, "not-str"},
|
||||
{"!fromJSON('{}')", false, "not-obj"},
|
||||
{"!fromJSON('[]')", false, "not-arr"},
|
||||
{`null == 0 }}`, true, "null-coercion"},
|
||||
{`true == 1 }}`, true, "boolean-coercion"},
|
||||
{`'' == 0 }}`, true, "string-0-coercion"},
|
||||
{`'3' == 3 }}`, true, "string-3-coercion"},
|
||||
{`0 == null }}`, true, "null-coercion-alt"},
|
||||
{`1 == true }}`, true, "boolean-coercion-alt"},
|
||||
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
||||
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
||||
{`'TEST' == 'test' }}`, true, "string-casing"},
|
||||
{"true > false }}", true, "bool-greater-than"},
|
||||
{"true >= false }}", true, "bool-greater-than-eq"},
|
||||
{"true >= true }}", true, "bool-greater-than-1"},
|
||||
{"true != false }}", true, "bool-not-equal"},
|
||||
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
||||
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
||||
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorsBooleanEvaluation(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
// true &&
|
||||
{"true && true", true, "true-and"},
|
||||
{"true && false", false, "true-and"},
|
||||
{"true && null", nil, "true-and"},
|
||||
{"true && -10", -10, "true-and"},
|
||||
{"true && 0", 0, "true-and"},
|
||||
{"true && 10", 10, "true-and"},
|
||||
{"true && 3.14", 3.14, "true-and"},
|
||||
{"true && 0.0", 0, "true-and"},
|
||||
{"true && Infinity", math.Inf(1), "true-and"},
|
||||
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
||||
{"true && NaN", math.NaN(), "true-and"},
|
||||
{"true && ''", "", "true-and"},
|
||||
{"true && 'abc'", "abc", "true-and"},
|
||||
// false &&
|
||||
{"false && true", false, "false-and"},
|
||||
{"false && false", false, "false-and"},
|
||||
{"false && null", false, "false-and"},
|
||||
{"false && -10", false, "false-and"},
|
||||
{"false && 0", false, "false-and"},
|
||||
{"false && 10", false, "false-and"},
|
||||
{"false && 3.14", false, "false-and"},
|
||||
{"false && 0.0", false, "false-and"},
|
||||
{"false && Infinity", false, "false-and"},
|
||||
// {"false && -Infinity", false, "false-and"},
|
||||
{"false && NaN", false, "false-and"},
|
||||
{"false && ''", false, "false-and"},
|
||||
{"false && 'abc'", false, "false-and"},
|
||||
// true ||
|
||||
{"true || true", true, "true-or"},
|
||||
{"true || false", true, "true-or"},
|
||||
{"true || null", true, "true-or"},
|
||||
{"true || -10", true, "true-or"},
|
||||
{"true || 0", true, "true-or"},
|
||||
{"true || 10", true, "true-or"},
|
||||
{"true || 3.14", true, "true-or"},
|
||||
{"true || 0.0", true, "true-or"},
|
||||
{"true || Infinity", true, "true-or"},
|
||||
// {"true || -Infinity", true, "true-or"},
|
||||
{"true || NaN", true, "true-or"},
|
||||
{"true || ''", true, "true-or"},
|
||||
{"true || 'abc'", true, "true-or"},
|
||||
// false ||
|
||||
{"false || true", true, "false-or"},
|
||||
{"false || false", false, "false-or"},
|
||||
{"false || null", nil, "false-or"},
|
||||
{"false || -10", -10, "false-or"},
|
||||
{"false || 0", 0, "false-or"},
|
||||
{"false || 10", 10, "false-or"},
|
||||
{"false || 3.14", 3.14, "false-or"},
|
||||
{"false || 0.0", 0, "false-or"},
|
||||
{"false || Infinity", math.Inf(1), "false-or"},
|
||||
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
||||
{"false || NaN", math.NaN(), "false-or"},
|
||||
{"false || ''", "", "false-or"},
|
||||
{"false || 'abc'", "abc", "false-or"},
|
||||
// null &&
|
||||
{"null && true", nil, "null-and"},
|
||||
{"null && false", nil, "null-and"},
|
||||
{"null && null", nil, "null-and"},
|
||||
{"null && -10", nil, "null-and"},
|
||||
{"null && 0", nil, "null-and"},
|
||||
{"null && 10", nil, "null-and"},
|
||||
{"null && 3.14", nil, "null-and"},
|
||||
{"null && 0.0", nil, "null-and"},
|
||||
{"null && Infinity", nil, "null-and"},
|
||||
// {"null && -Infinity", nil, "null-and"},
|
||||
{"null && NaN", nil, "null-and"},
|
||||
{"null && ''", nil, "null-and"},
|
||||
{"null && 'abc'", nil, "null-and"},
|
||||
// null ||
|
||||
{"null || true", true, "null-or"},
|
||||
{"null || false", false, "null-or"},
|
||||
{"null || null", nil, "null-or"},
|
||||
{"null || -10", -10, "null-or"},
|
||||
{"null || 0", 0, "null-or"},
|
||||
{"null || 10", 10, "null-or"},
|
||||
{"null || 3.14", 3.14, "null-or"},
|
||||
{"null || 0.0", 0, "null-or"},
|
||||
{"null || Infinity", math.Inf(1), "null-or"},
|
||||
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
||||
{"null || NaN", math.NaN(), "null-or"},
|
||||
{"null || ''", "", "null-or"},
|
||||
{"null || 'abc'", "abc", "null-or"},
|
||||
// -10 &&
|
||||
{"-10 && true", true, "neg-num-and"},
|
||||
{"-10 && false", false, "neg-num-and"},
|
||||
{"-10 && null", nil, "neg-num-and"},
|
||||
{"-10 && -10", -10, "neg-num-and"},
|
||||
{"-10 && 0", 0, "neg-num-and"},
|
||||
{"-10 && 10", 10, "neg-num-and"},
|
||||
{"-10 && 3.14", 3.14, "neg-num-and"},
|
||||
{"-10 && 0.0", 0, "neg-num-and"},
|
||||
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
||||
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
||||
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
||||
{"-10 && ''", "", "neg-num-and"},
|
||||
{"-10 && 'abc'", "abc", "neg-num-and"},
|
||||
// -10 ||
|
||||
{"-10 || true", -10, "neg-num-or"},
|
||||
{"-10 || false", -10, "neg-num-or"},
|
||||
{"-10 || null", -10, "neg-num-or"},
|
||||
{"-10 || -10", -10, "neg-num-or"},
|
||||
{"-10 || 0", -10, "neg-num-or"},
|
||||
{"-10 || 10", -10, "neg-num-or"},
|
||||
{"-10 || 3.14", -10, "neg-num-or"},
|
||||
{"-10 || 0.0", -10, "neg-num-or"},
|
||||
{"-10 || Infinity", -10, "neg-num-or"},
|
||||
// {"-10 || -Infinity", -10, "neg-num-or"},
|
||||
{"-10 || NaN", -10, "neg-num-or"},
|
||||
{"-10 || ''", -10, "neg-num-or"},
|
||||
{"-10 || 'abc'", -10, "neg-num-or"},
|
||||
// 0 &&
|
||||
{"0 && true", 0, "zero-and"},
|
||||
{"0 && false", 0, "zero-and"},
|
||||
{"0 && null", 0, "zero-and"},
|
||||
{"0 && -10", 0, "zero-and"},
|
||||
{"0 && 0", 0, "zero-and"},
|
||||
{"0 && 10", 0, "zero-and"},
|
||||
{"0 && 3.14", 0, "zero-and"},
|
||||
{"0 && 0.0", 0, "zero-and"},
|
||||
{"0 && Infinity", 0, "zero-and"},
|
||||
// {"0 && -Infinity", 0, "zero-and"},
|
||||
{"0 && NaN", 0, "zero-and"},
|
||||
{"0 && ''", 0, "zero-and"},
|
||||
{"0 && 'abc'", 0, "zero-and"},
|
||||
// 0 ||
|
||||
{"0 || true", true, "zero-or"},
|
||||
{"0 || false", false, "zero-or"},
|
||||
{"0 || null", nil, "zero-or"},
|
||||
{"0 || -10", -10, "zero-or"},
|
||||
{"0 || 0", 0, "zero-or"},
|
||||
{"0 || 10", 10, "zero-or"},
|
||||
{"0 || 3.14", 3.14, "zero-or"},
|
||||
{"0 || 0.0", 0, "zero-or"},
|
||||
{"0 || Infinity", math.Inf(1), "zero-or"},
|
||||
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
||||
{"0 || NaN", math.NaN(), "zero-or"},
|
||||
{"0 || ''", "", "zero-or"},
|
||||
{"0 || 'abc'", "abc", "zero-or"},
|
||||
// 10 &&
|
||||
{"10 && true", true, "pos-num-and"},
|
||||
{"10 && false", false, "pos-num-and"},
|
||||
{"10 && null", nil, "pos-num-and"},
|
||||
{"10 && -10", -10, "pos-num-and"},
|
||||
{"10 && 0", 0, "pos-num-and"},
|
||||
{"10 && 10", 10, "pos-num-and"},
|
||||
{"10 && 3.14", 3.14, "pos-num-and"},
|
||||
{"10 && 0.0", 0, "pos-num-and"},
|
||||
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
||||
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
||||
{"10 && NaN", math.NaN(), "pos-num-and"},
|
||||
{"10 && ''", "", "pos-num-and"},
|
||||
{"10 && 'abc'", "abc", "pos-num-and"},
|
||||
// 10 ||
|
||||
{"10 || true", 10, "pos-num-or"},
|
||||
{"10 || false", 10, "pos-num-or"},
|
||||
{"10 || null", 10, "pos-num-or"},
|
||||
{"10 || -10", 10, "pos-num-or"},
|
||||
{"10 || 0", 10, "pos-num-or"},
|
||||
{"10 || 10", 10, "pos-num-or"},
|
||||
{"10 || 3.14", 10, "pos-num-or"},
|
||||
{"10 || 0.0", 10, "pos-num-or"},
|
||||
{"10 || Infinity", 10, "pos-num-or"},
|
||||
// {"10 || -Infinity", 10, "pos-num-or"},
|
||||
{"10 || NaN", 10, "pos-num-or"},
|
||||
{"10 || ''", 10, "pos-num-or"},
|
||||
{"10 || 'abc'", 10, "pos-num-or"},
|
||||
// 3.14 &&
|
||||
{"3.14 && true", true, "pos-float-and"},
|
||||
{"3.14 && false", false, "pos-float-and"},
|
||||
{"3.14 && null", nil, "pos-float-and"},
|
||||
{"3.14 && -10", -10, "pos-float-and"},
|
||||
{"3.14 && 0", 0, "pos-float-and"},
|
||||
{"3.14 && 10", 10, "pos-float-and"},
|
||||
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
||||
{"3.14 && 0.0", 0, "pos-float-and"},
|
||||
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
||||
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
||||
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
||||
{"3.14 && ''", "", "pos-float-and"},
|
||||
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
||||
// 3.14 ||
|
||||
{"3.14 || true", 3.14, "pos-float-or"},
|
||||
{"3.14 || false", 3.14, "pos-float-or"},
|
||||
{"3.14 || null", 3.14, "pos-float-or"},
|
||||
{"3.14 || -10", 3.14, "pos-float-or"},
|
||||
{"3.14 || 0", 3.14, "pos-float-or"},
|
||||
{"3.14 || 10", 3.14, "pos-float-or"},
|
||||
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
||||
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
||||
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
||||
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
||||
{"3.14 || NaN", 3.14, "pos-float-or"},
|
||||
{"3.14 || ''", 3.14, "pos-float-or"},
|
||||
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
||||
// Infinity &&
|
||||
{"Infinity && true", true, "pos-inf-and"},
|
||||
{"Infinity && false", false, "pos-inf-and"},
|
||||
{"Infinity && null", nil, "pos-inf-and"},
|
||||
{"Infinity && -10", -10, "pos-inf-and"},
|
||||
{"Infinity && 0", 0, "pos-inf-and"},
|
||||
{"Infinity && 10", 10, "pos-inf-and"},
|
||||
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
||||
{"Infinity && 0.0", 0, "pos-inf-and"},
|
||||
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
||||
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
||||
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
||||
{"Infinity && ''", "", "pos-inf-and"},
|
||||
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
||||
// Infinity ||
|
||||
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
||||
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
||||
// -Infinity &&
|
||||
// {"-Infinity && true", true, "neg-inf-and"},
|
||||
// {"-Infinity && false", false, "neg-inf-and"},
|
||||
// {"-Infinity && null", nil, "neg-inf-and"},
|
||||
// {"-Infinity && -10", -10, "neg-inf-and"},
|
||||
// {"-Infinity && 0", 0, "neg-inf-and"},
|
||||
// {"-Infinity && 10", 10, "neg-inf-and"},
|
||||
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
||||
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
||||
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
||||
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
||||
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
||||
// {"-Infinity && ''", "", "neg-inf-and"},
|
||||
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
||||
// -Infinity ||
|
||||
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
||||
// NaN &&
|
||||
{"NaN && true", math.NaN(), "nan-and"},
|
||||
{"NaN && false", math.NaN(), "nan-and"},
|
||||
{"NaN && null", math.NaN(), "nan-and"},
|
||||
{"NaN && -10", math.NaN(), "nan-and"},
|
||||
{"NaN && 0", math.NaN(), "nan-and"},
|
||||
{"NaN && 10", math.NaN(), "nan-and"},
|
||||
{"NaN && 3.14", math.NaN(), "nan-and"},
|
||||
{"NaN && 0.0", math.NaN(), "nan-and"},
|
||||
{"NaN && Infinity", math.NaN(), "nan-and"},
|
||||
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
||||
{"NaN && NaN", math.NaN(), "nan-and"},
|
||||
{"NaN && ''", math.NaN(), "nan-and"},
|
||||
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
||||
// NaN ||
|
||||
{"NaN || true", true, "nan-or"},
|
||||
{"NaN || false", false, "nan-or"},
|
||||
{"NaN || null", nil, "nan-or"},
|
||||
{"NaN || -10", -10, "nan-or"},
|
||||
{"NaN || 0", 0, "nan-or"},
|
||||
{"NaN || 10", 10, "nan-or"},
|
||||
{"NaN || 3.14", 3.14, "nan-or"},
|
||||
{"NaN || 0.0", 0, "nan-or"},
|
||||
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
||||
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
||||
{"NaN || NaN", math.NaN(), "nan-or"},
|
||||
{"NaN || ''", "", "nan-or"},
|
||||
{"NaN || 'abc'", "abc", "nan-or"},
|
||||
// "" &&
|
||||
{"'' && true", "", "empty-str-and"},
|
||||
{"'' && false", "", "empty-str-and"},
|
||||
{"'' && null", "", "empty-str-and"},
|
||||
{"'' && -10", "", "empty-str-and"},
|
||||
{"'' && 0", "", "empty-str-and"},
|
||||
{"'' && 10", "", "empty-str-and"},
|
||||
{"'' && 3.14", "", "empty-str-and"},
|
||||
{"'' && 0.0", "", "empty-str-and"},
|
||||
{"'' && Infinity", "", "empty-str-and"},
|
||||
// {"'' && -Infinity", "", "empty-str-and"},
|
||||
{"'' && NaN", "", "empty-str-and"},
|
||||
{"'' && ''", "", "empty-str-and"},
|
||||
{"'' && 'abc'", "", "empty-str-and"},
|
||||
// "" ||
|
||||
{"'' || true", true, "empty-str-or"},
|
||||
{"'' || false", false, "empty-str-or"},
|
||||
{"'' || null", nil, "empty-str-or"},
|
||||
{"'' || -10", -10, "empty-str-or"},
|
||||
{"'' || 0", 0, "empty-str-or"},
|
||||
{"'' || 10", 10, "empty-str-or"},
|
||||
{"'' || 3.14", 3.14, "empty-str-or"},
|
||||
{"'' || 0.0", 0, "empty-str-or"},
|
||||
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
||||
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
||||
{"'' || NaN", math.NaN(), "empty-str-or"},
|
||||
{"'' || ''", "", "empty-str-or"},
|
||||
{"'' || 'abc'", "abc", "empty-str-or"},
|
||||
// "abc" &&
|
||||
{"'abc' && true", true, "str-and"},
|
||||
{"'abc' && false", false, "str-and"},
|
||||
{"'abc' && null", nil, "str-and"},
|
||||
{"'abc' && -10", -10, "str-and"},
|
||||
{"'abc' && 0", 0, "str-and"},
|
||||
{"'abc' && 10", 10, "str-and"},
|
||||
{"'abc' && 3.14", 3.14, "str-and"},
|
||||
{"'abc' && 0.0", 0, "str-and"},
|
||||
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
||||
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
||||
{"'abc' && NaN", math.NaN(), "str-and"},
|
||||
{"'abc' && ''", "", "str-and"},
|
||||
{"'abc' && 'abc'", "abc", "str-and"},
|
||||
// "abc" ||
|
||||
{"'abc' || true", "abc", "str-or"},
|
||||
{"'abc' || false", "abc", "str-or"},
|
||||
{"'abc' || null", "abc", "str-or"},
|
||||
{"'abc' || -10", "abc", "str-or"},
|
||||
{"'abc' || 0", "abc", "str-or"},
|
||||
{"'abc' || 10", "abc", "str-or"},
|
||||
{"'abc' || 3.14", "abc", "str-or"},
|
||||
{"'abc' || 0.0", "abc", "str-or"},
|
||||
{"'abc' || Infinity", "abc", "str-or"},
|
||||
// {"'abc' || -Infinity", "abc", "str-or"},
|
||||
{"'abc' || NaN", "abc", "str-or"},
|
||||
{"'abc' || ''", "abc", "str-or"},
|
||||
{"'abc' || 'abc'", "abc", "str-or"},
|
||||
// extra tests
|
||||
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
||||
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||
assert.True(t, math.IsNaN(output.(float64)))
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContexts(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"github.action", "push", "github-context"},
|
||||
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
||||
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
||||
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
||||
{"env.TEST", "value", "env-context"},
|
||||
{"job.status", "success", "job-context"},
|
||||
{"steps.step-id.outputs.name", "value", "steps-context"},
|
||||
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
||||
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
||||
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
||||
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
||||
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
||||
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
||||
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
||||
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
||||
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
||||
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
||||
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
||||
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
||||
// Disabled, since the interpreter is still too broken
|
||||
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
||||
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
||||
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
||||
{"runner.os", "Linux", "runner-context"},
|
||||
{"secrets.name", "value", "secrets-context"},
|
||||
{"vars.name", "value", "vars-context"},
|
||||
{"strategy.fail-fast", true, "strategy-context"},
|
||||
{"matrix.os", "Linux", "matrix-context"},
|
||||
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
||||
{"needs.job-id.result", "success", "needs-context"},
|
||||
{"contains(needs.*.result, 'success')", true, "needs-wildcard-context-contains-success"},
|
||||
{"contains(needs.*.result, 'failure')", false, "needs-wildcard-context-contains-failure"},
|
||||
{"inputs.name", "value", "inputs-context"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
Env: map[string]string{
|
||||
"TEST": "value",
|
||||
},
|
||||
Job: &model.JobContext{
|
||||
Status: "success",
|
||||
},
|
||||
Steps: map[string]*model.StepResult{
|
||||
"step-id": {
|
||||
Outputs: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
},
|
||||
"step-id2": {
|
||||
Outcome: model.StepStatusFailure,
|
||||
Conclusion: model.StepStatusSkipped,
|
||||
},
|
||||
},
|
||||
Runner: map[string]any{
|
||||
"os": "Linux",
|
||||
"temp": "/tmp",
|
||||
"tool_cache": "/opt/hostedtoolcache",
|
||||
},
|
||||
Secrets: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
Vars: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
Strategy: map[string]any{
|
||||
"fail-fast": true,
|
||||
},
|
||||
Matrix: map[string]any{
|
||||
"os": "Linux",
|
||||
},
|
||||
Needs: map[string]Needs{
|
||||
"job-id": {
|
||||
Outputs: map[string]string{
|
||||
"output-name": "value",
|
||||
},
|
||||
Result: "success",
|
||||
},
|
||||
"another-job-id": {
|
||||
Outputs: map[string]string{
|
||||
"output-name": "value",
|
||||
},
|
||||
Result: "success",
|
||||
},
|
||||
},
|
||||
Inputs: map[string]any{
|
||||
"name": "value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
act/exprparser/testdata/for-hashing-1.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-1.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hello
|
||||
1
act/exprparser/testdata/for-hashing-2.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-2.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
World!
|
||||
1
act/exprparser/testdata/for-hashing-3/data.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-3/data.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Knock knock!
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue