Compare commits
828 Commits
v0.7.0
...
feat807-i1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b3d5635e | ||
|
|
388db49448 | ||
|
|
c06765a5df | ||
|
|
e03340272a | ||
|
|
67e7f8ce9e | ||
|
|
fedad6574e | ||
|
|
ed9df4f139 | ||
|
|
1a2ed3a6a4 | ||
|
|
f9308406af | ||
|
|
1a28e1091c | ||
|
|
124458c3d6 | ||
|
|
8e2dbd1775 | ||
|
|
27188f4dff | ||
|
|
ef13f6fb3b | ||
|
|
92391254bc | ||
|
|
d3e87b2435 | ||
|
|
e5666dfdf2 | ||
|
|
e96e615761 | ||
|
|
c85aa0739d | ||
|
|
d814f3aaa4 | ||
|
|
3d5f9a76e4 | ||
|
|
d27528a771 | ||
|
|
04ea81e7cd | ||
|
|
d7769dec33 | ||
|
|
12adeadc94 | ||
|
|
b5429f7504 | ||
|
|
cf5c3ee536 | ||
|
|
86c450bd91 | ||
|
|
0d6ab099ac | ||
|
|
5110f83fae | ||
|
|
252e05e963 | ||
|
|
635ecdef72 | ||
|
|
b08d2b07bc | ||
|
|
3919ad3ccf | ||
|
|
aca4f5c286 | ||
|
|
387b4c66d9 | ||
|
|
7c40d2caa9 | ||
|
|
02203e7ce5 | ||
|
|
53583741ba | ||
|
|
12eb9671de | ||
|
|
29d66bfd97 | ||
|
|
57fde5ae7c | ||
|
|
471f902171 | ||
|
|
2e2aba1bbb | ||
|
|
f2347b2f77 | ||
|
|
a39645a297 | ||
|
|
806a0b92a0 | ||
|
|
a438357b45 | ||
|
|
206eb0513d | ||
|
|
5ad6837547 | ||
|
|
272a040c91 | ||
|
|
c04b9e5340 | ||
|
|
3f085a977c | ||
|
|
a1dd12a947 | ||
|
|
a7df43bd45 | ||
|
|
5d749c2ebf | ||
|
|
536ca15e90 | ||
|
|
703e423e04 | ||
|
|
780fec8e36 | ||
|
|
0a436600f4 | ||
|
|
32c2ce90e2 | ||
|
|
a864641692 | ||
|
|
344eee098d | ||
|
|
bc4b0a0b35 | ||
|
|
b23943e30b | ||
|
|
25ed6a71fb | ||
|
|
8dc6d05ed6 | ||
|
|
fe5a993fc9 | ||
|
|
6df5eb3787 | ||
|
|
bc3d5e97ea | ||
|
|
9909b6d481 | ||
|
|
90a32d1b67 | ||
|
|
472834ac42 | ||
|
|
b3f4c6f751 | ||
|
|
317303fc43 | ||
|
|
b6b579d55d | ||
|
|
6d6f4f092d | ||
|
|
7473681c5b | ||
|
|
54c8872d25 | ||
|
|
c5ce45f588 | ||
|
|
07a0c4dfe3 | ||
|
|
80bb94e745 | ||
|
|
6c89412f39 | ||
|
|
034e29cd74 | ||
|
|
0e0764eff8 | ||
|
|
e47db0b8c9 | ||
|
|
6d401dcd59 | ||
|
|
6609c2e928 | ||
|
|
a161d25d48 | ||
|
|
4adedf9436 | ||
|
|
1168e94534 | ||
|
|
b57bfe3eee | ||
|
|
3592e88e4f | ||
|
|
219cde4733 | ||
|
|
c82cd50d87 | ||
|
|
dae4893fe1 | ||
|
|
1e686f0428 | ||
|
|
08c5a5a4f6 | ||
|
|
9360f24d89 | ||
|
|
d0477b216f | ||
|
|
a812f4729c | ||
|
|
db324998e3 | ||
|
|
4ec65a80df | ||
|
|
f2b9700345 | ||
|
|
d8f8ab785c | ||
|
|
b316efe80b | ||
|
|
14a4587f5e | ||
|
|
afd99d2d68 | ||
|
|
7bba1c9c5e | ||
|
|
fd79afb429 | ||
|
|
d5f00597a5 | ||
|
|
1c4ccfe294 | ||
|
|
f48423d5aa | ||
|
|
5d98d9b54b | ||
|
|
132dd4acc4 | ||
|
|
c7e306841a | ||
|
|
5e74a3993b | ||
|
|
5bf10b89b1 | ||
|
|
bde9dd8b88 | ||
|
|
42d28db47a | ||
|
|
128601bb58 | ||
|
|
86addbdc9a | ||
|
|
de9be4bbe0 | ||
|
|
49b79aadfd | ||
|
|
6dab3eddea | ||
|
|
949f14b119 | ||
|
|
de2818de4c | ||
|
|
0f3fcb2917 | ||
|
|
3356fd9815 | ||
|
|
7bef930d0c | ||
|
|
db1a754b39 | ||
|
|
9b9b2731ba | ||
|
|
5523fc9023 | ||
|
|
a380fd9adc | ||
|
|
d3ecf1d7a8 | ||
|
|
6834c20b5d | ||
|
|
b9035659d2 | ||
|
|
5b47427484 | ||
|
|
6e95e1279a | ||
|
|
a2e781fb3f | ||
|
|
69c7f116b1 | ||
|
|
2ef1c90248 | ||
|
|
782df54570 | ||
|
|
0ba6645df0 | ||
|
|
0579251c70 | ||
|
|
c3363604ac | ||
|
|
09aa67ba61 | ||
|
|
4ff7ee4e60 | ||
|
|
5b81b35bf8 | ||
|
|
df3a529f0a | ||
|
|
43e1f25f89 | ||
|
|
7c6c9284f2 | ||
|
|
3d8eec2557 | ||
|
|
5a07638f4d | ||
|
|
87250d13d7 | ||
|
|
90d13684e5 | ||
|
|
25206e71cf | ||
|
|
6fa6dde637 | ||
|
|
e70817f776 | ||
|
|
ca5c606dfc | ||
|
|
ac872b577a | ||
|
|
2761959f93 | ||
|
|
7bf708faab | ||
|
|
c526209925 | ||
|
|
8215cf7857 | ||
|
|
5745606fe7 | ||
|
|
f15cf3e8be | ||
|
|
8e8b0578b2 | ||
|
|
abc929824c | ||
|
|
44e48423ed | ||
|
|
3883cc8b67 | ||
|
|
8e6272920b | ||
|
|
0cde215259 | ||
|
|
3fc54c095e | ||
|
|
80a0a15490 | ||
|
|
af49c78498 | ||
|
|
4839c5f313 | ||
|
|
e9c6feb3c4 | ||
|
|
b8803f380b | ||
|
|
16166c3367 | ||
|
|
db4b153ce1 | ||
|
|
50305e0eee | ||
|
|
53f31ba3b8 | ||
|
|
ffca440135 | ||
|
|
9fda8f9c92 | ||
|
|
a48503d821 | ||
|
|
f9c1941384 | ||
|
|
9520380388 | ||
|
|
a88d02b0b4 | ||
|
|
0a8501fcbb | ||
|
|
c9d50641c8 | ||
|
|
9e06cfbdf0 | ||
|
|
135a92feb4 | ||
|
|
cd4b5e0c80 | ||
|
|
3cd0506810 | ||
|
|
ffa2cf62f5 | ||
|
|
0e439d7ae6 | ||
|
|
a99c6c4cbe | ||
|
|
9e818c2882 | ||
|
|
c243a02e7a | ||
|
|
967286f45d | ||
|
|
8e794be13f | ||
|
|
a8f70d7f59 | ||
|
|
ab91ffe12c | ||
|
|
24b51a7e87 | ||
|
|
c2e63070e6 | ||
|
|
b6627098c2 | ||
|
|
097955e587 | ||
|
|
497a8392f6 | ||
|
|
dcce211676 | ||
|
|
631b29eddc | ||
|
|
9f12cbd43d | ||
|
|
b24825d453 | ||
|
|
3861e964f4 | ||
|
|
ca4428cff2 | ||
|
|
6b09c4f9b7 | ||
|
|
5b2d5e17ab | ||
|
|
be2acdbdfb | ||
|
|
723bf3874f | ||
|
|
ebc378230f | ||
|
|
7bef9c0708 | ||
|
|
1294ebaa8c | ||
|
|
f40baa1287 | ||
|
|
35e2cecee1 | ||
|
|
22c02a8fe9 | ||
|
|
08868eb3e0 | ||
|
|
8a827950d8 | ||
|
|
d724f75016 | ||
|
|
80d50378c5 | ||
|
|
f28f223624 | ||
|
|
082cf5772b | ||
|
|
44ceae40b5 | ||
|
|
b72cce810e | ||
|
|
ccaabf3b6b | ||
|
|
2232adbd8b | ||
|
|
cff999d7bb | ||
|
|
ec0cc84c7c | ||
|
|
64ef74321a | ||
|
|
6f53d1a35a | ||
|
|
f1c458b147 | ||
|
|
38244312c5 | ||
|
|
52ab0bd50d | ||
|
|
73082f1674 | ||
|
|
66c574f74d | ||
|
|
85a07237b1 | ||
|
|
781dad3e17 | ||
|
|
c5552d1b8e | ||
|
|
e0b94e4ff7 | ||
|
|
3089268d88 | ||
|
|
d9624053d2 | ||
|
|
9ebe2d96dd | ||
|
|
2f3475b96a | ||
|
|
06a484880b | ||
|
|
a78758123b | ||
|
|
f129bf3e97 | ||
|
|
dc78ec5135 | ||
|
|
10f7744a62 | ||
|
|
0f81ad5654 | ||
|
|
779fcf8e7f | ||
|
|
7c2b186a61 | ||
|
|
fe0bf77bbb | ||
|
|
0abe8883d1 | ||
|
|
84f2c2d735 | ||
|
|
5d63c90203 | ||
|
|
a97e7bbaae | ||
|
|
f3cfb0a940 | ||
|
|
b1ca43934f | ||
|
|
7afeb8a80d | ||
|
|
f8ced03792 | ||
|
|
1fdf56372b | ||
|
|
835b273700 | ||
|
|
fcc9203416 | ||
|
|
e25c5a014c | ||
|
|
fa9ba303aa | ||
|
|
e6dee37af0 | ||
|
|
d03e992b4f | ||
|
|
1a868be6ea | ||
|
|
e2fc8af87a | ||
|
|
70933d1056 | ||
|
|
7e0b0a05de | ||
|
|
980f65a08a | ||
|
|
8cf6d34362 | ||
|
|
70f139514f | ||
|
|
fa4ec04c47 | ||
|
|
7ebe4af77d | ||
|
|
579241db92 | ||
|
|
7d78871eee | ||
|
|
3a6e9d2fbe | ||
|
|
e4d98082dc | ||
|
|
cd26051144 | ||
|
|
27e584fc14 | ||
|
|
2bdc9322de | ||
|
|
35d5d75966 | ||
|
|
2610e3d02a | ||
|
|
d579f62fa7 | ||
|
|
d1b9820a29 | ||
|
|
13943c3d8b | ||
|
|
d8b800ddbc | ||
|
|
59f1a2f673 | ||
|
|
9ee652c818 | ||
|
|
816c1ea448 | ||
|
|
0bacaef71a | ||
|
|
2ef821f118 | ||
|
|
487cb4e755 | ||
|
|
06d3debf38 | ||
|
|
907f83aaff | ||
|
|
4b747a78cd | ||
|
|
d6f3dd8cda | ||
|
|
51632e367c | ||
|
|
6e98237419 | ||
|
|
ecc8857a32 | ||
|
|
7d05e81c37 | ||
|
|
6ce3fe7a9e | ||
|
|
9443284f52 | ||
|
|
4d6dadd17c | ||
|
|
d54d30a7be | ||
|
|
a08ea134fc | ||
|
|
c9ba16ef10 | ||
|
|
986171ecfe | ||
|
|
712b4528c0 | ||
|
|
03456ddcf8 | ||
|
|
ce32ed5b98 | ||
|
|
edeed41797 | ||
|
|
419727e1eb | ||
|
|
9165b5b215 | ||
|
|
0a38bba874 | ||
|
|
d9f6124609 | ||
|
|
5b16deb73e | ||
|
|
4e77c72fa2 | ||
|
|
1e5207517d | ||
|
|
2a28921984 | ||
|
|
b5bf7cdead | ||
|
|
8869a2c79c | ||
|
|
99d49a1f87 | ||
|
|
a53c0f08a3 | ||
|
|
0e40bb13fc | ||
|
|
db46087799 | ||
|
|
367a275672 | ||
|
|
b3a641e15a | ||
|
|
868b400af3 | ||
|
|
8fcae6810e | ||
|
|
913c580340 | ||
|
|
13a8b11d3d | ||
|
|
5af99c6fe3 | ||
|
|
2d35ac8f82 | ||
|
|
3db487f386 | ||
|
|
643769d4a6 | ||
|
|
2c49d3b5d9 | ||
|
|
714f515f0b | ||
|
|
672479bf4f | ||
|
|
8c3f7b3ec2 | ||
|
|
3aa0f4d263 | ||
|
|
2f35f04207 | ||
|
|
3b3b23142c | ||
|
|
9bd88fd10d | ||
|
|
3092d0b7eb | ||
|
|
d924d340d7 | ||
|
|
c1ffd02491 | ||
|
|
8e9dd8c2df | ||
|
|
1bfd6bbe95 | ||
|
|
715638e368 | ||
|
|
08c868bc1c | ||
|
|
9f46b12625 | ||
|
|
6fc25691bd | ||
|
|
c1713e0d01 | ||
|
|
8187f17d33 | ||
|
|
f0e194f63b | ||
|
|
eabf1f10e4 | ||
|
|
c913d858ee | ||
|
|
17f35ef705 | ||
|
|
0bdbb4a75d | ||
|
|
f9327b3337 | ||
|
|
bf6c9c8b3b | ||
|
|
45015a573b | ||
|
|
d4f0145161 | ||
|
|
fa53339fea | ||
|
|
e5396091a7 | ||
|
|
1ae18e1577 | ||
|
|
b953850a1f | ||
|
|
d0954abe29 | ||
|
|
c3cf5b5f9d | ||
|
|
6589730acc | ||
|
|
442dcff0f1 | ||
|
|
8bac1955a8 | ||
|
|
09a5534499 | ||
|
|
65c126f6a1 | ||
|
|
6adec680a4 | ||
|
|
b81d4fa7f2 | ||
|
|
d8f2e3da86 | ||
|
|
b0c0512515 | ||
|
|
bb9e044e89 | ||
|
|
520532cb9a | ||
|
|
44e09ecadf | ||
|
|
35ced4901a | ||
|
|
134416c9a3 | ||
|
|
8f7f4acc0d | ||
|
|
fde0d3ea9f | ||
|
|
477799af7e | ||
|
|
72570153a5 | ||
|
|
9f335b692f | ||
|
|
e88b22bd45 | ||
|
|
833053a2e1 | ||
|
|
64c52348f3 | ||
|
|
c3b79e6cc2 | ||
|
|
422a982d60 | ||
|
|
6e9fe26fde | ||
|
|
6bfa3096dc | ||
|
|
7cd2da4c6e | ||
|
|
739a5ec299 | ||
|
|
59e65222eb | ||
|
|
01d5d11c01 | ||
|
|
29a59cab44 | ||
|
|
be184c11a6 | ||
|
|
d6ab97ad25 | ||
|
|
6a0f76890e | ||
|
|
1946868248 | ||
|
|
84b02c711a | ||
|
|
679a681749 | ||
|
|
c35d1b14ec | ||
|
|
87df297a56 | ||
|
|
b8e420e0e8 | ||
|
|
f8c8668863 | ||
|
|
ced0746a81 | ||
|
|
39909d774f | ||
|
|
12e6e0557d | ||
|
|
e27282de3c | ||
|
|
fe63f9939a | ||
|
|
b623a5d452 | ||
|
|
d8113df979 | ||
|
|
b3ba36c02a | ||
|
|
37863e3f74 | ||
|
|
da6f39b137 | ||
|
|
4fe63d7102 | ||
|
|
7c8881f37d | ||
|
|
c20069fce0 | ||
|
|
2eb1c9e857 | ||
|
|
48e1fe769e | ||
|
|
2b8bc82d4e | ||
|
|
8f33151647 | ||
|
|
8e743a8d32 | ||
|
|
9f22e01d3b | ||
|
|
502c8718c5 | ||
|
|
220faa52e7 | ||
|
|
857c93bff9 | ||
|
|
ca5cf33c8f | ||
|
|
1cd620a45e | ||
|
|
4eb9a9a0af | ||
|
|
c82abae8e5 | ||
|
|
f56256f897 | ||
|
|
e31749e64d | ||
|
|
89d666f365 | ||
|
|
b502852966 | ||
|
|
e101397a2c | ||
|
|
ddcecc06d4 | ||
|
|
4237f452df | ||
|
|
3f9276ee4c | ||
|
|
5a1f437cf9 | ||
|
|
bb9cebd759 | ||
|
|
62e313d13f | ||
|
|
537ee24078 | ||
|
|
364756d9fa | ||
|
|
6eb1cff8c5 | ||
|
|
44e02c0342 | ||
|
|
b36767cdb7 | ||
|
|
67194109c6 | ||
|
|
08032be2c4 | ||
|
|
74b97a0036 | ||
|
|
eac3239817 | ||
|
|
9698aa7ad5 | ||
|
|
cbce2a70f5 | ||
|
|
a2d91ec2f9 | ||
|
|
d93a69df95 | ||
|
|
fb982ca8f1 | ||
|
|
4b979628b3 | ||
|
|
789750cc86 | ||
|
|
4125361fb5 | ||
|
|
6b8b562e7c | ||
|
|
2e9a117255 | ||
|
|
6d6a7e6923 | ||
|
|
4edb4e014f | ||
|
|
f020e5a633 | ||
|
|
5432424a40 | ||
|
|
e81bfb6f37 | ||
|
|
3c75c5fe25 | ||
|
|
7c5c693f17 | ||
|
|
2d98e33e98 | ||
|
|
4827d33ca1 | ||
|
|
71eb3feac9 | ||
|
|
5f290937d2 | ||
|
|
1c8aa35479 | ||
|
|
faccf2a651 | ||
|
|
4d99c12215 | ||
|
|
7c2047cfbf | ||
|
|
12d5cfe8e4 | ||
|
|
cbbcb51162 | ||
|
|
954a6a11b7 | ||
|
|
0c5e98b47d | ||
|
|
d941fef8d6 | ||
|
|
496de850c1 | ||
|
|
29fa33fb2b | ||
|
|
06fbb5ba34 | ||
|
|
a32f6f343d | ||
|
|
61d7b4f64c | ||
|
|
1840ab4bba | ||
|
|
ccb812cc33 | ||
|
|
b098cd5638 | ||
|
|
eefa7ca138 | ||
|
|
b5dcdc8807 | ||
|
|
4b4e5d5ebd | ||
|
|
54fd9561f0 | ||
|
|
fb67769928 | ||
|
|
9c8e632a09 | ||
|
|
0b6c2b032a | ||
|
|
f0e4cdc13e | ||
|
|
cfe31fbeac | ||
|
|
722dc0b3af | ||
|
|
de3353feba | ||
|
|
145e45b4a8 | ||
|
|
d0f57809d6 | ||
|
|
6c29377992 | ||
|
|
164902c0cb | ||
|
|
75466cb57f | ||
|
|
0e8fff7a51 | ||
|
|
7b72da857f | ||
|
|
b89546a1e0 | ||
|
|
22122a27b5 | ||
|
|
24a9e9f61c | ||
|
|
cf5378f604 | ||
|
|
1d8f51c141 | ||
|
|
87798cd1c8 | ||
|
|
fd1496df93 | ||
|
|
ea6e11000d | ||
|
|
ef257f65cf | ||
|
|
01a707c8e7 | ||
|
|
9293b28ef4 | ||
|
|
30c0fda1b6 | ||
|
|
548a458b9a | ||
|
|
111cd4ac64 | ||
|
|
54ab81a7de | ||
|
|
21344280a9 | ||
|
|
d3fa9736f4 | ||
|
|
f1ec419e3a | ||
|
|
de8c6b4ed8 | ||
|
|
e661cef2fe | ||
|
|
edf485bbe4 | ||
|
|
20eecffc40 | ||
|
|
232b180eef | ||
|
|
4a738ee362 | ||
|
|
f4d90306b3 | ||
|
|
7801a91149 | ||
|
|
19d4e38d94 | ||
|
|
bab57e0402 | ||
|
|
d2b3360bff | ||
|
|
1aaa5acbef | ||
|
|
5878eed7ec | ||
|
|
b0ebbdf68c | ||
|
|
0ec20d3093 | ||
|
|
c5ddae12cf | ||
|
|
b1e1850e9f | ||
|
|
06c2635e82 | ||
|
|
711ac742e1 | ||
|
|
201ab20131 | ||
|
|
716ba72217 | ||
|
|
95b16919a6 | ||
|
|
d3d000a1d0 | ||
|
|
9499f78121 | ||
|
|
fa36c9ee5c | ||
|
|
c460eb4d7a | ||
|
|
ab52270238 | ||
|
|
7c6fdebb3d | ||
|
|
cf3cd76064 | ||
|
|
5ef6b536ac | ||
|
|
adf5184a5d | ||
|
|
ea596a8701 | ||
|
|
15a3cb7241 | ||
|
|
f147da3017 | ||
|
|
dfaf2eb6a9 | ||
|
|
97f6a32c78 | ||
|
|
bcdd7498a1 | ||
|
|
c45947b573 | ||
|
|
85140c7dcf | ||
|
|
30e9a604cd | ||
|
|
8c769148ad | ||
|
|
4cc08d7211 | ||
|
|
48b6b6340b | ||
|
|
b857970236 | ||
|
|
1011fde9de | ||
|
|
bee89720d5 | ||
|
|
23bff41304 | ||
|
|
8243326692 | ||
|
|
17ae122595 | ||
|
|
b69d72fc8c | ||
|
|
c52498993b | ||
|
|
a4a82b4502 | ||
|
|
52d953a1c2 | ||
|
|
e145d82947 | ||
|
|
25df1fe26c | ||
|
|
106718f416 | ||
|
|
8464faa2a1 | ||
|
|
9354b911bb | ||
|
|
c8a5b82c89 | ||
|
|
1f884e9584 | ||
|
|
00b2c92e39 | ||
|
|
0796778d17 | ||
|
|
1eae1c03f0 | ||
|
|
a9209da167 | ||
|
|
43c2f14289 | ||
|
|
f378d56543 | ||
|
|
521d146d7b | ||
|
|
dc721f5870 | ||
|
|
3b0d726c2a | ||
|
|
f6226d19b8 | ||
|
|
71c091ae0d | ||
|
|
1fb008212a | ||
|
|
cab34e4069 | ||
|
|
e67e20ce18 | ||
|
|
d253c0d494 | ||
|
|
c74e8fc732 | ||
|
|
29358e5744 | ||
|
|
b59c102098 | ||
|
|
afaa1433ff | ||
|
|
f923016052 | ||
|
|
ca27e7f27a | ||
|
|
8fd9c2fce2 | ||
|
|
d4ca060945 | ||
|
|
d124c21d1b | ||
|
|
d2fb2cb863 | ||
|
|
0350daca8d | ||
|
|
06f54e300c | ||
|
|
135b940897 | ||
|
|
7856276092 | ||
|
|
bf14dcc3e8 | ||
|
|
21c1778822 | ||
|
|
337bfa74bb | ||
|
|
418b1ff544 | ||
|
|
092d866c73 | ||
|
|
50391c87e2 | ||
|
|
fd6645d068 | ||
|
|
3a6e326e5e | ||
|
|
b997b787c4 | ||
|
|
d227bdfc75 | ||
|
|
4ba6286c97 | ||
|
|
56ef453203 | ||
|
|
b573a8bafa | ||
|
|
59820e737e | ||
|
|
530eb20dfc | ||
|
|
446322dcbe | ||
|
|
2d311518a7 | ||
|
|
3bcd1bf665 | ||
|
|
88d5e22532 | ||
|
|
41a41cdf38 | ||
|
|
e6e21e9f46 | ||
|
|
f18aa8fe79 | ||
|
|
227e5883e9 | ||
|
|
87e835e873 | ||
|
|
965a099495 | ||
|
|
66ae15b4fb | ||
|
|
813c14d93c | ||
|
|
5d0af27a3f | ||
|
|
aa3fda6de9 | ||
|
|
9e4f8c9fee | ||
|
|
ce2e6f80fc | ||
|
|
bf4622e4f5 | ||
|
|
9655f57698 | ||
|
|
808694d6b5 | ||
|
|
cd12243b0f | ||
|
|
abfa921b7a | ||
|
|
91f3b1f138 | ||
|
|
9468839bf9 | ||
|
|
9ca2aa9bbd | ||
|
|
54c82a3a5c | ||
|
|
9360693f8d | ||
|
|
c54dd510ad | ||
|
|
b940c7bfbd | ||
|
|
1460d69cd1 | ||
|
|
a7619b06ba | ||
|
|
f3a5251fd4 | ||
|
|
2ec247827d | ||
|
|
e7a836a6b2 | ||
|
|
6d163ee1ef | ||
|
|
a12c1916ec | ||
|
|
4e1a7077d7 | ||
|
|
da453a6b7c | ||
|
|
0230c5bb59 | ||
|
|
a471b77f8d | ||
|
|
b1e4800605 | ||
|
|
7f5be16db8 | ||
|
|
791e069a4c | ||
|
|
20bfca97e0 | ||
|
|
3302c822f1 | ||
|
|
9a4115f086 | ||
|
|
f77a9b4db5 | ||
|
|
b3b3feac66 | ||
|
|
324f36513e | ||
|
|
ebf045d2e0 | ||
|
|
3ec161ba56 | ||
|
|
12be43018e | ||
|
|
0f51cb66e0 | ||
|
|
1b206f223f | ||
|
|
02d4161ddd | ||
|
|
173c673d37 | ||
|
|
4fe6bcc5db | ||
|
|
7e1d5338cf | ||
|
|
a67206dd8d | ||
|
|
5f3d856535 | ||
|
|
0244bc7317 | ||
|
|
7267516363 | ||
|
|
15d133324d | ||
|
|
5bf922325a | ||
|
|
b2b814a65b | ||
|
|
d2bc18b575 | ||
|
|
8cdb675abc | ||
|
|
a25829bbd9 | ||
|
|
194aa9a750 | ||
|
|
e21af77246 | ||
|
|
bfe9038630 | ||
|
|
a33123469a | ||
|
|
f353dc2c41 | ||
|
|
a2a367725b | ||
|
|
db90a0eed7 | ||
|
|
93dba3f92f | ||
|
|
5a20b9fc04 | ||
|
|
9793c3f3ee | ||
|
|
6963a1ae8a | ||
|
|
da1a5ead39 | ||
|
|
f1b5037ee5 | ||
|
|
cf18a3cd60 | ||
|
|
cc1b67575c | ||
|
|
50d33a07df | ||
|
|
fc0dedfda7 | ||
|
|
35dbacdfff | ||
|
|
ad0d23d686 | ||
|
|
786b94b285 | ||
|
|
0ddf4a1828 | ||
|
|
b244242cb2 | ||
|
|
49d4c5800f | ||
|
|
a198382c06 | ||
|
|
316017c516 | ||
|
|
f62cb483ce | ||
|
|
44c88f1ed5 | ||
|
|
9d1193c0b5 | ||
|
|
22d35eb32f | ||
|
|
2683ddb1ee | ||
|
|
fe7646e939 | ||
|
|
00528edd7c | ||
|
|
06c2ac4d5c | ||
|
|
a447d64d83 | ||
|
|
9260f23d41 | ||
|
|
d7082c3959 | ||
|
|
437e171639 | ||
|
|
6de45cd422 | ||
|
|
a065e0e259 | ||
|
|
eede27e263 | ||
|
|
8a900254ae | ||
|
|
5f4641af67 | ||
|
|
8a4be8b93a | ||
|
|
a712d5b77e | ||
|
|
5947e262fc | ||
|
|
c3f22fe989 | ||
|
|
2f003a5bb5 | ||
|
|
75085b213a | ||
|
|
91968b9f4b | ||
|
|
d881d97c06 | ||
|
|
1dd1ce2f47 | ||
|
|
640c0b39c1 | ||
|
|
25a4607ad6 | ||
|
|
064ffe2a7c | ||
|
|
4d759fbfb9 | ||
|
|
9c854cb39d | ||
|
|
27799e7033 | ||
|
|
3ac1ac6086 | ||
|
|
01db85ebc7 | ||
|
|
cdf4767d7d | ||
|
|
9bdd96527c | ||
|
|
13e3fed351 | ||
|
|
115a293e58 | ||
|
|
9c985e63ee | ||
|
|
c57a75f78f | ||
|
|
a96298db59 | ||
|
|
061921af8e | ||
|
|
6719c84a7a | ||
|
|
13f3335098 | ||
|
|
a79c26a7a4 | ||
|
|
abccb316f8 | ||
|
|
f14e954a9b | ||
|
|
5d4a2a7c25 | ||
|
|
ce90515c95 | ||
|
|
5a51495432 | ||
|
|
b99fe5bf55 | ||
|
|
d243a83c5c | ||
|
|
6cb658a1ef | ||
|
|
8a7f8f7c37 | ||
|
|
b7daf91723 | ||
|
|
66894e7596 | ||
|
|
7748adf0e6 | ||
|
|
758ac41648 | ||
|
|
3db2008c39 | ||
|
|
34a3f8186a | ||
|
|
8e0baf0e37 | ||
|
|
017863bfc0 | ||
|
|
f4ef63b8f8 | ||
|
|
394f2f2387 | ||
|
|
5dd094e0b1 | ||
|
|
7d073fdf0d | ||
|
|
e806214d0b | ||
|
|
419bf0aa77 | ||
|
|
cad8a6dc91 | ||
|
|
ef3596ff32 | ||
|
|
74dcec8b6b | ||
|
|
c0ae0d8d3f | ||
|
|
4637c5fbbd | ||
|
|
74c92de1d3 | ||
|
|
1688dbd8a0 | ||
|
|
f167f5b49e | ||
|
|
968efed6c3 | ||
|
|
c10dc38a89 | ||
|
|
2bc6b2995d | ||
|
|
edd6a41d6e | ||
|
|
9e713b7b81 | ||
|
|
1f8f08e998 | ||
|
|
008884ec59 | ||
|
|
230bfd15ac | ||
|
|
7cda9a0c7b | ||
|
|
6012453d2d | ||
|
|
2514672c35 | ||
|
|
6080e097da | ||
|
|
14bfc57870 | ||
|
|
32a4703a53 |
29
.codeclimate.yml
Normal file
29
.codeclimate.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
engines:
|
||||
gofmt:
|
||||
enabled: true
|
||||
golint:
|
||||
enabled: true
|
||||
govet:
|
||||
enabled: true
|
||||
csslint:
|
||||
enabled: true
|
||||
duplication:
|
||||
enabled: true
|
||||
config:
|
||||
languages:
|
||||
javascript:
|
||||
mass_threshold: 80
|
||||
eslint:
|
||||
enabled: true
|
||||
config:
|
||||
config: .eslintrc.yml
|
||||
fixme:
|
||||
enabled: true
|
||||
ratings:
|
||||
paths:
|
||||
- "**.css"
|
||||
- "**.js"
|
||||
- "**.go"
|
||||
exclude_paths:
|
||||
- test/
|
||||
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
*
|
||||
!dist
|
||||
!build
|
||||
|
||||
284
.eslintrc.yml
Normal file
284
.eslintrc.yml
Normal file
@@ -0,0 +1,284 @@
|
||||
env:
|
||||
browser: true
|
||||
jquery: true
|
||||
|
||||
# globals:
|
||||
# angular: true
|
||||
# $: true
|
||||
# _: true
|
||||
# moment: true
|
||||
# filesize: true
|
||||
# splitargs: true
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
|
||||
# http://eslint.org/docs/rules/
|
||||
rules:
|
||||
# Possible Errors
|
||||
no-await-in-loop: off
|
||||
no-cond-assign: error
|
||||
no-console: off
|
||||
no-constant-condition: error
|
||||
no-control-regex: error
|
||||
no-debugger: error
|
||||
no-dupe-args: error
|
||||
no-dupe-keys: error
|
||||
no-duplicate-case: error
|
||||
no-empty-character-class: error
|
||||
no-empty: error
|
||||
no-ex-assign: error
|
||||
no-extra-boolean-cast: error
|
||||
no-extra-parens: off
|
||||
no-extra-semi: error
|
||||
no-func-assign: error
|
||||
no-inner-declarations:
|
||||
- error
|
||||
- functions
|
||||
no-invalid-regexp: error
|
||||
no-irregular-whitespace: error
|
||||
no-negated-in-lhs: error
|
||||
no-obj-calls: error
|
||||
no-prototype-builtins: off
|
||||
no-regex-spaces: error
|
||||
no-sparse-arrays: error
|
||||
no-template-curly-in-string: off
|
||||
no-unexpected-multiline: error
|
||||
no-unreachable: error
|
||||
no-unsafe-finally: off
|
||||
no-unsafe-negation: off
|
||||
use-isnan: error
|
||||
valid-jsdoc: off
|
||||
valid-typeof: error
|
||||
|
||||
# Best Practices
|
||||
accessor-pairs: error
|
||||
array-callback-return: off
|
||||
block-scoped-var: off
|
||||
class-methods-use-this: off
|
||||
complexity:
|
||||
- error
|
||||
- 6
|
||||
consistent-return: off
|
||||
curly: off
|
||||
default-case: off
|
||||
dot-location: off
|
||||
dot-notation: off
|
||||
eqeqeq: error
|
||||
guard-for-in: error
|
||||
no-alert: error
|
||||
no-caller: error
|
||||
no-case-declarations: error
|
||||
no-div-regex: error
|
||||
no-else-return: off
|
||||
no-empty-function: off
|
||||
no-empty-pattern: error
|
||||
no-eq-null: error
|
||||
no-eval: error
|
||||
no-extend-native: error
|
||||
no-extra-bind: error
|
||||
no-extra-label: off
|
||||
no-fallthrough: error
|
||||
no-floating-decimal: off
|
||||
no-global-assign: off
|
||||
no-implicit-coercion: off
|
||||
no-implied-eval: error
|
||||
no-invalid-this: off
|
||||
no-iterator: error
|
||||
no-labels:
|
||||
- error
|
||||
- allowLoop: true
|
||||
allowSwitch: true
|
||||
no-lone-blocks: error
|
||||
no-loop-func: error
|
||||
no-magic-number: off
|
||||
no-multi-spaces: off
|
||||
no-multi-str: off
|
||||
no-native-reassign: error
|
||||
no-new-func: error
|
||||
no-new-wrappers: error
|
||||
no-new: error
|
||||
no-octal-escape: error
|
||||
no-octal: error
|
||||
no-param-reassign: off
|
||||
no-proto: error
|
||||
no-redeclare: error
|
||||
no-restricted-properties: off
|
||||
no-return-assign: error
|
||||
no-return-await: off
|
||||
no-script-url: error
|
||||
no-self-assign: off
|
||||
no-self-compare: error
|
||||
no-sequences: off
|
||||
no-throw-literal: off
|
||||
no-unmodified-loop-condition: off
|
||||
no-unused-expressions: error
|
||||
no-unused-labels: off
|
||||
no-useless-call: error
|
||||
no-useless-concat: error
|
||||
no-useless-escape: off
|
||||
no-useless-return: off
|
||||
no-void: error
|
||||
no-warning-comments: off
|
||||
no-with: error
|
||||
prefer-promise-reject-errors: off
|
||||
radix: error
|
||||
require-await: off
|
||||
vars-on-top: off
|
||||
wrap-iife: error
|
||||
yoda: off
|
||||
|
||||
# Strict
|
||||
strict: off
|
||||
|
||||
# Variables
|
||||
init-declarations: off
|
||||
no-catch-shadow: error
|
||||
no-delete-var: error
|
||||
no-label-var: error
|
||||
no-restricted-globals: off
|
||||
no-shadow-restricted-names: error
|
||||
no-shadow: off
|
||||
no-undef-init: error
|
||||
no-undef: off
|
||||
no-undefined: off
|
||||
no-unused-vars: off
|
||||
no-use-before-define: off
|
||||
|
||||
# Node.js and CommonJS
|
||||
callback-return: error
|
||||
global-require: error
|
||||
handle-callback-err: error
|
||||
no-mixed-requires: off
|
||||
no-new-require: off
|
||||
no-path-concat: error
|
||||
no-process-env: off
|
||||
no-process-exit: error
|
||||
no-restricted-modules: off
|
||||
no-sync: off
|
||||
|
||||
# Stylistic Issues
|
||||
array-bracket-spacing: off
|
||||
block-spacing: off
|
||||
brace-style: off
|
||||
camelcase: off
|
||||
capitalized-comments: off
|
||||
comma-dangle:
|
||||
- error
|
||||
- never
|
||||
comma-spacing: off
|
||||
comma-style: off
|
||||
computed-property-spacing: off
|
||||
consistent-this: off
|
||||
eol-last: off
|
||||
func-call-spacing: off
|
||||
func-name-matching: off
|
||||
func-names: off
|
||||
func-style: off
|
||||
id-length: off
|
||||
id-match: off
|
||||
indent: off
|
||||
jsx-quotes: off
|
||||
key-spacing: off
|
||||
keyword-spacing: off
|
||||
line-comment-position: off
|
||||
linebreak-style:
|
||||
- error
|
||||
- unix
|
||||
lines-around-comment: off
|
||||
lines-around-directive: off
|
||||
max-depth: off
|
||||
max-len: off
|
||||
max-nested-callbacks: off
|
||||
max-params: off
|
||||
max-statements-per-line: off
|
||||
max-statements:
|
||||
- error
|
||||
- 30
|
||||
multiline-ternary: off
|
||||
new-cap: off
|
||||
new-parens: off
|
||||
newline-after-var: off
|
||||
newline-before-return: off
|
||||
newline-per-chained-call: off
|
||||
no-array-constructor: off
|
||||
no-bitwise: off
|
||||
no-continue: off
|
||||
no-inline-comments: off
|
||||
no-lonely-if: off
|
||||
no-mixed-operators: off
|
||||
no-mixed-spaces-and-tabs: off
|
||||
no-multi-assign: off
|
||||
no-multiple-empty-lines: off
|
||||
no-negated-condition: off
|
||||
no-nested-ternary: off
|
||||
no-new-object: off
|
||||
no-plusplus: off
|
||||
no-restricted-syntax: off
|
||||
no-spaced-func: off
|
||||
no-tabs: off
|
||||
no-ternary: off
|
||||
no-trailing-spaces: off
|
||||
no-underscore-dangle: off
|
||||
no-unneeded-ternary: off
|
||||
object-curly-newline: off
|
||||
object-curly-spacing: off
|
||||
object-property-newline: off
|
||||
one-var-declaration-per-line: off
|
||||
one-var: off
|
||||
operator-assignment: off
|
||||
operator-linebreak: off
|
||||
padded-blocks: off
|
||||
quote-props: off
|
||||
quotes:
|
||||
- error
|
||||
- single
|
||||
require-jsdoc: off
|
||||
semi-spacing: off
|
||||
semi:
|
||||
- error
|
||||
- always
|
||||
sort-keys: off
|
||||
sort-vars: off
|
||||
space-before-blocks: off
|
||||
space-before-function-paren: off
|
||||
space-in-parens: off
|
||||
space-infix-ops: off
|
||||
space-unary-ops: off
|
||||
spaced-comment: off
|
||||
template-tag-spacing: off
|
||||
unicode-bom: off
|
||||
wrap-regex: off
|
||||
|
||||
# ECMAScript 6
|
||||
arrow-body-style: off
|
||||
arrow-parens: off
|
||||
arrow-spacing: off
|
||||
constructor-super: off
|
||||
generator-star-spacing: off
|
||||
no-class-assign: off
|
||||
no-confusing-arrow: off
|
||||
no-const-assign: off
|
||||
no-dupe-class-members: off
|
||||
no-duplicate-imports: off
|
||||
no-new-symbol: off
|
||||
no-restricted-imports: off
|
||||
no-this-before-super: off
|
||||
no-useless-computed-key: off
|
||||
no-useless-constructor: off
|
||||
no-useless-rename: off
|
||||
no-var: off
|
||||
object-shorthand: off
|
||||
prefer-arrow-callback: off
|
||||
prefer-const: off
|
||||
prefer-destructuring: off
|
||||
prefer-numeric-literals: off
|
||||
prefer-rest-params: off
|
||||
prefer-reflect: off
|
||||
prefer-spread: off
|
||||
prefer-template: off
|
||||
require-yield: off
|
||||
rest-spread-spacing: off
|
||||
sort-imports: off
|
||||
symbol-description: off
|
||||
template-curly-spacing: off
|
||||
yield-star-spacing: off
|
||||
44
.github/ISSUE_TEMPLATE.md
vendored
Normal file
44
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
|
||||
Thanks for opening an issue on Portainer !
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
|
||||
|
||||
If you are reporting a new issue, make sure that we do not have any duplicates
|
||||
already open. You can ensure this by searching the issue list for this
|
||||
repository. If there is a duplicate, please close your issue and add a comment
|
||||
to the existing issue instead.
|
||||
|
||||
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
|
||||
|
||||
If you suspect your issue is a bug, please edit your issue description to
|
||||
include the BUG REPORT INFORMATION shown below.
|
||||
|
||||
---------------------------------------------------
|
||||
BUG REPORT INFORMATION
|
||||
---------------------------------------------------
|
||||
You do NOT have to include this information if this is a FEATURE REQUEST
|
||||
-->
|
||||
|
||||
**Description**
|
||||
|
||||
<!--
|
||||
Briefly describe the problem you are having in a few paragraphs.
|
||||
-->
|
||||
|
||||
**Steps to reproduce the issue:**
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
|
||||
|
||||
**Technical details:**
|
||||
|
||||
* Portainer version:
|
||||
* Target Docker version (the host/cluster you manage):
|
||||
* Platform (windows/linux):
|
||||
* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
|
||||
* Target Swarm version (if applicable):
|
||||
* Browser:
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
logs/*
|
||||
!.gitkeep
|
||||
*.esproj/*
|
||||
node_modules
|
||||
.idea
|
||||
*.iml
|
||||
bower_components
|
||||
dist
|
||||
portainer-checksum.txt
|
||||
api/cmd/portainer/portainer*
|
||||
.tmp
|
||||
|
||||
46
CODE_OF_CONDUCT.md
Normal file
46
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
79
CONTRIBUTING.md
Normal file
79
CONTRIBUTING.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Some basic conventions for contributing to this project.
|
||||
|
||||
### General
|
||||
|
||||
Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork.
|
||||
|
||||
* Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring
|
||||
* Develop in a topic branch, not master/develop
|
||||
|
||||
When creating a new branch, prefix it with the *type* of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator).
|
||||
|
||||
For example, if you work on a bugfix for the issue #361, you could name the branch `fix361-template-selection`.
|
||||
|
||||
### Issues open to contribution
|
||||
|
||||
Want to contribute but don't know where to start?
|
||||
|
||||
Some of the open issues are labeled with prefix `exp/`, this is used to mark them as available for contributors to work on. All of these have an attributed difficulty level:
|
||||
|
||||
* **beginner**: a task that should be accessible with users not familiar with the codebase
|
||||
* **intermediate**: a task that require some understanding of the project codebase or some experience in
|
||||
either AngularJS or Golang
|
||||
* **advanced**: a task that require a deep understanding of the project codebase
|
||||
|
||||
You can have a use Github filters to list these issues:
|
||||
|
||||
* beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner
|
||||
* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
|
||||
* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
|
||||
|
||||
### Linting
|
||||
|
||||
Please check your code using `grunt lint` before submitting your pull requests.
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
Each commit message should include a **type**, a **scope** and a **subject**:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
```
|
||||
|
||||
Lines should not exceed 100 characters. This allows the message to be easier to read on github as well as in various git tools and produces a nice, neat commit log ie:
|
||||
|
||||
```
|
||||
#271 feat(containers): add exposed ports in the containers view
|
||||
#270 fix(templates): fix a display issue in the templates view
|
||||
#269 style(dashboard): update dashboard with new layout
|
||||
```
|
||||
|
||||
#### Type
|
||||
|
||||
Must be one of the following:
|
||||
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **docs**: Documentation only changes
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
semi-colons, etc)
|
||||
* **refactor**: A code change that neither fixes a bug or adds a feature
|
||||
* **test**: Adding missing tests
|
||||
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
|
||||
generation
|
||||
|
||||
#### Scope
|
||||
|
||||
The scope could be anything specifying place of the commit change. For example `networks`,
|
||||
`containers`, `images` etc...
|
||||
You can use the **area** label tag associated on the issue here (for `area/containers` use `containers` as a scope...)
|
||||
|
||||
#### Subject
|
||||
|
||||
The subject contains succinct description of the change:
|
||||
|
||||
* use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
* don't capitalize first letter
|
||||
* no dot (.) at the end
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM scratch
|
||||
|
||||
COPY dockerui /
|
||||
COPY dist /
|
||||
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT ["/dockerui"]
|
||||
60
LICENSE
60
LICENSE
@@ -1,7 +1,59 @@
|
||||
DockerUI: Copyright (c) 2013-2014 Michael Crosby. crosbymichael.com
|
||||
Portainer: Copyright (c) 2016 Portainer.io
|
||||
|
||||
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:
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
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.
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgment in the product documentation would be
|
||||
appreciated but is not required.
|
||||
2. Altered source versions must be plainly marked as such, and must not be
|
||||
misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
|
||||
Portainer contains code which was originally under this license:
|
||||
|
||||
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io)
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
|
||||
rdash-angular: Copyright (c) [2014] [Elliot Hesp]
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
|
||||
32
Makefile
32
Makefile
@@ -1,32 +0,0 @@
|
||||
.PHONY: build run
|
||||
|
||||
.SUFFIXES:
|
||||
|
||||
OPEN = $(shell which xdg-open || which open)
|
||||
PORT ?= 9000
|
||||
|
||||
install:
|
||||
npm install -g grunt-cli
|
||||
npm install
|
||||
|
||||
build:
|
||||
grunt build
|
||||
docker build --rm -t dockerui .
|
||||
|
||||
build-release:
|
||||
grunt build
|
||||
docker run --rm -v $(shell pwd):/src centurylink/golang-builder
|
||||
shasum dockerui > dockerui-checksum.txt
|
||||
|
||||
test:
|
||||
grunt
|
||||
|
||||
run:
|
||||
-docker stop dockerui
|
||||
-docker rm dockerui
|
||||
docker run -d -p $(PORT):9000 -v /var/run/docker.sock:/docker.sock --name dockerui dockerui -e /docker.sock
|
||||
|
||||
open:
|
||||
$(OPEN) localhost:$(PORT)
|
||||
|
||||
|
||||
96
README.md
96
README.md
@@ -1,81 +1,55 @@
|
||||
## DockerUI
|
||||
|
||||

|
||||
DockerUI is a web interface for the Docker Remote API. The goal is to provide a pure client side implementation so it is effortless to connect and manage docker. This project is not complete and is still under heavy development.
|
||||
<p align="center">
|
||||
<img title="portainer" src='https://portainer.io/images/logo_alt.png' />
|
||||
</p>
|
||||
|
||||

|
||||
[](https://hub.docker.com/r/portainer/portainer/)
|
||||
[](http://microbadger.com/images/portainer/portainer "Image size")
|
||||
[](http://portainer.readthedocs.io/en/stable/?badge=stable)
|
||||
[]( https://g.codefresh.io/repositories/portainer/portainer/builds?filter=trigger:build;branch:develop;service:5922a08a3a1aab000116fcc6~portainer-ci)
|
||||
[](https://codeclimate.com/github/portainer/portainer)
|
||||
[](https://portainer.io/slack/)
|
||||
[](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
|
||||
|
||||
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
|
||||
|
||||
### Goals
|
||||
* Minimal dependencies - I really want to keep this project a pure html/js app.
|
||||
* Consistency - The web UI should be consistent with the commands found on the docker CLI.
|
||||
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container).
|
||||
|
||||
### Container Quickstart
|
||||
1. Run: `docker run -d -p 9000:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock dockerui/dockerui`
|
||||
**_Portainer_** allows you to manage your Docker containers, images, volumes, networks and more ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*.
|
||||
|
||||
2. Open your browser to `http://<dockerd host ip>:9000`
|
||||
## Demo
|
||||
|
||||
<img src="https://portainer.io/images/screenshots/portainer.gif" width="77%"/>
|
||||
|
||||
Bind mounting the Unix socket into the DockerUI container is much more secure than exposing your docker daemon over TCP. The `--privileged` flag is required for hosts using SELinux. You should still secure your DockerUI instance behind some type of auth. Directions for using Nginx auth are [here](https://github.com/crosbymichael/dockerui/wiki/Dockerui-with-Nginx-HTTP-Auth).
|
||||
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **admin** and the password **tryportainer**).
|
||||
|
||||
### Specify socket to connect to Docker daemon
|
||||
Please note that the public demo cluster is **reset every 15min**.
|
||||
|
||||
By default DockerUI connects to the Docker daemon with`/var/run/docker.sock`. For this to work you need to bind mount the unix socket into the container with `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
## Getting started
|
||||
|
||||
You can use the `-e` flag to change this socket:
|
||||
* [Deploy Portainer](https://portainer.readthedocs.io/en/latest/deployment.html)
|
||||
* [Documentation](https://portainer.readthedocs.io)
|
||||
|
||||
# Connect to a tcp socket:
|
||||
$ docker run -d -p 9000:9000 --privileged dockerui/dockerui -e http://127.0.0.1:2375
|
||||
## Getting help
|
||||
|
||||
### Change address/port DockerUI is served on
|
||||
DockerUI listens on port 9000 by default. If you run DockerUI inside a container then you can bind the container's internal port to any external address and port:
|
||||
* Issues: https://github.com/portainer/portainer/issues
|
||||
* FAQ: https://portainer.readthedocs.io/en/latest/faq.html
|
||||
* Slack (chat): https://portainer.io/slack/
|
||||
* Gitter (chat): https://gitter.im/portainer/Lobby
|
||||
|
||||
# Expose DockerUI on 10.20.30.1:80
|
||||
$ docker run -d -p 10.20.30.1:80:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock dockerui/dockerui
|
||||
## Reporting bugs and contributing
|
||||
|
||||
### Check the [wiki](//github.com/crosbymichael/dockerui/wiki) for more info about using dockerui
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
* Want to help us build **_portainer_**? Follow our [contribution guidelines](https://portainer.readthedocs.io/en/latest/contribute.html) to build it locally and make a pull request. We need all the help we can get!
|
||||
|
||||
### Stack
|
||||
* [Angular.js](https://github.com/angular/angular.js)
|
||||
* [Bootstrap](http://getbootstrap.com/)
|
||||
* [Gritter](https://github.com/jboesch/Gritter)
|
||||
* [Spin.js](https://github.com/fgnass/spin.js/)
|
||||
* [Golang](https://golang.org/)
|
||||
## Limitations
|
||||
|
||||
**_Portainer_** has full support for the following Docker versions:
|
||||
|
||||
### Todo:
|
||||
* Full repository support
|
||||
* Search
|
||||
* Push files to a container
|
||||
* Unit tests
|
||||
* Docker 1.10 to the latest version
|
||||
* Docker Swarm >= 1.2.3
|
||||
|
||||
Partial support for the following Docker versions (some features may not be available):
|
||||
|
||||
### License - MIT
|
||||
The DockerUI code is licensed under the MIT license.
|
||||
|
||||
|
||||
**DockerUI:**
|
||||
Copyright (c) 2014-2015 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com)
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
* Docker 1.9
|
||||
|
||||
144
api/bolt/datastore.go
Normal file
144
api/bolt/datastore.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// Store defines the implementation of portainer.DataStore using
|
||||
// BoltDB as the storage system.
|
||||
type Store struct {
|
||||
// Path where is stored the BoltDB database.
|
||||
Path string
|
||||
|
||||
// Services
|
||||
UserService *UserService
|
||||
TeamService *TeamService
|
||||
TeamMembershipService *TeamMembershipService
|
||||
EndpointService *EndpointService
|
||||
ResourceControlService *ResourceControlService
|
||||
VersionService *VersionService
|
||||
SettingsService *SettingsService
|
||||
RegistryService *RegistryService
|
||||
DockerHubService *DockerHubService
|
||||
|
||||
db *bolt.DB
|
||||
checkForDataMigration bool
|
||||
}
|
||||
|
||||
const (
|
||||
databaseFileName = "portainer.db"
|
||||
versionBucketName = "version"
|
||||
userBucketName = "users"
|
||||
teamBucketName = "teams"
|
||||
teamMembershipBucketName = "team_membership"
|
||||
endpointBucketName = "endpoints"
|
||||
resourceControlBucketName = "resource_control"
|
||||
settingsBucketName = "settings"
|
||||
registryBucketName = "registries"
|
||||
dockerhubBucketName = "dockerhub"
|
||||
)
|
||||
|
||||
// NewStore initializes a new Store and the associated services
|
||||
func NewStore(storePath string) (*Store, error) {
|
||||
store := &Store{
|
||||
Path: storePath,
|
||||
UserService: &UserService{},
|
||||
TeamService: &TeamService{},
|
||||
TeamMembershipService: &TeamMembershipService{},
|
||||
EndpointService: &EndpointService{},
|
||||
ResourceControlService: &ResourceControlService{},
|
||||
VersionService: &VersionService{},
|
||||
SettingsService: &SettingsService{},
|
||||
RegistryService: &RegistryService{},
|
||||
DockerHubService: &DockerHubService{},
|
||||
}
|
||||
store.UserService.store = store
|
||||
store.TeamService.store = store
|
||||
store.TeamMembershipService.store = store
|
||||
store.EndpointService.store = store
|
||||
store.ResourceControlService.store = store
|
||||
store.VersionService.store = store
|
||||
store.SettingsService.store = store
|
||||
store.RegistryService.store = store
|
||||
store.DockerHubService.store = store
|
||||
|
||||
_, err := os.Stat(storePath + "/" + databaseFileName)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
store.checkForDataMigration = false
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
store.checkForDataMigration = true
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Open opens and initializes the BoltDB database.
|
||||
func (store *Store) Open() error {
|
||||
path := store.Path + "/" + databaseFileName
|
||||
|
||||
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.db = db
|
||||
|
||||
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
|
||||
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
||||
registryBucketName, dockerhubBucketName}
|
||||
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
|
||||
for _, bucket := range bucketsToCreate {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(bucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Close closes the BoltDB database.
|
||||
func (store *Store) Close() error {
|
||||
if store.db != nil {
|
||||
return store.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateData automatically migrate the data based on the DBVersion.
|
||||
func (store *Store) MigrateData() error {
|
||||
if !store.checkForDataMigration {
|
||||
err := store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
version, err := store.VersionService.DBVersion()
|
||||
if err == portainer.ErrDBVersionNotFound {
|
||||
version = 0
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if version < portainer.DBVersion {
|
||||
log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion)
|
||||
migrator := NewMigrator(store, version)
|
||||
err = migrator.Migrate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
61
api/bolt/dockerhub_service.go
Normal file
61
api/bolt/dockerhub_service.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// DockerHubService represents a service for managing registries.
|
||||
type DockerHubService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
const (
|
||||
dbDockerHubKey = "DOCKERHUB"
|
||||
)
|
||||
|
||||
// DockerHub returns the DockerHub object.
|
||||
func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(dockerhubBucketName))
|
||||
value := bucket.Get([]byte(dbDockerHubKey))
|
||||
if value == nil {
|
||||
return portainer.ErrDockerHubNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dockerhub portainer.DockerHub
|
||||
err = internal.UnmarshalDockerHub(data, &dockerhub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dockerhub, nil
|
||||
}
|
||||
|
||||
// StoreDockerHub persists a DockerHub object.
|
||||
func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(dockerhubBucketName))
|
||||
|
||||
data, err := internal.MarshalDockerHub(dockerhub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put([]byte(dbDockerHubKey), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
154
api/bolt/endpoint_service.go
Normal file
154
api/bolt/endpoint_service.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// EndpointService represents a service for managing endpoints.
|
||||
type EndpointService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Endpoint returns an endpoint by ID.
|
||||
func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrEndpointNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var endpoint portainer.Endpoint
|
||||
err = internal.UnmarshalEndpoint(data, &endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &endpoint, nil
|
||||
}
|
||||
|
||||
// Endpoints return an array containing all the endpoints.
|
||||
func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) {
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var endpoint portainer.Endpoint
|
||||
err := internal.UnmarshalEndpoint(v, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoints = append(endpoints, endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// Synchronize creates, updates and deletes endpoints inside a single transaction.
|
||||
func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
|
||||
for _, endpoint := range toCreate {
|
||||
err := storeNewEndpoint(endpoint, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, endpoint := range toUpdate {
|
||||
err := marshalAndStoreEndpoint(endpoint, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, endpoint := range toDelete {
|
||||
err := bucket.Delete(internal.Itob(int(endpoint.ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateEndpoint assign an ID to a new endpoint and saves it.
|
||||
func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
err := storeNewEndpoint(endpoint, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateEndpoint updates an endpoint.
|
||||
func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
|
||||
data, err := internal.MarshalEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteEndpoint deletes an endpoint.
|
||||
func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(endpointBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
|
||||
data, err := internal.MarshalEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(endpoint.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
|
||||
id, _ := bucket.NextSequence()
|
||||
endpoint.ID = portainer.EndpointID(id)
|
||||
return marshalAndStoreEndpoint(endpoint, bucket)
|
||||
}
|
||||
97
api/bolt/internal/internal.go
Normal file
97
api/bolt/internal/internal.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// MarshalUser encodes a user to binary format.
|
||||
func MarshalUser(user *portainer.User) ([]byte, error) {
|
||||
return json.Marshal(user)
|
||||
}
|
||||
|
||||
// UnmarshalUser decodes a user from a binary data.
|
||||
func UnmarshalUser(data []byte, user *portainer.User) error {
|
||||
return json.Unmarshal(data, user)
|
||||
}
|
||||
|
||||
// MarshalTeam encodes a team to binary format.
|
||||
func MarshalTeam(team *portainer.Team) ([]byte, error) {
|
||||
return json.Marshal(team)
|
||||
}
|
||||
|
||||
// UnmarshalTeam decodes a team from a binary data.
|
||||
func UnmarshalTeam(data []byte, team *portainer.Team) error {
|
||||
return json.Unmarshal(data, team)
|
||||
}
|
||||
|
||||
// MarshalTeamMembership encodes a team membership to binary format.
|
||||
func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) {
|
||||
return json.Marshal(membership)
|
||||
}
|
||||
|
||||
// UnmarshalTeamMembership decodes a team membership from a binary data.
|
||||
func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error {
|
||||
return json.Unmarshal(data, membership)
|
||||
}
|
||||
|
||||
// MarshalEndpoint encodes an endpoint to binary format.
|
||||
func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) {
|
||||
return json.Marshal(endpoint)
|
||||
}
|
||||
|
||||
// UnmarshalEndpoint decodes an endpoint from a binary data.
|
||||
func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
|
||||
return json.Unmarshal(data, endpoint)
|
||||
}
|
||||
|
||||
// MarshalRegistry encodes a registry to binary format.
|
||||
func MarshalRegistry(registry *portainer.Registry) ([]byte, error) {
|
||||
return json.Marshal(registry)
|
||||
}
|
||||
|
||||
// UnmarshalRegistry decodes a registry from a binary data.
|
||||
func UnmarshalRegistry(data []byte, registry *portainer.Registry) error {
|
||||
return json.Unmarshal(data, registry)
|
||||
}
|
||||
|
||||
// MarshalResourceControl encodes a resource control object to binary format.
|
||||
func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) {
|
||||
return json.Marshal(rc)
|
||||
}
|
||||
|
||||
// UnmarshalResourceControl decodes a resource control object from a binary data.
|
||||
func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error {
|
||||
return json.Unmarshal(data, rc)
|
||||
}
|
||||
|
||||
// MarshalSettings encodes a settings object to binary format.
|
||||
func MarshalSettings(settings *portainer.Settings) ([]byte, error) {
|
||||
return json.Marshal(settings)
|
||||
}
|
||||
|
||||
// UnmarshalSettings decodes a settings object from a binary data.
|
||||
func UnmarshalSettings(data []byte, settings *portainer.Settings) error {
|
||||
return json.Unmarshal(data, settings)
|
||||
}
|
||||
|
||||
// MarshalDockerHub encodes a Dockerhub object to binary format.
|
||||
func MarshalDockerHub(settings *portainer.DockerHub) ([]byte, error) {
|
||||
return json.Marshal(settings)
|
||||
}
|
||||
|
||||
// UnmarshalDockerHub decodes a Dockerhub object from a binary data.
|
||||
func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error {
|
||||
return json.Unmarshal(data, settings)
|
||||
}
|
||||
|
||||
// Itob returns an 8-byte big endian representation of v.
|
||||
// This function is typically used for encoding integer IDs to byte slices
|
||||
// so that they can be used as BoltDB keys.
|
||||
func Itob(v int) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(v))
|
||||
return b
|
||||
}
|
||||
39
api/bolt/migrate_dbversion0.go
Normal file
39
api/bolt/migrate_dbversion0.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateAdminUserToDBVersion1() error {
|
||||
u, err := m.UserService.UserByUsername("admin")
|
||||
if err == nil {
|
||||
admin := &portainer.User{
|
||||
Username: "admin",
|
||||
Password: u.Password,
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
err = m.UserService.CreateUser(admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.removeLegacyAdminUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil && err != portainer.ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) removeLegacyAdminUser() error {
|
||||
return m.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
err := bucket.Delete([]byte("admin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
103
api/bolt/migrate_dbversion1.go
Normal file
103
api/bolt/migrate_dbversion1.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateResourceControlsToDBVersion2() error {
|
||||
legacyResourceControls, err := m.retrieveLegacyResourceControls()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, resourceControl := range legacyResourceControls {
|
||||
resourceControl.SubResourceIDs = []string{}
|
||||
resourceControl.TeamAccesses = []portainer.TeamResourceAccess{}
|
||||
|
||||
owner, err := m.UserService.User(resourceControl.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if owner.Role == portainer.AdministratorRole {
|
||||
resourceControl.AdministratorsOnly = true
|
||||
resourceControl.UserAccesses = []portainer.UserResourceAccess{}
|
||||
} else {
|
||||
resourceControl.AdministratorsOnly = false
|
||||
userAccess := portainer.UserResourceAccess{
|
||||
UserID: resourceControl.OwnerID,
|
||||
AccessLevel: portainer.ReadWriteAccessLevel,
|
||||
}
|
||||
resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess}
|
||||
}
|
||||
|
||||
err = m.ResourceControlService.CreateResourceControl(&resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateEndpointsToDBVersion2() error {
|
||||
legacyEndpoints, err := m.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range legacyEndpoints {
|
||||
endpoint.AuthorizedTeams = []portainer.TeamID{}
|
||||
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) {
|
||||
legacyResourceControls := make([]portainer.ResourceControl, 0)
|
||||
err := m.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte("containerResourceControl"))
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var resourceControl portainer.ResourceControl
|
||||
err := internal.UnmarshalResourceControl(v, &resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceControl.Type = portainer.ContainerResourceControl
|
||||
legacyResourceControls = append(legacyResourceControls, resourceControl)
|
||||
}
|
||||
|
||||
bucket = tx.Bucket([]byte("serviceResourceControl"))
|
||||
cursor = bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var resourceControl portainer.ResourceControl
|
||||
err := internal.UnmarshalResourceControl(v, &resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceControl.Type = portainer.ServiceResourceControl
|
||||
legacyResourceControls = append(legacyResourceControls, resourceControl)
|
||||
}
|
||||
|
||||
bucket = tx.Bucket([]byte("volumeResourceControl"))
|
||||
cursor = bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var resourceControl portainer.ResourceControl
|
||||
err := internal.UnmarshalResourceControl(v, &resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceControl.Type = portainer.VolumeResourceControl
|
||||
legacyResourceControls = append(legacyResourceControls, resourceControl)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return legacyResourceControls, err
|
||||
}
|
||||
25
api/bolt/migrate_dbversion2.go
Normal file
25
api/bolt/migrate_dbversion2.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func (m *Migrator) updateSettingsToVersion3() error {
|
||||
legacySettings, err := m.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacySettings.AuthenticationMethod = portainer.AuthenticationInternal
|
||||
legacySettings.LDAPSettings = portainer.LDAPSettings{
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
SearchSettings: []portainer.LDAPSearchSettings{
|
||||
portainer.LDAPSearchSettings{},
|
||||
},
|
||||
}
|
||||
|
||||
err = m.SettingsService.StoreSettings(legacySettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
65
api/bolt/migrator.go
Normal file
65
api/bolt/migrator.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
type Migrator struct {
|
||||
UserService *UserService
|
||||
EndpointService *EndpointService
|
||||
ResourceControlService *ResourceControlService
|
||||
SettingsService *SettingsService
|
||||
VersionService *VersionService
|
||||
CurrentDBVersion int
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewMigrator creates a new Migrator.
|
||||
func NewMigrator(store *Store, version int) *Migrator {
|
||||
return &Migrator{
|
||||
UserService: store.UserService,
|
||||
EndpointService: store.EndpointService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
SettingsService: store.SettingsService,
|
||||
VersionService: store.VersionService,
|
||||
CurrentDBVersion: version,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate checks the database version and migrate the existing data to the most recent data model.
|
||||
func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer < 1.12
|
||||
if m.CurrentDBVersion == 0 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.CurrentDBVersion == 1 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.updateEndpointsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.CurrentDBVersion == 2 {
|
||||
err := m.updateSettingsToVersion3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
114
api/bolt/registry_service.go
Normal file
114
api/bolt/registry_service.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// RegistryService represents a service for managing registries.
|
||||
type RegistryService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Registry returns an registry by ID.
|
||||
func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(registryBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var registry portainer.Registry
|
||||
err = internal.UnmarshalRegistry(data, ®istry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// Registries returns an array containing all the registries.
|
||||
func (service *RegistryService) Registries() ([]portainer.Registry, error) {
|
||||
var registries = make([]portainer.Registry, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(registryBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var registry portainer.Registry
|
||||
err := internal.UnmarshalRegistry(v, ®istry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registries = append(registries, registry)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
// CreateRegistry creates a new registry.
|
||||
func (service *RegistryService) CreateRegistry(registry *portainer.Registry) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(registryBucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
registry.ID = portainer.RegistryID(id)
|
||||
|
||||
data, err := internal.MarshalRegistry(registry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(registry.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRegistry updates an registry.
|
||||
func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error {
|
||||
data, err := internal.MarshalRegistry(registry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(registryBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRegistry deletes an registry.
|
||||
func (service *RegistryService) DeleteRegistry(ID portainer.RegistryID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(registryBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
148
api/bolt/resource_control_service.go
Normal file
148
api/bolt/resource_control_service.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// ResourceControlService represents a service for managing resource controls.
|
||||
type ResourceControlService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// ResourceControl returns a ResourceControl object by ID
|
||||
func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrResourceControlNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resourceControl portainer.ResourceControl
|
||||
err = internal.UnmarshalResourceControl(data, &resourceControl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resourceControl, nil
|
||||
}
|
||||
|
||||
// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal
|
||||
// to the main ResourceID or in SubResourceIDs
|
||||
func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) {
|
||||
var resourceControl *portainer.ResourceControl
|
||||
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var rc portainer.ResourceControl
|
||||
err := internal.UnmarshalResourceControl(v, &rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rc.ResourceID == resourceID {
|
||||
resourceControl = &rc
|
||||
}
|
||||
for _, subResourceID := range rc.SubResourceIDs {
|
||||
if subResourceID == resourceID {
|
||||
resourceControl = &rc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resourceControl == nil {
|
||||
return portainer.ErrResourceControlNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resourceControl, nil
|
||||
}
|
||||
|
||||
// ResourceControls returns all the ResourceControl objects
|
||||
func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) {
|
||||
var rcs = make([]portainer.ResourceControl, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var resourceControl portainer.ResourceControl
|
||||
err := internal.UnmarshalResourceControl(v, &resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcs = append(rcs, resourceControl)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rcs, nil
|
||||
}
|
||||
|
||||
// CreateResourceControl creates a new ResourceControl object
|
||||
func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
id, _ := bucket.NextSequence()
|
||||
resourceControl.ID = portainer.ResourceControlID(id)
|
||||
data, err := internal.MarshalResourceControl(resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(resourceControl.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateResourceControl saves a ResourceControl object.
|
||||
func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error {
|
||||
data, err := internal.MarshalResourceControl(resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteResourceControl deletes a ResourceControl object by ID
|
||||
func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(resourceControlBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
61
api/bolt/settings_service.go
Normal file
61
api/bolt/settings_service.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// SettingsService represents a service to manage application settings.
|
||||
type SettingsService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
const (
|
||||
dbSettingsKey = "SETTINGS"
|
||||
)
|
||||
|
||||
// Settings retrieve the settings object.
|
||||
func (service *SettingsService) Settings() (*portainer.Settings, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(settingsBucketName))
|
||||
value := bucket.Get([]byte(dbSettingsKey))
|
||||
if value == nil {
|
||||
return portainer.ErrSettingsNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings portainer.Settings
|
||||
err = internal.UnmarshalSettings(data, &settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// StoreSettings persists a Settings object.
|
||||
func (service *SettingsService) StoreSettings(settings *portainer.Settings) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(settingsBucketName))
|
||||
|
||||
data, err := internal.MarshalSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put([]byte(dbSettingsKey), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
217
api/bolt/team_membership_service.go
Normal file
217
api/bolt/team_membership_service.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// TeamMembershipService represents a service for managing TeamMembership objects.
|
||||
type TeamMembershipService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// TeamMembership returns a TeamMembership object by ID
|
||||
func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrTeamMembershipNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var membership portainer.TeamMembership
|
||||
err = internal.UnmarshalTeamMembership(data, &membership)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
// TeamMemberships return an array containing all the TeamMembership objects.
|
||||
func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) {
|
||||
var memberships = make([]portainer.TeamMembership, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var membership portainer.TeamMembership
|
||||
err := internal.UnmarshalTeamMembership(v, &membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberships = append(memberships, membership)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present.
|
||||
func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) {
|
||||
var memberships = make([]portainer.TeamMembership, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var membership portainer.TeamMembership
|
||||
err := internal.UnmarshalTeamMembership(v, &membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if membership.UserID == userID {
|
||||
memberships = append(memberships, membership)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present.
|
||||
func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) {
|
||||
var memberships = make([]portainer.TeamMembership, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var membership portainer.TeamMembership
|
||||
err := internal.UnmarshalTeamMembership(v, &membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if membership.TeamID == teamID {
|
||||
memberships = append(memberships, membership)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// UpdateTeamMembership saves a TeamMembership object.
|
||||
func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error {
|
||||
data, err := internal.MarshalTeamMembership(membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTeamMembership creates a new TeamMembership object.
|
||||
func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
membership.ID = portainer.TeamMembershipID(id)
|
||||
|
||||
data, err := internal.MarshalTeamMembership(membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(membership.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeamMembership deletes a TeamMembership object.
|
||||
func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID.
|
||||
func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var membership portainer.TeamMembership
|
||||
err := internal.UnmarshalTeamMembership(v, &membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if membership.UserID == userID {
|
||||
err := bucket.Delete(internal.Itob(int(membership.ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID.
|
||||
func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamMembershipBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var membership portainer.TeamMembership
|
||||
err := internal.UnmarshalTeamMembership(v, &membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if membership.TeamID == teamID {
|
||||
err := bucket.Delete(internal.Itob(int(membership.ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
144
api/bolt/team_service.go
Normal file
144
api/bolt/team_service.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// TeamService represents a service for managing teams.
|
||||
type TeamService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Team returns a Team by ID
|
||||
func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrTeamNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var team portainer.Team
|
||||
err = internal.UnmarshalTeam(data, &team)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
// TeamByName returns a team by name.
|
||||
func (service *TeamService) TeamByName(name string) (*portainer.Team, error) {
|
||||
var team *portainer.Team
|
||||
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var t portainer.Team
|
||||
err := internal.UnmarshalTeam(v, &t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Name == name {
|
||||
team = &t
|
||||
}
|
||||
}
|
||||
|
||||
if team == nil {
|
||||
return portainer.ErrTeamNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
// Teams return an array containing all the teams.
|
||||
func (service *TeamService) Teams() ([]portainer.Team, error) {
|
||||
var teams = make([]portainer.Team, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var team portainer.Team
|
||||
err := internal.UnmarshalTeam(v, &team)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teams = append(teams, team)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// UpdateTeam saves a Team.
|
||||
func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error {
|
||||
data, err := internal.MarshalTeam(team)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTeam creates a new Team.
|
||||
func (service *TeamService) CreateTeam(team *portainer.Team) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
team.ID = portainer.TeamID(id)
|
||||
|
||||
data, err := internal.MarshalTeam(team)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(team.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeam deletes a Team.
|
||||
func (service *TeamService) DeleteTeam(ID portainer.TeamID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(teamBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
170
api/bolt/user_service.go
Normal file
170
api/bolt/user_service.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// UserService represents a service for managing users.
|
||||
type UserService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// User returns a user by ID
|
||||
func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
value := bucket.Get(internal.Itob(int(ID)))
|
||||
if value == nil {
|
||||
return portainer.ErrUserNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user portainer.User
|
||||
err = internal.UnmarshalUser(data, &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UserByUsername returns a user by username.
|
||||
func (service *UserService) UserByUsername(username string) (*portainer.User, error) {
|
||||
var user *portainer.User
|
||||
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var u portainer.User
|
||||
err := internal.UnmarshalUser(v, &u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Username == username {
|
||||
user = &u
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return portainer.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Users return an array containing all the users.
|
||||
func (service *UserService) Users() ([]portainer.User, error) {
|
||||
var users = make([]portainer.User, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var user portainer.User
|
||||
err := internal.UnmarshalUser(v, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UsersByRole return an array containing all the users with the specified role.
|
||||
func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||
var users = make([]portainer.User, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var user portainer.User
|
||||
err := internal.UnmarshalUser(v, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Role == role {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateUser saves a user.
|
||||
func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error {
|
||||
data, err := internal.MarshalUser(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
err = bucket.Put(internal.Itob(int(ID)), data)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (service *UserService) CreateUser(user *portainer.User) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
user.ID = portainer.UserID(id)
|
||||
|
||||
data, err := internal.MarshalUser(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put(internal.Itob(int(user.ID)), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user.
|
||||
func (service *UserService) DeleteUser(ID portainer.UserID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(userBucketName))
|
||||
err := bucket.Delete(internal.Itob(int(ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
58
api/bolt/version_service.go
Normal file
58
api/bolt/version_service.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// VersionService represents a service to manage stored versions.
|
||||
type VersionService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
const (
|
||||
dBVersionKey = "DB_VERSION"
|
||||
)
|
||||
|
||||
// DBVersion retrieves the stored database version.
|
||||
func (service *VersionService) DBVersion() (int, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(versionBucketName))
|
||||
value := bucket.Get([]byte(dBVersionKey))
|
||||
if value == nil {
|
||||
return portainer.ErrDBVersionNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
dbVersion, err := strconv.Atoi(string(data))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return dbVersion, nil
|
||||
}
|
||||
|
||||
// StoreDBVersion store the database version.
|
||||
func (service *VersionService) StoreDBVersion(version int) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(versionBucketName))
|
||||
|
||||
data := []byte(strconv.Itoa(version))
|
||||
err := bucket.Put([]byte(dBVersionKey), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
140
api/cli/cli.go
Normal file
140
api/cli/cli.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
// Service implements the CLIService interface
|
||||
type Service struct{}
|
||||
|
||||
const (
|
||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
||||
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
||||
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password")
|
||||
)
|
||||
|
||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||
func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
kingpin.Version(version)
|
||||
|
||||
flags := &portainer.CLIFlags{
|
||||
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
|
||||
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
|
||||
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
|
||||
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(),
|
||||
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(),
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
||||
// Deprecated flags
|
||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
|
||||
}
|
||||
|
||||
kingpin.Parse()
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
// ValidateFlags validates the values of the flags.
|
||||
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
|
||||
if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" {
|
||||
return errEndpointExcludeExternal
|
||||
}
|
||||
|
||||
err := validateEndpoint(*flags.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateExternalEndpoints(*flags.ExternalEndpoints)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateSyncInterval(*flags.SyncInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *flags.NoAuth && (*flags.AdminPassword != "") {
|
||||
return errNoAuthExcludeAdminPassword
|
||||
}
|
||||
|
||||
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEndpoint(endpoint string) error {
|
||||
if endpoint != "" {
|
||||
if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") {
|
||||
return errInvalidEndpointProtocol
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint, "unix://") {
|
||||
socketPath := strings.TrimPrefix(endpoint, "unix://")
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errSocketNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExternalEndpoints(externalEndpoints string) error {
|
||||
if externalEndpoints != "" {
|
||||
if _, err := os.Stat(externalEndpoints); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errEndpointsFileNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSyncInterval(syncInterval string) error {
|
||||
if syncInterval != defaultSyncInterval {
|
||||
_, err := time.ParseDuration(syncInterval)
|
||||
if err != nil {
|
||||
return errInvalidSyncInterval
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeprecationWarnings(templates, logo string, labels []portainer.Pair) {
|
||||
if templates != "" {
|
||||
log.Println("Warning: the --templates / -t flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
if logo != "" {
|
||||
log.Println("Warning: the --logo flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
if labels != nil {
|
||||
log.Println("Warning: the --hide-label / -l flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
}
|
||||
19
api/cli/defaults.go
Normal file
19
api/cli/defaults.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// +build !windows
|
||||
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "."
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "/certs/portainer.crt"
|
||||
defaultSSLKeyPath = "/certs/portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
)
|
||||
17
api/cli/defaults_windows.go
Normal file
17
api/cli/defaults_windows.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "."
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
||||
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
)
|
||||
40
api/cli/pairlist.go
Normal file
40
api/cli/pairlist.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"fmt"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type pairList []portainer.Pair
|
||||
|
||||
// Set implementation for a list of portainer.Pair
|
||||
func (l *pairList) Set(value string) error {
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("expected NAME=VALUE got '%s'", value)
|
||||
}
|
||||
p := new(portainer.Pair)
|
||||
p.Name = parts[0]
|
||||
p.Value = parts[1]
|
||||
*l = append(*l, *p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// String implementation for a list of pair
|
||||
func (l *pairList) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsCumulative implementation for a list of pair
|
||||
func (l *pairList) IsCumulative() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func pairs(s kingpin.Settings) (target *[]portainer.Pair) {
|
||||
target = new([]portainer.Pair)
|
||||
s.SetValue((*pairList)(target))
|
||||
return
|
||||
}
|
||||
254
api/cmd/portainer/main.go
Normal file
254
api/cmd/portainer/main.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package main // import "github.com/portainer/portainer"
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt"
|
||||
"github.com/portainer/portainer/cli"
|
||||
"github.com/portainer/portainer/cron"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"github.com/portainer/portainer/file"
|
||||
"github.com/portainer/portainer/http"
|
||||
"github.com/portainer/portainer/jwt"
|
||||
"github.com/portainer/portainer/ldap"
|
||||
|
||||
"log"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
var cli portainer.CLIService = &cli.Service{}
|
||||
flags, err := cli.ParseFlags(portainer.APIVersion)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = cli.ValidateFlags(flags)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func initFileService(dataStorePath string) portainer.FileService {
|
||||
fileService, err := file.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return fileService
|
||||
}
|
||||
|
||||
func initStore(dataStorePath string) *bolt.Store {
|
||||
store, err := bolt.NewStore(dataStorePath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.MigrateData()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
if authenticationEnabled {
|
||||
jwtService, err := jwt.NewService()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return jwtService
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initCryptoService() portainer.CryptoService {
|
||||
return &crypto.Service{}
|
||||
}
|
||||
|
||||
func initLDAPService() portainer.LDAPService {
|
||||
return &ldap.Service{}
|
||||
}
|
||||
|
||||
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
|
||||
authorizeEndpointMgmt := true
|
||||
if externalEnpointFile != "" {
|
||||
authorizeEndpointMgmt = false
|
||||
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
|
||||
endpointWatcher := cron.NewWatcher(endpointService, syncInterval)
|
||||
err := endpointWatcher.WatchEndpointFile(externalEnpointFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
return authorizeEndpointMgmt
|
||||
}
|
||||
|
||||
func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status {
|
||||
return &portainer.Status{
|
||||
Analytics: !*flags.NoAnalytics,
|
||||
Authentication: !*flags.NoAuth,
|
||||
EndpointManagement: authorizeEndpointMgmt,
|
||||
Version: portainer.APIVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func initDockerHub(dockerHubService portainer.DockerHubService) error {
|
||||
_, err := dockerHubService.DockerHub()
|
||||
if err == portainer.ErrDockerHubNotFound {
|
||||
dockerhub := &portainer.DockerHub{
|
||||
Authentication: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
}
|
||||
return dockerHubService.StoreDockerHub(dockerhub)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error {
|
||||
_, err := settingsService.Settings()
|
||||
if err == portainer.ErrSettingsNotFound {
|
||||
settings := &portainer.Settings{
|
||||
LogoURL: *flags.Logo,
|
||||
DisplayExternalContributors: true,
|
||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||
LDAPSettings: portainer.LDAPSettings{
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
SearchSettings: []portainer.LDAPSearchSettings{
|
||||
portainer.LDAPSearchSettings{},
|
||||
},
|
||||
},
|
||||
Language: "en",
|
||||
}
|
||||
|
||||
if *flags.Templates != "" {
|
||||
settings.TemplatesURL = *flags.Templates
|
||||
} else {
|
||||
settings.TemplatesURL = portainer.DefaultTemplatesURL
|
||||
}
|
||||
|
||||
if *flags.Labels != nil {
|
||||
settings.BlackListedLabels = *flags.Labels
|
||||
} else {
|
||||
settings.BlackListedLabels = make([]portainer.Pair, 0)
|
||||
}
|
||||
|
||||
return settingsService.StoreSettings(settings)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
|
||||
endpoints, err := endpointService.Endpoints()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return &endpoints[0]
|
||||
}
|
||||
|
||||
func main() {
|
||||
flags := initCLI()
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
|
||||
store := initStore(*flags.Data)
|
||||
defer store.Close()
|
||||
|
||||
jwtService := initJWTService(!*flags.NoAuth)
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
ldapService := initLDAPService()
|
||||
|
||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
||||
|
||||
err := initSettings(store.SettingsService, flags)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = initDockerHub(store.DockerHubService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
|
||||
|
||||
if *flags.Endpoint != "" {
|
||||
var endpoints []portainer.Endpoint
|
||||
endpoints, err := store.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: *flags.Endpoint,
|
||||
TLS: *flags.TLSVerify,
|
||||
TLSCACertPath: *flags.TLSCacert,
|
||||
TLSCertPath: *flags.TLSCert,
|
||||
TLSKeyPath: *flags.TLSKey,
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
err = store.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
|
||||
}
|
||||
}
|
||||
|
||||
if *flags.AdminPassword != "" {
|
||||
log.Printf("Creating admin user with password hash %s", *flags.AdminPassword)
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: *flags.AdminPassword,
|
||||
}
|
||||
err := store.UserService.CreateUser(user)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var server portainer.Server = &http.Server{
|
||||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
AssetsPath: *flags.Assets,
|
||||
AuthDisabled: *flags.NoAuth,
|
||||
EndpointManagement: authorizeEndpointMgmt,
|
||||
UserService: store.UserService,
|
||||
TeamService: store.TeamService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
EndpointService: store.EndpointService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
SettingsService: store.SettingsService,
|
||||
RegistryService: store.RegistryService,
|
||||
DockerHubService: store.DockerHubService,
|
||||
CryptoService: cryptoService,
|
||||
JWTService: jwtService,
|
||||
FileService: fileService,
|
||||
LDAPService: ldapService,
|
||||
SSL: *flags.SSL,
|
||||
SSLCert: *flags.SSLCert,
|
||||
SSLKey: *flags.SSLKey,
|
||||
}
|
||||
|
||||
log.Printf("Starting Portainer on %s", *flags.Addr)
|
||||
err = server.Start()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
174
api/cron/endpoint_sync.go
Normal file
174
api/cron/endpoint_sync.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type (
|
||||
endpointSyncJob struct {
|
||||
logger *log.Logger
|
||||
endpointService portainer.EndpointService
|
||||
endpointFilePath string
|
||||
}
|
||||
|
||||
synchronization struct {
|
||||
endpointsToCreate []*portainer.Endpoint
|
||||
endpointsToUpdate []*portainer.Endpoint
|
||||
endpointsToDelete []*portainer.Endpoint
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty.
|
||||
ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty")
|
||||
)
|
||||
|
||||
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
|
||||
return endpointSyncJob{
|
||||
logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
endpointService: endpointService,
|
||||
endpointFilePath: endpointFilePath,
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSyncError(err error, logger *log.Logger) bool {
|
||||
if err != nil {
|
||||
logger.Printf("Endpoint synchronization error: %s", err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
if endpoint.Name != "" && endpoint.URL != "" {
|
||||
if !strings.HasPrefix(endpoint.URL, "unix://") && !strings.HasPrefix(endpoint.URL, "tcp://") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int {
|
||||
for idx, v := range endpoints {
|
||||
if endpoint.Name == v.Name && isValidEndpoint(&v) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint {
|
||||
var endpoint *portainer.Endpoint
|
||||
if original.URL != updated.URL || original.TLS != updated.TLS ||
|
||||
(updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) ||
|
||||
(updated.TLS && original.TLSCertPath != updated.TLSCertPath) ||
|
||||
(updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) {
|
||||
endpoint = original
|
||||
endpoint.URL = updated.URL
|
||||
if updated.TLS {
|
||||
endpoint.TLS = true
|
||||
endpoint.TLSCACertPath = updated.TLSCACertPath
|
||||
endpoint.TLSCertPath = updated.TLSCertPath
|
||||
endpoint.TLSKeyPath = updated.TLSKeyPath
|
||||
} else {
|
||||
endpoint.TLS = false
|
||||
endpoint.TLSCACertPath = ""
|
||||
endpoint.TLSCertPath = ""
|
||||
endpoint.TLSKeyPath = ""
|
||||
}
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func (sync synchronization) requireSync() bool {
|
||||
if len(sync.endpointsToCreate) != 0 || len(sync.endpointsToUpdate) != 0 || len(sync.endpointsToDelete) != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TMP: endpointSyncJob method to access logger, should be generic
|
||||
func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization {
|
||||
endpointsToCreate := make([]*portainer.Endpoint, 0)
|
||||
endpointsToUpdate := make([]*portainer.Endpoint, 0)
|
||||
endpointsToDelete := make([]*portainer.Endpoint, 0)
|
||||
|
||||
for idx := range storedEndpoints {
|
||||
fidx := endpointExists(&storedEndpoints[idx], fileEndpoints)
|
||||
if fidx != -1 {
|
||||
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
|
||||
if endpoint != nil {
|
||||
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
|
||||
endpointsToUpdate = append(endpointsToUpdate, endpoint)
|
||||
} else {
|
||||
job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
||||
}
|
||||
} else {
|
||||
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
||||
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
|
||||
}
|
||||
}
|
||||
|
||||
for idx, endpoint := range fileEndpoints {
|
||||
if !isValidEndpoint(&endpoint) {
|
||||
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
|
||||
continue
|
||||
}
|
||||
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
|
||||
if sidx == -1 {
|
||||
job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
|
||||
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
|
||||
}
|
||||
}
|
||||
|
||||
return &synchronization{
|
||||
endpointsToCreate: endpointsToCreate,
|
||||
endpointsToUpdate: endpointsToUpdate,
|
||||
endpointsToDelete: endpointsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func (job endpointSyncJob) Sync() error {
|
||||
data, err := ioutil.ReadFile(job.endpointFilePath)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileEndpoints []portainer.Endpoint
|
||||
err = json.Unmarshal(data, &fileEndpoints)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(fileEndpoints) == 0 {
|
||||
return ErrEmptyEndpointArray
|
||||
}
|
||||
|
||||
storedEndpoints, err := job.endpointService.Endpoints()
|
||||
if endpointSyncError(err, job.logger) {
|
||||
return err
|
||||
}
|
||||
|
||||
sync := job.prepareSyncData(storedEndpoints, fileEndpoints)
|
||||
if sync.requireSync() {
|
||||
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
return err
|
||||
}
|
||||
job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (job endpointSyncJob) Run() {
|
||||
job.logger.Println("Endpoint synchronization job started.")
|
||||
err := job.Sync()
|
||||
endpointSyncError(err, job.logger)
|
||||
}
|
||||
40
api/cron/watcher.go
Normal file
40
api/cron/watcher.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/robfig/cron"
|
||||
)
|
||||
|
||||
// Watcher represents a service for managing crons.
|
||||
type Watcher struct {
|
||||
Cron *cron.Cron
|
||||
EndpointService portainer.EndpointService
|
||||
syncInterval string
|
||||
}
|
||||
|
||||
// NewWatcher initializes a new service.
|
||||
func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher {
|
||||
return &Watcher{
|
||||
Cron: cron.New(),
|
||||
EndpointService: endpointService,
|
||||
syncInterval: syncInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// WatchEndpointFile starts a cron job to synchronize the endpoints from a file
|
||||
func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error {
|
||||
job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService)
|
||||
|
||||
err := job.Sync()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
watcher.Cron.Start()
|
||||
return nil
|
||||
}
|
||||
22
api/crypto/crypto.go
Normal file
22
api/crypto/crypto.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Service represents a service for encrypting/hashing data.
|
||||
type Service struct{}
|
||||
|
||||
// Hash hashes a string using the bcrypt algorithm
|
||||
func (*Service) Hash(data string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
||||
func (*Service) CompareHashAndData(hash string, data string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
||||
}
|
||||
34
api/crypto/tls.go
Normal file
34
api/crypto/tls.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) {
|
||||
|
||||
config := &tls.Config{}
|
||||
|
||||
if certPath != "" && keyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if caCertPath != "" {
|
||||
caCert, err := ioutil.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
config.InsecureSkipVerify = skipTLSVerify
|
||||
return config, nil
|
||||
}
|
||||
87
api/errors.go
Normal file
87
api/errors.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package portainer
|
||||
|
||||
// General errors.
|
||||
const (
|
||||
ErrUnauthorized = Error("Unauthorized")
|
||||
ErrResourceAccessDenied = Error("Access denied to resource")
|
||||
ErrResourceNotFound = Error("Unable to find resource")
|
||||
ErrUnsupportedDockerAPI = Error("Unsupported Docker API response")
|
||||
ErrMissingSecurityContext = Error("Unable to find security details in request context")
|
||||
)
|
||||
|
||||
// User errors.
|
||||
const (
|
||||
ErrUserNotFound = Error("User not found")
|
||||
ErrUserAlreadyExists = Error("User already exists")
|
||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed.")
|
||||
ErrAdminAlreadyInitialized = Error("Admin user already initialized")
|
||||
)
|
||||
|
||||
// Team errors.
|
||||
const (
|
||||
ErrTeamNotFound = Error("Team not found")
|
||||
ErrTeamAlreadyExists = Error("Team already exists")
|
||||
)
|
||||
|
||||
// TeamMembership errors.
|
||||
const (
|
||||
ErrTeamMembershipNotFound = Error("Team membership not found")
|
||||
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
|
||||
)
|
||||
|
||||
// ResourceControl errors.
|
||||
const (
|
||||
ErrResourceControlNotFound = Error("Resource control not found")
|
||||
ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource")
|
||||
ErrInvalidResourceControlType = Error("Unsupported resource control type")
|
||||
)
|
||||
|
||||
// Endpoint errors.
|
||||
const (
|
||||
ErrEndpointNotFound = Error("Endpoint not found")
|
||||
ErrEndpointAccessDenied = Error("Access denied to endpoint")
|
||||
)
|
||||
|
||||
// Registry errors.
|
||||
const (
|
||||
ErrRegistryNotFound = Error("Registry not found")
|
||||
ErrRegistryAlreadyExists = Error("A registry is already defined for this URL")
|
||||
)
|
||||
|
||||
// Version errors.
|
||||
const (
|
||||
ErrDBVersionNotFound = Error("DB version not found")
|
||||
)
|
||||
|
||||
// Settings errors.
|
||||
const (
|
||||
ErrSettingsNotFound = Error("Settings not found")
|
||||
)
|
||||
|
||||
// DockerHub errors.
|
||||
const (
|
||||
ErrDockerHubNotFound = Error("Dockerhub not found")
|
||||
)
|
||||
|
||||
// Crypto errors.
|
||||
const (
|
||||
ErrCryptoHashFailure = Error("Unable to hash data")
|
||||
)
|
||||
|
||||
// JWT errors.
|
||||
const (
|
||||
ErrSecretGeneration = Error("Unable to generate secret key")
|
||||
ErrInvalidJWTToken = Error("Invalid JWT token")
|
||||
ErrMissingContextData = Error("Unable to find JWT data in request context")
|
||||
)
|
||||
|
||||
// File errors.
|
||||
const (
|
||||
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
|
||||
)
|
||||
|
||||
// Error represents an application error.
|
||||
type Error string
|
||||
|
||||
// Error returns the error message.
|
||||
func (e Error) Error() string { return string(e) }
|
||||
141
api/file/file.go
Normal file
141
api/file/file.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
const (
|
||||
// TLSStorePath represents the subfolder where TLS files are stored in the file store folder.
|
||||
TLSStorePath = "tls"
|
||||
// LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath.
|
||||
LDAPStorePath = "ldap"
|
||||
// TLSCACertFile represents the name on disk for a TLS CA file.
|
||||
TLSCACertFile = "ca.pem"
|
||||
// TLSCertFile represents the name on disk for a TLS certificate file.
|
||||
TLSCertFile = "cert.pem"
|
||||
// TLSKeyFile represents the name on disk for a TLS key file.
|
||||
TLSKeyFile = "key.pem"
|
||||
)
|
||||
|
||||
// Service represents a service for managing files and directories.
|
||||
type Service struct {
|
||||
dataStorePath string
|
||||
fileStorePath string
|
||||
}
|
||||
|
||||
// NewService initializes a new service. It creates a data directory and a directory to store files
|
||||
// inside this directory if they don't exist.
|
||||
func NewService(dataStorePath, fileStorePath string) (*Service, error) {
|
||||
service := &Service{
|
||||
dataStorePath: dataStorePath,
|
||||
fileStorePath: path.Join(dataStorePath, fileStorePath),
|
||||
}
|
||||
|
||||
// Checking if a mount directory exists is broken with Go on Windows.
|
||||
// This will need to be reviewed after the issue has been fixed in Go.
|
||||
// See: https://github.com/portainer/portainer/issues/474
|
||||
// err := createDirectoryIfNotExist(dataStorePath, 0755)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
err := service.createDirectoryInStoreIfNotExist(TLSStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
|
||||
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
|
||||
storePath := path.Join(TLSStorePath, folder)
|
||||
err := service.createDirectoryInStoreIfNotExist(storePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileName string
|
||||
switch fileType {
|
||||
case portainer.TLSFileCA:
|
||||
fileName = TLSCACertFile
|
||||
case portainer.TLSFileCert:
|
||||
fileName = TLSCertFile
|
||||
case portainer.TLSFileKey:
|
||||
fileName = TLSKeyFile
|
||||
default:
|
||||
return portainer.ErrUndefinedTLSFileType
|
||||
}
|
||||
|
||||
tlsFilePath := path.Join(storePath, fileName)
|
||||
err = service.createFileInStore(tlsFilePath, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint.
|
||||
func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) {
|
||||
var fileName string
|
||||
switch fileType {
|
||||
case portainer.TLSFileCA:
|
||||
fileName = TLSCACertFile
|
||||
case portainer.TLSFileCert:
|
||||
fileName = TLSCertFile
|
||||
case portainer.TLSFileKey:
|
||||
fileName = TLSKeyFile
|
||||
default:
|
||||
return "", portainer.ErrUndefinedTLSFileType
|
||||
}
|
||||
return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil
|
||||
}
|
||||
|
||||
// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint.
|
||||
func (service *Service) DeleteTLSFiles(folder string) error {
|
||||
storePath := path.Join(service.fileStorePath, TLSStorePath, folder)
|
||||
err := os.RemoveAll(storePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
|
||||
func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
|
||||
path := path.Join(service.fileStorePath, name)
|
||||
return createDirectoryIfNotExist(path, 0700)
|
||||
}
|
||||
|
||||
// createDirectoryIfNotExist creates a directory if it doesn't exists on the file system.
|
||||
func createDirectoryIfNotExist(path string, mode uint32) error {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
err = os.Mkdir(path, os.FileMode(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createFile creates a new file in the file store with the content from r.
|
||||
func (service *Service) createFileInStore(filePath string, r io.Reader) error {
|
||||
path := path.Join(service.fileStorePath, filePath)
|
||||
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
22
api/http/error/error.go
Normal file
22
api/http/error/error.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package error
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// errorResponse is a generic response for sending a error.
|
||||
type errorResponse struct {
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes an error message to the response and logger.
|
||||
func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) {
|
||||
if logger != nil {
|
||||
logger.Printf("http error: %s (code=%d)", err, code)
|
||||
}
|
||||
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
|
||||
}
|
||||
126
api/http/handler/auth.go
Normal file
126
api/http/handler/auth.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
// AuthHandler represents an HTTP API handler for managing authentication.
|
||||
type AuthHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
authDisabled bool
|
||||
UserService portainer.UserService
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
SettingsService portainer.SettingsService
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrInvalidCredentialsFormat is an error raised when credentials format is not valid
|
||||
ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format")
|
||||
// ErrInvalidCredentials is an error raised when credentials for a user are invalid
|
||||
ErrInvalidCredentials = portainer.Error("Invalid credentials")
|
||||
// ErrAuthDisabled is an error raised when trying to access the authentication endpoints
|
||||
// when the server has been started with the --no-auth flag
|
||||
ErrAuthDisabled = portainer.Error("Authentication is disabled")
|
||||
)
|
||||
|
||||
// NewAuthHandler returns a new instance of AuthHandler.
|
||||
func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler {
|
||||
h := &AuthHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
authDisabled: authDisabled,
|
||||
}
|
||||
h.Handle("/auth",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postAuthRequest struct {
|
||||
Username string `valid:"required"`
|
||||
Password string `valid:"required"`
|
||||
}
|
||||
|
||||
postAuthResponse struct {
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
)
|
||||
|
||||
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if handler.authDisabled {
|
||||
httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postAuthRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var username = req.Username
|
||||
var password = req.Password
|
||||
|
||||
u, err := handler.UserService.UserByUsername(username)
|
||||
if err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
|
||||
err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = handler.CryptoService.CompareHashAndData(u.Password, password)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tokenData := &portainer.TokenData{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
}
|
||||
|
||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger)
|
||||
}
|
||||
94
api/http/handler/docker.go
Normal file
94
api/http/handler/docker.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// DockerHandler represents an HTTP API handler for proxying requests to the Docker API.
|
||||
type DockerHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewDockerHandler returns a new instance of DockerHandler.
|
||||
func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
|
||||
h := &DockerHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.PathPrefix("/{id}/docker").Handler(
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI)))
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool {
|
||||
for _, authorizedUserID := range endpoint.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
|
||||
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
|
||||
for _, membership := range memberships {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
parsedID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpointID := portainer.EndpointID(parsedID)
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetProxy(string(endpointID))
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
|
||||
}
|
||||
89
api/http/handler/dockerhub.go
Normal file
89
api/http/handler/dockerhub.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// DockerHubHandler represents an HTTP API handler for managing DockerHub.
|
||||
type DockerHubHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
DockerHubService portainer.DockerHubService
|
||||
}
|
||||
|
||||
// NewDockerHubHandler returns a new instance of NewDockerHubHandler.
|
||||
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
|
||||
h := &DockerHubHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/dockerhub",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
|
||||
h.Handle("/dockerhub",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
putDockerHubRequest struct {
|
||||
Authentication bool `valid:""`
|
||||
Username string `valid:""`
|
||||
Password string `valid:""`
|
||||
}
|
||||
)
|
||||
|
||||
// handleGetDockerHub handles GET requests on /dockerhub
|
||||
func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) {
|
||||
dockerhub, err := handler.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, dockerhub, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
// handlePutDockerHub handles PUT requests on /dockerhub
|
||||
func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) {
|
||||
var req putDockerHubRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub := &portainer.DockerHub{
|
||||
Authentication: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
if req.Authentication {
|
||||
dockerhub.Authentication = true
|
||||
dockerhub.Username = req.Username
|
||||
dockerhub.Password = req.Password
|
||||
}
|
||||
|
||||
err = handler.DockerHubService.StoreDockerHub(dockerhub)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
}
|
||||
}
|
||||
360
api/http/handler/endpoint.go
Normal file
360
api/http/handler/endpoint.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// EndpointHandler represents an HTTP API handler for managing Docker endpoints.
|
||||
type EndpointHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
authorizeEndpointManagement bool
|
||||
EndpointService portainer.EndpointService
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints
|
||||
// when the server has been started with the --external-endpoints flag
|
||||
ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled")
|
||||
)
|
||||
|
||||
// NewEndpointHandler returns a new instance of EndpointHandler.
|
||||
func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler {
|
||||
h := &EndpointHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
authorizeEndpointManagement: authorizeEndpointManagement,
|
||||
}
|
||||
h.Handle("/endpoints",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}/access",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postEndpointsRequest struct {
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool
|
||||
}
|
||||
|
||||
postEndpointsResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
putEndpointAccessRequest struct {
|
||||
AuthorizedUsers []int `valid:"-"`
|
||||
AuthorizedTeams []int `valid:"-"`
|
||||
}
|
||||
|
||||
putEndpointsRequest struct {
|
||||
Name string `valid:"-"`
|
||||
URL string `valid:"-"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool `valid:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
// handleGetEndpoints handles GET requests on /endpoints
|
||||
func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoints, err := handler.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, filteredEndpoints, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePostEndpoints handles POST requests on /endpoints
|
||||
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||
if !handler.authorizeEndpointManagement {
|
||||
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postEndpointsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
PublicURL: req.PublicURL,
|
||||
TLS: req.TLS,
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
|
||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.TLS {
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSCACertPath = caCertPath
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSKeyPath = keyPath
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetEndpoint handles GET requests on /endpoints/:id
|
||||
func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, endpoint, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access
|
||||
func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putEndpointAccessRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AuthorizedUsers != nil {
|
||||
authorizedUserIDs := []portainer.UserID{}
|
||||
for _, value := range req.AuthorizedUsers {
|
||||
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
|
||||
}
|
||||
endpoint.AuthorizedUsers = authorizedUserIDs
|
||||
}
|
||||
|
||||
if req.AuthorizedTeams != nil {
|
||||
authorizedTeamIDs := []portainer.TeamID{}
|
||||
for _, value := range req.AuthorizedTeams {
|
||||
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
|
||||
}
|
||||
endpoint.AuthorizedTeams = authorizedTeamIDs
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handlePutEndpoint handles PUT requests on /endpoints/:id
|
||||
func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if !handler.authorizeEndpointManagement {
|
||||
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putEndpointsRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
endpoint.Name = req.Name
|
||||
}
|
||||
|
||||
if req.URL != "" {
|
||||
endpoint.URL = req.URL
|
||||
}
|
||||
|
||||
if req.PublicURL != "" {
|
||||
endpoint.PublicURL = req.PublicURL
|
||||
}
|
||||
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
if req.TLS {
|
||||
endpoint.TLS = true
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSCACertPath = caCertPath
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSKeyPath = keyPath
|
||||
} else {
|
||||
endpoint.TLS = false
|
||||
endpoint.TLSCACertPath = ""
|
||||
endpoint.TLSCertPath = ""
|
||||
endpoint.TLSKeyPath = ""
|
||||
err = handler.FileService.DeleteTLSFiles(folder)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteEndpoint handles DELETE requests on /endpoints/:id
|
||||
func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if !handler.authorizeEndpointManagement {
|
||||
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
handler.ProxyManager.DeleteProxy(string(endpointID))
|
||||
|
||||
err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if endpoint.TLS {
|
||||
err = handler.FileService.DeleteTLSFiles(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
62
api/http/handler/file.go
Normal file
62
api/http/handler/file.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileHandler represents an HTTP API handler for managing static files.
|
||||
type FileHandler struct {
|
||||
http.Handler
|
||||
Logger *log.Logger
|
||||
allowedDirectories map[string]bool
|
||||
}
|
||||
|
||||
// NewFileHandler returns a new instance of FileHandler.
|
||||
func NewFileHandler(assetPath string) *FileHandler {
|
||||
h := &FileHandler{
|
||||
Handler: http.FileServer(http.Dir(assetPath)),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
allowedDirectories: map[string]bool{
|
||||
"/": true,
|
||||
"/css": true,
|
||||
"/js": true,
|
||||
"/images": true,
|
||||
"/fonts": true,
|
||||
"/i18n": true,
|
||||
},
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func isHTML(acceptContent []string) bool {
|
||||
for _, accept := range acceptContent {
|
||||
if strings.Contains(accept, "text/html") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requestDirectory := path.Dir(r.URL.Path)
|
||||
if !handler.allowedDirectories[requestDirectory] {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !isHTML(r.Header["Accept"]) {
|
||||
w.Header().Set("Cache-Control", "max-age=31536000")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
|
||||
handler.Handler.ServeHTTP(w, r)
|
||||
}
|
||||
85
api/http/handler/handler.go
Normal file
85
api/http/handler/handler.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
)
|
||||
|
||||
// Handler is a collection of all the service handlers.
|
||||
type Handler struct {
|
||||
AuthHandler *AuthHandler
|
||||
UserHandler *UserHandler
|
||||
TeamHandler *TeamHandler
|
||||
TeamMembershipHandler *TeamMembershipHandler
|
||||
EndpointHandler *EndpointHandler
|
||||
RegistryHandler *RegistryHandler
|
||||
DockerHubHandler *DockerHubHandler
|
||||
ResourceHandler *ResourceHandler
|
||||
StatusHandler *StatusHandler
|
||||
SettingsHandler *SettingsHandler
|
||||
TemplatesHandler *TemplatesHandler
|
||||
DockerHandler *DockerHandler
|
||||
WebSocketHandler *WebSocketHandler
|
||||
UploadHandler *UploadHandler
|
||||
FileHandler *FileHandler
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrInvalidJSON defines an error raised the app is unable to parse request data
|
||||
ErrInvalidJSON = portainer.Error("Invalid JSON")
|
||||
// ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid
|
||||
ErrInvalidRequestFormat = portainer.Error("Invalid request data format")
|
||||
// ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid
|
||||
ErrInvalidQueryFormat = portainer.Error("Invalid query format")
|
||||
)
|
||||
|
||||
// ServeHTTP delegates a request to the appropriate subhandler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/auth"):
|
||||
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
||||
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
|
||||
if strings.Contains(r.URL.Path, "/docker") {
|
||||
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
|
||||
} else {
|
||||
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
|
||||
}
|
||||
case strings.HasPrefix(r.URL.Path, "/api/registries"):
|
||||
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
|
||||
http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/settings"):
|
||||
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/status"):
|
||||
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/templates"):
|
||||
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/upload"):
|
||||
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/users"):
|
||||
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/teams"):
|
||||
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
|
||||
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/websocket"):
|
||||
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/"):
|
||||
h.FileHandler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
|
||||
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
|
||||
}
|
||||
}
|
||||
314
api/http/handler/registry.go
Normal file
314
api/http/handler/registry.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// RegistryHandler represents an HTTP API handler for managing Docker registries.
|
||||
type RegistryHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
RegistryService portainer.RegistryService
|
||||
}
|
||||
|
||||
// NewRegistryHandler returns a new instance of RegistryHandler.
|
||||
func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler {
|
||||
h := &RegistryHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/registries",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost)
|
||||
h.Handle("/registries",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet)
|
||||
h.Handle("/registries/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet)
|
||||
h.Handle("/registries/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut)
|
||||
h.Handle("/registries/{id}/access",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut)
|
||||
h.Handle("/registries/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postRegistriesRequest struct {
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
Authentication bool `valid:""`
|
||||
Username string `valid:""`
|
||||
Password string `valid:""`
|
||||
}
|
||||
|
||||
postRegistriesResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
putRegistryAccessRequest struct {
|
||||
AuthorizedUsers []int `valid:"-"`
|
||||
AuthorizedTeams []int `valid:"-"`
|
||||
}
|
||||
|
||||
putRegistriesRequest struct {
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
Authentication bool `valid:""`
|
||||
Username string `valid:""`
|
||||
Password string `valid:""`
|
||||
}
|
||||
)
|
||||
|
||||
// handleGetRegistries handles GET requests on /registries
|
||||
func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, filteredRegistries, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePostRegistries handles POST requests on /registries
|
||||
func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) {
|
||||
var req postRegistriesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
for _, r := range registries {
|
||||
if r.URL == req.URL {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Authentication: req.Authentication,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
|
||||
err = handler.RegistryService.CreateRegistry(registry)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetRegistry handles GET requests on /registries/:id
|
||||
func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
registryID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrRegistryNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, registry, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutRegistryAccess handles PUT requests on /registries/:id/access
|
||||
func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
registryID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putRegistryAccessRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrRegistryNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AuthorizedUsers != nil {
|
||||
authorizedUserIDs := []portainer.UserID{}
|
||||
for _, value := range req.AuthorizedUsers {
|
||||
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
|
||||
}
|
||||
registry.AuthorizedUsers = authorizedUserIDs
|
||||
}
|
||||
|
||||
if req.AuthorizedTeams != nil {
|
||||
authorizedTeamIDs := []portainer.TeamID{}
|
||||
for _, value := range req.AuthorizedTeams {
|
||||
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
|
||||
}
|
||||
registry.AuthorizedTeams = authorizedTeamIDs
|
||||
}
|
||||
|
||||
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handlePutRegistry handles PUT requests on /registries/:id
|
||||
func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
registryID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putRegistriesRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrRegistryNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
for _, r := range registries {
|
||||
if r.URL == req.URL && r.ID != registry.ID {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
registry.Name = req.Name
|
||||
}
|
||||
|
||||
if req.URL != "" {
|
||||
registry.URL = req.URL
|
||||
}
|
||||
|
||||
if req.Authentication {
|
||||
registry.Authentication = true
|
||||
registry.Username = req.Username
|
||||
registry.Password = req.Password
|
||||
} else {
|
||||
registry.Authentication = false
|
||||
registry.Username = ""
|
||||
registry.Password = ""
|
||||
}
|
||||
|
||||
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteRegistry handles DELETE requests on /registries/:id
|
||||
func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
registryID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrRegistryNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
258
api/http/handler/resource_control.go
Normal file
258
api/http/handler/resource_control.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ResourceHandler represents an HTTP API handler for managing resource controls.
|
||||
type ResourceHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
}
|
||||
|
||||
// NewResourceHandler returns a new instance of ResourceHandler.
|
||||
func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler {
|
||||
h := &ResourceHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/resource_controls",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost)
|
||||
h.Handle("/resource_controls/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut)
|
||||
h.Handle("/resource_controls/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postResourcesRequest struct {
|
||||
ResourceID string `valid:"required"`
|
||||
Type string `valid:"required"`
|
||||
AdministratorsOnly bool `valid:"-"`
|
||||
Users []int `valid:"-"`
|
||||
Teams []int `valid:"-"`
|
||||
SubResourceIDs []string `valid:"-"`
|
||||
}
|
||||
|
||||
putResourcesRequest struct {
|
||||
AdministratorsOnly bool `valid:"-"`
|
||||
Users []int `valid:"-"`
|
||||
Teams []int `valid:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
// handlePostResources handles POST requests on /resources
|
||||
func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) {
|
||||
var req postResourcesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var resourceControlType portainer.ResourceControlType
|
||||
switch req.Type {
|
||||
case "container":
|
||||
resourceControlType = portainer.ContainerResourceControl
|
||||
case "service":
|
||||
resourceControlType = portainer.ServiceResourceControl
|
||||
case "volume":
|
||||
resourceControlType = portainer.VolumeResourceControl
|
||||
default:
|
||||
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID)
|
||||
if err != nil && err != portainer.ErrResourceControlNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if rc != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var userAccesses = make([]portainer.UserResourceAccess, 0)
|
||||
for _, v := range req.Users {
|
||||
userAccess := portainer.UserResourceAccess{
|
||||
UserID: portainer.UserID(v),
|
||||
AccessLevel: portainer.ReadWriteAccessLevel,
|
||||
}
|
||||
userAccesses = append(userAccesses, userAccess)
|
||||
}
|
||||
|
||||
var teamAccesses = make([]portainer.TeamResourceAccess, 0)
|
||||
for _, v := range req.Teams {
|
||||
teamAccess := portainer.TeamResourceAccess{
|
||||
TeamID: portainer.TeamID(v),
|
||||
AccessLevel: portainer.ReadWriteAccessLevel,
|
||||
}
|
||||
teamAccesses = append(teamAccesses, teamAccess)
|
||||
}
|
||||
|
||||
resourceControl := portainer.ResourceControl{
|
||||
ResourceID: req.ResourceID,
|
||||
SubResourceIDs: req.SubResourceIDs,
|
||||
Type: resourceControlType,
|
||||
AdministratorsOnly: req.AdministratorsOnly,
|
||||
UserAccesses: userAccesses,
|
||||
TeamAccesses: teamAccesses,
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ResourceControlService.CreateResourceControl(&resourceControl)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// handlePutResources handles PUT requests on /resources/:id
|
||||
func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
resourceControlID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putResourcesRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID))
|
||||
|
||||
if err == portainer.ErrResourceControlNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resourceControl.AdministratorsOnly = req.AdministratorsOnly
|
||||
|
||||
var userAccesses = make([]portainer.UserResourceAccess, 0)
|
||||
for _, v := range req.Users {
|
||||
userAccess := portainer.UserResourceAccess{
|
||||
UserID: portainer.UserID(v),
|
||||
AccessLevel: portainer.ReadWriteAccessLevel,
|
||||
}
|
||||
userAccesses = append(userAccesses, userAccess)
|
||||
}
|
||||
resourceControl.UserAccesses = userAccesses
|
||||
|
||||
var teamAccesses = make([]portainer.TeamResourceAccess, 0)
|
||||
for _, v := range req.Teams {
|
||||
teamAccess := portainer.TeamResourceAccess{
|
||||
TeamID: portainer.TeamID(v),
|
||||
AccessLevel: portainer.ReadWriteAccessLevel,
|
||||
}
|
||||
teamAccesses = append(teamAccesses, teamAccess)
|
||||
}
|
||||
resourceControl.TeamAccesses = teamAccesses
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteResources handles DELETE requests on /resources/:id
|
||||
func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
resourceControlID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID))
|
||||
|
||||
if err == portainer.ErrResourceControlNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
171
api/http/handler/settings.go
Normal file
171
api/http/handler/settings.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/file"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// SettingsHandler represents an HTTP API handler for managing Settings.
|
||||
type SettingsHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
SettingsService portainer.SettingsService
|
||||
LDAPService portainer.LDAPService
|
||||
FileService portainer.FileService
|
||||
}
|
||||
|
||||
// NewSettingsHandler returns a new instance of OldSettingsHandler.
|
||||
func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
|
||||
h := &SettingsHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/settings",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
|
||||
h.Handle("/settings",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut)
|
||||
h.Handle("/settings/public",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet)
|
||||
h.Handle("/settings/authentication/checkLDAP",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
publicSettingsResponse struct {
|
||||
LogoURL string `json:"LogoURL"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
}
|
||||
|
||||
putSettingsRequest struct {
|
||||
TemplatesURL string `valid:"required"`
|
||||
LogoURL string `valid:""`
|
||||
BlackListedLabels []portainer.Pair `valid:""`
|
||||
DisplayExternalContributors bool `valid:""`
|
||||
AuthenticationMethod int `valid:"required"`
|
||||
LDAPSettings portainer.LDAPSettings `valid:""`
|
||||
Language string `valid:"required"`
|
||||
}
|
||||
|
||||
putSettingsLDAPCheckRequest struct {
|
||||
LDAPSettings portainer.LDAPSettings `valid:""`
|
||||
}
|
||||
)
|
||||
|
||||
// handleGetSettings handles GET requests on /settings
|
||||
func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, settings, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
// handleGetPublicSettings handles GET requests on /settings/public
|
||||
func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: settings.LogoURL,
|
||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
}
|
||||
|
||||
encodeJSON(w, publicSettings, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
// handlePutSettings handles PUT requests on /settings
|
||||
func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req putSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
settings := &portainer.Settings{
|
||||
TemplatesURL: req.TemplatesURL,
|
||||
LogoURL: req.LogoURL,
|
||||
BlackListedLabels: req.BlackListedLabels,
|
||||
DisplayExternalContributors: req.DisplayExternalContributors,
|
||||
LDAPSettings: req.LDAPSettings,
|
||||
Language: req.Language,
|
||||
}
|
||||
|
||||
if req.AuthenticationMethod == 1 {
|
||||
settings.AuthenticationMethod = portainer.AuthenticationInternal
|
||||
} else if req.AuthenticationMethod == 2 {
|
||||
settings.AuthenticationMethod = portainer.AuthenticationLDAP
|
||||
} else {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
|
||||
settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
|
||||
} else {
|
||||
settings.LDAPSettings.TLSConfig.TLSCACertPath = ""
|
||||
err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.SettingsService.StoreSettings(settings)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check
|
||||
func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var req putSettingsLDAPCheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
|
||||
req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
|
||||
}
|
||||
|
||||
err = handler.LDAPService.TestConnectivity(&req.LDAPSettings)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
38
api/http/handler/status.go
Normal file
38
api/http/handler/status.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// StatusHandler represents an HTTP API handler for managing Status.
|
||||
type StatusHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
Status *portainer.Status
|
||||
}
|
||||
|
||||
// NewStatusHandler returns a new instance of StatusHandler.
|
||||
func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler {
|
||||
h := &StatusHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
Status: status,
|
||||
}
|
||||
h.Handle("/status",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// handleGetStatus handles GET requests on /status
|
||||
func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
encodeJSON(w, handler.Status, handler.Logger)
|
||||
return
|
||||
}
|
||||
262
api/http/handler/team.go
Normal file
262
api/http/handler/team.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TeamHandler represents an HTTP API handler for managing teams.
|
||||
type TeamHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
}
|
||||
|
||||
// NewTeamHandler returns a new instance of TeamHandler.
|
||||
func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
|
||||
h := &TeamHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/teams",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost)
|
||||
h.Handle("/teams",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
|
||||
h.Handle("/teams/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet)
|
||||
h.Handle("/teams/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut)
|
||||
h.Handle("/teams/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete)
|
||||
h.Handle("/teams/{id}/memberships",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postTeamsRequest struct {
|
||||
Name string `valid:"required"`
|
||||
}
|
||||
|
||||
postTeamsResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
putTeamRequest struct {
|
||||
Name string `valid:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
// handlePostTeams handles POST requests on /teams
|
||||
func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) {
|
||||
var req postTeamsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
team, err := handler.TeamService.TeamByName(req.Name)
|
||||
if err != nil && err != portainer.ErrTeamNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if team != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
team = &portainer.Team{
|
||||
Name: req.Name,
|
||||
}
|
||||
|
||||
err = handler.TeamService.CreateTeam(team)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetTeams handles GET requests on /teams
|
||||
func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
teams, err := handler.TeamService.Teams()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredTeams := security.FilterUserTeams(teams, securityContext)
|
||||
|
||||
encodeJSON(w, filteredTeams, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetTeam handles GET requests on /teams/:id
|
||||
func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
tid, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
teamID := portainer.TeamID(tid)
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedTeamManagement(teamID, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
team, err := handler.TeamService.Team(teamID)
|
||||
if err == portainer.ErrTeamNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &team, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutTeam handles PUT requests on /teams/:id
|
||||
func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
teamID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putTeamRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
team, err := handler.TeamService.Team(portainer.TeamID(teamID))
|
||||
if err == portainer.ErrTeamNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
team.Name = req.Name
|
||||
}
|
||||
|
||||
err = handler.TeamService.UpdateTeam(team.ID, team)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteTeam handles DELETE requests on /teams/:id
|
||||
func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
teamID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.TeamService.Team(portainer.TeamID(teamID))
|
||||
|
||||
if err == portainer.ErrTeamNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetMemberships handles GET requests on /teams/:id/memberships
|
||||
func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
tid, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
teamID := portainer.TeamID(tid)
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedTeamManagement(teamID, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, memberships, handler.Logger)
|
||||
}
|
||||
242
api/http/handler/team_membership.go
Normal file
242
api/http/handler/team_membership.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TeamMembershipHandler represents an HTTP API handler for managing teams.
|
||||
type TeamMembershipHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
}
|
||||
|
||||
// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler.
|
||||
func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler {
|
||||
h := &TeamMembershipHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/team_memberships",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost)
|
||||
h.Handle("/team_memberships",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet)
|
||||
h.Handle("/team_memberships/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut)
|
||||
h.Handle("/team_memberships/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postTeamMembershipsRequest struct {
|
||||
UserID int `valid:"required"`
|
||||
TeamID int `valid:"required"`
|
||||
Role int `valid:"required"`
|
||||
}
|
||||
|
||||
postTeamMembershipsResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
putTeamMembershipRequest struct {
|
||||
UserID int `valid:"required"`
|
||||
TeamID int `valid:"required"`
|
||||
Role int `valid:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// handlePostTeamMemberships handles POST requests on /team_memberships
|
||||
func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postTeamMembershipsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
userID := portainer.UserID(req.UserID)
|
||||
teamID := portainer.TeamID(req.TeamID)
|
||||
role := portainer.MembershipRole(req.Role)
|
||||
|
||||
if !security.AuthorizedTeamManagement(teamID, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if len(memberships) > 0 {
|
||||
for _, membership := range memberships {
|
||||
if membership.UserID == userID && membership.TeamID == teamID {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membership := &portainer.TeamMembership{
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
err = handler.TeamMembershipService.CreateTeamMembership(membership)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetTeamsMemberships handles GET requests on /team_memberships
|
||||
func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMemberships()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, memberships, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutTeamMembership handles PUT requests on /team_memberships/:id
|
||||
func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
membershipID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putTeamMembershipRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
userID := portainer.UserID(req.UserID)
|
||||
teamID := portainer.TeamID(req.TeamID)
|
||||
role := portainer.MembershipRole(req.Role)
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedTeamManagement(teamID, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID))
|
||||
if err == portainer.ErrTeamMembershipNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if securityContext.IsTeamLeader && membership.Role != role {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
membership.UserID = userID
|
||||
membership.TeamID = teamID
|
||||
membership.Role = role
|
||||
|
||||
err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id
|
||||
func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
membershipID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID))
|
||||
if err == portainer.ErrTeamMembershipNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
73
api/http/handler/templates.go
Normal file
73
api/http/handler/templates.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
// TemplatesHandler represents an HTTP API handler for managing templates.
|
||||
type TemplatesHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
SettingsService portainer.SettingsService
|
||||
}
|
||||
|
||||
const (
|
||||
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
|
||||
)
|
||||
|
||||
// NewTemplatesHandler returns a new instance of TemplatesHandler.
|
||||
func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler {
|
||||
h := &TemplatesHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/templates",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
||||
|
||||
// handleGetTemplates handles GET requests on /templates?key=<key>
|
||||
func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.FormValue("key")
|
||||
if key == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var templatesURL string
|
||||
if key == "containers" {
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
templatesURL = settings.TemplatesURL
|
||||
} else if key == "linuxserver.io" {
|
||||
templatesURL = containerTemplatesURLLinuxServerIo
|
||||
} else {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Get(templatesURL)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(body)
|
||||
}
|
||||
69
api/http/handler/upload.go
Normal file
69
api/http/handler/upload.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// UploadHandler represents an HTTP API handler for managing file uploads.
|
||||
type UploadHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
FileService portainer.FileService
|
||||
}
|
||||
|
||||
// NewUploadHandler returns a new instance of UploadHandler.
|
||||
func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
|
||||
h := &UploadHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost)
|
||||
return h
|
||||
}
|
||||
|
||||
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder
|
||||
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
certificate := vars["certificate"]
|
||||
|
||||
folder := r.FormValue("folder")
|
||||
if folder == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var fileType portainer.TLSFileType
|
||||
switch certificate {
|
||||
case "ca":
|
||||
fileType = portainer.TLSFileCA
|
||||
case "cert":
|
||||
fileType = portainer.TLSFileCert
|
||||
case "key":
|
||||
fileType = portainer.TLSFileKey
|
||||
default:
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.FileService.StoreTLSFile(folder, fileType, file)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
451
api/http/handler/user.go
Normal file
451
api/http/handler/user.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// UserHandler represents an HTTP API handler for managing users.
|
||||
type UserHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
UserService portainer.UserService
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
CryptoService portainer.CryptoService
|
||||
SettingsService portainer.SettingsService
|
||||
}
|
||||
|
||||
// NewUserHandler returns a new instance of UserHandler.
|
||||
func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler {
|
||||
h := &UserHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/users",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost)
|
||||
h.Handle("/users",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet)
|
||||
h.Handle("/users/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet)
|
||||
h.Handle("/users/{id}",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut)
|
||||
h.Handle("/users/{id}",
|
||||
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete)
|
||||
h.Handle("/users/{id}/memberships",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
|
||||
h.Handle("/users/{id}/passwd",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost)
|
||||
h.Handle("/users/admin/check",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet)
|
||||
h.Handle("/users/admin/init",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postUsersRequest struct {
|
||||
Username string `valid:"required"`
|
||||
Password string `valid:""`
|
||||
Role int `valid:"required"`
|
||||
}
|
||||
|
||||
postUsersResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
postUserPasswdRequest struct {
|
||||
Password string `valid:"required"`
|
||||
}
|
||||
|
||||
postUserPasswdResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
putUserRequest struct {
|
||||
Password string `valid:"-"`
|
||||
Role int `valid:"-"`
|
||||
}
|
||||
|
||||
postAdminInitRequest struct {
|
||||
Password string `valid:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// handlePostUsers handles POST requests on /users
|
||||
func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) {
|
||||
var req postUsersRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if securityContext.IsTeamLeader && req.Role == 1 {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.ContainsAny(req.Username, " ") {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.UserService.UserByUsername(req.Username)
|
||||
if err != nil && err != portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if user != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var role portainer.UserRole
|
||||
if req.Role == 1 {
|
||||
role = portainer.AdministratorRole
|
||||
} else {
|
||||
role = portainer.StandardUserRole
|
||||
}
|
||||
|
||||
user = &portainer.User{
|
||||
Username: req.Username,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||
user.Password, err = handler.CryptoService.Hash(req.Password)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.UserService.CreateUser(user)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetUsers handles GET requests on /users
|
||||
func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := handler.UserService.Users()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredUsers := security.FilterUsers(users, securityContext)
|
||||
|
||||
for i := range filteredUsers {
|
||||
filteredUsers[i].Password = ""
|
||||
}
|
||||
|
||||
encodeJSON(w, filteredUsers, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePostUserPasswd handles POST requests on /users/:id/passwd
|
||||
func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
userID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postUserPasswdRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var password = req.Password
|
||||
|
||||
u, err := handler.UserService.User(portainer.UserID(userID))
|
||||
if err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
valid := true
|
||||
err = handler.CryptoService.CompareHashAndData(u.Password, password)
|
||||
if err != nil {
|
||||
valid = false
|
||||
}
|
||||
|
||||
encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetUser handles GET requests on /users/:id
|
||||
func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
userID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.UserService.User(portainer.UserID(userID))
|
||||
if err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
encodeJSON(w, &user, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutUser handles PUT requests on /users/:id
|
||||
func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
userID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putUserRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" && req.Role == 0 {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.UserService.User(portainer.UserID(userID))
|
||||
if err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
user.Password, err = handler.CryptoService.Hash(req.Password)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Role != 0 {
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
if req.Role == 1 {
|
||||
user.Role = portainer.AdministratorRole
|
||||
} else {
|
||||
user.Role = portainer.StandardUserRole
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.UserService.UpdateUser(user.ID, user)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetAdminCheck handles GET requests on /users/admin/check
|
||||
func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if len(users) == 0 {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handlePostAdminInit handles POST requests on /users/admin/init
|
||||
func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) {
|
||||
var req postAdminInitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.UserService.UserByUsername("admin")
|
||||
if err == portainer.ErrUserNotFound {
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
user.Password, err = handler.CryptoService.Hash(req.Password)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.UserService.CreateUser(user)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if user != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteUser handles DELETE requests on /users/:id
|
||||
func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
userID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.UserService.User(portainer.UserID(userID))
|
||||
|
||||
if err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.UserService.DeleteUser(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetMemberships handles GET requests on /users/:id/memberships
|
||||
func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
userID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, memberships, handler.Logger)
|
||||
}
|
||||
196
api/http/handler/websocket.go
Normal file
196
api/http/handler/websocket.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
|
||||
type WebSocketHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
}
|
||||
|
||||
// NewWebSocketHandler returns a new instance of WebSocketHandler.
|
||||
func NewWebSocketHandler() *WebSocketHandler {
|
||||
h := &WebSocketHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec))
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
|
||||
qry := ws.Request().URL.Query()
|
||||
execID := qry.Get("id")
|
||||
edpID := qry.Get("endpointId")
|
||||
|
||||
parsedID, err := strconv.Atoi(edpID)
|
||||
if err != nil {
|
||||
log.Printf("Unable to parse endpoint ID: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
endpointID := portainer.EndpointID(parsedID)
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err != nil {
|
||||
log.Printf("Unable to retrieve endpoint: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
endpointURL, err := url.Parse(endpoint.URL)
|
||||
if err != nil {
|
||||
log.Printf("Unable to parse endpoint URL: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
var host string
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
host = endpointURL.Host
|
||||
} else if endpointURL.Scheme == "unix" {
|
||||
host = endpointURL.Path
|
||||
}
|
||||
|
||||
// Should not be managed here
|
||||
var tlsConfig *tls.Config
|
||||
if endpoint.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create TLS configuration: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
|
||||
log.Fatalf("error during hijack: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type execConfig struct {
|
||||
Tty bool
|
||||
Detach bool
|
||||
}
|
||||
|
||||
// hijack allows to upgrade an HTTP connection to a TCP connection
|
||||
// It redirects IO streams for stdin, stdout and stderr to a websocket
|
||||
func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error {
|
||||
execConfig := &execConfig{
|
||||
Tty: true,
|
||||
Detach: false,
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(execConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling exec config: %s", err)
|
||||
}
|
||||
|
||||
rdr := bytes.NewReader(buf)
|
||||
|
||||
req, err := http.NewRequest(method, path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during hijack request: %s", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Docker-Client")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "tcp")
|
||||
req.Host = addr
|
||||
|
||||
var (
|
||||
dial net.Conn
|
||||
dialErr error
|
||||
)
|
||||
|
||||
if tlsConfig == nil {
|
||||
dial, dialErr = net.Dial(scheme, addr)
|
||||
} else {
|
||||
dial, dialErr = tls.Dial(scheme, addr, tlsConfig)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
}
|
||||
|
||||
// When we set up a TCP connection for hijack, there could be long periods
|
||||
// of inactivity (a long running command with no output) that in certain
|
||||
// network setups may cause ECONNTIMEOUT, leaving the client in an unknown
|
||||
// state. Setting TCP KeepAlive on the socket connection will prohibit
|
||||
// ECONNTIMEOUT unless the socket connection truly is broken
|
||||
if tcpConn, ok := dial.(*net.TCPConn); ok {
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clientconn := httputil.NewClientConn(dial, nil)
|
||||
defer clientconn.Close()
|
||||
|
||||
// Server hijacks the connection, error 'connection closed' expected
|
||||
clientconn.Do(req)
|
||||
|
||||
rwc, br := clientconn.Hijack()
|
||||
defer rwc.Close()
|
||||
|
||||
if started != nil {
|
||||
started <- rwc
|
||||
}
|
||||
|
||||
var receiveStdout chan error
|
||||
|
||||
if stdout != nil || stderr != nil {
|
||||
go func() (err error) {
|
||||
if setRawTerminal && stdout != nil {
|
||||
_, err = io.Copy(stdout, br)
|
||||
}
|
||||
return err
|
||||
}()
|
||||
}
|
||||
|
||||
go func() error {
|
||||
if in != nil {
|
||||
io.Copy(rwc, in)
|
||||
}
|
||||
|
||||
if conn, ok := rwc.(interface {
|
||||
CloseWrite() error
|
||||
}); ok {
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if stdout != nil || stderr != nil {
|
||||
if err := <-receiveStdout; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
fmt.Println(br)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
21
api/http/proxy/access_control.go
Normal file
21
api/http/proxy/access_control.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
|
||||
for _, authorizedUserAccess := range resourceControl.UserAccesses {
|
||||
if userID == authorizedUserAccess.UserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, authorizedTeamAccess := range resourceControl.TeamAccesses {
|
||||
for _, userTeamID := range userTeamIDs {
|
||||
if userTeamID == authorizedTeamAccess.TeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
108
api/http/proxy/containers.go
Normal file
108
api/http/proxy/containers.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier
|
||||
ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found")
|
||||
containerIdentifier = "Id"
|
||||
containerLabelForServiceIdentifier = "com.docker.swarm.service.id"
|
||||
)
|
||||
|
||||
// containerListOperation extracts the response as a JSON object, loop through the containers array
|
||||
// decorate and/or filter the containers based on resource controls before rewriting the response
|
||||
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ContainerList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls,
|
||||
executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.labelBlackList != nil {
|
||||
responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// containerInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated container.
|
||||
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// ContainerInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[containerIdentifier] == nil {
|
||||
return ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
containerID := responseObject[containerIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
|
||||
executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
|
||||
executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// extractContainerLabelsFromContainerInspectObject retrieve the Labels of the container if present.
|
||||
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
func extractContainerLabelsFromContainerInspectObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Config.Labels
|
||||
containerConfigObject := extractJSONField(responseObject, "Config")
|
||||
if containerConfigObject != nil {
|
||||
containerLabelsObject := extractJSONField(containerConfigObject, "Labels")
|
||||
return containerLabelsObject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractContainerLabelsFromContainerListObject retrieve the Labels of the container if present.
|
||||
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func extractContainerLabelsFromContainerListObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Labels
|
||||
containerLabelsObject := extractJSONField(responseObject, "Labels")
|
||||
return containerLabelsObject
|
||||
}
|
||||
90
api/http/proxy/decorator.go
Normal file
90
api/http/proxy/decorator.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control.
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
volumeObject = decorateObject(volumeObject, resourceControl)
|
||||
}
|
||||
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
|
||||
}
|
||||
|
||||
return decoratedVolumeData, nil
|
||||
}
|
||||
|
||||
// decorateContainerList loops through all containers and will decorate any container with an existing resource control.
|
||||
// Check is based on the container ID and optional Swarm service ID.
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
}
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
}
|
||||
}
|
||||
|
||||
decoratedContainerData = append(decoratedContainerData, containerObject)
|
||||
}
|
||||
|
||||
return decoratedContainerData, nil
|
||||
}
|
||||
|
||||
// decorateServiceList loops through all services and will decorate any service with an existing resource control.
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
serviceObject = decorateObject(serviceObject, resourceControl)
|
||||
}
|
||||
decoratedServiceData = append(decoratedServiceData, serviceObject)
|
||||
}
|
||||
|
||||
return decoratedServiceData, nil
|
||||
}
|
||||
|
||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||
metadata := make(map[string]interface{})
|
||||
metadata["ResourceControl"] = resourceControl
|
||||
object["Portainer"] = metadata
|
||||
return object
|
||||
}
|
||||
58
api/http/proxy/factory.go
Normal file
58
api/http/proxy/factory.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
)
|
||||
|
||||
// proxyFactory is a factory to create reverse proxies to Docker endpoints
|
||||
type proxyFactory struct {
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
SettingsService portainer.SettingsService
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
|
||||
u.Scheme = "http"
|
||||
return factory.createReverseProxy(u)
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
u.Scheme = "https"
|
||||
proxy := factory.createReverseProxy(u)
|
||||
config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy.Transport.(*proxyTransport).dockerTransport.TLSClientConfig = config
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
|
||||
proxy := &socketProxy{}
|
||||
transport := &proxyTransport{
|
||||
ResourceControlService: factory.ResourceControlService,
|
||||
TeamMembershipService: factory.TeamMembershipService,
|
||||
SettingsService: factory.SettingsService,
|
||||
dockerTransport: newSocketTransport(path),
|
||||
}
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy {
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
||||
transport := &proxyTransport{
|
||||
ResourceControlService: factory.ResourceControlService,
|
||||
TeamMembershipService: factory.TeamMembershipService,
|
||||
SettingsService: factory.SettingsService,
|
||||
dockerTransport: newHTTPTransport(),
|
||||
}
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
112
api/http/proxy/filter.go
Normal file
112
api/http/proxy/filter.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (these volumes will be decorated).
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
filteredVolumeData = append(filteredVolumeData, volumeObject)
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
volumeObject = decorateObject(volumeObject, resourceControl)
|
||||
filteredVolumeData = append(filteredVolumeData, volumeObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredVolumeData, nil
|
||||
}
|
||||
|
||||
// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated).
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
// check if container is part of a Swarm service
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if serviceResourceControl == nil {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
} else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) {
|
||||
containerObject = decorateObject(containerObject, serviceResourceControl)
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
|
||||
// any labels in the labels black list.
|
||||
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil {
|
||||
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (these services will be decorated).
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
filteredServiceData = append(filteredServiceData, serviceObject)
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
serviceObject = decorateObject(serviceObject, resourceControl)
|
||||
filteredServiceData = append(filteredServiceData, serviceObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredServiceData, nil
|
||||
}
|
||||
69
api/http/proxy/manager.go
Normal file
69
api/http/proxy/manager.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// Manager represents a service used to manage Docker proxies.
|
||||
type Manager struct {
|
||||
proxyFactory *proxyFactory
|
||||
proxies cmap.ConcurrentMap
|
||||
}
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
|
||||
return &Manager{
|
||||
proxies: cmap.New(),
|
||||
proxyFactory: &proxyFactory{
|
||||
ResourceControlService: resourceControlService,
|
||||
TeamMembershipService: teamMembershipService,
|
||||
SettingsService: settingsService,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
var proxy http.Handler
|
||||
|
||||
endpointURL, err := url.Parse(endpoint.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
if endpoint.TLS {
|
||||
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
proxy = manager.proxyFactory.newHTTPProxy(endpointURL)
|
||||
}
|
||||
} else {
|
||||
// Assume unix:// scheme
|
||||
proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path)
|
||||
}
|
||||
|
||||
manager.proxies.Set(string(endpoint.ID), proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// GetProxy returns the proxy associated to a key
|
||||
func (manager *Manager) GetProxy(key string) http.Handler {
|
||||
proxy, ok := manager.proxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// DeleteProxy deletes the proxy associated to a key
|
||||
func (manager *Manager) DeleteProxy(key string) {
|
||||
manager.proxies.Remove(key)
|
||||
}
|
||||
95
api/http/proxy/response.go
Normal file
95
api/http/proxy/response.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
|
||||
ErrEmptyResponseBody = portainer.Error("Empty response body")
|
||||
)
|
||||
|
||||
func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} {
|
||||
object := jsonObject[key]
|
||||
if object != nil {
|
||||
return object.(map[string]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) {
|
||||
responseData, err := getResponseBodyAsGenericJSON(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseObject := responseData.(map[string]interface{})
|
||||
return responseObject, nil
|
||||
}
|
||||
|
||||
func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
|
||||
responseData, err := getResponseBodyAsGenericJSON(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseObject := responseData.([]interface{})
|
||||
return responseObject, nil
|
||||
}
|
||||
|
||||
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
|
||||
var data interface{}
|
||||
if response.Body != nil {
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
return nil, ErrEmptyResponseBody
|
||||
}
|
||||
|
||||
func writeAccessDeniedResponse() (*http.Response, error) {
|
||||
response := &http.Response{}
|
||||
err := rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func rewriteAccessDeniedResponse(response *http.Response) error {
|
||||
return rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func rewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error {
|
||||
jsonData, err := json.Marshal(newResponseData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := ioutil.NopCloser(bytes.NewReader(jsonData))
|
||||
response.StatusCode = statusCode
|
||||
response.Body = body
|
||||
response.ContentLength = int64(len(jsonData))
|
||||
|
||||
if response.Header == nil {
|
||||
response.Header = make(http.Header)
|
||||
}
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
|
||||
|
||||
return nil
|
||||
}
|
||||
46
api/http/proxy/reverse_proxy.go
Normal file
46
api/http/proxy/reverse_proxy.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
targetQuery := target.RawQuery
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
req.Host = req.URL.Host
|
||||
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||
} else {
|
||||
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||
}
|
||||
if _, ok := req.Header["User-Agent"]; !ok {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{Director: director}
|
||||
}
|
||||
|
||||
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
|
||||
// included here for use in NewSingleHostReverseProxyWithHostHeader
|
||||
// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
64
api/http/proxy/service.go
Normal file
64
api/http/proxy/service.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
|
||||
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
|
||||
serviceIdentifier = "ID"
|
||||
)
|
||||
|
||||
// serviceListOperation extracts the response as a JSON array, loop through the service array
|
||||
// decorate and/or filter the services based on resource controls before rewriting the response
|
||||
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ServiceList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// serviceInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the service based on resource control and either rewrite an access denied response
|
||||
// or a decorated service.
|
||||
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// ServiceInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[serviceIdentifier] == nil {
|
||||
return ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
serviceID := responseObject[serviceIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
40
api/http/proxy/socket.go
Normal file
40
api/http/proxy/socket.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package proxy
|
||||
|
||||
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
)
|
||||
|
||||
type socketProxy struct {
|
||||
Transport *proxyTransport
|
||||
}
|
||||
|
||||
func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Force URL/domain to http/unixsocket to be able to
|
||||
// use http.Transport RoundTrip to do the requests via the socket
|
||||
r.URL.Scheme = "http"
|
||||
r.URL.Host = "unixsocket"
|
||||
|
||||
res, err := proxy.Transport.proxyDockerRequest(r)
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
if res != nil && res.StatusCode != 0 {
|
||||
code = res.StatusCode
|
||||
}
|
||||
httperror.WriteErrorResponse(w, err, code, nil)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
for k, vv := range res.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(w, res.Body); err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
|
||||
}
|
||||
}
|
||||
283
api/http/proxy/transport.go
Normal file
283
api/http/proxy/transport.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
type (
|
||||
proxyTransport struct {
|
||||
dockerTransport *http.Transport
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
SettingsService portainer.SettingsService
|
||||
}
|
||||
restrictedOperationContext struct {
|
||||
isAdmin bool
|
||||
userID portainer.UserID
|
||||
userTeamIDs []portainer.TeamID
|
||||
resourceControls []portainer.ResourceControl
|
||||
}
|
||||
operationExecutor struct {
|
||||
operationContext *restrictedOperationContext
|
||||
labelBlackList []portainer.Pair
|
||||
}
|
||||
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
|
||||
)
|
||||
|
||||
func newSocketTransport(socketPath string) *http.Transport {
|
||||
return &http.Transport{
|
||||
Dial: func(proto, addr string) (conn net.Conn, err error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPTransport() *http.Transport {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return p.proxyDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
return p.dockerTransport.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
path := request.URL.Path
|
||||
|
||||
if strings.HasPrefix(path, "/containers") {
|
||||
return p.proxyContainerRequest(request)
|
||||
} else if strings.HasPrefix(path, "/services") {
|
||||
return p.proxyServiceRequest(request)
|
||||
} else if strings.HasPrefix(path, "/volumes") {
|
||||
return p.proxyVolumeRequest(request)
|
||||
} else if strings.HasPrefix(path, "/swarm") {
|
||||
return p.proxySwarmRequest(request)
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/containers/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/containers/prune":
|
||||
return p.administratorOperation(request)
|
||||
|
||||
case "/containers/json":
|
||||
return p.rewriteOperationWithLabelFiltering(request, containerListOperation)
|
||||
|
||||
default:
|
||||
// This section assumes /containers/**
|
||||
if match, _ := path.Match("/containers/*/*", requestPath); match {
|
||||
// Handle /containers/{id}/{action} requests
|
||||
containerID := path.Base(path.Dir(requestPath))
|
||||
action := path.Base(requestPath)
|
||||
|
||||
if action == "json" {
|
||||
return p.rewriteOperation(request, containerInspectOperation)
|
||||
}
|
||||
return p.restrictedOperation(request, containerID)
|
||||
} else if match, _ := path.Match("/containers/*", requestPath); match {
|
||||
// Handle /containers/{id} requests
|
||||
containerID := path.Base(requestPath)
|
||||
return p.restrictedOperation(request, containerID)
|
||||
}
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/services/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/services":
|
||||
return p.rewriteOperation(request, serviceListOperation)
|
||||
|
||||
default:
|
||||
// This section assumes /services/**
|
||||
if match, _ := path.Match("/services/*/*", requestPath); match {
|
||||
// Handle /services/{id}/{action} requests
|
||||
serviceID := path.Base(path.Dir(requestPath))
|
||||
return p.restrictedOperation(request, serviceID)
|
||||
} else if match, _ := path.Match("/services/*", requestPath); match {
|
||||
// Handle /services/{id} requests
|
||||
serviceID := path.Base(requestPath)
|
||||
|
||||
if request.Method == http.MethodGet {
|
||||
return p.rewriteOperation(request, serviceInspectOperation)
|
||||
}
|
||||
return p.restrictedOperation(request, serviceID)
|
||||
}
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/volumes/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/volumes/prune":
|
||||
return p.administratorOperation(request)
|
||||
|
||||
case "/volumes":
|
||||
return p.rewriteOperation(request, volumeListOperation)
|
||||
|
||||
default:
|
||||
// assume /volumes/{name}
|
||||
if request.Method == http.MethodGet {
|
||||
return p.rewriteOperation(request, volumeInspectOperation)
|
||||
}
|
||||
volumeID := path.Base(requestPath)
|
||||
return p.restrictedOperation(request, volumeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
|
||||
return p.administratorOperation(request)
|
||||
}
|
||||
|
||||
// restrictedOperation ensures that the current user has the required authorizations
|
||||
// before executing the original request.
|
||||
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
|
||||
var err error
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
|
||||
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userTeamIDs := make([]portainer.TeamID, 0)
|
||||
for _, membership := range teamMemberships {
|
||||
userTeamIDs = append(userTeamIDs, membership.TeamID)
|
||||
}
|
||||
|
||||
resourceControls, err := p.ResourceControlService.ResourceControls()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceControl := getResourceControlByResourceID(resourceID, resourceControls)
|
||||
if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
|
||||
return writeAccessDeniedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
// rewriteOperation will create a new operation context with data that will be used
|
||||
// to decorate the original request's response as well as retrieve all the black listed labels
|
||||
// to filter the resources.
|
||||
func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
|
||||
operationContext, err := p.createOperationContext(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, err := p.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executor := &operationExecutor{
|
||||
operationContext: operationContext,
|
||||
labelBlackList: settings.BlackListedLabels,
|
||||
}
|
||||
|
||||
return p.executeRequestAndRewriteResponse(request, operation, executor)
|
||||
}
|
||||
|
||||
// rewriteOperation will create a new operation context with data that will be used
|
||||
// to decorate the original request's response.
|
||||
func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
|
||||
operationContext, err := p.createOperationContext(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executor := &operationExecutor{
|
||||
operationContext: operationContext,
|
||||
}
|
||||
|
||||
return p.executeRequestAndRewriteResponse(request, operation, executor)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
|
||||
response, err := p.executeDockerRequest(request)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
err = operation(request, response, executor)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// administratorOperation ensures that the user has administrator privileges
|
||||
// before executing the original request.
|
||||
func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return writeAccessDeniedResponse()
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
|
||||
var err error
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceControls, err := p.ResourceControlService.ResourceControls()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationContext := &restrictedOperationContext{
|
||||
isAdmin: true,
|
||||
userID: tokenData.ID,
|
||||
resourceControls: resourceControls,
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
operationContext.isAdmin = false
|
||||
|
||||
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userTeamIDs := make([]portainer.TeamID, 0)
|
||||
for _, membership := range teamMemberships {
|
||||
userTeamIDs = append(userTeamIDs, membership.TeamID)
|
||||
}
|
||||
operationContext.userTeamIDs = userTeamIDs
|
||||
}
|
||||
|
||||
return operationContext, nil
|
||||
}
|
||||
32
api/http/proxy/utils.go
Normal file
32
api/http/proxy/utils.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
|
||||
for _, resourceControl := range resourceControls {
|
||||
if resourceID == resourceControl.ResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
for _, subResourceID := range resourceControl.SubResourceIDs {
|
||||
if resourceID == subResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
|
||||
for key, value := range containerLabels {
|
||||
labelName := key
|
||||
labelValue := value.(string)
|
||||
|
||||
for _, blackListedLabel := range labelBlackList {
|
||||
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
73
api/http/proxy/volumes.go
Normal file
73
api/http/proxy/volumes.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier
|
||||
ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found")
|
||||
volumeIdentifier = "Name"
|
||||
)
|
||||
|
||||
// volumeListOperation extracts the response as a JSON object, loop through the volume array
|
||||
// decorate and/or filter the volumes based on resource controls before rewriting the response
|
||||
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// VolumeList response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The "Volumes" field contains the list of volumes as an array of JSON objects
|
||||
// Response schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
if responseObject["Volumes"] != nil {
|
||||
volumeData := responseObject["Volumes"].([]interface{})
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Overwrite the original volume list
|
||||
responseObject["Volumes"] = volumeData
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// volumeInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the volume based on resource control and either rewrite an access denied response
|
||||
// or a decorated volume.
|
||||
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// VolumeInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[volumeIdentifier] == nil {
|
||||
return ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
volumeID := responseObject[volumeIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
123
api/http/security/authorization.go
Normal file
123
api/http/security/authorization.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package security
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// AuthorizedResourceControlDeletion ensure that the user can delete a resource control object.
|
||||
// A non-administrator user cannot delete a resource control where:
|
||||
// * the AdministratorsOnly flag is set
|
||||
// * he is not one of the users in the user accesses
|
||||
// * he is not a member of any team within the team accesses
|
||||
func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
if resourceControl.AdministratorsOnly {
|
||||
return false
|
||||
}
|
||||
|
||||
userAccessesCount := len(resourceControl.UserAccesses)
|
||||
teamAccessesCount := len(resourceControl.TeamAccesses)
|
||||
|
||||
if teamAccessesCount > 0 {
|
||||
for _, access := range resourceControl.TeamAccesses {
|
||||
for _, membership := range context.UserMemberships {
|
||||
if membership.TeamID == access.TeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if userAccessesCount > 0 {
|
||||
for _, access := range resourceControl.UserAccesses {
|
||||
if access.UserID == context.UserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AuthorizedResourceControlUpdate ensure that the user can update a resource control object.
|
||||
// It reuses the creation restrictions and adds extra checks.
|
||||
// A non-administrator user cannot update a resource control where:
|
||||
// * he wants to put one or more user in the user accesses
|
||||
func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
|
||||
userAccessesCount := len(resourceControl.UserAccesses)
|
||||
if !context.IsAdmin && userAccessesCount > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return AuthorizedResourceControlCreation(resourceControl, context)
|
||||
}
|
||||
|
||||
// AuthorizedResourceControlCreation ensure that the user can create a resource control object.
|
||||
// A non-administrator user cannot create a resource control where:
|
||||
// * the AdministratorsOnly flag is set
|
||||
// * he wants to add more than one user in the user accesses
|
||||
// * he wants to add a team he is not a member of
|
||||
func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
if resourceControl.AdministratorsOnly {
|
||||
return false
|
||||
}
|
||||
|
||||
userAccessesCount := len(resourceControl.UserAccesses)
|
||||
teamAccessesCount := len(resourceControl.TeamAccesses)
|
||||
if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if userAccessesCount == 1 {
|
||||
access := resourceControl.UserAccesses[0]
|
||||
if access.UserID == context.UserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if teamAccessesCount > 0 {
|
||||
for _, access := range resourceControl.TeamAccesses {
|
||||
isMember := false
|
||||
for _, membership := range context.UserMemberships {
|
||||
if membership.TeamID == access.TeamID {
|
||||
isMember = true
|
||||
}
|
||||
}
|
||||
if !isMember {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizedTeamManagement ensure that access to the management of the specified team is granted.
|
||||
// It will check if the user is either administrator or leader of that team.
|
||||
func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, membership := range context.UserMemberships {
|
||||
if membership.TeamID == teamID && membership.Role == portainer.TeamLeader {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AuthorizedUserManagement ensure that access to the management of the specified user is granted.
|
||||
// It will check if the user is either administrator or the owner of the user account.
|
||||
func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin || context.UserID == userID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
176
api/http/security/bouncer.go
Normal file
176
api/http/security/bouncer.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
RequestBouncer struct {
|
||||
jwtService portainer.JWTService
|
||||
teamMembershipService portainer.TeamMembershipService
|
||||
authDisabled bool
|
||||
}
|
||||
|
||||
// RestrictedRequestContext is a data structure containing information
|
||||
// used in RestrictedAccess
|
||||
RestrictedRequestContext struct {
|
||||
IsAdmin bool
|
||||
IsTeamLeader bool
|
||||
UserID portainer.UserID
|
||||
UserMemberships []portainer.TeamMembership
|
||||
}
|
||||
)
|
||||
|
||||
// NewRequestBouncer initializes a new RequestBouncer
|
||||
func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
|
||||
return &RequestBouncer{
|
||||
jwtService: jwtService,
|
||||
teamMembershipService: teamMembershipService,
|
||||
authDisabled: authDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
// PublicAccess defines a security check for public endpoints.
|
||||
// No authentication is required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
||||
h = mwSecureHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// AuthenticatedAccess defines a security check for private endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwCheckAuthentication(h)
|
||||
h = mwSecureHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// RestrictedAccess defines a security check for restricted endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The request context will be enhanced with a RestrictedRequestContext object
|
||||
// that might be used later to authorize/filter access to resources.
|
||||
func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.AuthenticatedAccess(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// AdministratorAccess defines a chain of middleware for restricted endpoints.
|
||||
// Authentication as well as administrator role are required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler {
|
||||
h = mwCheckAdministratorRole(h)
|
||||
h = bouncer.AuthenticatedAccess(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// mwSecureHeaders provides secure headers middleware for handlers.
|
||||
func mwSecureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Add("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// mwUpgradeToRestrictedRequest will enhance the current request with
|
||||
// a new RestrictedRequestContext object.
|
||||
func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := storeRestrictedRequestContext(r, requestContext)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// mwCheckAdministratorRole check the role of the user associated to the request
|
||||
func mwCheckAdministratorRole(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil || tokenData.Role != portainer.AdministratorRole {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// mwCheckAuthentication provides Authentication middleware for handlers
|
||||
func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenData *portainer.TokenData
|
||||
if !bouncer.authDisabled {
|
||||
var token string
|
||||
|
||||
// Get token from the Authorization header
|
||||
tokens, ok := r.Header["Authorization"]
|
||||
if ok && len(tokens) >= 1 {
|
||||
token = tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tokenData = &portainer.TokenData{
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
}
|
||||
|
||||
ctx := storeTokenData(r, tokenData)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) {
|
||||
requestContext := &RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if userRole != portainer.AdministratorRole {
|
||||
requestContext.IsAdmin = false
|
||||
memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isTeamLeader := false
|
||||
for _, membership := range memberships {
|
||||
if membership.Role == portainer.TeamLeader {
|
||||
isTeamLeader = true
|
||||
}
|
||||
}
|
||||
|
||||
requestContext.IsTeamLeader = isTeamLeader
|
||||
requestContext.UserMemberships = memberships
|
||||
}
|
||||
|
||||
return requestContext, nil
|
||||
}
|
||||
50
api/http/security/context.go
Normal file
50
api/http/security/context.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type (
|
||||
contextKey int
|
||||
)
|
||||
|
||||
const (
|
||||
contextAuthenticationKey contextKey = iota
|
||||
contextRestrictedRequest
|
||||
)
|
||||
|
||||
// storeTokenData stores a TokenData object inside the request context and returns the enhanced context.
|
||||
func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context {
|
||||
return context.WithValue(request.Context(), contextAuthenticationKey, tokenData)
|
||||
}
|
||||
|
||||
// RetrieveTokenData returns the TokenData object stored in the request context.
|
||||
func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) {
|
||||
contextData := request.Context().Value(contextAuthenticationKey)
|
||||
if contextData == nil {
|
||||
return nil, portainer.ErrMissingContextData
|
||||
}
|
||||
|
||||
tokenData := contextData.(*portainer.TokenData)
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
|
||||
// and returns the enhanced context.
|
||||
func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
|
||||
return context.WithValue(request.Context(), contextRestrictedRequest, requestContext)
|
||||
}
|
||||
|
||||
// RetrieveRestrictedRequestContext returns the RestrictedRequestContext object stored in the request context.
|
||||
func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) {
|
||||
contextData := request.Context().Value(contextRestrictedRequest)
|
||||
if contextData == nil {
|
||||
return nil, portainer.ErrMissingSecurityContext
|
||||
}
|
||||
|
||||
requestContext := contextData.(*RestrictedRequestContext)
|
||||
return requestContext, nil
|
||||
}
|
||||
129
api/http/security/filter.go
Normal file
129
api/http/security/filter.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package security
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// FilterUserTeams filters teams based on user role.
|
||||
// non-administrator users only have access to team they are member of.
|
||||
func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
|
||||
filteredTeams := teams
|
||||
|
||||
if !context.IsAdmin {
|
||||
filteredTeams = make([]portainer.Team, 0)
|
||||
for _, membership := range context.UserMemberships {
|
||||
for _, team := range teams {
|
||||
if team.ID == membership.TeamID {
|
||||
filteredTeams = append(filteredTeams, team)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredTeams
|
||||
}
|
||||
|
||||
// FilterLeaderTeams filters teams based on user role.
|
||||
// Team leaders only have access to team they lead.
|
||||
func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
|
||||
filteredTeams := teams
|
||||
|
||||
if context.IsTeamLeader {
|
||||
filteredTeams = make([]portainer.Team, 0)
|
||||
for _, membership := range context.UserMemberships {
|
||||
for _, team := range teams {
|
||||
if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader {
|
||||
filteredTeams = append(filteredTeams, team)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredTeams
|
||||
}
|
||||
|
||||
// FilterUsers filters users based on user role.
|
||||
// Non-administrator users only have access to non-administrator users.
|
||||
func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User {
|
||||
filteredUsers := users
|
||||
|
||||
if !context.IsAdmin {
|
||||
filteredUsers = make([]portainer.User, 0)
|
||||
|
||||
for _, user := range users {
|
||||
if user.Role != portainer.AdministratorRole {
|
||||
filteredUsers = append(filteredUsers, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredUsers
|
||||
}
|
||||
|
||||
// FilterRegistries filters registries based on user role and team memberships.
|
||||
// Non administrator users only have access to authorized endpoints.
|
||||
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) {
|
||||
|
||||
filteredRegistries := registries
|
||||
if !context.IsAdmin {
|
||||
filteredRegistries = make([]portainer.Registry, 0)
|
||||
|
||||
for _, registry := range registries {
|
||||
if isRegistryAccessAuthorized(®istry, context.UserID, context.UserMemberships) {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredRegistries, nil
|
||||
}
|
||||
|
||||
// FilterEndpoints filters endpoints based on user role and team memberships.
|
||||
// Non administrator users only have access to authorized endpoints.
|
||||
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
|
||||
filteredEndpoints := endpoints
|
||||
|
||||
if !context.IsAdmin {
|
||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints, nil
|
||||
}
|
||||
|
||||
func isRegistryAccessAuthorized(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
for _, authorizedUserID := range registry.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range registry.AuthorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
for _, authorizedUserID := range endpoint.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
109
api/http/server.go
Normal file
109
api/http/server.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/handler"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Server implements the portainer.Server interface
|
||||
type Server struct {
|
||||
BindAddress string
|
||||
AssetsPath string
|
||||
AuthDisabled bool
|
||||
EndpointManagement bool
|
||||
Status *portainer.Status
|
||||
UserService portainer.UserService
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
EndpointService portainer.EndpointService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
SettingsService portainer.SettingsService
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
FileService portainer.FileService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
LDAPService portainer.LDAPService
|
||||
Handler *handler.Handler
|
||||
SSL bool
|
||||
SSLCert string
|
||||
SSLKey string
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (server *Server) Start() error {
|
||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
|
||||
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
|
||||
|
||||
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
|
||||
authHandler.UserService = server.UserService
|
||||
authHandler.CryptoService = server.CryptoService
|
||||
authHandler.JWTService = server.JWTService
|
||||
authHandler.LDAPService = server.LDAPService
|
||||
authHandler.SettingsService = server.SettingsService
|
||||
var userHandler = handler.NewUserHandler(requestBouncer)
|
||||
userHandler.UserService = server.UserService
|
||||
userHandler.TeamService = server.TeamService
|
||||
userHandler.TeamMembershipService = server.TeamMembershipService
|
||||
userHandler.CryptoService = server.CryptoService
|
||||
userHandler.ResourceControlService = server.ResourceControlService
|
||||
userHandler.SettingsService = server.SettingsService
|
||||
var teamHandler = handler.NewTeamHandler(requestBouncer)
|
||||
teamHandler.TeamService = server.TeamService
|
||||
teamHandler.TeamMembershipService = server.TeamMembershipService
|
||||
var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer)
|
||||
teamMembershipHandler.TeamMembershipService = server.TeamMembershipService
|
||||
var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status)
|
||||
var settingsHandler = handler.NewSettingsHandler(requestBouncer)
|
||||
settingsHandler.SettingsService = server.SettingsService
|
||||
settingsHandler.LDAPService = server.LDAPService
|
||||
settingsHandler.FileService = server.FileService
|
||||
var templatesHandler = handler.NewTemplatesHandler(requestBouncer)
|
||||
templatesHandler.SettingsService = server.SettingsService
|
||||
var dockerHandler = handler.NewDockerHandler(requestBouncer)
|
||||
dockerHandler.EndpointService = server.EndpointService
|
||||
dockerHandler.TeamMembershipService = server.TeamMembershipService
|
||||
dockerHandler.ProxyManager = proxyManager
|
||||
var websocketHandler = handler.NewWebSocketHandler()
|
||||
websocketHandler.EndpointService = server.EndpointService
|
||||
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
|
||||
endpointHandler.EndpointService = server.EndpointService
|
||||
endpointHandler.FileService = server.FileService
|
||||
endpointHandler.ProxyManager = proxyManager
|
||||
var registryHandler = handler.NewRegistryHandler(requestBouncer)
|
||||
registryHandler.RegistryService = server.RegistryService
|
||||
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
|
||||
dockerHubHandler.DockerHubService = server.DockerHubService
|
||||
var resourceHandler = handler.NewResourceHandler(requestBouncer)
|
||||
resourceHandler.ResourceControlService = server.ResourceControlService
|
||||
var uploadHandler = handler.NewUploadHandler(requestBouncer)
|
||||
uploadHandler.FileService = server.FileService
|
||||
var fileHandler = handler.NewFileHandler(server.AssetsPath)
|
||||
|
||||
server.Handler = &handler.Handler{
|
||||
AuthHandler: authHandler,
|
||||
UserHandler: userHandler,
|
||||
TeamHandler: teamHandler,
|
||||
TeamMembershipHandler: teamMembershipHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
DockerHubHandler: dockerHubHandler,
|
||||
ResourceHandler: resourceHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
StatusHandler: statusHandler,
|
||||
TemplatesHandler: templatesHandler,
|
||||
DockerHandler: dockerHandler,
|
||||
WebSocketHandler: websocketHandler,
|
||||
FileHandler: fileHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
}
|
||||
|
||||
if server.SSL {
|
||||
return http.ListenAndServeTLS(server.BindAddress, server.SSLCert, server.SSLKey, server.Handler)
|
||||
}
|
||||
return http.ListenAndServe(server.BindAddress, server.Handler)
|
||||
}
|
||||
79
api/jwt/jwt.go
Normal file
79
api/jwt/jwt.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gorilla/securecookie"
|
||||
)
|
||||
|
||||
// Service represents a service for managing JWT tokens.
|
||||
type Service struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
UserID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role int `json:"role"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
|
||||
func NewService() (*Service, error) {
|
||||
secret := securecookie.GenerateRandomKey(32)
|
||||
if secret == nil {
|
||||
return nil, portainer.ErrSecretGeneration
|
||||
}
|
||||
service := &Service{
|
||||
secret,
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GenerateToken generates a new JWT token.
|
||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
||||
expireToken := time.Now().Add(time.Hour * 8).Unix()
|
||||
cl := claims{
|
||||
int(data.ID),
|
||||
data.Username,
|
||||
int(data.Role),
|
||||
jwt.StandardClaims{
|
||||
ExpiresAt: expireToken,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
|
||||
|
||||
signedToken, err := token.SignedString(service.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
|
||||
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
return nil, msg
|
||||
}
|
||||
return service.secret, nil
|
||||
})
|
||||
if err == nil && parsedToken != nil {
|
||||
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
|
||||
tokenData := &portainer.TokenData{
|
||||
ID: portainer.UserID(cl.UserID),
|
||||
Username: cl.Username,
|
||||
Role: portainer.UserRole(cl.Role),
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, portainer.ErrInvalidJWTToken
|
||||
}
|
||||
123
api/ldap/ldap.go
Normal file
123
api/ldap/ldap.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
|
||||
"gopkg.in/ldap.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrUserNotFound defines an error raised when the user is not found via LDAP search
|
||||
// or that too many entries (> 1) are returned.
|
||||
ErrUserNotFound = portainer.Error("User not found or too many entries returned")
|
||||
)
|
||||
|
||||
// Service represents a service used to authenticate users against a LDAP/AD.
|
||||
type Service struct{}
|
||||
|
||||
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
|
||||
var userDN string
|
||||
found := false
|
||||
for _, searchSettings := range settings {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
searchSettings.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username),
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Deliberately skip errors on the search request so that we can jump to other search settings
|
||||
// if any issue arise with the current one.
|
||||
sr, _ := conn.Search(searchRequest)
|
||||
|
||||
if len(sr.Entries) == 1 {
|
||||
found = true
|
||||
userDN = sr.Entries[0].DN
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", ErrUserNotFound
|
||||
}
|
||||
|
||||
return userDN, nil
|
||||
}
|
||||
|
||||
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
|
||||
if settings.TLSConfig.TLS || settings.StartTLS {
|
||||
config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.ServerName = strings.Split(settings.URL, ":")[0]
|
||||
|
||||
if settings.TLSConfig.TLS {
|
||||
return ldap.DialTLS("tcp", settings.URL, config)
|
||||
}
|
||||
|
||||
conn, err := ldap.Dial("tcp", settings.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = conn.StartTLS(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
return ldap.Dial("tcp", settings.URL)
|
||||
}
|
||||
|
||||
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
|
||||
func (*Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error {
|
||||
|
||||
connection, err := createConnection(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userDN, err := searchUser(username, connection, settings.SearchSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = connection.Bind(userDN, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnectivity is used to test a connection against the LDAP server using the credentials
|
||||
// specified in the LDAPSettings.
|
||||
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
||||
|
||||
connection, err := createConnection(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
400
api/portainer.go
Normal file
400
api/portainer.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package portainer
|
||||
|
||||
import "io"
|
||||
|
||||
type (
|
||||
// Pair defines a key/value string pair
|
||||
Pair struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// CLIFlags represents the available flags on the CLI.
|
||||
CLIFlags struct {
|
||||
Addr *string
|
||||
Assets *string
|
||||
Data *string
|
||||
ExternalEndpoints *string
|
||||
SyncInterval *string
|
||||
Endpoint *string
|
||||
NoAuth *bool
|
||||
NoAnalytics *bool
|
||||
TLSVerify *bool
|
||||
TLSCacert *string
|
||||
TLSCert *string
|
||||
TLSKey *string
|
||||
SSL *bool
|
||||
SSLCert *string
|
||||
SSLKey *string
|
||||
AdminPassword *string
|
||||
// Deprecated fields
|
||||
Logo *string
|
||||
Templates *string
|
||||
Labels *[]Pair
|
||||
}
|
||||
|
||||
// Status represents the application status.
|
||||
Status struct {
|
||||
Authentication bool `json:"Authentication"`
|
||||
EndpointManagement bool `json:"EndpointManagement"`
|
||||
Analytics bool `json:"Analytics"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
// LDAPSettings represents the settings used to connect to a LDAP server.
|
||||
LDAPSettings struct {
|
||||
ReaderDN string `json:"ReaderDN"`
|
||||
Password string `json:"Password"`
|
||||
URL string `json:"URL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
StartTLS bool `json:"StartTLS"`
|
||||
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
|
||||
}
|
||||
|
||||
// TLSConfiguration represents a TLS configuration.
|
||||
TLSConfiguration struct {
|
||||
TLS bool `json:"TLS"`
|
||||
TLSSkipVerify bool `json:"TLSSkipVerify"`
|
||||
TLSCACertPath string `json:"TLSCACert,omitempty"`
|
||||
TLSCertPath string `json:"TLSCert,omitempty"`
|
||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPSearchSettings represents settings used to search for users in a LDAP server.
|
||||
LDAPSearchSettings struct {
|
||||
BaseDN string `json:"BaseDN"`
|
||||
Filter string `json:"Filter"`
|
||||
UserNameAttribute string `json:"UserNameAttribute"`
|
||||
}
|
||||
|
||||
// Settings represents the application settings.
|
||||
Settings struct {
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
Language string `json:"Language"`
|
||||
}
|
||||
|
||||
// User represents a user account.
|
||||
User struct {
|
||||
ID UserID `json:"Id"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password,omitempty"`
|
||||
Role UserRole `json:"Role"`
|
||||
}
|
||||
|
||||
// UserID represents a user identifier
|
||||
UserID int
|
||||
|
||||
// UserRole represents the role of a user. It can be either an administrator
|
||||
// or a regular user
|
||||
UserRole int
|
||||
|
||||
// AuthenticationMethod represents the authentication method used to authenticate a user.
|
||||
AuthenticationMethod int
|
||||
|
||||
// Team represents a list of user accounts.
|
||||
Team struct {
|
||||
ID TeamID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// TeamID represents a team identifier
|
||||
TeamID int
|
||||
|
||||
// TeamMembership represents a membership association between a user and a team
|
||||
TeamMembership struct {
|
||||
ID TeamMembershipID `json:"Id"`
|
||||
UserID UserID `json:"UserID"`
|
||||
TeamID TeamID `json:"TeamID"`
|
||||
Role MembershipRole `json:"Role"`
|
||||
}
|
||||
|
||||
// TeamMembershipID represents a team membership identifier
|
||||
TeamMembershipID int
|
||||
|
||||
// MembershipRole represents the role of a user within a team
|
||||
MembershipRole int
|
||||
|
||||
// TokenData represents the data embedded in a JWT token.
|
||||
TokenData struct {
|
||||
ID UserID
|
||||
Username string
|
||||
Role UserRole
|
||||
}
|
||||
|
||||
// RegistryID represents a registry identifier.
|
||||
RegistryID int
|
||||
|
||||
// Registry represents a Docker registry with all the info required
|
||||
// to connect to it.
|
||||
Registry struct {
|
||||
ID RegistryID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
}
|
||||
|
||||
// DockerHub represents all the required information to connect and use the
|
||||
// Docker Hub.
|
||||
DockerHub struct {
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
}
|
||||
|
||||
// EndpointID represents an endpoint identifier.
|
||||
EndpointID int
|
||||
|
||||
// Endpoint represents a Docker endpoint with all the info required
|
||||
// to connect to it.
|
||||
Endpoint struct {
|
||||
ID EndpointID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
PublicURL string `json:"PublicURL"`
|
||||
TLS bool `json:"TLS"`
|
||||
TLSCACertPath string `json:"TLSCACert,omitempty"`
|
||||
TLSCertPath string `json:"TLSCert,omitempty"`
|
||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
}
|
||||
|
||||
// ResourceControlID represents a resource control identifier.
|
||||
ResourceControlID int
|
||||
|
||||
// ResourceControl represent a reference to a Docker resource with specific access controls
|
||||
ResourceControl struct {
|
||||
ID ResourceControlID `json:"Id"`
|
||||
ResourceID string `json:"ResourceId"`
|
||||
SubResourceIDs []string `json:"SubResourceIds"`
|
||||
Type ResourceControlType `json:"Type"`
|
||||
AdministratorsOnly bool `json:"AdministratorsOnly"`
|
||||
|
||||
UserAccesses []UserResourceAccess `json:"UserAccesses"`
|
||||
TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated: OwnerID field is deprecated in DBVersion == 2
|
||||
OwnerID UserID `json:"OwnerId"`
|
||||
// Deprecated: AccessLevel field is deprecated in DBVersion == 2
|
||||
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
|
||||
}
|
||||
|
||||
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service).
|
||||
ResourceControlType int
|
||||
|
||||
// UserResourceAccess represents the level of control on a resource for a specific user.
|
||||
UserResourceAccess struct {
|
||||
UserID UserID `json:"UserId"`
|
||||
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
|
||||
}
|
||||
|
||||
// TeamResourceAccess represents the level of control on a resource for a specific team.
|
||||
TeamResourceAccess struct {
|
||||
TeamID TeamID `json:"TeamId"`
|
||||
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
|
||||
}
|
||||
|
||||
// ResourceAccessLevel represents the level of control associated to a resource.
|
||||
ResourceAccessLevel int
|
||||
|
||||
// TLSFileType represents a type of TLS file required to connect to a Docker endpoint.
|
||||
// It can be either a TLS CA file, a TLS certificate file or a TLS key file.
|
||||
TLSFileType int
|
||||
|
||||
// CLIService represents a service for managing CLI.
|
||||
CLIService interface {
|
||||
ParseFlags(version string) (*CLIFlags, error)
|
||||
ValidateFlags(flags *CLIFlags) error
|
||||
}
|
||||
|
||||
// DataStore defines the interface to manage the data.
|
||||
DataStore interface {
|
||||
Open() error
|
||||
Close() error
|
||||
MigrateData() error
|
||||
}
|
||||
|
||||
// Server defines the interface to serve the API.
|
||||
Server interface {
|
||||
Start() error
|
||||
}
|
||||
|
||||
// UserService represents a service for managing user data.
|
||||
UserService interface {
|
||||
User(ID UserID) (*User, error)
|
||||
UserByUsername(username string) (*User, error)
|
||||
Users() ([]User, error)
|
||||
UsersByRole(role UserRole) ([]User, error)
|
||||
CreateUser(user *User) error
|
||||
UpdateUser(ID UserID, user *User) error
|
||||
DeleteUser(ID UserID) error
|
||||
}
|
||||
|
||||
// TeamService represents a service for managing user data.
|
||||
TeamService interface {
|
||||
Team(ID TeamID) (*Team, error)
|
||||
TeamByName(name string) (*Team, error)
|
||||
Teams() ([]Team, error)
|
||||
CreateTeam(team *Team) error
|
||||
UpdateTeam(ID TeamID, team *Team) error
|
||||
DeleteTeam(ID TeamID) error
|
||||
}
|
||||
|
||||
// TeamMembershipService represents a service for managing team membership data.
|
||||
TeamMembershipService interface {
|
||||
TeamMembership(ID TeamMembershipID) (*TeamMembership, error)
|
||||
TeamMemberships() ([]TeamMembership, error)
|
||||
TeamMembershipsByUserID(userID UserID) ([]TeamMembership, error)
|
||||
TeamMembershipsByTeamID(teamID TeamID) ([]TeamMembership, error)
|
||||
CreateTeamMembership(membership *TeamMembership) error
|
||||
UpdateTeamMembership(ID TeamMembershipID, membership *TeamMembership) error
|
||||
DeleteTeamMembership(ID TeamMembershipID) error
|
||||
DeleteTeamMembershipByUserID(userID UserID) error
|
||||
DeleteTeamMembershipByTeamID(teamID TeamID) error
|
||||
}
|
||||
|
||||
// EndpointService represents a service for managing endpoint data.
|
||||
EndpointService interface {
|
||||
Endpoint(ID EndpointID) (*Endpoint, error)
|
||||
Endpoints() ([]Endpoint, error)
|
||||
CreateEndpoint(endpoint *Endpoint) error
|
||||
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
|
||||
DeleteEndpoint(ID EndpointID) error
|
||||
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
||||
}
|
||||
|
||||
// RegistryService represents a service for managing registry data.
|
||||
RegistryService interface {
|
||||
Registry(ID RegistryID) (*Registry, error)
|
||||
Registries() ([]Registry, error)
|
||||
CreateRegistry(registry *Registry) error
|
||||
UpdateRegistry(ID RegistryID, registry *Registry) error
|
||||
DeleteRegistry(ID RegistryID) error
|
||||
}
|
||||
|
||||
// DockerHubService represents a service for managing the DockerHub object.
|
||||
DockerHubService interface {
|
||||
DockerHub() (*DockerHub, error)
|
||||
StoreDockerHub(registry *DockerHub) error
|
||||
}
|
||||
|
||||
// SettingsService represents a service for managing application settings.
|
||||
SettingsService interface {
|
||||
Settings() (*Settings, error)
|
||||
StoreSettings(settings *Settings) error
|
||||
}
|
||||
|
||||
// VersionService represents a service for managing version data.
|
||||
VersionService interface {
|
||||
DBVersion() (int, error)
|
||||
StoreDBVersion(version int) error
|
||||
}
|
||||
|
||||
// ResourceControlService represents a service for managing resource control data.
|
||||
ResourceControlService interface {
|
||||
ResourceControl(ID ResourceControlID) (*ResourceControl, error)
|
||||
ResourceControlByResourceID(resourceID string) (*ResourceControl, error)
|
||||
ResourceControls() ([]ResourceControl, error)
|
||||
CreateResourceControl(rc *ResourceControl) error
|
||||
UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error
|
||||
DeleteResourceControl(ID ResourceControlID) error
|
||||
}
|
||||
|
||||
// CryptoService represents a service for encrypting/hashing data.
|
||||
CryptoService interface {
|
||||
Hash(data string) (string, error)
|
||||
CompareHashAndData(hash string, data string) error
|
||||
}
|
||||
|
||||
// JWTService represents a service for managing JWT tokens.
|
||||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||
}
|
||||
|
||||
// FileService represents a service for managing files.
|
||||
FileService interface {
|
||||
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
|
||||
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
|
||||
DeleteTLSFiles(folder string) error
|
||||
}
|
||||
|
||||
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
|
||||
EndpointWatcher interface {
|
||||
WatchEndpointFile(endpointFilePath string) error
|
||||
}
|
||||
|
||||
// LDAPService represents a service used to authenticate users against a LDAP/AD.
|
||||
LDAPService interface {
|
||||
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
||||
TestConnectivity(settings *LDAPSettings) error
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API.
|
||||
APIVersion = "1.14.0"
|
||||
// DBVersion is the version number of the Portainer database.
|
||||
DBVersion = 3
|
||||
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
|
||||
)
|
||||
|
||||
const (
|
||||
// TLSFileCA represents a TLS CA certificate file.
|
||||
TLSFileCA TLSFileType = iota
|
||||
// TLSFileCert represents a TLS certificate file.
|
||||
TLSFileCert
|
||||
// TLSFileKey represents a TLS key file.
|
||||
TLSFileKey
|
||||
)
|
||||
|
||||
const (
|
||||
_ MembershipRole = iota
|
||||
// TeamLeader represents a leader role inside a team
|
||||
TeamLeader
|
||||
// TeamMember represents a member role inside a team
|
||||
TeamMember
|
||||
)
|
||||
|
||||
const (
|
||||
_ UserRole = iota
|
||||
// AdministratorRole represents an administrator user role
|
||||
AdministratorRole
|
||||
// StandardUserRole represents a regular user role
|
||||
StandardUserRole
|
||||
)
|
||||
|
||||
const (
|
||||
_ AuthenticationMethod = iota
|
||||
// AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
|
||||
AuthenticationInternal
|
||||
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
|
||||
AuthenticationLDAP
|
||||
)
|
||||
|
||||
const (
|
||||
_ ResourceAccessLevel = iota
|
||||
// ReadWriteAccessLevel represents an access level with read-write permissions on a resource
|
||||
ReadWriteAccessLevel
|
||||
)
|
||||
|
||||
const (
|
||||
_ ResourceControlType = iota
|
||||
// ContainerResourceControl represents a resource control associated to a Docker container
|
||||
ContainerResourceControl
|
||||
// ServiceResourceControl represents a resource control associated to a Docker service
|
||||
ServiceResourceControl
|
||||
// VolumeResourceControl represents a resource control associated to a Docker volume
|
||||
VolumeResourceControl
|
||||
)
|
||||
2483
api/swagger.yaml
Normal file
2483
api/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
823
app/app.js
823
app/app.js
@@ -1,40 +1,783 @@
|
||||
angular.module('dockerui', ['dockerui.templates', 'ngRoute', 'dockerui.services', 'dockerui.filters', 'masthead', 'footer', 'dashboard', 'container', 'containers', 'images', 'image', 'startContainer', 'sidebar', 'info', 'builder', 'containerLogs', 'containerTop'])
|
||||
.config(['$routeProvider', function ($routeProvider) {
|
||||
'use strict';
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: 'app/components/dashboard/dashboard.html',
|
||||
controller: 'DashboardController'
|
||||
});
|
||||
$routeProvider.when('/containers/', {
|
||||
templateUrl: 'app/components/containers/containers.html',
|
||||
controller: 'ContainersController'
|
||||
});
|
||||
$routeProvider.when('/containers/:id/', {
|
||||
templateUrl: 'app/components/container/container.html',
|
||||
controller: 'ContainerController'
|
||||
});
|
||||
$routeProvider.when('/containers/:id/logs/', {
|
||||
templateUrl: 'app/components/containerLogs/containerlogs.html',
|
||||
controller: 'ContainerLogsController'
|
||||
});
|
||||
$routeProvider.when('/containers/:id/top', {
|
||||
templateUrl: 'app/components/containerTop/containerTop.html',
|
||||
controller: 'ContainerTopController'
|
||||
});
|
||||
$routeProvider.when('/images/', {
|
||||
templateUrl: 'app/components/images/images.html',
|
||||
controller: 'ImagesController'
|
||||
});
|
||||
$routeProvider.when('/images/:id*/', {
|
||||
templateUrl: 'app/components/image/image.html',
|
||||
controller: 'ImageController'
|
||||
});
|
||||
$routeProvider.when('/info', {templateUrl: 'app/components/info/info.html', controller: 'InfoController'});
|
||||
$routeProvider.otherwise({redirectTo: '/'});
|
||||
}])
|
||||
// This is your docker url that the api will use to make requests
|
||||
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
|
||||
.constant('DOCKER_ENDPOINT', 'dockerapi')
|
||||
.constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243
|
||||
.constant('UI_VERSION', 'v0.7.0')
|
||||
.constant('DOCKER_API_VERSION', 'v1.17');
|
||||
angular.module('portainer.filters', []);
|
||||
angular.module('portainer.rest', ['ngResource']);
|
||||
angular.module('portainer.services', []);
|
||||
angular.module('portainer.helpers', []);
|
||||
angular.module('portainer', [
|
||||
'ui.bootstrap',
|
||||
'ui.router',
|
||||
'isteven-multi-select',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'ngFileUpload',
|
||||
'angularUtils.directives.dirPagination',
|
||||
'LocalStorageModule',
|
||||
'angular-jwt',
|
||||
'angular-google-analytics',
|
||||
'pascalprecht.translate',
|
||||
'portainer.templates',
|
||||
'portainer.filters',
|
||||
'portainer.rest',
|
||||
'portainer.helpers',
|
||||
'portainer.services',
|
||||
'auth',
|
||||
'dashboard',
|
||||
'container',
|
||||
'containerConsole',
|
||||
'containerLogs',
|
||||
'serviceLogs',
|
||||
'containers',
|
||||
'createContainer',
|
||||
'createNetwork',
|
||||
'createRegistry',
|
||||
'createSecret',
|
||||
'createService',
|
||||
'createVolume',
|
||||
'docker',
|
||||
'endpoint',
|
||||
'endpointAccess',
|
||||
'endpointInit',
|
||||
'endpoints',
|
||||
'events',
|
||||
'image',
|
||||
'images',
|
||||
'main',
|
||||
'network',
|
||||
'networks',
|
||||
'node',
|
||||
'registries',
|
||||
'registry',
|
||||
'registryAccess',
|
||||
'secrets',
|
||||
'secret',
|
||||
'service',
|
||||
'services',
|
||||
'settings',
|
||||
'settingsAuthentication',
|
||||
'sidebar',
|
||||
'stats',
|
||||
'swarm',
|
||||
'task',
|
||||
'team',
|
||||
'teams',
|
||||
'templates',
|
||||
'user',
|
||||
'users',
|
||||
'userSettings',
|
||||
'volume',
|
||||
'volumes'])
|
||||
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', '$translateProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider, $translateProvider) {
|
||||
'use strict';
|
||||
|
||||
var environment = '@@ENVIRONMENT';
|
||||
if (environment === 'production') {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
}
|
||||
|
||||
localStorageServiceProvider
|
||||
.setPrefix('portainer');
|
||||
|
||||
jwtOptionsProvider.config({
|
||||
tokenGetter: ['LocalStorage', function(LocalStorage) {
|
||||
return LocalStorage.getJWT();
|
||||
}],
|
||||
unauthenticatedRedirector: ['$state', function($state) {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
}]
|
||||
});
|
||||
$httpProvider.interceptors.push('jwtInterceptor');
|
||||
|
||||
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
|
||||
AnalyticsProvider.startOffline(true);
|
||||
|
||||
$urlRouterProvider.otherwise('/auth');
|
||||
|
||||
toastr.options.timeOut = 3000;
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
'mouseenter': 'mouseleave',
|
||||
'click': 'click',
|
||||
'focus': 'blur',
|
||||
'outsideClick': 'outsideClick'
|
||||
});
|
||||
|
||||
$translateProvider.useSanitizeValueStrategy('sanitizeParameters');
|
||||
$translateProvider
|
||||
.useStaticFilesLoader({
|
||||
'prefix': 'i18n/',
|
||||
'suffix': '.json'
|
||||
});
|
||||
$translateProvider.preferredLanguage('en');
|
||||
$translateProvider.fallbackLanguage('en');
|
||||
|
||||
$stateProvider
|
||||
.state('root', {
|
||||
abstract: true,
|
||||
resolve: {
|
||||
requiresLogin: ['StateManager', function (StateManager) {
|
||||
var applicationState = StateManager.getState();
|
||||
return applicationState.application.authentication;
|
||||
}]
|
||||
}
|
||||
})
|
||||
.state('auth', {
|
||||
parent: 'root',
|
||||
url: '/auth',
|
||||
params: {
|
||||
logout: false,
|
||||
error: ''
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/auth/auth.html',
|
||||
controller: 'AuthenticationController'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
requiresLogin: false
|
||||
}
|
||||
})
|
||||
.state('containers', {
|
||||
parent: 'root',
|
||||
url: '/containers/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containers/containers.html',
|
||||
controller: 'ContainersController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('container', {
|
||||
url: '^/containers/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/container/container.html',
|
||||
controller: 'ContainerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('stats', {
|
||||
url: '^/containers/:id/stats',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/stats/stats.html',
|
||||
controller: 'StatsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('containerlogs', {
|
||||
url: '^/containers/:id/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containerLogs/containerlogs.html',
|
||||
controller: 'ContainerLogsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('servicelogs', {
|
||||
url: '^/services/:id/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/serviceLogs/servicelogs.html',
|
||||
controller: 'ServiceLogsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('console', {
|
||||
url: '^/containers/:id/console',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containerConsole/containerConsole.html',
|
||||
controller: 'ContainerConsoleController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('dashboard', {
|
||||
parent: 'root',
|
||||
url: '/dashboard',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/dashboard/dashboard.html',
|
||||
controller: 'DashboardController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions', {
|
||||
abstract: true,
|
||||
url: '/actions',
|
||||
views: {
|
||||
'content@': {
|
||||
template: '<div ui-view="content@"></div>'
|
||||
},
|
||||
'sidebar@': {
|
||||
template: '<div ui-view="sidebar@"></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create', {
|
||||
abstract: true,
|
||||
url: '/create',
|
||||
views: {
|
||||
'content@': {
|
||||
template: '<div ui-view="content@"></div>'
|
||||
},
|
||||
'sidebar@': {
|
||||
template: '<div ui-view="sidebar@"></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.container', {
|
||||
url: '/container/:from',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createContainer/createcontainer.html',
|
||||
controller: 'CreateContainerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.network', {
|
||||
url: '/network',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createNetwork/createnetwork.html',
|
||||
controller: 'CreateNetworkController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.registry', {
|
||||
url: '/registry',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createRegistry/createregistry.html',
|
||||
controller: 'CreateRegistryController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.secret', {
|
||||
url: '/secret',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createSecret/createsecret.html',
|
||||
controller: 'CreateSecretController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.service', {
|
||||
url: '/service',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createService/createservice.html',
|
||||
controller: 'CreateServiceController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.volume', {
|
||||
url: '/volume',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createVolume/createvolume.html',
|
||||
controller: 'CreateVolumeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('docker', {
|
||||
url: '/docker/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/docker/docker.html',
|
||||
controller: 'DockerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoints', {
|
||||
url: '/endpoints/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpoints/endpoints.html',
|
||||
controller: 'EndpointsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoint', {
|
||||
url: '^/endpoints/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpoint/endpoint.html',
|
||||
controller: 'EndpointController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoint.access', {
|
||||
url: '^/endpoints/:id/access',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpointAccess/endpointAccess.html',
|
||||
controller: 'EndpointAccessController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpointInit', {
|
||||
url: '/init/endpoint',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpointInit/endpointInit.html',
|
||||
controller: 'EndpointInitController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('events', {
|
||||
url: '/events/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/events/events.html',
|
||||
controller: 'EventsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('images', {
|
||||
url: '/images/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/images/images.html',
|
||||
controller: 'ImagesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('image', {
|
||||
url: '^/images/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/image/image.html',
|
||||
controller: 'ImageController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('networks', {
|
||||
url: '/networks/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/networks/networks.html',
|
||||
controller: 'NetworksController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('network', {
|
||||
url: '^/networks/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/network/network.html',
|
||||
controller: 'NetworkController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('node', {
|
||||
url: '^/nodes/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/node/node.html',
|
||||
controller: 'NodeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registries', {
|
||||
url: '/registries/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registries/registries.html',
|
||||
controller: 'RegistriesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registry', {
|
||||
url: '^/registries/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registry/registry.html',
|
||||
controller: 'RegistryController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registry.access', {
|
||||
url: '^/registries/:id/access',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registryAccess/registryAccess.html',
|
||||
controller: 'RegistryAccessController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('secrets', {
|
||||
url: '^/secrets/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/secrets/secrets.html',
|
||||
controller: 'SecretsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('secret', {
|
||||
url: '^/secret/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/secret/secret.html',
|
||||
controller: 'SecretController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('services', {
|
||||
url: '/services/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/services/services.html',
|
||||
controller: 'ServicesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('service', {
|
||||
url: '^/service/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/service/service.html',
|
||||
controller: 'ServiceController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('settings', {
|
||||
url: '/settings/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/settings/settings.html',
|
||||
controller: 'SettingsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('settings_authentication', {
|
||||
url: '^/settings/authentication',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
|
||||
controller: 'SettingsAuthenticationController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('task', {
|
||||
url: '^/task/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/task/task.html',
|
||||
controller: 'TaskController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('templates', {
|
||||
url: '/templates/',
|
||||
params: {
|
||||
key: 'containers',
|
||||
hide_descriptions: false
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('templates_linuxserver', {
|
||||
url: '^/templates/linuxserver.io',
|
||||
params: {
|
||||
key: 'linuxserver.io',
|
||||
hide_descriptions: true
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('volumes', {
|
||||
url: '/volumes/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/volumes/volumes.html',
|
||||
controller: 'VolumesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('volume', {
|
||||
url: '^/volumes/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/volume/volume.html',
|
||||
controller: 'VolumeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('users', {
|
||||
url: '/users/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/users/users.html',
|
||||
controller: 'UsersController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('user', {
|
||||
url: '^/users/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/user/user.html',
|
||||
controller: 'UserController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('userSettings', {
|
||||
url: '/userSettings/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/userSettings/userSettings.html',
|
||||
controller: 'UserSettingsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('teams', {
|
||||
url: '/teams/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/teams/teams.html',
|
||||
controller: 'TeamsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('team', {
|
||||
url: '^/teams/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/team/team.html',
|
||||
controller: 'TeamController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('swarm', {
|
||||
url: '/swarm/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/swarm/swarm.html',
|
||||
controller: 'SwarmController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
});
|
||||
}])
|
||||
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) {
|
||||
EndpointProvider.initialize();
|
||||
StateManager.initialize().then(function success(state) {
|
||||
if (state.application.authentication) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
authManager.redirectWhenUnauthenticated();
|
||||
Authentication.init();
|
||||
$rootScope.$on('tokenHasExpired', function($state) {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
});
|
||||
}
|
||||
if (state.application.analytics) {
|
||||
Analytics.offline(false);
|
||||
Analytics.registerScriptTags();
|
||||
Analytics.registerTrackers();
|
||||
$rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromParams) {
|
||||
Analytics.trackPage(toState.url);
|
||||
Analytics.pageView();
|
||||
});
|
||||
}
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
});
|
||||
|
||||
$rootScope.$state = $state;
|
||||
}])
|
||||
// This is your docker url that the api will use to make requests
|
||||
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
|
||||
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
|
||||
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
||||
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
||||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||
.constant('API_ENDPOINT_STATUS', 'api/status')
|
||||
.constant('API_ENDPOINT_USERS', 'api/users')
|
||||
.constant('API_ENDPOINT_TEAMS', 'api/teams')
|
||||
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
|
||||
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
|
||||
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
|
||||
.constant('PAGINATION_MAX_ITEMS', 10);
|
||||
|
||||
101
app/components/auth/auth.html
Normal file
101
app/components/auth/auth.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<div class="page-wrapper">
|
||||
<!-- login box -->
|
||||
<div class="container simple-box">
|
||||
<div class="col-md-6 col-md-offset-3 col-sm-6 col-sm-offset-3">
|
||||
<!-- login box logo -->
|
||||
<div class="row">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
|
||||
<img ng-if="!logo" src="images/logo_alt.png" class="simple-box-logo" alt="Portainer">
|
||||
</div>
|
||||
<!-- !login box logo -->
|
||||
<!-- init password panel -->
|
||||
<div class="panel panel-default" ng-if="initPassword">
|
||||
<div class="panel-body">
|
||||
<!-- init password form -->
|
||||
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
Please specify a password for the <b>admin</b> user account.
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password.length >= 8]" aria-hidden="true"></i>
|
||||
Your password must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- password input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="admin_password" type="password" class="form-control" name="password" ng-model="initPasswordData.password" autofocus>
|
||||
</div>
|
||||
<!-- !password input -->
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password !== '' && initPasswordData.password === initPasswordData.password_confirmation]" aria-hidden="true"></i>
|
||||
Confirm your password
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- password confirmation input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="password_confirmation" type="password" class="form-control" name="password" ng-model="initPasswordData.password_confirmation">
|
||||
</div>
|
||||
<!-- !password confirmation input -->
|
||||
<!-- validate button -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="initPasswordData.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> Unable to create default user
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary pull-right" ng-disabled="initPasswordData.password.length < 8 || initPasswordData.password !== initPasswordData.password_confirmation" ng-click="createAdminUser()"><i class="fa fa-key" aria-hidden="true"></i> Validate</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !validate button -->
|
||||
</form>
|
||||
<!-- !init password form -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !init password panel -->
|
||||
<!-- login panel -->
|
||||
<div class="panel panel-default" ng-if="!initPassword">
|
||||
<div class="panel-body">
|
||||
<!-- login form -->
|
||||
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
|
||||
<!-- username input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-user" aria-hidden="true"></i></span>
|
||||
<input id="username" type="text" class="form-control" name="username" ng-model="authData.username" placeholder="Username" translate translate-attr-placeholder="COMMON.PLACEHOLDERS.USERNAME">
|
||||
</div>
|
||||
<!-- !username input -->
|
||||
<!-- password input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="password" type="password" class="form-control" name="password" ng-model="authData.password" autofocus>
|
||||
</div>
|
||||
<!-- !password input -->
|
||||
<!-- login button -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="authData.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ authData.error }}
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary pull-right" ng-click="authenticateUser()"><i class="fa fa-sign-in" aria-hidden="true"></i> <por-translation key="AUTH.LOGIN"></por-translation></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !login button -->
|
||||
</form>
|
||||
<!-- !login form -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !login panel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !login box -->
|
||||
</div>
|
||||
113
app/components/auth/authController.js
Normal file
113
app/components/auth/authController.js
Normal file
@@ -0,0 +1,113 @@
|
||||
angular.module('auth', [])
|
||||
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications',
|
||||
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) {
|
||||
|
||||
$scope.authData = {
|
||||
username: 'admin',
|
||||
password: '',
|
||||
error: ''
|
||||
};
|
||||
$scope.initPasswordData = {
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
error: false
|
||||
};
|
||||
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
|
||||
if (!$scope.applicationState.application.authentication) {
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
if (data.length > 0) {
|
||||
endpointID = EndpointProvider.endpointID();
|
||||
if (!endpointID) {
|
||||
endpointID = data[0].Id;
|
||||
EndpointProvider.setEndpointID(endpointID);
|
||||
}
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {
|
||||
$state.go('dashboard');
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
});
|
||||
}
|
||||
else {
|
||||
$state.go('endpointInit');
|
||||
}
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
});
|
||||
} else {
|
||||
Users.checkAdminUser({}, function () {},
|
||||
function (e) {
|
||||
if (e.status === 404) {
|
||||
$scope.initPassword = true;
|
||||
} else {
|
||||
Notifications.error('Failure', e, 'Unable to verify administrator account existence');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($stateParams.logout) {
|
||||
Authentication.logout();
|
||||
}
|
||||
|
||||
if ($stateParams.error) {
|
||||
$scope.authData.error = $stateParams.error;
|
||||
Authentication.logout();
|
||||
}
|
||||
|
||||
if (Authentication.isAuthenticated()) {
|
||||
$state.go('dashboard');
|
||||
}
|
||||
|
||||
$scope.createAdminUser = function() {
|
||||
var password = $sanitize($scope.initPasswordData.password);
|
||||
Users.initAdminUser({password: password}, function (d) {
|
||||
$scope.initPassword = false;
|
||||
$timeout(function() {
|
||||
var element = $window.document.getElementById('password');
|
||||
if(element) {
|
||||
element.focus();
|
||||
}
|
||||
});
|
||||
}, function (e) {
|
||||
$scope.initPassword.error = true;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.authenticateUser = function() {
|
||||
$scope.authenticationError = false;
|
||||
var username = $sanitize($scope.authData.username);
|
||||
var password = $sanitize($scope.authData.password);
|
||||
Authentication.login(username, password)
|
||||
.then(function success(data) {
|
||||
return EndpointService.endpoints();
|
||||
})
|
||||
.then(function success(data) {
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
if (data.length > 0) {
|
||||
endpointID = EndpointProvider.endpointID();
|
||||
if (!endpointID) {
|
||||
endpointID = data[0].Id;
|
||||
EndpointProvider.setEndpointID(endpointID);
|
||||
}
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {
|
||||
$state.go('dashboard');
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
});
|
||||
}
|
||||
else if (data.length === 0 && userDetails.role === 1) {
|
||||
$state.go('endpointInit');
|
||||
} else if (data.length === 0 && userDetails.role === 2) {
|
||||
Authentication.logout();
|
||||
$scope.authData.error = 'User not allowed. Please contact your administrator.';
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.authData.error = 'Authentication error';
|
||||
});
|
||||
};
|
||||
}]);
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="build-modal" class="modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h3>Build Image</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="editor"></div>
|
||||
<p>{{ messages }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="" class="btn btn-primary" ng-click="build()">Build</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
angular.module('builder', [])
|
||||
.controller('BuilderController', ['$scope', 'Dockerfile', 'Messages',
|
||||
function($scope, Dockerfile, Messages) {
|
||||
$scope.template = 'app/components/builder/builder.html';
|
||||
}]);
|
||||
@@ -1,157 +1,334 @@
|
||||
<div class="detail">
|
||||
<rd-header>
|
||||
<rd-header-title title="CONTAINER_DETAILS.HEADER.TITLE">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers"><por-translation key="COMMON.DOCKER.CONTAINERS"></por-translation></a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div ng-if="!container.edit">
|
||||
<h4>Container: {{ container.Name }}
|
||||
<button class="btn btn-primary"
|
||||
ng-click="container.edit = true;">Rename
|
||||
</button>
|
||||
</h4>
|
||||
</div>
|
||||
<div ng-if="container.edit">
|
||||
<h4>
|
||||
Container:
|
||||
<input type="text" ng-model="container.newContainerName">
|
||||
<button class="btn btn-success"
|
||||
ng-click="renameContainer()">Edit
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
ng-click="container.edit = false;">×</button>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="btn-group detail">
|
||||
<button class="btn btn-success"
|
||||
ng-click="start()"
|
||||
ng-show="!container.State.Running">Start
|
||||
</button>
|
||||
<button class="btn btn-warning"
|
||||
ng-click="stop()"
|
||||
ng-show="container.State.Running && !container.State.Paused">Stop
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
ng-click="kill()"
|
||||
ng-show="container.State.Running && !container.State.Paused">Kill
|
||||
</button>
|
||||
<button class="btn btn-info"
|
||||
ng-click="pause()"
|
||||
ng-show="container.State.Running && !container.State.Paused">Pause
|
||||
</button>
|
||||
<button class="btn btn-success"
|
||||
ng-click="unpause()"
|
||||
ng-show="container.State.Running && container.State.Paused">Unpause
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Created:</td>
|
||||
<td>{{ container.Created }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Path:</td>
|
||||
<td>{{ container.Path }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Args:</td>
|
||||
<td>{{ container.Args }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Exposed Ports:</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li ng-repeat="(k, v) in container.Config.ExposedPorts">{{ k }}</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Environment:</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li ng-repeat="k in container.Config.Env">{{ k }}</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Publish All:</td>
|
||||
<td>{{ container.HostConfig.PublishAllPorts }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ports:</td>
|
||||
<td>
|
||||
<ul style="display:inline-table">
|
||||
<li ng-repeat="(containerport, hostports) in container.HostConfig.PortBindings">
|
||||
{{ containerport }} => <span class="label label-default" ng-repeat="(k,v) in hostports">{{ v.HostIp }}:{{ v.HostPort }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hostname:</td>
|
||||
<td>{{ container.Config.Hostname }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IPAddress:</td>
|
||||
<td>{{ container.NetworkSettings.IPAddress }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cmd:</td>
|
||||
<td>{{ container.Config.Cmd }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Entrypoint:</td>
|
||||
<td>{{ container.Config.Entrypoint }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Volumes:</td>
|
||||
<td>{{ container.Volumes }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>SysInitpath:</td>
|
||||
<td>{{ container.SysInitPath }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image:</td>
|
||||
<td><a href="#/images/{{ container.Image }}/">{{ container.Image }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>State:</td>
|
||||
<td><span class="label {{ container.State|getstatelabel }}">{{ container.State|getstatetext }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Logs:</td>
|
||||
<td><a href="#/containers/{{ container.Id }}/logs">stdout/stderr</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Top:</td>
|
||||
<td><a href="#/containers/{{ container.Id }}/top">Top</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="row-fluid">
|
||||
<div class="span1">
|
||||
Changes:
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cogs" title="CONTAINER_DETAILS.WIDGET_ACTIONS.TITLE"></rd-widget-header>
|
||||
<rd-widget-body classes="padding">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<button class="btn btn-success" ng-click="start()" ng-disabled="container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.START"></por-translation></button>
|
||||
<button class="btn btn-danger" ng-click="stop()" ng-disabled="!container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.STOP"></por-translation></button>
|
||||
<button class="btn btn-danger" ng-click="kill()" ng-disabled="!container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.KILL"></por-translation></button>
|
||||
<button class="btn btn-primary" ng-click="restart()" ng-disabled="!container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.RESTART"></por-translation></button>
|
||||
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.PAUSE"></por-translation></button>
|
||||
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.RESUME"></por-translation></button>
|
||||
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.REMOVE"></por-translation></button>
|
||||
<button class="btn btn-danger" ng-click="recreate()"><i class="fa fa-refresh space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.RECREATE"></por-translation></button>
|
||||
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i><por-translation key="COMMON.VERBS.DUPLICATE"></por-translation> / <por-translation key="COMMON.VERBS.EDIT"></por-translation></button>
|
||||
</div>
|
||||
<div class="span5">
|
||||
<i class="icon-refresh" style="width:32px;height:32px;" ng-click="getChanges()"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="well well-large">
|
||||
<ul>
|
||||
<li ng-repeat="change in changes | filter:hasContent">
|
||||
<strong>{{ change.Path }}</strong> {{ change.Kind }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="btn-remove">
|
||||
<button class="btn btn-large btn-block btn-primary btn-danger" ng-click="remove()">Remove Container</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.TITLE"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><por-translation key="COMMON.DOCKER.ID"></por-translation></td>
|
||||
<td>{{ container.Id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ROW_NAME"></por-translation></td>
|
||||
<td ng-if="!container.edit">
|
||||
{{ container.Name|trimcontainername }}
|
||||
<a href="" data-toggle="tooltip" title="Edit container name" ng-click="container.edit = true;"><i class="fa fa-edit"></i></a>
|
||||
</td>
|
||||
<td ng-if="container.edit">
|
||||
<form ng-submit="renameContainer()">
|
||||
<input type="text" class="containerNameInput" ng-model="container.newContainerName">
|
||||
<a href="" ng-click="container.edit = false;"><i class="fa fa-times"></i></a>
|
||||
<a href="" ng-click="renameContainer()"><i class="fa fa-check-square-o"></i></a>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.NetworkSettings.IPAddress">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ROW_IP"></por-translation></td>
|
||||
<td>{{ container.NetworkSettings.IPAddress }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><por-translation key="COMMON.NOUNS.STATUS"></por-translation></td>
|
||||
<td>
|
||||
<i class="fa fa-heartbeat space-right green-icon" ng-if="container.State.Running"></i>
|
||||
<i class="fa fa-heartbeat space-right red-icon" ng-if="!container.State.Running && container.State.Status !== 'created'"></i>
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.STATUS.RUNNING" values="{time: activityTime}" ng-if="container.State.Running"></por-translation>
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.STATUS.CREATED" values="{time: activityTime}" ng-if="container.State.Status === 'created'"></por-translation>
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.STATUS.STOPPED" values="{time: activityTime, code: container.State.ExitCode}" ng-if="!container.State.Running && container.State.Status !== 'created'"></por-translation>
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.STATUS.DEAD" values="{time: activityTime, code: container.State.ExitCode}" ng-if="container.State.Dead"></por-translation>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ROW_CREATED"></por-translation></td>
|
||||
<td>{{ container.Created|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="container.State.Running">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ROW_START_TIME"></por-translation></td>
|
||||
<td>{{ container.State.StartedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!container.State.Running && container.State.Status !== 'created'">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ROW_FINISHED"></por-translation></td>
|
||||
<td>{{ container.State.FinishedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ACTIONS.STATS"></por-translation></a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ACTIONS.LOGS"></por-translation></a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_STATUS.ACTIONS.CONSOLE"></por-translation></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel
|
||||
ng-if="container && applicationState.application.authentication"
|
||||
resource-id="container.Id"
|
||||
resource-control="container.ResourceControl"
|
||||
resource-type="'container'">
|
||||
</por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div ng-if="container.State.Health" class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="CONTAINER_DETAILS.WIDGET_CONTAINER_HEALTH.TITLE"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><por-translation key="COMMON.NOUNS.STATUS"></por-translation></td>
|
||||
<td>
|
||||
<i ng-class="{'healthy': 'fa fa-heartbeat space-right green-icon', 'unhealthy': 'fa fa-heartbeat space-right red-icon', 'starting': 'fa fa-heartbeat space-right orange-icon'}[container.State.Health.Status]"></i>
|
||||
{{ container.State.Health.Status }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_HEALTH.FAILURE_COUNT"></por-translation></td>
|
||||
<td>{{ container.State.Health.FailingStreak }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_HEALTH.LAST_OUTPUT"></por-translation></td>
|
||||
<td>{{ container.State.Health.Log[container.State.Health.Log.length - 1].Output }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-clone" title="CONTAINER_DETAILS.WIDGET_CREATE_IMAGE.TITLE"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- tag-description -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CREATE_IMAGE.DESCRIPTION"></por-translation>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tag-description -->
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="config.Registry"></por-image-registry>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CREATE_IMAGE.TAG_NOTE"></por-translation>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="commit()"><por-translation key="COMMON.VERBS.CREATE"></por-translation></button>
|
||||
<i id="createImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="CONTAINER_DETAILS.WIDGET_CONTAINER_DETAILS.TITLE"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_DETAILS.ROW_IMAGE"></por-translation></td>
|
||||
<td><a ui-sref="image({id: container.Image})">{{ container.Image }}</a></td>
|
||||
</tr>
|
||||
<tr ng-if="portBindings.length > 0">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_DETAILS.ROW_PORT"></por-translation></td>
|
||||
<td>
|
||||
<div ng-repeat="portMapping in portBindings">
|
||||
{{ portMapping.container }} <i class="fa fa-long-arrow-right"></i> {{ portMapping.host }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CMD</td>
|
||||
<td><code>{{ container.Config.Cmd|command }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ENV</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="var in container.Config.Env track by $index">
|
||||
<td>{{ var|key: '=' }}</td>
|
||||
<td>{{ var|value: '=' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!(container.Config.Labels | emptyobject)">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_DETAILS.ROW_LABELS"></por-translation></td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="(k, v) in container.Config.Labels">
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.HostConfig.RestartPolicy.Name !== 'no'">
|
||||
<td><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_DETAILS.ROW_RESTART_POLICIES"></por-translation></td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr>
|
||||
<td class="col-md-3"><por-translation key="COMMON.NOUNS.NAME"></por-translation></td>
|
||||
<td>{{ container.HostConfig.RestartPolicy.Name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-md-3">MaximumRetryCount</td>
|
||||
<td>
|
||||
{{ container.HostConfig.RestartPolicy.MaximumRetryCount }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="container.HostConfig.Binds.length > 0">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cubes" title="CONTAINER_DETAILS.WIDGET_CONTAINER_VOLUMES.TITLE"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_VOLUMES.TABLE.HEADERS.HOST"></por-translation></th>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_VOLUMES.TABLE.HEADERS.CONTAINER"></por-translation></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="vol in container.HostConfig.Binds">
|
||||
<td>{{ vol|key: ':' }}</td>
|
||||
<td>{{ vol|value: ':' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-sitemap" title="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TITLE">
|
||||
<div class="pull-right">
|
||||
<por-translation key="WIDGETS.PAGINATION.TEXT"></por-translation>
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<option value="0" translate>COMMON.UI.ALL</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.HEADERS.NETWORK_NAME"></por-translation></th>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.HEADERS.IP"></por-translation></th>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.HEADERS.GATEWAY"></por-translation></th>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.HEADERS.MAC"></por-translation></th>
|
||||
<th><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.HEADERS.ACTIONS"></por-translation></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="(key, value) in container.NetworkSettings.Networks | itemsPerPage: state.pagination_count">
|
||||
<td><a ui-sref="network({id: value.NetworkID})">{{ key }}</a></td>
|
||||
<td>{{ value.IPAddress || '-' }}</td>
|
||||
<td>{{ value.Gateway || '-' }}</td>
|
||||
<td>{{ value.MacAddress || '-' }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(container, value.NetworkID)"><i class="fa fa-trash space-right" aria-hidden="true"></i><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.DISCONNECT"></por-translation></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
|
||||
<td colspan="5" class="text-center text-muted">
|
||||
<por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.TABLE.EMPTY"></por-translation>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
<hr />
|
||||
<form class="form-horizontal">
|
||||
<!-- network-input -->
|
||||
<div class="row">
|
||||
<label for="container_network" class="col-sm-3 col-lg-2 control-label text-left"><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.CONNECT_NETWORK"></por-translation></label>
|
||||
<div class="col-sm-5 col-lg-4">
|
||||
<select class="form-control" ng-model="selectedNetwork" id="container_network">
|
||||
<option selected disabled hidden value="" translate>CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.SELECT_NETWORK</option>
|
||||
<option ng-repeat="net in availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!selectedNetwork" ng-click="containerJoinNetwork(container, selectedNetwork)"><por-translation key="CONTAINER_DETAILS.WIDGET_CONTAINER_NETWORKS.CONNECT"></por-translation></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,122 +1,315 @@
|
||||
angular.module('container', [])
|
||||
.controller('ContainerController', ['$scope', '$routeParams', '$location', 'Container', 'Messages', 'ViewSpinner',
|
||||
function($scope, $routeParams, $location, Container, Messages, ViewSpinner) {
|
||||
$scope.changes = [];
|
||||
$scope.edit = false;
|
||||
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
|
||||
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
|
||||
$scope.activityTime = 0;
|
||||
$scope.portBindings = [];
|
||||
$scope.config = {
|
||||
Image: '',
|
||||
Registry: ''
|
||||
};
|
||||
$scope.state = {};
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('container_networks');
|
||||
|
||||
var update = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.get({id: $routeParams.id}, function(d) {
|
||||
$scope.container = d;
|
||||
$scope.container.edit = false;
|
||||
$scope.container.newContainerName = d.Name;
|
||||
ViewSpinner.stop();
|
||||
}, function(e) {
|
||||
if (e.status === 404) {
|
||||
$('.detail').hide();
|
||||
Messages.error("Not found", "Container not found.");
|
||||
} else {
|
||||
Messages.error("Failure", e.data);
|
||||
}
|
||||
ViewSpinner.stop();
|
||||
$scope.changePaginationCount = function() {
|
||||
Pagination.setPaginationCount('container_networks', $scope.state.pagination_count);
|
||||
};
|
||||
|
||||
var update = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.get({id: $stateParams.id}, function (d) {
|
||||
var container = new ContainerDetailsViewModel(d);
|
||||
$scope.container = container;
|
||||
$scope.container.edit = false;
|
||||
$scope.container.newContainerName = $filter('trimcontainername')(container.Name);
|
||||
|
||||
if (container.State.Running) {
|
||||
$scope.activityTime = moment.duration(moment(container.State.StartedAt).utc().diff(moment().utc())).humanize();
|
||||
} else if (container.State.Status === 'created') {
|
||||
$scope.activityTime = moment.duration(moment(container.Created).utc().diff(moment().utc())).humanize();
|
||||
} else {
|
||||
$scope.activityTime = moment.duration(moment().utc().diff(moment(container.State.FinishedAt).utc())).humanize();
|
||||
}
|
||||
|
||||
$scope.portBindings = [];
|
||||
if (container.NetworkSettings.Ports) {
|
||||
angular.forEach(Object.keys(container.NetworkSettings.Ports), function(portMapping) {
|
||||
if (container.NetworkSettings.Ports[portMapping]) {
|
||||
var mapping = {};
|
||||
mapping.container = portMapping;
|
||||
mapping.host = container.NetworkSettings.Ports[portMapping][0].HostIp + ':' + container.NetworkSettings.Ports[portMapping][0].HostPort;
|
||||
$scope.portBindings.push(mapping);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container info');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.start = function(){
|
||||
ViewSpinner.spin();
|
||||
Container.start({
|
||||
id: $scope.container.Id,
|
||||
HostConfig: $scope.container.HostConfig
|
||||
}, function(d) {
|
||||
update();
|
||||
Messages.send("Container started", $routeParams.id);
|
||||
}, function(e) {
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to start." + e.data);
|
||||
$scope.start = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.start({id: $scope.container.Id}, {}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container started', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to start container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.stop = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.stop({id: $stateParams.id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container stopped', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to stop container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.kill = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.kill({id: $stateParams.id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container killed', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to kill container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.commit = function () {
|
||||
$('#createImageSpinner').show();
|
||||
var image = $scope.config.Image;
|
||||
var registry = $scope.config.Registry;
|
||||
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
|
||||
ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
|
||||
$('#createImageSpinner').hide();
|
||||
update();
|
||||
Notifications.success('Container commited', $stateParams.id);
|
||||
}, function (e) {
|
||||
$('#createImageSpinner').hide();
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to commit container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.pause = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.pause({id: $stateParams.id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container paused', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to pause container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.unpause = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.unpause({id: $stateParams.id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container unpaused', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to unpause container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.confirmRemove = function () {
|
||||
var title = 'You are about to remove a container.';
|
||||
if ($scope.container.State.Running) {
|
||||
title = 'You are about to remove a running container.';
|
||||
}
|
||||
ModalService.confirmContainerDeletion(
|
||||
title,
|
||||
function (result) {
|
||||
if(!result) { return; }
|
||||
var cleanAssociatedVolumes = false;
|
||||
if (result[0]) {
|
||||
cleanAssociatedVolumes = true;
|
||||
}
|
||||
$scope.remove(cleanAssociatedVolumes);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
$scope.remove = function(cleanAssociatedVolumes) {
|
||||
$('#loadingViewSpinner').show();
|
||||
ContainerService.remove($scope.container, cleanAssociatedVolumes)
|
||||
.then(function success() {
|
||||
Notifications.success('Container successfully removed');
|
||||
$state.go('containers', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.restart = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.restart({id: $stateParams.id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container restarted', $stateParams.id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to restart container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.renameContainer = function () {
|
||||
var container = $scope.container;
|
||||
Container.rename({id: $stateParams.id, 'name': container.newContainerName}, function (d) {
|
||||
if (d.message) {
|
||||
container.newContainerName = container.Name;
|
||||
Notifications.error('Unable to rename container', {}, d.message);
|
||||
} else {
|
||||
container.Name = container.newContainerName;
|
||||
Notifications.success('Container successfully renamed', container.Name);
|
||||
}
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to rename container');
|
||||
});
|
||||
$scope.container.edit = false;
|
||||
};
|
||||
|
||||
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
|
||||
$('#loadingViewSpinner').show();
|
||||
Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) {
|
||||
if (container.message) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Error', d, 'Unable to disconnect container from network');
|
||||
} else {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.success('Container left network', $stateParams.id);
|
||||
$state.go('container', {id: $stateParams.id}, {reload: true});
|
||||
}
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to disconnect container from network');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.duplicate = function() {
|
||||
ModalService.confirmExperimentalFeature(function (experimental) {
|
||||
if(!experimental) { return; }
|
||||
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.confirmRemove = function () {
|
||||
var title = 'You are about to remove a container.';
|
||||
if ($scope.container.State.Running) {
|
||||
title = 'You are about to remove a running container.';
|
||||
}
|
||||
ModalService.confirmContainerDeletion(
|
||||
title,
|
||||
function (result) {
|
||||
if(!result) { return; }
|
||||
var cleanAssociatedVolumes = false;
|
||||
if (result[0]) {
|
||||
cleanAssociatedVolumes = true;
|
||||
}
|
||||
$scope.remove(cleanAssociatedVolumes);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function recreateContainer(pullImage) {
|
||||
$('#loadingViewSpinner').show();
|
||||
var container = $scope.container;
|
||||
var config = ContainerHelper.configFromContainer(container.Model);
|
||||
ContainerService.remove(container, true)
|
||||
.then(function success() {
|
||||
return RegistryService.retrieveRegistryFromRepository(container.Config.Image);
|
||||
})
|
||||
.then(function success(data) {
|
||||
return $q.when(!pullImage || ImageService.pullImage(container.Config.Image, data, true));
|
||||
})
|
||||
.then(function success() {
|
||||
return ContainerService.createAndStartContainer(config);
|
||||
})
|
||||
.then(function success(data) {
|
||||
if (!container.ResourceControl) {
|
||||
return true;
|
||||
} else {
|
||||
var containerIdentifier = data.Id;
|
||||
var resourceControl = container.ResourceControl;
|
||||
var users = resourceControl.UserAccesses.map(function(u) {
|
||||
return u.UserId;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.stop = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.stop({id: $routeParams.id}, function(d) {
|
||||
update();
|
||||
Messages.send("Container stopped", $routeParams.id);
|
||||
}, function(e) {
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to stop." + e.data);
|
||||
var teams = resourceControl.TeamAccesses.map(function(t) {
|
||||
return t.TeamId;
|
||||
});
|
||||
};
|
||||
return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly,
|
||||
users, teams, containerIdentifier, 'container', []);
|
||||
}
|
||||
})
|
||||
.then(function success(data) {
|
||||
Notifications.success('Container successfully re-created');
|
||||
$state.go('containers', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to re-create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.kill = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.kill({id: $routeParams.id}, function(d) {
|
||||
update();
|
||||
Messages.send("Container killed", $routeParams.id);
|
||||
}, function(e) {
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to die." + e.data);
|
||||
});
|
||||
};
|
||||
$scope.recreate = function() {
|
||||
ModalService.confirmExperimentalFeature(function (experimental) {
|
||||
if(!experimental) { return; }
|
||||
|
||||
$scope.pause = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.pause({id: $routeParams.id}, function(d) {
|
||||
update();
|
||||
Messages.send("Container paused", $routeParams.id);
|
||||
}, function(e) {
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to pause." + e.data);
|
||||
});
|
||||
};
|
||||
ModalService.confirmContainerRecreation(function (result) {
|
||||
if(!result) { return; }
|
||||
var pullImage = false;
|
||||
if (result[0]) {
|
||||
pullImage = true;
|
||||
}
|
||||
recreateContainer(pullImage);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.unpause = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.unpause({id: $routeParams.id}, function(d) {
|
||||
update();
|
||||
Messages.send("Container unpaused", $routeParams.id);
|
||||
}, function(e) {
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to unpause." + e.data);
|
||||
});
|
||||
};
|
||||
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
|
||||
$('#joinNetworkSpinner').show();
|
||||
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {
|
||||
if (container.message) {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.error('Error', d, 'Unable to connect container to network');
|
||||
} else {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.success('Container joined network', $stateParams.id);
|
||||
$state.go('container', {id: $stateParams.id}, {reload: true});
|
||||
}
|
||||
}, function (e) {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to connect container to network');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.remove = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.remove({id: $routeParams.id}, function(d) {
|
||||
update();
|
||||
Messages.send("Container removed", $routeParams.id);
|
||||
}, function(e){
|
||||
update();
|
||||
Messages.error("Failure", "Container failed to remove." + e.data);
|
||||
});
|
||||
};
|
||||
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
NetworkService.networks(
|
||||
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
||||
false,
|
||||
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
|
||||
provider === 'DOCKER_SWARM'
|
||||
)
|
||||
.then(function success(data) {
|
||||
var networks = data;
|
||||
$scope.availableNetworks = networks;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve networks');
|
||||
});
|
||||
|
||||
$scope.hasContent = function(data) {
|
||||
return data !== null && data !== undefined;
|
||||
};
|
||||
|
||||
$scope.getChanges = function() {
|
||||
ViewSpinner.spin();
|
||||
Container.changes({id: $routeParams.id}, function(d) {
|
||||
$scope.changes = d;
|
||||
ViewSpinner.stop();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.renameContainer = function () {
|
||||
// #FIXME fix me later to handle http status to show the correct error message
|
||||
Container.rename({id: $routeParams.id, 'name': $scope.container.newContainerName}, function(data){
|
||||
if (data.name){
|
||||
$scope.container.Name = data.name;
|
||||
Messages.send("Container renamed", $routeParams.id);
|
||||
}else {
|
||||
$scope.container.newContainerName = $scope.container.Name;
|
||||
Messages.error("Failure", "Container failed to rename.");
|
||||
}
|
||||
});
|
||||
$scope.container.edit = false;
|
||||
};
|
||||
|
||||
update();
|
||||
$scope.getChanges();
|
||||
update();
|
||||
}]);
|
||||
|
||||
76
app/components/containerConsole/containerConsole.html
Normal file
76
app/components/containerConsole/containerConsole.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container console">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content ng-if="state.loaded">
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Console
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="state.loaded">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-terminal" title="Console">
|
||||
<div class="pull-right">
|
||||
<i id="loadConsoleSpinner" class="fa fa-cog fa-2x fa-spin" style="margin-top: 5px; display: none;"></i>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div ng-if="!state.connected">
|
||||
<!-- command-list -->
|
||||
<div class="form-group">
|
||||
<label for="command" class="col-lg-1 text-left col-sm-2 control-label">Command</label>
|
||||
<div class="col-lg-11 col-sm-10">
|
||||
<div class="input-group" ng-if="!formValues.isCustomCommand">
|
||||
<span class="input-group-addon">
|
||||
<i class="fa fa-linux" aria-hidden="true" ng-if="imageOS == 'linux'"></i>
|
||||
<i class="fa fa-windows" aria-hidden="true" ng-if="imageOS == 'windows'"></i>
|
||||
</span>
|
||||
<select class="form-control" ng-model="formValues.command" id="command">
|
||||
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
|
||||
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
|
||||
<option value="powershell" ng-if="imageOS == 'windows'">powershell</option>
|
||||
<option value="cmd.exe" ng-if="imageOS == 'windows'">cmd.exe</option>
|
||||
</select>
|
||||
</div>
|
||||
<input class="form-control" ng-if="formValues.isCustomCommand" type="text" name="custom-command" ng-model="formValues.customCommand" placeholder="e.g. ps aux">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !command-list -->
|
||||
<div class="form-group col-lg-12">
|
||||
<label for="command" class="text-left control-label">Use custom command</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.isCustomCommand"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username" class="col-lg-1 text-left col-sm-2 control-label">
|
||||
User
|
||||
<portainer-tooltip position="bottom" message="Format is one of: user, user:group, uid or uid:gid"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-lg-11 col-sm-10">
|
||||
<input class="form-control" type="text" name="username" ng-model="formValues.user" placeholder="root">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-lg-offset-1 col-sm-offset-2 col-lg-11 col-sm-10">
|
||||
<button type="button" class="btn btn-primary" ng-click="connect()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="state.connected">
|
||||
<label>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command <code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code></label>
|
||||
<button type="button" class="btn btn-default" ng-click="disconnect()">Disconnect</button>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
117
app/components/containerConsole/containerConsoleController.js
Normal file
117
app/components/containerConsole/containerConsoleController.js
Normal file
@@ -0,0 +1,117 @@
|
||||
angular.module('containerConsole', [])
|
||||
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
|
||||
function ($scope, $stateParams, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
|
||||
$scope.state = {};
|
||||
$scope.state.loaded = false;
|
||||
$scope.state.connected = false;
|
||||
$scope.formValues = {};
|
||||
|
||||
var socket, term;
|
||||
|
||||
// Ensure the socket is closed before leaving the view
|
||||
$scope.$on('$stateChangeStart', function (event, next, current) {
|
||||
if (socket && socket !== null) {
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
Container.get({id: $stateParams.id}, function(d) {
|
||||
$scope.container = d;
|
||||
if (d.message) {
|
||||
Notifications.error('Error', d, 'Unable to retrieve container details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
} else {
|
||||
Image.get({id: d.Image}, function(imgData) {
|
||||
$scope.imageOS = imgData.Os;
|
||||
$scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
|
||||
$scope.state.loaded = true;
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve image details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
|
||||
$scope.connect = function() {
|
||||
$('#loadConsoleSpinner').show();
|
||||
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
|
||||
var termHeight = 30;
|
||||
var command = $scope.formValues.isCustomCommand ?
|
||||
$scope.formValues.customCommand : $scope.formValues.command;
|
||||
var execConfig = {
|
||||
id: $stateParams.id,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
User: $scope.formValues.user,
|
||||
Cmd: ContainerHelper.commandStringToArray(command)
|
||||
};
|
||||
|
||||
var execId;
|
||||
ContainerService.createExec(execConfig)
|
||||
.then(function success(data) {
|
||||
execId = data.Id;
|
||||
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
|
||||
if (url.indexOf('https') > -1) {
|
||||
url = url.replace('https://', 'wss://');
|
||||
} else {
|
||||
url = url.replace('http://', 'ws://');
|
||||
}
|
||||
initTerm(url, termHeight, termWidth);
|
||||
return ExecService.resizeTTY(execId, termHeight, termWidth, 2000);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to exec into container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadConsoleSpinner').hide();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.disconnect = function() {
|
||||
$scope.state.connected = false;
|
||||
if (socket !== null) {
|
||||
socket.close();
|
||||
}
|
||||
if (term !== null) {
|
||||
term.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
function initTerm(url, height, width) {
|
||||
socket = new WebSocket(url);
|
||||
|
||||
$scope.state.connected = true;
|
||||
socket.onopen = function(evt) {
|
||||
$('#loadConsoleSpinner').hide();
|
||||
term = new Terminal();
|
||||
|
||||
term.on('data', function (data) {
|
||||
socket.send(data);
|
||||
});
|
||||
term.open(document.getElementById('terminal-container'), true);
|
||||
term.resize(width, height);
|
||||
term.setOption('cursorBlink', true);
|
||||
term.fit();
|
||||
|
||||
window.onresize = function() {
|
||||
term.fit();
|
||||
};
|
||||
|
||||
socket.onmessage = function (e) {
|
||||
term.write(e.data);
|
||||
};
|
||||
socket.onerror = function (error) {
|
||||
$scope.state.connected = false;
|
||||
};
|
||||
socket.onclose = function(evt) {
|
||||
$scope.state.connected = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
}]);
|
||||
@@ -1,76 +1,75 @@
|
||||
angular.module('containerLogs', [])
|
||||
.controller('ContainerLogsController', ['$scope', '$routeParams', '$location', '$anchorScroll', 'ContainerLogs', 'Container', 'ViewSpinner',
|
||||
function($scope, $routeParams, $location, $anchorScroll, ContainerLogs, Container, ViewSpinner) {
|
||||
$scope.stdout = '';
|
||||
$scope.stderr = '';
|
||||
$scope.showTimestamps = false;
|
||||
$scope.tailLines = 2000;
|
||||
.controller('ContainerLogsController', ['$scope', '$stateParams', '$anchorScroll', 'ContainerLogs', 'Container',
|
||||
function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
|
||||
$scope.state = {};
|
||||
$scope.state.displayTimestampsOut = false;
|
||||
$scope.state.displayTimestampsErr = false;
|
||||
$scope.stdout = '';
|
||||
$scope.stderr = '';
|
||||
$scope.tailLines = 2000;
|
||||
|
||||
ViewSpinner.spin();
|
||||
Container.get({id: $routeParams.id}, function(d) {
|
||||
$scope.container = d;
|
||||
ViewSpinner.stop();
|
||||
}, function(e) {
|
||||
if (e.status === 404) {
|
||||
Messages.error("Not found", "Container not found.");
|
||||
} else {
|
||||
Messages.error("Failure", e.data);
|
||||
}
|
||||
ViewSpinner.stop();
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.get({id: $stateParams.id}, function (d) {
|
||||
$scope.container = d;
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container info');
|
||||
});
|
||||
|
||||
function getLogs() {
|
||||
$('#loadingViewSpinner').show();
|
||||
getLogsStdout();
|
||||
getLogsStderr();
|
||||
$('#loadingViewSpinner').hide();
|
||||
}
|
||||
|
||||
function getLogsStderr() {
|
||||
ContainerLogs.get($stateParams.id, {
|
||||
stdout: 0,
|
||||
stderr: 1,
|
||||
timestamps: $scope.state.displayTimestampsErr,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stderr = data;
|
||||
});
|
||||
}
|
||||
|
||||
function getLogs() {
|
||||
ViewSpinner.spin();
|
||||
ContainerLogs.get($routeParams.id, {
|
||||
stdout: 1,
|
||||
stderr: 0,
|
||||
timestamps: $scope.showTimestamps,
|
||||
tail: $scope.tailLines
|
||||
}, function(data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stdout = data;
|
||||
ViewSpinner.stop();
|
||||
});
|
||||
|
||||
ContainerLogs.get($routeParams.id, {
|
||||
stdout: 0,
|
||||
stderr: 1,
|
||||
timestamps: $scope.showTimestamps,
|
||||
tail: $scope.tailLines
|
||||
}, function(data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stderr = data;
|
||||
ViewSpinner.stop();
|
||||
});
|
||||
}
|
||||
|
||||
// initial call
|
||||
getLogs();
|
||||
var logIntervalId = window.setInterval(getLogs, 5000);
|
||||
|
||||
$scope.$on("$destroy", function(){
|
||||
// clearing interval when view changes
|
||||
clearInterval(logIntervalId);
|
||||
function getLogsStdout() {
|
||||
ContainerLogs.get($stateParams.id, {
|
||||
stdout: 1,
|
||||
stderr: 0,
|
||||
timestamps: $scope.state.displayTimestampsOut,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stdout = data;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.scrollTo = function(id) {
|
||||
$location.hash(id);
|
||||
$anchorScroll();
|
||||
};
|
||||
// initial call
|
||||
getLogs();
|
||||
var logIntervalId = window.setInterval(getLogs, 5000);
|
||||
|
||||
$scope.toggleTimestamps = function() {
|
||||
getLogs();
|
||||
};
|
||||
$scope.$on('$destroy', function () {
|
||||
// clearing interval when view changes
|
||||
clearInterval(logIntervalId);
|
||||
});
|
||||
|
||||
$scope.toggleTail = function() {
|
||||
getLogs();
|
||||
};
|
||||
$scope.toggleTimestampsOut = function () {
|
||||
getLogsStdout();
|
||||
};
|
||||
|
||||
$scope.toggleTimestampsErr = function () {
|
||||
getLogsStderr();
|
||||
};
|
||||
}]);
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
<div class="row logs">
|
||||
<div class="col-xs-12">
|
||||
<h4>Logs for container: <a href="#/containers/{{ container.Id }}/">{{ container.Name }}</a></td></h4>
|
||||
<div class="btn-group detail">
|
||||
<button class="btn btn-info" ng-click="scrollTo('stdout')">stdout</button>
|
||||
<button class="btn btn-warning" ng-click="scrollTo('stderr')">stderr</button>
|
||||
</div>
|
||||
<div class="pull-right col-xs-6">
|
||||
<div class="col-xs-6">
|
||||
<a class="btn btn-primary" ng-click="toggleTail()" role="button">Reload logs</a>
|
||||
<input id="tailLines" type="number" ng-style="{width: '45px'}"
|
||||
ng-model="tailLines" ng-keypress="($event.which === 13)? toggleTail() : 0"/>
|
||||
<label for="tailLines">lines</label>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<input id="timestampToggle" type="checkbox" ng-model="showTimestamps"
|
||||
ng-change="toggleTimestamps()"/> <label for="timestampToggle">Timestamps</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<rd-header>
|
||||
<rd-header-title title="Container logs">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Logs
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="col-xs-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 id="stdout" class="panel-title">STDOUT</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon grey pull-left">
|
||||
<i class="fa fa-server"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 id="stderr" class="panel-title">STDERR</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="title">{{ container.Name|trimcontainername }}</div>
|
||||
<div class="comment">Name</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title="Stdout logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
|
||||
<label for="displayAllTsOut">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Stderr logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
|
||||
<label for="displayAllTsErr">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<div class="containerTop">
|
||||
<div class="form-group col-xs-2">
|
||||
<input type="text" class="form-control" placeholder="[options] (aux)" ng-model="ps_args">
|
||||
</div>
|
||||
<button type="button" class="btn btn-default" ng-click="getTop()">Submit</button>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th ng-repeat="title in containerTop.Titles">{{title}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="processInfos in containerTop.Processes">
|
||||
<td ng-repeat="processInfo in processInfos track by $index">{{processInfo}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,19 +0,0 @@
|
||||
angular.module('containerTop', [])
|
||||
.controller('ContainerTopController', ['$scope', '$routeParams', 'ContainerTop', 'ViewSpinner', function ($scope, $routeParams, ContainerTop, ViewSpinner) {
|
||||
$scope.ps_args = '';
|
||||
|
||||
/**
|
||||
* Get container processes
|
||||
*/
|
||||
$scope.getTop = function () {
|
||||
ViewSpinner.spin();
|
||||
ContainerTop.get($routeParams.id, {
|
||||
ps_args: $scope.ps_args
|
||||
}, function (data) {
|
||||
$scope.containerTop = data;
|
||||
ViewSpinner.stop();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.getTop();
|
||||
}]);
|
||||
@@ -1,45 +1,141 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container list">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="containers" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
<i id="loadContainersSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Containers</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<h2>Containers:</h2>
|
||||
|
||||
<div>
|
||||
<ul class="nav nav-pills pull-left">
|
||||
<li class="dropdown">
|
||||
<a class="dropdown-toggle" id="drop4" role="button" data-toggle="dropdown" data-target="#">Actions <b class="caret"></b></a>
|
||||
<ul id="menu1" class="dropdown-menu" role="menu" aria-labelledby="drop4">
|
||||
<li><a tabindex="-1" href="" ng-click="startAction()">Start</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="stopAction()">Stop</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="restartAction()">Restart</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="killAction()">Kill</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="pauseAction()">Pause</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="unpauseAction()">Unpause</a></li>
|
||||
<li><a tabindex="-1" href="" ng-click="removeAction()">Remove</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="pull-right">
|
||||
<input type="checkbox" ng-model="displayAll" id="displayAll" ng-change="toggleGetAll()"/> <label for="displayAll">Display All</label>
|
||||
</div>
|
||||
<div class="col-lg-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Containers">
|
||||
<div class="pull-right">
|
||||
Items per page:
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-taskbar classes="col-lg-12">
|
||||
<div class="pull-left">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<button type="button" class="btn btn-success btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount || state.noStoppedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
|
||||
<button type="button" class="btn btn-danger btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
|
||||
<button type="button" class="btn btn-danger btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
|
||||
<button type="button" class="btn btn-primary btn-responsive" ng-click="restartAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
|
||||
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
|
||||
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount || state.noPausedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
|
||||
<button type="button" class="btn btn-danger btn-responsive" ng-click="confirmRemoveAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
|
||||
</div>
|
||||
<a class="btn btn-primary" type="button" ui-sref="actions.create.container"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add container</a>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<input type="checkbox" ng-model="state.displayAll" id="displayAll" ng-change="toggleGetAll()" style="margin-top: -2px; margin-right: 5px;"/><label for="displayAll">Show all containers</label>
|
||||
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
|
||||
</div>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Status')">
|
||||
State
|
||||
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Names')">
|
||||
Name
|
||||
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Image')">
|
||||
Image
|
||||
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="state.displayIP">
|
||||
<a ui-sref="containers" ng-click="order('IP')">
|
||||
IP Address
|
||||
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<a ui-sref="containers" ng-click="order('Host')">
|
||||
Host IP
|
||||
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Ports')">
|
||||
Published Ports
|
||||
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.application.authentication">
|
||||
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="container in (state.filteredContainers = ( containers | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
|
||||
<td><input type="checkbox" ng-model="container.Checked" ng-change="selectItem(container)"/></td>
|
||||
<td>
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) !== -1" class="label label-{{ container.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ container.Status }}</span>
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) === -1" class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status }}</span>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: 40}}</a></td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: 40}}</a></td>
|
||||
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
|
||||
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>
|
||||
<td>
|
||||
<a ng-if="container.Ports.length > 0" ng-repeat="p in container.Ports" class="image-tag" ng-href="http://{{ PublicURL || p.host }}:{{p.public}}" target="_blank">
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i> {{p.public}}:{{ p.private }}
|
||||
</a>
|
||||
<span ng-if="container.Ports.length == 0" >-</span>
|
||||
</td>
|
||||
<td ng-if="applicationState.application.authentication">
|
||||
<span>
|
||||
<i ng-class="container.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||
{{ container.ResourceControl.Ownership ? container.ResourceControl.Ownership : container.ResourceControl.Ownership = 'public' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!containers">
|
||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="containers.length == 0">
|
||||
<td colspan="9" class="text-center text-muted">No containers available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="containers" class="pull-left pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" ng-model="toggle" ng-change="toggleSelectAll()" /> Action</th>
|
||||
<th>Name</th>
|
||||
<th>Image</th>
|
||||
<th>Command</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="container in containers|orderBy:predicate">
|
||||
<td><input type="checkbox" ng-model="container.Checked" /></td>
|
||||
<td><a href="#/containers/{{ container.Id }}/">{{ container|containername}}</a></td>
|
||||
<td><a href="#/images/{{ container.Image }}/">{{ container.Image }}</a></td>
|
||||
<td>{{ container.Command|truncate:40 }}</td>
|
||||
<td>{{ container.Created|getdate }}</td>
|
||||
<td><span class="label label-{{ container.Status|statusbadge }}">{{ container.Status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,111 +1,247 @@
|
||||
angular.module('containers', [])
|
||||
.controller('ContainersController', ['$scope', 'Container', 'Settings', 'Messages', 'ViewSpinner',
|
||||
function($scope, Container, Settings, Messages, ViewSpinner) {
|
||||
$scope.predicate = '-Created';
|
||||
$scope.toggle = false;
|
||||
$scope.displayAll = Settings.displayAll;
|
||||
.controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider',
|
||||
function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) {
|
||||
$scope.state = {};
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('containers');
|
||||
$scope.state.displayAll = true;
|
||||
$scope.state.displayIP = false;
|
||||
$scope.sortType = 'State';
|
||||
$scope.sortReverse = false;
|
||||
$scope.state.selectedItemCount = 0;
|
||||
$scope.order = function (sortType) {
|
||||
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
|
||||
$scope.sortType = sortType;
|
||||
};
|
||||
$scope.PublicURL = EndpointProvider.endpointPublicURL();
|
||||
|
||||
var update = function(data) {
|
||||
ViewSpinner.spin();
|
||||
Container.query(data, function(d) {
|
||||
$scope.containers = d.map(function(item) {
|
||||
return new ContainerViewModel(item); });
|
||||
ViewSpinner.stop();
|
||||
$scope.changePaginationCount = function() {
|
||||
Pagination.setPaginationCount('containers', $scope.state.pagination_count);
|
||||
};
|
||||
|
||||
$scope.cleanAssociatedVolumes = false;
|
||||
|
||||
var update = function (data) {
|
||||
$('#loadContainersSpinner').show();
|
||||
$scope.state.selectedItemCount = 0;
|
||||
Container.query(data, function (d) {
|
||||
var containers = d;
|
||||
$scope.containers = containers.map(function (container) {
|
||||
var model = new ContainerViewModel(container);
|
||||
model.Status = $filter('containerstatus')(model.Status);
|
||||
|
||||
EntityListService.rememberPreviousSelection($scope.containers, model, function onSelect(model){
|
||||
$scope.selectItem(model);
|
||||
});
|
||||
};
|
||||
|
||||
var batch = function(items, action, msg) {
|
||||
ViewSpinner.spin();
|
||||
var counter = 0;
|
||||
var complete = function() {
|
||||
counter = counter -1;
|
||||
if (counter === 0) {
|
||||
ViewSpinner.stop();
|
||||
update({all: Settings.displayAll ? 1 : 0});
|
||||
}
|
||||
};
|
||||
angular.forEach(items, function(c) {
|
||||
if (c.Checked) {
|
||||
if(action === Container.start){
|
||||
Container.get({id: c.Id}, function(d) {
|
||||
c = d;
|
||||
counter = counter + 1;
|
||||
action({id: c.Id, HostConfig: c.HostConfig || {}}, function(d) {
|
||||
Messages.send("Container " + msg, c.Id);
|
||||
var index = $scope.containers.indexOf(c);
|
||||
complete();
|
||||
}, function(e) {
|
||||
Messages.error("Failure", e.data);
|
||||
complete();
|
||||
});
|
||||
}, function(e) {
|
||||
if (e.status === 404) {
|
||||
$('.detail').hide();
|
||||
Messages.error("Not found", "Container not found.");
|
||||
} else {
|
||||
Messages.error("Failure", e.data);
|
||||
}
|
||||
complete();
|
||||
});
|
||||
}
|
||||
else{
|
||||
counter = counter + 1;
|
||||
action({id: c.Id}, function(d) {
|
||||
Messages.send("Container " + msg, c.Id);
|
||||
var index = $scope.containers.indexOf(c);
|
||||
complete();
|
||||
}, function(e) {
|
||||
Messages.error("Failure", e.data);
|
||||
complete();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
if (counter === 0) {
|
||||
ViewSpinner.stop();
|
||||
if (model.IP) {
|
||||
$scope.state.displayIP = true;
|
||||
}
|
||||
};
|
||||
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') {
|
||||
model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]];
|
||||
}
|
||||
return model;
|
||||
});
|
||||
updateSelectionFlags();
|
||||
$('#loadContainersSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve containers');
|
||||
$scope.containers = [];
|
||||
});
|
||||
};
|
||||
|
||||
$scope.toggleSelectAll = function() {
|
||||
angular.forEach($scope.containers, function(i) {
|
||||
i.Checked = $scope.toggle;
|
||||
});
|
||||
var batch = function (items, action, msg) {
|
||||
$('#loadContainersSpinner').show();
|
||||
var counter = 0;
|
||||
var complete = function () {
|
||||
counter = counter - 1;
|
||||
if (counter === 0) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
update({all: $scope.state.displayAll ? 1 : 0});
|
||||
}
|
||||
};
|
||||
angular.forEach(items, function (c) {
|
||||
if (c.Checked) {
|
||||
counter = counter + 1;
|
||||
if (action === Container.start) {
|
||||
action({id: c.Id}, {}, function (d) {
|
||||
Notifications.success('Container ' + msg, c.Id);
|
||||
complete();
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to start container');
|
||||
complete();
|
||||
});
|
||||
}
|
||||
else if (action === Container.remove) {
|
||||
ContainerService.remove(c, $scope.cleanAssociatedVolumes)
|
||||
.then(function success() {
|
||||
Notifications.success('Container successfully removed');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove container');
|
||||
})
|
||||
.finally(function final() {
|
||||
complete();
|
||||
});
|
||||
}
|
||||
else if (action === Container.pause) {
|
||||
action({id: c.Id}, function (d) {
|
||||
if (d.message) {
|
||||
Notifications.success('Container is already paused', c.Id);
|
||||
} else {
|
||||
Notifications.success('Container ' + msg, c.Id);
|
||||
}
|
||||
complete();
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to pause container');
|
||||
complete();
|
||||
});
|
||||
}
|
||||
else {
|
||||
action({id: c.Id}, function (d) {
|
||||
Notifications.success('Container ' + msg, c.Id);
|
||||
complete();
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'An error occured');
|
||||
complete();
|
||||
});
|
||||
|
||||
$scope.toggleGetAll = function() {
|
||||
Settings.displayAll = $scope.displayAll;
|
||||
update({all: Settings.displayAll ? 1 : 0});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
if (counter === 0) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.startAction = function() {
|
||||
batch($scope.containers, Container.start, "Started");
|
||||
};
|
||||
$scope.selectItems = function (allSelected) {
|
||||
angular.forEach($scope.state.filteredContainers, function (container) {
|
||||
if (container.Checked !== allSelected) {
|
||||
container.Checked = allSelected;
|
||||
toggleItemSelection(container);
|
||||
}
|
||||
});
|
||||
updateSelectionFlags();
|
||||
};
|
||||
|
||||
$scope.stopAction = function() {
|
||||
batch($scope.containers, Container.stop, "Stopped");
|
||||
};
|
||||
$scope.selectItem = function (item) {
|
||||
toggleItemSelection(item);
|
||||
updateSelectionFlags();
|
||||
};
|
||||
|
||||
$scope.restartAction = function() {
|
||||
batch($scope.containers, Container.restart, "Restarted");
|
||||
};
|
||||
$scope.toggleGetAll = function () {
|
||||
update({all: $scope.state.displayAll ? 1 : 0});
|
||||
};
|
||||
|
||||
$scope.killAction = function() {
|
||||
batch($scope.containers, Container.kill, "Killed");
|
||||
};
|
||||
$scope.startAction = function () {
|
||||
batch($scope.containers, Container.start, 'Started');
|
||||
};
|
||||
|
||||
$scope.pauseAction = function() {
|
||||
batch($scope.containers, Container.pause, "Paused");
|
||||
};
|
||||
$scope.stopAction = function () {
|
||||
batch($scope.containers, Container.stop, 'Stopped');
|
||||
};
|
||||
|
||||
$scope.unpauseAction = function() {
|
||||
batch($scope.containers, Container.unpause, "Unpaused");
|
||||
};
|
||||
$scope.restartAction = function () {
|
||||
batch($scope.containers, Container.restart, 'Restarted');
|
||||
};
|
||||
|
||||
$scope.removeAction = function() {
|
||||
batch($scope.containers, Container.remove, "Removed");
|
||||
};
|
||||
$scope.killAction = function () {
|
||||
batch($scope.containers, Container.kill, 'Killed');
|
||||
};
|
||||
|
||||
update({all: Settings.displayAll ? 1 : 0});
|
||||
$scope.pauseAction = function () {
|
||||
batch($scope.containers, Container.pause, 'Paused');
|
||||
};
|
||||
|
||||
$scope.unpauseAction = function () {
|
||||
batch($scope.containers, Container.unpause, 'Unpaused');
|
||||
};
|
||||
|
||||
$scope.removeAction = function () {
|
||||
batch($scope.containers, Container.remove, 'Removed');
|
||||
};
|
||||
|
||||
$scope.confirmRemoveAction = function () {
|
||||
var isOneContainerRunning = false;
|
||||
angular.forEach($scope.containers, function (c) {
|
||||
if (c.Checked && c.State === 'running') {
|
||||
isOneContainerRunning = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
var title = 'You are about to remove one or more container.';
|
||||
if (isOneContainerRunning) {
|
||||
title = 'You are about to remove one or more running containers.';
|
||||
}
|
||||
ModalService.confirmContainerDeletion(
|
||||
title,
|
||||
function (result) {
|
||||
if(!result) { return; }
|
||||
$scope.cleanAssociatedVolumes = false;
|
||||
if (result[0]) {
|
||||
$scope.cleanAssociatedVolumes = true;
|
||||
}
|
||||
$scope.removeAction();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function toggleItemSelection(item) {
|
||||
if (item.Checked) {
|
||||
$scope.state.selectedItemCount++;
|
||||
} else {
|
||||
$scope.state.selectedItemCount--;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectionFlags() {
|
||||
$scope.state.noStoppedItemsSelected = true;
|
||||
$scope.state.noRunningItemsSelected = true;
|
||||
$scope.state.noPausedItemsSelected = true;
|
||||
$scope.containers.forEach(function(container) {
|
||||
if(!container.Checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(container.Status === 'paused') {
|
||||
$scope.state.noPausedItemsSelected = false;
|
||||
} else if(container.Status === 'stopped' ||
|
||||
container.Status === 'created') {
|
||||
$scope.state.noStoppedItemsSelected = false;
|
||||
} else if(container.Status === 'running') {
|
||||
$scope.state.noRunningItemsSelected = false;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
function retrieveSwarmHostsInfo(data) {
|
||||
var swarm_hosts = {};
|
||||
var systemStatus = data.SystemStatus;
|
||||
var node_count = parseInt(systemStatus[3][1], 10);
|
||||
var node_offset = 4;
|
||||
for (i = 0; i < node_count; i++) {
|
||||
var host = {};
|
||||
host.name = _.trim(systemStatus[node_offset][0]);
|
||||
host.ip = _.split(systemStatus[node_offset][1], ':')[0];
|
||||
swarm_hosts[host.name] = host.ip;
|
||||
node_offset += 9;
|
||||
}
|
||||
return swarm_hosts;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||
$q.when(provider !== 'DOCKER_SWARM' || SystemService.info())
|
||||
.then(function success(data) {
|
||||
if (provider === 'DOCKER_SWARM') {
|
||||
$scope.swarm_hosts = retrieveSwarmHostsInfo(data);
|
||||
}
|
||||
update({all: $scope.state.displayAll ? 1 : 0});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve cluster information');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
|
||||
547
app/components/createContainer/createContainerController.js
Normal file
547
app/components/createContainer/createContainerController.js
Normal file
@@ -0,0 +1,547 @@
|
||||
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
|
||||
// See app/components/templates/templatesController.js as a reference.
|
||||
angular.module('createContainer', [])
|
||||
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
|
||||
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
|
||||
|
||||
$scope.formValues = {
|
||||
alwaysPull: true,
|
||||
Console: 'none',
|
||||
Volumes: [],
|
||||
NetworkContainer: '',
|
||||
Labels: [],
|
||||
ExtraHosts: [],
|
||||
IPv4: '',
|
||||
IPv6: '',
|
||||
AccessControlData: new AccessControlFormData()
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: ''
|
||||
};
|
||||
|
||||
$scope.config = {
|
||||
Image: '',
|
||||
Env: [],
|
||||
Cmd: '',
|
||||
ExposedPorts: {},
|
||||
HostConfig: {
|
||||
RestartPolicy: {
|
||||
Name: 'no'
|
||||
},
|
||||
PortBindings: [],
|
||||
PublishAllPorts: false,
|
||||
Binds: [],
|
||||
NetworkMode: 'bridge',
|
||||
Privileged: false,
|
||||
ExtraHosts: [],
|
||||
Devices:[]
|
||||
},
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: {}
|
||||
},
|
||||
Labels: {}
|
||||
};
|
||||
|
||||
$scope.addVolume = function() {
|
||||
$scope.formValues.Volumes.push({ name: '', containerPath: '', readOnly: false, type: 'volume' });
|
||||
};
|
||||
|
||||
$scope.removeVolume = function(index) {
|
||||
$scope.formValues.Volumes.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function() {
|
||||
$scope.config.Env.push({ name: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeEnvironmentVariable = function(index) {
|
||||
$scope.config.Env.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function() {
|
||||
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
||||
};
|
||||
|
||||
$scope.removePortBinding = function(index) {
|
||||
$scope.config.HostConfig.PortBindings.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addLabel = function() {
|
||||
$scope.formValues.Labels.push({ name: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeLabel = function(index) {
|
||||
$scope.formValues.Labels.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addExtraHost = function() {
|
||||
$scope.formValues.ExtraHosts.push({ value: '' });
|
||||
};
|
||||
|
||||
$scope.removeExtraHost = function(index) {
|
||||
$scope.formValues.ExtraHosts.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addDevice = function() {
|
||||
$scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' });
|
||||
};
|
||||
|
||||
$scope.removeDevice = function(index) {
|
||||
$scope.config.HostConfig.Devices.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.fromContainerMultipleNetworks = false;
|
||||
|
||||
function prepareImageConfig(config) {
|
||||
var image = config.Image;
|
||||
var registry = $scope.formValues.Registry;
|
||||
var imageConfig = ImageHelper.createImageConfigForContainer(image, registry.URL);
|
||||
config.Image = imageConfig.fromImage + ':' + imageConfig.tag;
|
||||
$scope.imageConfig = imageConfig;
|
||||
}
|
||||
|
||||
function preparePortBindings(config) {
|
||||
var bindings = {};
|
||||
config.HostConfig.PortBindings.forEach(function (portBinding) {
|
||||
if (portBinding.containerPort) {
|
||||
var key = portBinding.containerPort + '/' + portBinding.protocol;
|
||||
var binding = {};
|
||||
if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) {
|
||||
var hostAndPort = portBinding.hostPort.split(':');
|
||||
binding.HostIp = hostAndPort[0];
|
||||
binding.HostPort = hostAndPort[1];
|
||||
} else {
|
||||
binding.HostPort = portBinding.hostPort;
|
||||
}
|
||||
bindings[key] = [binding];
|
||||
config.ExposedPorts[key] = {};
|
||||
}
|
||||
});
|
||||
config.HostConfig.PortBindings = bindings;
|
||||
}
|
||||
|
||||
function prepareConsole(config) {
|
||||
var value = $scope.formValues.Console;
|
||||
var openStdin = true;
|
||||
var tty = true;
|
||||
if (value === 'tty') {
|
||||
openStdin = false;
|
||||
} else if (value === 'interactive') {
|
||||
tty = false;
|
||||
} else if (value === 'none') {
|
||||
openStdin = false;
|
||||
tty = false;
|
||||
}
|
||||
config.OpenStdin = openStdin;
|
||||
config.Tty = tty;
|
||||
}
|
||||
|
||||
function prepareEnvironmentVariables(config) {
|
||||
var env = [];
|
||||
config.Env.forEach(function (v) {
|
||||
if (v.name && v.value) {
|
||||
env.push(v.name + '=' + v.value);
|
||||
}
|
||||
});
|
||||
config.Env = env;
|
||||
}
|
||||
|
||||
function prepareVolumes(config) {
|
||||
var binds = [];
|
||||
var volumes = {};
|
||||
|
||||
$scope.formValues.Volumes.forEach(function (volume) {
|
||||
var name = volume.name;
|
||||
var containerPath = volume.containerPath;
|
||||
if (name && containerPath) {
|
||||
var bind = name + ':' + containerPath;
|
||||
volumes[containerPath] = {};
|
||||
if (volume.readOnly) {
|
||||
bind += ':ro';
|
||||
}
|
||||
binds.push(bind);
|
||||
}
|
||||
});
|
||||
config.HostConfig.Binds = binds;
|
||||
config.Volumes = volumes;
|
||||
}
|
||||
|
||||
function prepareNetworkConfig(config) {
|
||||
var mode = config.HostConfig.NetworkMode;
|
||||
var container = $scope.formValues.NetworkContainer;
|
||||
var containerName = container;
|
||||
if (container && typeof container === 'object') {
|
||||
containerName = $filter('trimcontainername')(container.Names[0]);
|
||||
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') {
|
||||
containerName = $filter('swarmcontainername')(container);
|
||||
}
|
||||
}
|
||||
var networkMode = mode;
|
||||
if (containerName) {
|
||||
networkMode += ':' + containerName;
|
||||
config.Hostname = '';
|
||||
}
|
||||
config.HostConfig.NetworkMode = networkMode;
|
||||
|
||||
config.NetworkingConfig.EndpointsConfig[networkMode] = {
|
||||
IPAMConfig: {
|
||||
IPv4Address: $scope.formValues.IPv4,
|
||||
IPv6Address: $scope.formValues.IPv6
|
||||
}
|
||||
};
|
||||
|
||||
$scope.formValues.ExtraHosts.forEach(function (v) {
|
||||
if (v.value) {
|
||||
config.HostConfig.ExtraHosts.push(v.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prepareLabels(config) {
|
||||
var labels = {};
|
||||
$scope.formValues.Labels.forEach(function (label) {
|
||||
if (label.name && label.value) {
|
||||
labels[label.name] = label.value;
|
||||
}
|
||||
});
|
||||
config.Labels = labels;
|
||||
}
|
||||
|
||||
function prepareDevices(config) {
|
||||
var path = [];
|
||||
config.HostConfig.Devices.forEach(function (p) {
|
||||
if (p.pathOnHost) {
|
||||
if(p.pathInContainer === '') {
|
||||
p.pathInContainer = p.pathOnHost;
|
||||
}
|
||||
path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'});
|
||||
}
|
||||
});
|
||||
config.HostConfig.Devices = path;
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var config = angular.copy($scope.config);
|
||||
config.Cmd = ContainerHelper.commandStringToArray(config.Cmd);
|
||||
prepareNetworkConfig(config);
|
||||
prepareImageConfig(config);
|
||||
preparePortBindings(config);
|
||||
prepareConsole(config);
|
||||
prepareEnvironmentVariables(config);
|
||||
prepareVolumes(config);
|
||||
prepareLabels(config);
|
||||
prepareDevices(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function confirmCreateContainer() {
|
||||
var deferred = $q.defer();
|
||||
Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise
|
||||
.then(function success(data) {
|
||||
var existingContainer = data[0];
|
||||
if (existingContainer) {
|
||||
ModalService.confirm({
|
||||
title: 'Are you sure ?',
|
||||
message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Replace',
|
||||
className: 'btn-danger'
|
||||
}
|
||||
},
|
||||
callback: function onConfirm(confirmed) {
|
||||
if(!confirmed) { deferred.resolve(false); }
|
||||
else {
|
||||
// Remove old container
|
||||
ContainerService.remove(existingContainer, true)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Container Removed', existingContainer.Id);
|
||||
deferred.resolve(true);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to remove container', err: err });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
deferred.resolve(true);
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve containers');
|
||||
return undefined;
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function loadFromContainerCmd(d) {
|
||||
if ($scope.config.Cmd) {
|
||||
$scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd);
|
||||
} else {
|
||||
$scope.config.Cmd = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerPortBindings(d) {
|
||||
var bindings = [];
|
||||
for (var p in $scope.config.HostConfig.PortBindings) {
|
||||
if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) {
|
||||
var hostPort = '';
|
||||
if ($scope.config.HostConfig.PortBindings[p][0].HostIp) {
|
||||
hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':';
|
||||
}
|
||||
hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort;
|
||||
var b = {
|
||||
'hostPort': hostPort,
|
||||
'containerPort': p.split('/')[0],
|
||||
'protocol': p.split('/')[1]
|
||||
};
|
||||
bindings.push(b);
|
||||
}
|
||||
}
|
||||
$scope.config.HostConfig.PortBindings = bindings;
|
||||
}
|
||||
|
||||
function loadFromContainerVolumes(d) {
|
||||
for (var v in d.Mounts) {
|
||||
if ({}.hasOwnProperty.call(d.Mounts, v)) {
|
||||
var mount = d.Mounts[v];
|
||||
var volume = {
|
||||
'type': mount.Type,
|
||||
'name': mount.Name || mount.Source,
|
||||
'containerPath': mount.Destination,
|
||||
'readOnly': mount.RW === false
|
||||
};
|
||||
$scope.formValues.Volumes.push(volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerNetworkConfig(d) {
|
||||
$scope.config.NetworkingConfig = {
|
||||
EndpointsConfig: {}
|
||||
};
|
||||
var networkMode = d.HostConfig.NetworkMode;
|
||||
if (networkMode === 'default') {
|
||||
$scope.config.HostConfig.NetworkMode = 'bridge';
|
||||
if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) {
|
||||
$scope.config.HostConfig.NetworkMode = 'nat';
|
||||
}
|
||||
}
|
||||
if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) {
|
||||
var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1];
|
||||
$scope.config.HostConfig.NetworkMode = 'container';
|
||||
for (var c in $scope.runningContainers) {
|
||||
if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) {
|
||||
$scope.formValues.NetworkContainer = $scope.runningContainers[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
$scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2;
|
||||
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) {
|
||||
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) {
|
||||
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) {
|
||||
$scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address;
|
||||
}
|
||||
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) {
|
||||
$scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address;
|
||||
}
|
||||
}
|
||||
}
|
||||
$scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode];
|
||||
// ExtraHosts
|
||||
for (var h in $scope.config.HostConfig.ExtraHosts) {
|
||||
if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) {
|
||||
$scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]});
|
||||
$scope.config.HostConfig.ExtraHosts = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerEnvrionmentVariables(d) {
|
||||
var envArr = [];
|
||||
for (var e in $scope.config.Env) {
|
||||
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
|
||||
var arr = $scope.config.Env[e].split(/\=(.+)/);
|
||||
envArr.push({'name': arr[0], 'value': arr[1]});
|
||||
}
|
||||
}
|
||||
$scope.config.Env = envArr;
|
||||
}
|
||||
|
||||
function loadFromContainerLabels(d) {
|
||||
for (var l in $scope.config.Labels) {
|
||||
if ({}.hasOwnProperty.call($scope.config.Labels, l)) {
|
||||
$scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerConsole(d) {
|
||||
if ($scope.config.OpenStdin && $scope.config.Tty) {
|
||||
$scope.formValues.Console = 'both';
|
||||
} else if (!$scope.config.OpenStdin && $scope.config.Tty) {
|
||||
$scope.formValues.Console = 'tty';
|
||||
} else if ($scope.config.OpenStdin && !$scope.config.Tty) {
|
||||
$scope.formValues.Console = 'interactive';
|
||||
} else if (!$scope.config.OpenStdin && !$scope.config.Tty) {
|
||||
$scope.formValues.Console = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerDevices(d) {
|
||||
var path = [];
|
||||
for (var dev in $scope.config.HostConfig.Devices) {
|
||||
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
|
||||
var device = $scope.config.HostConfig.Devices[dev];
|
||||
path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer});
|
||||
}
|
||||
}
|
||||
$scope.config.HostConfig.Devices = path;
|
||||
}
|
||||
|
||||
function loadFromContainerImageConfig(d) {
|
||||
// If no registry found, we let default DockerHub and let full image path
|
||||
var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image);
|
||||
RegistryService.retrieveRegistryFromRepository($scope.config.Image)
|
||||
.then(function success(data) {
|
||||
if (data) {
|
||||
$scope.config.Image = imageInfo.image;
|
||||
$scope.formValues.Registry = data;
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrive registry');
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromContainerSpec() {
|
||||
// Get container
|
||||
Container.get({ id: $stateParams.from }).$promise
|
||||
.then(function success(d) {
|
||||
var fromContainer = new ContainerDetailsViewModel(d);
|
||||
if (!fromContainer.ResourceControl) {
|
||||
$scope.formValues.AccessControlData.AccessControlEnabled = false;
|
||||
}
|
||||
$scope.fromContainer = fromContainer;
|
||||
$scope.config = ContainerHelper.configFromContainer(fromContainer.Model);
|
||||
loadFromContainerCmd(d);
|
||||
loadFromContainerPortBindings(d);
|
||||
loadFromContainerVolumes(d);
|
||||
loadFromContainerNetworkConfig(d);
|
||||
loadFromContainerEnvrionmentVariables(d);
|
||||
loadFromContainerLabels(d);
|
||||
loadFromContainerConsole(d);
|
||||
loadFromContainerDevices(d);
|
||||
loadFromContainerImageConfig(d);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve container');
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
Volume.query({}, function (d) {
|
||||
$scope.availableVolumes = d.Volumes;
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve volumes');
|
||||
});
|
||||
|
||||
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
NetworkService.networks(
|
||||
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
||||
false,
|
||||
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
|
||||
provider === 'DOCKER_SWARM'
|
||||
)
|
||||
.then(function success(data) {
|
||||
var networks = data;
|
||||
networks.push({ Name: 'container' });
|
||||
$scope.availableNetworks = networks;
|
||||
|
||||
if (_.find(networks, {'Name': 'nat'})) {
|
||||
$scope.config.HostConfig.NetworkMode = 'nat';
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve networks');
|
||||
});
|
||||
|
||||
Container.query({}, function (d) {
|
||||
var containers = d;
|
||||
$scope.runningContainers = containers;
|
||||
if ($stateParams.from !== '') {
|
||||
loadFromContainerSpec();
|
||||
} else {
|
||||
$scope.fromContainer = {};
|
||||
$scope.formValues.Registry = {};
|
||||
}
|
||||
}, function(e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve running containers');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
confirmCreateContainer()
|
||||
.then(function success(confirm) {
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
$('#createContainerSpinner').show();
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
$('#createContainerSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var config = prepareConfiguration();
|
||||
createContainer(config, accessControlData);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
});
|
||||
};
|
||||
|
||||
function createContainer(config, accessControlData) {
|
||||
$q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true))
|
||||
.finally(function final() {
|
||||
ContainerService.createAndStartContainer(config)
|
||||
.then(function success(data) {
|
||||
var containerIdentifier = data.Id;
|
||||
var userId = Authentication.getUserDetails().ID;
|
||||
return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Container successfully created');
|
||||
$state.go('containers', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createContainerSpinner').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
515
app/components/createContainer/createcontainer.html
Normal file
515
app/components/createContainer/createcontainer.html
Normal file
@@ -0,0 +1,515 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create container"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > Add container
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Image configuration
|
||||
</div>
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- always-pull -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
Always pull the image
|
||||
<portainer-tooltip position="bottom" message="When enabled, Portainer will automatically try to pull the specified image before creating the container."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !always-pull -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Ports configuration
|
||||
</div>
|
||||
<!-- publish-exposed-ports -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Publish all exposed ports
|
||||
<portainer-tooltip position="bottom" message="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="config.HostConfig.PublishAllPorts"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !publish-exposed-ports -->
|
||||
<!-- port-mapping -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Port mapping</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPortBinding()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
|
||||
</span>
|
||||
</div>
|
||||
<!-- port-mapping-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px;">
|
||||
<!-- host-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
|
||||
</div>
|
||||
<!-- !host-port -->
|
||||
<span style="margin: 0 10px 0 10px;">
|
||||
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
<!-- container-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
|
||||
</div>
|
||||
<!-- !container-port -->
|
||||
<!-- protocol-actions -->
|
||||
<div class="input-group col-sm-3 input-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
|
||||
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !protocol-actions -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !port-mapping-input-list -->
|
||||
</div>
|
||||
<!-- !port-mapping -->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="create()">Start container</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
|
||||
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
<span class="small text-muted" style="margin-left: 5px;">This container is connected to multiple networks, only one network will be kept at creation time.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cog" title="Advanced container settings"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<ul class="nav nav-pills nav-justified">
|
||||
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command</a></li>
|
||||
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
|
||||
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
|
||||
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
|
||||
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
|
||||
<li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li>
|
||||
<li class="interactive"><a data-target="#runtime" data-toggle="tab">Runtime</a></li>
|
||||
</ul>
|
||||
<!-- tab-content -->
|
||||
<div class="tab-content">
|
||||
<!-- tab-command -->
|
||||
<div class="tab-pane active" id="command">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- command-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="config.Cmd" id="container_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !command-input -->
|
||||
<!-- entrypoint-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_entrypoint" class="col-sm-2 col-lg-1 control-label text-left">Entry Point</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="config.Entrypoint" id="container_entrypoint" placeholder="e.g. /bin/sh -c">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !entrypoint-input -->
|
||||
<!-- workdir-user-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_workingdir" class="col-sm-2 col-lg-1 control-label text-left">Working Dir</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class="form-control" ng-model="config.WorkingDir" id="container_workingdir" placeholder="e.g. /myapp">
|
||||
</div>
|
||||
<label for="container_user" class="col-sm-1 control-label text-left">User</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class="form-control" ng-model="config.User" id="container_user" placeholder="e.g. nginx">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !workdir-user-input -->
|
||||
<!-- console -->
|
||||
<div class="form-group">
|
||||
<label for="container_console" class="col-sm-2 col-lg-1 control-label text-left">Console</label>
|
||||
<div class="col-sm-10 col-lg-11">
|
||||
<div class="col-sm-4">
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="container_console" ng-model="formValues.Console" value="both">
|
||||
Interactive & TTY <span class="small text-muted">(-i -t)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="container_console" ng-model="formValues.Console" value="interactive">
|
||||
Interactive <span class="small text-muted">(-i)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-offset-2 col-sm-10 col-lg-offset-1 col-lg-11">
|
||||
<div class="col-sm-4">
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="container_console" ng-model="formValues.Console" value="tty">
|
||||
TTY <span class="small text-muted">(-t)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="container_console" ng-model="formValues.Console" value="none">
|
||||
None
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !console -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-command -->
|
||||
<!-- tab-volume -->
|
||||
<div class="tab-pane" id="volumes">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- volumes -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Volume mapping</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addVolume()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
|
||||
</span>
|
||||
</div>
|
||||
<!-- volumes-input-list -->
|
||||
<div class="form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="volume in formValues.Volumes">
|
||||
<!-- volume-line1 -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<!-- container-path -->
|
||||
<div class="input-group input-group-sm col-sm-6">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
|
||||
</div>
|
||||
<!-- !container-path -->
|
||||
<!-- volume-type -->
|
||||
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
|
||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !volume-type -->
|
||||
</div>
|
||||
<!-- !volume-line1 -->
|
||||
<!-- volume-line2 -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 5px;">
|
||||
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
|
||||
<!-- volume -->
|
||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
|
||||
<span class="input-group-addon">volume</span>
|
||||
<select class="form-control" ng-model="volume.name">
|
||||
<option selected disabled hidden value="">Select a volume</option>
|
||||
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- !volume -->
|
||||
<!-- bind -->
|
||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
|
||||
</div>
|
||||
<!-- !bind -->
|
||||
<!-- read-only -->
|
||||
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
|
||||
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !read-only -->
|
||||
</div>
|
||||
<!-- !volume-line2 -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !volumes-input-list -->
|
||||
</div>
|
||||
</form>
|
||||
<!-- !volumes -->
|
||||
</div>
|
||||
<!-- !tab-volume -->
|
||||
<!-- tab-network -->
|
||||
<div class="tab-pane" id="network">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="form-group" ng-if="globalNetworkCount === 0 && applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE'">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">You don't have any shared network. Head over the <a ui-sref="networks">networks view</a> to create one.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- network-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-left">Network</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" ng-model="config.HostConfig.NetworkMode" id="container_network">
|
||||
<option selected disabled hidden value="">Select a network</option>
|
||||
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !network-input -->
|
||||
<!-- container-name-input -->
|
||||
<div class="form-group" ng-if="config.HostConfig.NetworkMode == 'container'">
|
||||
<label for="container_network_container" class="col-sm-2 col-lg-1 control-label text-left">Container</label>
|
||||
<div class="col-sm-9">
|
||||
<select ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'" ng-options="container|containername for container in runningContainers" class="form-control" ng-model="formValues.NetworkContainer">
|
||||
<option selected disabled hidden value="">Select a container</option>
|
||||
</select>
|
||||
<select ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'" ng-options="container|swarmcontainername for container in runningContainers" class="form-control" ng-model="formValues.NetworkContainer">
|
||||
<option selected disabled hidden value="">Select a container</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !container-name-input -->
|
||||
<!-- hostname-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_hostname" class="col-sm-2 col-lg-1 control-label text-left">Hostname</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="config.Hostname" id="container_hostname" placeholder="e.g. web01">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !hostname-input -->
|
||||
<!-- domainname-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_domainname" class="col-sm-2 col-lg-1 control-label text-left">Domain Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="config.Domainname" id="container_domainname" placeholder="e.g. example.com">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !domainname -->
|
||||
<!-- ipv4-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_ipv4" class="col-sm-2 col-lg-1 control-label text-left">IPv4 Address</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="formValues.IPv4" id="container_ipv4" placeholder="e.g. 172.20.0.7">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !ipv4-input -->
|
||||
<!-- ipv6-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_ipv6" class="col-sm-2 col-lg-1 control-label text-left">IPv6 Address</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" ng-model="formValues.IPv6" id="container_ipv6" placeholder="e.g. a:b:c:d::1234">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !ipv6-input -->
|
||||
<!-- extra-hosts-variables -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Hosts file entries</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addExtraHost()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add additional entry
|
||||
</span>
|
||||
</div>
|
||||
<!-- extra-hosts-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="variable in formValues.ExtraHosts" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeExtraHost($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !extra-hosts-input-list -->
|
||||
</div>
|
||||
<!-- !extra-hosts-variables -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-network -->
|
||||
<!-- tab-labels -->
|
||||
<div class="tab-pane" id="labels">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- labels -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Labels</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
|
||||
</span>
|
||||
</div>
|
||||
<!-- labels-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo">
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !labels-input-list -->
|
||||
</div>
|
||||
<!-- !labels-->
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-labels -->
|
||||
<!-- tab-env -->
|
||||
<div class="tab-pane" id="env">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- environment-variables -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Environment variables</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
|
||||
</span>
|
||||
</div>
|
||||
<!-- environment-variable-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !environment-variable-input-list -->
|
||||
</div>
|
||||
<!-- !environment-variables -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-labels -->
|
||||
<!-- tab-restart-policy -->
|
||||
<div class="tab-pane" id="restart-policy">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Restart policy
|
||||
</label>
|
||||
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
|
||||
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'no'">
|
||||
Never
|
||||
</label>
|
||||
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'always'">
|
||||
Always
|
||||
</label>
|
||||
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'on-failure'">
|
||||
On failure
|
||||
</label>
|
||||
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'unless-stopped'">
|
||||
Unless stopped
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-restart-policy -->
|
||||
<!-- tab-runtime -->
|
||||
<div class="tab-pane" id="runtime">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- privileged-mode -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
Privileged mode
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="config.HostConfig.Privileged"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !privileged-mode -->
|
||||
</form>
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- devices -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Devices</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDevice()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add device
|
||||
</span>
|
||||
</div>
|
||||
<!-- devices-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="device in config.HostConfig.Devices" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="device.pathOnHost" placeholder="e.g. /dev/tty0">
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="device.pathInContainer" placeholder="e.g. /dev/tty0">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDevice($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !devices-input-list -->
|
||||
</div>
|
||||
<!-- !devices-->
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<!-- !tab-runtime -->
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
92
app/components/createNetwork/createNetworkController.js
Normal file
92
app/components/createNetwork/createNetworkController.js
Normal file
@@ -0,0 +1,92 @@
|
||||
angular.module('createNetwork', [])
|
||||
.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', 'LabelHelper',
|
||||
function ($scope, $state, Notifications, Network, LabelHelper) {
|
||||
$scope.formValues = {
|
||||
DriverOptions: [],
|
||||
Subnet: '',
|
||||
Gateway: '',
|
||||
Labels: []
|
||||
};
|
||||
|
||||
$scope.config = {
|
||||
Driver: 'bridge',
|
||||
CheckDuplicate: true,
|
||||
Internal: false,
|
||||
// Force IPAM Driver to 'default', should not be required.
|
||||
// See: https://github.com/docker/docker/issues/25735
|
||||
IPAM: {
|
||||
Driver: 'default',
|
||||
Config: []
|
||||
},
|
||||
Labels: {}
|
||||
};
|
||||
|
||||
$scope.addDriverOption = function() {
|
||||
$scope.formValues.DriverOptions.push({ name: '', value: '' });
|
||||
};
|
||||
|
||||
$scope.removeDriverOption = function(index) {
|
||||
$scope.formValues.DriverOptions.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addLabel = function() {
|
||||
$scope.formValues.Labels.push({ key: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeLabel = function(index) {
|
||||
$scope.formValues.Labels.splice(index, 1);
|
||||
};
|
||||
|
||||
function createNetwork(config) {
|
||||
$('#createNetworkSpinner').show();
|
||||
Network.create(config, function (d) {
|
||||
if (d.message) {
|
||||
$('#createNetworkSpinner').hide();
|
||||
Notifications.error('Unable to create network', {}, d.message);
|
||||
} else {
|
||||
Notifications.success('Network created', d.Id);
|
||||
$('#createNetworkSpinner').hide();
|
||||
$state.go('networks', {}, {reload: true});
|
||||
}
|
||||
}, function (e) {
|
||||
$('#createNetworkSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to create network');
|
||||
});
|
||||
}
|
||||
|
||||
function prepareIPAMConfiguration(config) {
|
||||
if ($scope.formValues.Subnet) {
|
||||
var ipamConfig = {};
|
||||
ipamConfig.Subnet = $scope.formValues.Subnet;
|
||||
if ($scope.formValues.Gateway) {
|
||||
ipamConfig.Gateway = $scope.formValues.Gateway ;
|
||||
}
|
||||
config.IPAM.Config.push(ipamConfig);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDriverOptions(config) {
|
||||
var options = {};
|
||||
$scope.formValues.DriverOptions.forEach(function (option) {
|
||||
options[option.name] = option.value;
|
||||
});
|
||||
config.Options = options;
|
||||
}
|
||||
|
||||
function prepareLabelsConfig(config) {
|
||||
config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels);
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var config = angular.copy($scope.config);
|
||||
prepareIPAMConfiguration(config);
|
||||
prepareDriverOptions(config);
|
||||
prepareLabelsConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
var config = prepareConfiguration();
|
||||
createNetwork(config);
|
||||
};
|
||||
}]);
|
||||
135
app/components/createNetwork/createnetwork.html
Normal file
135
app/components/createNetwork/createnetwork.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create network"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="networks">Networks</a> > Add network
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="network_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="config.Name" id="network_name" placeholder="e.g. myNetwork">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Network configuration
|
||||
</div>
|
||||
<!-- subnet-gateway-inputs -->
|
||||
<div class="form-group">
|
||||
<label for="network_subnet" class="col-sm-2 col-lg-1 control-label text-left">Subnet</label>
|
||||
<div class="col-sm-4 col-lg-5">
|
||||
<input type="text" class="form-control" ng-model="formValues.Subnet" id="network_subnet" placeholder="e.g. 172.20.0.0/16">
|
||||
</div>
|
||||
<label for="network_gateway" class="col-sm-2 col-lg-1 control-label text-left">Gateway</label>
|
||||
<div class="col-sm-4 col-lg-5">
|
||||
<input type="text" class="form-control" ng-model="formValues.Gateway" id="network_gateway" placeholder="e.g. 172.20.10.11">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !subnet-gateway-inputs -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Driver configuration
|
||||
</div>
|
||||
<!-- driver-input -->
|
||||
<div class="form-group">
|
||||
<label for="network_driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !driver-input -->
|
||||
<!-- driver-options -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">
|
||||
Driver options
|
||||
<portainer-tooltip position="bottom" message="Driver options are specific to the selected driver. Please refer to the selected driver documentation."></portainer-tooltip>
|
||||
</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDriverOption()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add driver option
|
||||
</span>
|
||||
</div>
|
||||
<!-- driver-options-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="option in formValues.DriverOptions" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="option.name" placeholder="e.g. com.docker.network.bridge.enable_icc">
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDriverOption($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !driver-options-input-list -->
|
||||
</div>
|
||||
<!-- !driver-options -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Advanced configuration
|
||||
</div>
|
||||
<!-- labels -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Labels</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
|
||||
</span>
|
||||
</div>
|
||||
<!-- labels-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !labels-input-list -->
|
||||
</div>
|
||||
<!-- !labels-->
|
||||
<!-- internal -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
Restrict external access to the network
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="config.Internal"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !internal -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Name" ng-click="create()">Create network</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="networks">Cancel</a>
|
||||
<i id="createNetworkSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
49
app/components/createRegistry/createRegistryController.js
Normal file
49
app/components/createRegistry/createRegistryController.js
Normal file
@@ -0,0 +1,49 @@
|
||||
angular.module('createRegistry', [])
|
||||
.controller('CreateRegistryController', ['$scope', '$state', 'RegistryService', 'Notifications',
|
||||
function ($scope, $state, RegistryService, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
RegistryType: 'quay'
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
Name: 'Quay',
|
||||
URL: 'quay.io',
|
||||
Authentication: true,
|
||||
Username: '',
|
||||
Password: ''
|
||||
};
|
||||
|
||||
$scope.selectQuayRegistry = function() {
|
||||
$scope.formValues.Name = 'Quay';
|
||||
$scope.formValues.URL = 'quay.io';
|
||||
$scope.formValues.Authentication = true;
|
||||
};
|
||||
|
||||
$scope.selectCustomRegistry = function() {
|
||||
$scope.formValues.Name = '';
|
||||
$scope.formValues.URL = '';
|
||||
$scope.formValues.Authentication = false;
|
||||
};
|
||||
|
||||
$scope.addRegistry = function() {
|
||||
$('#createRegistrySpinner').show();
|
||||
var registryName = $scope.formValues.Name;
|
||||
var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, '');
|
||||
var authentication = $scope.formValues.Authentication;
|
||||
var username = $scope.formValues.Username;
|
||||
var password = $scope.formValues.Password;
|
||||
|
||||
RegistryService.createRegistry(registryName, registryURL, authentication, username, password)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Registry successfully created');
|
||||
$state.go('registries');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create registry');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createRegistrySpinner').hide();
|
||||
});
|
||||
};
|
||||
}]);
|
||||
117
app/components/createRegistry/createregistry.html
Normal file
117
app/components/createRegistry/createregistry.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create registry">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display:none"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="registries">Registries</a> > Add registry
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Registry provider
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group" style="margin-bottom: 0">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-click="selectQuayRegistry()">
|
||||
<input type="radio" id="registry_quay" ng-model="state.RegistryType" value="quay">
|
||||
<label for="registry_quay">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Quay.io
|
||||
</div>
|
||||
<p>Quay container registry</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-click="selectCustomRegistry()">
|
||||
<input type="radio" id="registry_custom" ng-model="state.RegistryType" value="custom">
|
||||
<label for="registry_custom">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Custom registry
|
||||
</div>
|
||||
<p>Define your own registry</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title" ng-if="state.RegistryType === 'custom'">
|
||||
Important notice
|
||||
</div>
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Docker requires you to connect to a <a href="https://docs.docker.com/registry/deploying/#running-a-domain-registry" target="_blank">secure registry</a>.
|
||||
You can find more information about how to connect to an insecure registry <a href="https://docs.docker.com/registry/insecure/" target="_blank">in the Docker documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Registry details
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_name" ng-model="formValues.Name" placeholder="e.g. my-registry">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- registry-url-input -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Registry URL
|
||||
<portainer-tooltip position="bottom" message="URL or IP address of a Docker registry. Any protocol will be stripped."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:5000 or myregistry.domain.tld">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !registry-url-input -->
|
||||
<!-- authentication-checkbox -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<div class="col-sm-12">
|
||||
<label for="registry_auth" class="control-label text-left">
|
||||
Authentication
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.Authentication"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !authentication-checkbox -->
|
||||
<!-- authentication-credentials -->
|
||||
<div ng-if="formValues.Authentication || state.RegistryType === 'quay'">
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="credentials_username" ng-model="formValues.Username">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="credentials_password" ng-model="formValues.Password">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- !authentication-credentials -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.URL || (formValues.Authentication && (!formValues.Username || !formValues.Password))" ng-click="addRegistry()">Add registry</button>
|
||||
<i id="createRegistrySpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
58
app/components/createSecret/createSecretController.js
Normal file
58
app/components/createSecret/createSecretController.js
Normal file
@@ -0,0 +1,58 @@
|
||||
angular.module('createSecret', [])
|
||||
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper',
|
||||
function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
Data: '',
|
||||
Labels: [],
|
||||
encodeSecret: true
|
||||
};
|
||||
|
||||
$scope.addLabel = function() {
|
||||
$scope.formValues.Labels.push({ key: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeLabel = function(index) {
|
||||
$scope.formValues.Labels.splice(index, 1);
|
||||
};
|
||||
|
||||
function prepareLabelsConfig(config) {
|
||||
config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels);
|
||||
}
|
||||
|
||||
function prepareSecretData(config) {
|
||||
if ($scope.formValues.encodeSecret) {
|
||||
config.Data = btoa(unescape(encodeURIComponent($scope.formValues.Data)));
|
||||
} else {
|
||||
config.Data = $scope.formValues.Data;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var config = {};
|
||||
config.Name = $scope.formValues.Name;
|
||||
prepareSecretData(config);
|
||||
prepareLabelsConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function createSecret(config) {
|
||||
$('#createSecretSpinner').show();
|
||||
SecretService.create(config)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Secret successfully created');
|
||||
$state.go('secrets', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create secret');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createSecretSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
var config = prepareConfiguration();
|
||||
createSecret(config);
|
||||
};
|
||||
}]);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user