Compare commits
468
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fbef6ff51 | ||
|
|
38ee2e4eb5 | ||
|
|
e4f7249f1e | ||
|
|
e3ca516b97 | ||
|
|
88daab379f | ||
|
|
00ed3caafe | ||
|
|
daad6468c6 | ||
|
|
3d347a936d | ||
|
|
87bda475df | ||
|
|
44dfa39737 | ||
|
|
d38f6da26f | ||
|
|
5667cfc054 | ||
|
|
ecd0e70261 | ||
|
|
5bbb95f1ff | ||
|
|
5aa3edbda2 | ||
|
|
9bdebb963b | ||
|
|
c690598868 | ||
|
|
ba59ac91d7 | ||
|
|
5170699ae4 | ||
|
|
09d2f418eb | ||
|
|
cc9d04acd7 | ||
|
|
b5c8c55c72 | ||
|
|
38b987144e | ||
|
|
8136bf1c92 | ||
|
|
5ddec99114 | ||
|
|
58cebd43ce | ||
|
|
11a015e0b6 | ||
|
|
745b7e43b4 | ||
|
|
a72866c725 | ||
|
|
2ec7f26387 | ||
|
|
72865e7f96 | ||
|
|
6a0d2830d6 | ||
|
|
b3710f26ae | ||
|
|
f6cd9db408 | ||
|
|
7a3a2b558b | ||
|
|
53aad5cb37 | ||
|
|
fd65e1f022 | ||
|
|
d16b8cb011 | ||
|
|
37611bdc66 | ||
|
|
7352cdf3fe | ||
|
|
8e084d6ca0 | ||
|
|
017c71a6e8 | ||
|
|
488fbb383b | ||
|
|
cfd635146b | ||
|
|
7d737a5534 | ||
|
|
0d21246b06 | ||
|
|
ec99f5f8aa | ||
|
|
9d9f65c5a3 | ||
|
|
87271f4f37 | ||
|
|
9bebc23f6a | ||
|
|
682c267b10 | ||
|
|
4de35b579f | ||
|
|
fb4d520ba4 | ||
|
|
1f72d4f37d | ||
|
|
ecada92dc5 | ||
|
|
410d62849a | ||
|
|
927ccaa119 | ||
|
|
d58d65b751 | ||
|
|
802b18e990 | ||
|
|
8064051f2f | ||
|
|
485b2387e4 | ||
|
|
ae4d782566 | ||
|
|
8f16699bab | ||
|
|
5ba919a638 | ||
|
|
af7aeb2bba | ||
|
|
1f11c5d6b5 | ||
|
|
8694bfe9be | ||
|
|
637b5a4736 | ||
|
|
e0cc46260b | ||
|
|
c1ad97d26f | ||
|
|
1e8f8106b0 | ||
|
|
926e323569 | ||
|
|
8d32758918 | ||
|
|
7eed6160eb | ||
|
|
c57dc4daa1 | ||
|
|
4c75cebcd6 | ||
|
|
a820788c7d | ||
|
|
52a02abd3f | ||
|
|
a92455b2a5 | ||
|
|
933d5e7d4d | ||
|
|
ba43fd90c6 | ||
|
|
01ee514c73 | ||
|
|
1eb30329c6 | ||
|
|
0ef55abdf4 | ||
|
|
8341807d92 | ||
|
|
c2712daccb | ||
|
|
8033da1176 | ||
|
|
5afab577a7 | ||
|
|
4be9b9df9a | ||
|
|
a905f0ce53 | ||
|
|
0c4d3be912 | ||
|
|
2b7ba61543 | ||
|
|
f9c7e18985 | ||
|
|
32e7028029 | ||
|
|
cc3eddf02d | ||
|
|
d73ad6db15 | ||
|
|
5280f0732c | ||
|
|
5ca135646a | ||
|
|
e1da38b6e8 | ||
|
|
f7c45d17d7 | ||
|
|
cb4cdfe69a | ||
|
|
bed57dd35b | ||
|
|
c38a2242ba | ||
|
|
d3488dc907 | ||
|
|
1fa7787d3b | ||
|
|
54f053cfcc | ||
|
|
7b5e802efb | ||
|
|
390b9de54a | ||
|
|
4fc9835f43 | ||
|
|
9a46e20b4a | ||
|
|
74ca512f92 | ||
|
|
1c856f7615 | ||
|
|
cc2513bd2f | ||
|
|
22527a5d93 | ||
|
|
25663b59a0 | ||
|
|
aa416d50b3 | ||
|
|
16bf5ce5ab | ||
|
|
52ae671234 | ||
|
|
5ffff1ed07 | ||
|
|
18a02b1717 | ||
|
|
3a61fc89c6 | ||
|
|
38c2d1749a | ||
|
|
9900db8d25 | ||
|
|
e53a644718 | ||
|
|
62b56e198b | ||
|
|
f4d1dae478 | ||
|
|
bf75c25152 | ||
|
|
b888999b61 | ||
|
|
e9555cf99e | ||
|
|
11b866986d | ||
|
|
67fc2183b7 | ||
|
|
02baa72c1e | ||
|
|
383102b988 | ||
|
|
3da36725e3 | ||
|
|
ac7d323201 | ||
|
|
a846d51c0c | ||
|
|
f5da80dc9c | ||
|
|
f94a67adb4 | ||
|
|
f1d453251c | ||
|
|
12dc7efd5b | ||
|
|
117a30bb82 | ||
|
|
ccc6c815e4 | ||
|
|
ba9e4b098f | ||
|
|
69aa3a998f | ||
|
|
00fd2cebf9 | ||
|
|
684a99027b | ||
|
|
5143361dcd | ||
|
|
6c956c1a64 | ||
|
|
a749f82ca5 | ||
|
|
6e9ea47b7d | ||
|
|
b1818400ca | ||
|
|
f7bdafbdc5 | ||
|
|
a236544ee9 | ||
|
|
e082f8a31c | ||
|
|
fa1b560a91 | ||
|
|
d6f7e225be | ||
|
|
22cf82b99b | ||
|
|
ad2181db0b | ||
|
|
3525d13e69 | ||
|
|
8847de116d | ||
|
|
7d9dcb263d | ||
|
|
b3771cce32 | ||
|
|
9717591224 | ||
|
|
64ae7c8efe | ||
|
|
d8d7605ce5 | ||
|
|
6995cbfb23 | ||
|
|
c6a757d6fb | ||
|
|
c01f9dbb21 | ||
|
|
0ec43a9325 | ||
|
|
266c661958 | ||
|
|
8ea4c4ee37 | ||
|
|
725dfc23a6 | ||
|
|
39ff757b39 | ||
|
|
e187bfc941 | ||
|
|
dd36929a81 | ||
|
|
2902f6baab | ||
|
|
f38f7861a0 | ||
|
|
84a68521b0 | ||
|
|
ce7ba47a32 | ||
|
|
a20e9a30cf | ||
|
|
52cec0e9f3 | ||
|
|
9a6866cf8e | ||
|
|
15d7e6483e | ||
|
|
e1c710a093 | ||
|
|
0c700ded9d | ||
|
|
50b7e2300d | ||
|
|
a6379d02f1 | ||
|
|
6545f3469f | ||
|
|
58f7f9384a | ||
|
|
7ec9934751 | ||
|
|
c4c3f61a23 | ||
|
|
c856f404e3 | ||
|
|
eed989cbf5 | ||
|
|
630dc2814a | ||
|
|
570b174a00 | ||
|
|
700fc0ea40 | ||
|
|
23ebd4166b | ||
|
|
3683044bf7 | ||
|
|
fb10906816 | ||
|
|
86074dab6a | ||
|
|
015d8da21d | ||
|
|
d847c4f3ec | ||
|
|
a2e4869403 | ||
|
|
5ffe34bb83 | ||
|
|
e3b16fd742 | ||
|
|
80261959e7 | ||
|
|
7db9360ba4 | ||
|
|
899d775873 | ||
|
|
581cc80d5d | ||
|
|
24cf6c70b8 | ||
|
|
ae25b58610 | ||
|
|
2cf5a26934 | ||
|
|
5ed78da30b | ||
|
|
3fb1b212b6 | ||
|
|
ca7258ef28 | ||
|
|
1dbb749732 | ||
|
|
525fdcf4a7 | ||
|
|
40208373e4 | ||
|
|
4a0885cf2d | ||
|
|
0224f17733 | ||
|
|
b7ab2f994e | ||
|
|
0182ce134f | ||
|
|
69666d3fee | ||
|
|
605004286a | ||
|
|
bbc975141f | ||
|
|
0b2dcef57e | ||
|
|
9f7e95c515 | ||
|
|
8db84bfe7d | ||
|
|
9c8151bdb7 | ||
|
|
43aaed225d | ||
|
|
b805c5f004 | ||
|
|
29344451d6 | ||
|
|
5511f8ed32 | ||
|
|
362040bd3c | ||
|
|
40393c3a5b | ||
|
|
7f972bac85 | ||
|
|
d532d9505a | ||
|
|
4cd22f3719 | ||
|
|
f5291517c1 | ||
|
|
ceed913143 | ||
|
|
3086aa78d5 | ||
|
|
6c4b9345cb | ||
|
|
2ab84e2a61 | ||
|
|
48e7f98956 | ||
|
|
d7c7d47a61 | ||
|
|
be268ace42 | ||
|
|
a6b0b16909 | ||
|
|
8c64352acf | ||
|
|
fd38cc336b | ||
|
|
f5429b9c14 | ||
|
|
1344a6e3d2 | ||
|
|
9a2e889ef9 | ||
|
|
a58c9aba59 | ||
|
|
0263cc4eff | ||
|
|
b40215dd36 | ||
|
|
4e978bd5b5 | ||
|
|
0a8083d4d5 | ||
|
|
376fd60974 | ||
|
|
8bc63c1fdb | ||
|
|
4f461e4cb3 | ||
|
|
b25c747522 | ||
|
|
01d6b8f227 | ||
|
|
330552563c | ||
|
|
581cf2a813 | ||
|
|
f6edf6cbd5 | ||
|
|
db6db4d38c | ||
|
|
127b7daecc | ||
|
|
6597728e5c | ||
|
|
9e9773f45d | ||
|
|
9fe97f5d40 | ||
|
|
a53d2de13b | ||
|
|
d9d8441b6e | ||
|
|
e72c1b037c | ||
|
|
bce9a2c124 | ||
|
|
f57188914e | ||
|
|
3bd297a22d | ||
|
|
85774847b7 | ||
|
|
8fcf351613 | ||
|
|
05d28d1d0a | ||
|
|
a3253b42d8 | ||
|
|
b69059fd37 | ||
|
|
c122aa9910 | ||
|
|
03a3ccb4de | ||
|
|
87ec67778b | ||
|
|
bd5fe0cae1 | ||
|
|
e8213b9515 | ||
|
|
617b656cf7 | ||
|
|
042285e664 | ||
|
|
7d28b62206 | ||
|
|
a27b294031 | ||
|
|
0a4c4c60c5 | ||
|
|
635849c37a | ||
|
|
3cc3a2a9dd | ||
|
|
6d4d6fcdb9 | ||
|
|
4fdb310ca4 | ||
|
|
9ce82cb011 | ||
|
|
db37143db6 | ||
|
|
2a5b3e0eec | ||
|
|
ef53fe02a3 | ||
|
|
a6c2bf5230 | ||
|
|
32f56e8213 | ||
|
|
f757e64ba4 | ||
|
|
188f765b5c | ||
|
|
8c8b1748ee | ||
|
|
c204ab09d1 | ||
|
|
db5c5444c5 | ||
|
|
209eb31c58 | ||
|
|
956cdbcfbe | ||
|
|
c5fb066c75 | ||
|
|
90b90be3cb | ||
|
|
a6a8c3f1e7 | ||
|
|
b7904f7ee0 | ||
|
|
23396f54e0 | ||
|
|
e42f1ddbd7 | ||
|
|
d1a70d2465 | ||
|
|
3c8efbd0a0 | ||
|
|
247e010fba | ||
|
|
f0167a800f | ||
|
|
b35cf3139d | ||
|
|
64ecf531bd | ||
|
|
ec0f7a6790 | ||
|
|
930c44f037 | ||
|
|
a6fce1084d | ||
|
|
23b7bbd548 | ||
|
|
42d3c5cd2c | ||
|
|
1bf693938c | ||
|
|
da4101a042 | ||
|
|
dc33f0655c | ||
|
|
3c210f96fc | ||
|
|
dd5baeda46 | ||
|
|
7d33c1c113 | ||
|
|
8a4d9fecea | ||
|
|
cb259a9086 | ||
|
|
9d8ec6f794 | ||
|
|
acc9526cd5 | ||
|
|
43a160a9cc | ||
|
|
4de2626a33 | ||
|
|
255e08a301 | ||
|
|
71409b06ae | ||
|
|
f25bca727d | ||
|
|
fb73312e80 | ||
|
|
9a626b7260 | ||
|
|
ede80410ea | ||
|
|
00c720137a | ||
|
|
890cff93d5 | ||
|
|
678b719cef | ||
|
|
1b7c11b849 | ||
|
|
c6a7489f11 | ||
|
|
f7fcef56c7 | ||
|
|
29fa554146 | ||
|
|
2f10b30283 | ||
|
|
391a2653d9 | ||
|
|
55be07d711 | ||
|
|
75757e37fb | ||
|
|
2be31a4d3b | ||
|
|
4b74a9d5fe | ||
|
|
4f2539c59f | ||
|
|
7df1f3da7b | ||
|
|
fb57998aa8 | ||
|
|
c4eceacafb | ||
|
|
974fc460c4 | ||
|
|
87e84ae90d | ||
|
|
2c026f99ca | ||
|
|
6298744918 | ||
|
|
5342261db0 | ||
|
|
fd540a53b1 | ||
|
|
03283414cb | ||
|
|
beaa67c280 | ||
|
|
d67ddab290 | ||
|
|
b143136590 | ||
|
|
8fc8a3f7cf | ||
|
|
2b21722cf3 | ||
|
|
c3fb7384e3 | ||
|
|
fde19383df | ||
|
|
777e414f0a | ||
|
|
6d7309cc9c | ||
|
|
ec03be6936 | ||
|
|
5dc918287c | ||
|
|
55811b20da | ||
|
|
09554da362 | ||
|
|
074f5ebfae | ||
|
|
260bc93414 | ||
|
|
ad05dc07f0 | ||
|
|
12ea002660 | ||
|
|
4c46a708ab | ||
|
|
b57a727195 | ||
|
|
00207de04d | ||
|
|
3761e236a3 | ||
|
|
ff52e8e841 | ||
|
|
b61f5d835c | ||
|
|
1a86d3459d | ||
|
|
6bd7098cfe | ||
|
|
06ff7c9760 | ||
|
|
703f410161 | ||
|
|
46ab3b31ca | ||
|
|
a84d6a3c22 | ||
|
|
54471d41d5 | ||
|
|
8b224f6922 | ||
|
|
005c9b4259 | ||
|
|
6f53e78e6a | ||
|
|
f4532f3801 | ||
|
|
b90c47a994 | ||
|
|
db55792f42 | ||
|
|
5996c0fa63 | ||
|
|
485e3cf02e | ||
|
|
ee319a6d0f | ||
|
|
b47b596062 | ||
|
|
7de0e5a1c4 | ||
|
|
7114fd1c00 | ||
|
|
4192952a37 | ||
|
|
b82c74865e | ||
|
|
ca350604e3 | ||
|
|
5a7608460b | ||
|
|
2565ced7fe | ||
|
|
a81f0b9145 | ||
|
|
1f841ca629 | ||
|
|
537c37351a | ||
|
|
0f9e01e393 | ||
|
|
90118b3481 | ||
|
|
412a4d3c7a | ||
|
|
960bef455a | ||
|
|
344d3cd44f | ||
|
|
6636ef3ac1 | ||
|
|
496f8fa370 | ||
|
|
25148db971 | ||
|
|
71ad471958 | ||
|
|
96ff3c38bb | ||
|
|
ed56d9edcc | ||
|
|
b78f527c39 | ||
|
|
096282ae1a | ||
|
|
0be6df46bc | ||
|
|
c5cc15daa0 | ||
|
|
0301c3b076 | ||
|
|
84382b4ab4 | ||
|
|
26ec841db1 | ||
|
|
1ed93bbdde | ||
|
|
ef83441b2f | ||
|
|
180bf3fd35 | ||
|
|
d1403c04c4 | ||
|
|
8d12bc45a1 | ||
|
|
9f0f7c374a | ||
|
|
b8ce4c49d6 | ||
|
|
90070de028 | ||
|
|
073f0d86a1 | ||
|
|
ec6c987ef9 | ||
|
|
3f8699a59b | ||
|
|
2b463d060d | ||
|
|
9d464708b5 | ||
|
|
3e49ce6ae9 | ||
|
|
6e60a8614c | ||
|
|
eecdcf308d | ||
|
|
3e500e8346 | ||
|
|
ea66b5b408 | ||
|
|
655f4895d0 | ||
|
|
dae4aa1f64 | ||
|
|
20f92633f7 | ||
|
|
d0bed64c68 | ||
|
|
1412dd84fe | ||
|
|
ad88560061 | ||
|
|
8d0b3feff7 | ||
|
|
6672427565 | ||
|
|
4f9c0e0ce7 | ||
|
|
dc9f3ab090 | ||
|
|
ead1ff7ec6 | ||
|
|
807e8b7b5f | ||
|
|
db0a21bc60 | ||
|
|
19d466d5d6 | ||
|
|
65473d8c15 |
@@ -0,0 +1,30 @@
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
!**/.gitignore
|
||||
!.git/HEAD
|
||||
!.git/config
|
||||
!.git/packed-refs
|
||||
!.git/refs/heads/**
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Dist Tests
|
||||
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches:
|
||||
# - master
|
||||
# tags:
|
||||
# - 'v*.*.*'
|
||||
# paths-ignore:
|
||||
# - '**/*.md'
|
||||
# - '.gitignore'
|
||||
# - 'docker/**'
|
||||
# - '!docker/job.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: Branch (master)
|
||||
required: false
|
||||
type: string
|
||||
source:
|
||||
description: Repository with tests (current)
|
||||
required: false
|
||||
type: string
|
||||
nameprefix:
|
||||
description: Runner prefix (codex-dist-tests)
|
||||
required: false
|
||||
type: string
|
||||
namespace:
|
||||
description: Runner namespace (default)
|
||||
required: false
|
||||
type: string
|
||||
command:
|
||||
description: Runner command (dotnet test Tests)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
SOURCE: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
NAMEPREFIX: codex-dist-tests
|
||||
NAMESPACE: default
|
||||
COMMAND: dotnet test Tests/CodexTests
|
||||
JOB_MANIFEST: docker/job.yaml
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
KUBE_VERSION: v1.26.1
|
||||
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Variables
|
||||
run: |
|
||||
[[ -n "${{ github.event.inputs.branch }}" ]] && echo "BRANCH=${{ github.event.inputs.branch }}" >>"$GITHUB_ENV" || echo "BRANCH=${{ env.BRANCH }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ github.event.inputs.source }}" ]] && echo "SOURCE=${{ github.event.inputs.source }}" >>"$GITHUB_ENV" || echo "SOURCE=${{ env.SOURCE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ github.event.inputs.nameprefix }}" ]] && echo "NAMEPREFIX=${{ github.event.inputs.nameprefix }}" >>"$GITHUB_ENV" || echo "NAMEPREFIX=${{ env.NAMEPREFIX }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ github.event.inputs.namespace }}" ]] && echo "NAMESPACE=${{ github.event.inputs.namespace }}" >>"$GITHUB_ENV" || echo "NAMESPACE=${{ env.NAMESPACE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ github.event.inputs.command }}" ]] && COMMAND="${{ github.event.inputs.command }}" || COMMAND="${{ env.COMMAND }}"
|
||||
echo "COMMAND=$(jq -c 'split(" ")' <<< '"'$COMMAND'"')" >>"$GITHUB_ENV"
|
||||
echo "RUNID=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
|
||||
echo "TESTID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Kubectl - Install ${{ env.KUBE_VERSION }}
|
||||
uses: azure/setup-kubectl@v3
|
||||
with:
|
||||
version: ${{ env.KUBE_VERSION }}
|
||||
|
||||
- name: Kubectl - Kubeconfig
|
||||
run: |
|
||||
mkdir -p "${HOME}"/.kube
|
||||
echo "${{ env.KUBE_CONFIG }}" | base64 -d > "${HOME}"/.kube/config
|
||||
|
||||
- name: Kubectl - Create Job
|
||||
run: |
|
||||
envsubst < ${{ env.JOB_MANIFEST }} | kubectl apply -f -
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Docker - AutoClient
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Tools/AutoClient/**'
|
||||
- '!Tools/AutoClient/docker/docker-compose.yaml'
|
||||
- 'Framework/**'
|
||||
- 'ProjectPlugins/**'
|
||||
- .github/workflows/docker-autoclient.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
uses: ./.github/workflows/docker-reusable.yml
|
||||
with:
|
||||
docker_file: Tools/AutoClient/docker/Dockerfile
|
||||
docker_repo: codexstorage/codex-autoclient
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Docker - Discord Bot
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Tools/BiblioTech/**'
|
||||
- '!Tools/BiblioTech/docker/docker-compose.yaml'
|
||||
- 'Framework/**'
|
||||
- 'ProjectPlugins/**'
|
||||
- .github/workflows/docker-discordbot.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
uses: ./.github/workflows/docker-reusable.yml
|
||||
with:
|
||||
docker_file: Tools/BiblioTech/docker/Dockerfile
|
||||
docker_repo: codexstorage/codex-discordbot
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Docker - KeyMaker
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Tools/KeyMaker/**'
|
||||
- 'Framework/**'
|
||||
- 'ProjectPlugins/**'
|
||||
- .github/workflows/docker-KeyMaker.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
uses: ./.github/workflows/docker-reusable.yml
|
||||
with:
|
||||
docker_file: Tools/KeyMaker/docker/Dockerfile
|
||||
docker_repo: codexstorage/codex-keymaker
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Docker - MarketInsights API
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Tools/MarketInsights/**'
|
||||
- 'Framework/**'
|
||||
- 'ProjectPlugins/**'
|
||||
- .github/workflows/docker-marketinsights.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
uses: ./.github/workflows/docker-reusable.yml
|
||||
with:
|
||||
docker_file: Tools/MarketInsights/Dockerfile
|
||||
docker_repo: codexstorage/codex-marketinsights
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker - Reusable
|
||||
name: Reusable - Docker
|
||||
|
||||
|
||||
on:
|
||||
@@ -66,26 +66,26 @@ jobs:
|
||||
PLATFORM: ${{ format('{0}/{1}', 'linux', matrix.target.arch) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker - Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKER_REPO }}
|
||||
|
||||
- name: Docker - Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker - Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker - Build and Push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKER_FILE }}
|
||||
@@ -101,10 +101,10 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Docker - Upload digest
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests
|
||||
path: /tmp/digests/*
|
||||
name: digests-${{ matrix.target.arch }}
|
||||
path: /tmp/digests
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -139,17 +139,18 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Docker - Download digests
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
path: /tmp/digests
|
||||
|
||||
- name: Docker - Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker - Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKER_REPO }}
|
||||
flavor: |
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
type=sha,enable=${{ env.TAG_SHA }}
|
||||
|
||||
- name: Docker - Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Docker - Rewarder Bot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Tools/TestNetRewarder/**'
|
||||
- '!Tools/TestNetRewarder/docker/docker-compose.yaml'
|
||||
- 'Framework/**'
|
||||
- 'ProjectPlugins/**'
|
||||
- .github/workflows/docker-rewarder.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
uses: ./.github/workflows/docker-reusable.yml
|
||||
with:
|
||||
docker_file: Tools/TestNetRewarder/docker/Dockerfile
|
||||
docker_repo: codexstorage/codex-rewarderbot
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker
|
||||
name: Docker - Runner
|
||||
|
||||
|
||||
on:
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
paths:
|
||||
- docker/Dockerfile
|
||||
- docker/docker-entrypoint.sh
|
||||
- .github/workflows/docker.yml
|
||||
- .github/workflows/docker-runner.yml
|
||||
- .github/workflows/docker-reusable.yml
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Report - HoldMyBeerTest
|
||||
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: '30 1 */2 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Call runner
|
||||
uses: ./.github/workflows/run-continuous-tests.yaml
|
||||
with:
|
||||
source: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
branch: master
|
||||
nameprefix: c-tests-report-holdmybeertest-48h
|
||||
tests_target_duration: 48h
|
||||
tests_filter: HoldMyBeerTest
|
||||
tests_cleanup: true
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Report - PeersTest
|
||||
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: '30 1 */2 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Call runner
|
||||
uses: ./.github/workflows/run-continuous-tests.yaml
|
||||
with:
|
||||
source: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
branch: master
|
||||
nameprefix: c-tests-report-peerstest-48h
|
||||
tests_target_duration: 48h
|
||||
tests_filter: PeersTest
|
||||
tests_cleanup: true
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,170 @@
|
||||
name: Run Continuous Tests
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source:
|
||||
description: Repository with tests (current)
|
||||
required: false
|
||||
type: string
|
||||
branch:
|
||||
description: Branch with tests (master)
|
||||
required: false
|
||||
type: string
|
||||
codexdockerimage:
|
||||
description: Codex Docker image (codexstorage/nim-codex:latest-dist-tests)
|
||||
required: false
|
||||
type: string
|
||||
nameprefix:
|
||||
description: Resources prefix (c-tests)
|
||||
required: false
|
||||
type: string
|
||||
tests_target_duration:
|
||||
description: Runner target duration (2d)
|
||||
required: false
|
||||
type: string
|
||||
tests_filter:
|
||||
description: Runner tests filter ("")
|
||||
required: false
|
||||
type: string
|
||||
tests_cleanup:
|
||||
description: Runner tests cleanup
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
source:
|
||||
description: Repository with tests (current)
|
||||
required: false
|
||||
type: string
|
||||
branch:
|
||||
description: Branch with tests (master)
|
||||
required: false
|
||||
type: string
|
||||
codexdockerimage:
|
||||
description: Codex Docker image (codexstorage/nim-codex:latest-dist-tests)
|
||||
required: false
|
||||
type: string
|
||||
nameprefix:
|
||||
description: Resources prefix (c-tests)
|
||||
required: false
|
||||
type: string
|
||||
tests_target_duration:
|
||||
description: Runner target duration (2d)
|
||||
required: false
|
||||
type: string
|
||||
tests_filter:
|
||||
description: Runner tests filter ("")
|
||||
required: false
|
||||
type: string
|
||||
tests_cleanup:
|
||||
description: Runner tests cleanup
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_source:
|
||||
description: Workflow source
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
|
||||
env:
|
||||
SOURCE: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
CODEXDOCKERIMAGE: codexstorage/nim-codex:latest-dist-tests
|
||||
NAMEPREFIX: c-tests
|
||||
NAMESPACE: default
|
||||
TESTS_TARGET_DURATION: 2d
|
||||
TESTS_FILTER: ""
|
||||
TESTS_CLEANUP: true
|
||||
JOB_MANIFEST: docker/continuous-tests-job.yaml
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
KUBE_VERSION: v1.28.2
|
||||
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Continuous Tests ${{ inputs.tests_filter }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.workflow_source }}
|
||||
|
||||
- name: Variables
|
||||
run: |
|
||||
RUNID=$(date +%Y%m%d-%H%M%S)
|
||||
echo "RUNID=${RUNID}" >> $GITHUB_ENV
|
||||
echo "TESTID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
[[ -n "${{ inputs.source }}" ]] && echo "SOURCE=${{ inputs.source }}" >>"$GITHUB_ENV" || echo "SOURCE=${{ env.SOURCE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.branch }}" ]] && echo "BRANCH=${{ inputs.branch }}" >>"$GITHUB_ENV" || echo "BRANCH=${{ env.BRANCH }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.codexdockerimage }}" ]] && echo "CODEXDOCKERIMAGE=${{ inputs.codexdockerimage }}" >>"$GITHUB_ENV" || echo "CODEXDOCKERIMAGE=${{ env.CODEXDOCKERIMAGE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.nameprefix }}" ]] && NAMEPREFIX="`awk '{ print tolower($0) }' <<< ${{ inputs.nameprefix }}`" || NAMEPREFIX="`awk '{ print tolower($0) }' <<< ${{ env.NAMEPREFIX }}`"
|
||||
echo "NAMEPREFIX=${NAMEPREFIX}-${RUNID}" >>"$GITHUB_ENV"
|
||||
echo "DEPLOYMENT_NAMESPACE=${NAMEPREFIX}-${RUNID}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.namespace }}" ]] && echo "NAMESPACE=${{ inputs.namespace }}" >>"$GITHUB_ENV" || echo "NAMESPACE=${{ env.NAMESPACE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.tests_target_duration }}" ]] && echo "TESTS_TARGET_DURATION=${{ inputs.tests_target_duration }}" >>"$GITHUB_ENV" || echo "TESTS_TARGET_DURATION=${{ env.TESTS_TARGET_DURATION }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.tests_filter }}" ]] && echo "TESTS_FILTER=${{ inputs.tests_filter }}" >>"$GITHUB_ENV" || echo "TESTS_FILTERS=${{ env.TESTS_FILTERS }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.tests_cleanup }}" ]] && echo "TESTS_CLEANUP=${{ inputs.tests_cleanup }}" >>"$GITHUB_ENV" || echo "TESTS_CLEANUP=${{ env.TESTS_CLEANUP }}" >>"$GITHUB_ENV"
|
||||
|
||||
- name: Kubectl - Install ${{ env.KUBE_VERSION }}
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: ${{ env.KUBE_VERSION }}
|
||||
|
||||
- name: Kubectl - Kubeconfig
|
||||
run: |
|
||||
mkdir -p "${HOME}"/.kube
|
||||
echo "${{ env.KUBE_CONFIG }}" | base64 -d > "${HOME}"/.kube/config
|
||||
|
||||
- name: Kubectl - Create Job
|
||||
run: |
|
||||
envsubst < ${{ env.JOB_MANIFEST }} | kubectl apply -f -
|
||||
|
||||
- name: Tests Identification
|
||||
run: |
|
||||
echo "----"
|
||||
echo "Repository: ${{ env.SOURCE }}"
|
||||
echo "Branch: ${{ env.BRANCH }}"
|
||||
echo "Runner job: ${{ env.NAMEPREFIX }}"
|
||||
echo "Runner pod: `kubectl get pod --selector job-name=${{ env.NAMEPREFIX }} -ojsonpath='{.items[0].metadata.name}'`"
|
||||
echo "Runner namespace: ${{ env.NAMESPACE }}"
|
||||
echo "----"
|
||||
echo "Tests runid: ${{ env.RUNID }}"
|
||||
echo "Tests codexdockerimage: ${{ env.CODEXDOCKERIMAGE }}"
|
||||
echo "Tests namespace: ${{ env.DEPLOYMENT_NAMESPACE }}"
|
||||
echo "Tests duration: ${{ env.TESTS_TARGET_DURATION }}"
|
||||
echo "Tests filter: ${{ env.TESTS_FILTER }}"
|
||||
echo "Tests cleanup: ${{ env.TESTS_CLEANUP }}"
|
||||
echo "----"
|
||||
|
||||
- name: Show Runner logs
|
||||
if: false
|
||||
run: |
|
||||
# Variables
|
||||
# We need more than 300 seconds because Auto Scaler may take 3 minutes to tun a node
|
||||
duration=600
|
||||
namespace="${{ env.NAMESPACE }}"
|
||||
pod=$(kubectl get pod --selector job-name=${{ env.NAMEPREFIX }} -o jsonpath="{.items[0].metadata.name}")
|
||||
|
||||
# Check Pod status
|
||||
WAIT=120
|
||||
SECONDS=0
|
||||
sleep=1
|
||||
while (( SECONDS < WAIT )); do
|
||||
phase=$(kubectl get pod ${pod} -n ${namespace} -o jsonpath="{.status.phase}")
|
||||
[[ "${phase}" == "Running" ]] && { echo "Pod $pod is in $phase state - Get the logs"; break; } || { echo "Pod $pod is in $phase state - Retry in $sleep seconds / $((WAIT - SECONDS))"; }
|
||||
sleep $sleep
|
||||
done
|
||||
|
||||
# Get logs
|
||||
timeout $duration \
|
||||
kubectl logs $pod \
|
||||
-n $namespace \
|
||||
-f \
|
||||
--tail=-1 \
|
||||
--timestamps || true
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Run Dist Tests
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source:
|
||||
description: Repository with tests (current)
|
||||
required: false
|
||||
type: string
|
||||
branch:
|
||||
description: Branch with tests (master)
|
||||
required: false
|
||||
type: string
|
||||
nameprefix:
|
||||
description: Runner name prefix (d-tests-runner)
|
||||
required: false
|
||||
type: string
|
||||
namespace:
|
||||
description: Runner namespace (default)
|
||||
required: false
|
||||
type: string
|
||||
command:
|
||||
description: Command (dotnet test Tests/CodexTests)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
|
||||
env:
|
||||
SOURCE: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
NAMEPREFIX: d-tests-runner
|
||||
NAMESPACE: default
|
||||
COMMAND: dotnet test Tests/CodexTests
|
||||
JOB_MANIFEST: docker/dist-tests-job.yaml
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
KUBE_VERSION: v1.28.2
|
||||
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Variables
|
||||
run: |
|
||||
echo "COMMAND=$(jq -c 'split(" ")' <<< '"'$COMMAND'"')" >>"$GITHUB_ENV"
|
||||
echo "RUNID=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
|
||||
echo "TESTID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
[[ -n "${{ inputs.branch }}" ]] && echo "BRANCH=${{ inputs.branch }}" >>"$GITHUB_ENV" || echo "BRANCH=${{ env.BRANCH }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.source }}" ]] && echo "SOURCE=${{ inputs.source }}" >>"$GITHUB_ENV" || echo "SOURCE=${{ env.SOURCE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.nameprefix }}" ]] && echo "NAMEPREFIX=${{ inputs.nameprefix }}" >>"$GITHUB_ENV" || echo "NAMEPREFIX=${{ env.NAMEPREFIX }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.namespace }}" ]] && echo "NAMESPACE=${{ inputs.namespace }}" >>"$GITHUB_ENV" || echo "NAMESPACE=${{ env.NAMESPACE }}" >>"$GITHUB_ENV"
|
||||
[[ -n "${{ inputs.command }}" ]] && COMMAND="${{ inputs.command }}" || COMMAND="${{ env.COMMAND }}"
|
||||
|
||||
- name: Kubectl - Install ${{ env.KUBE_VERSION }}
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: ${{ env.KUBE_VERSION }}
|
||||
|
||||
- name: Kubectl - Kubeconfig
|
||||
run: |
|
||||
mkdir -p "${HOME}"/.kube
|
||||
echo "${{ env.KUBE_CONFIG }}" | base64 -d > "${HOME}"/.kube/config
|
||||
|
||||
- name: Kubectl - Create Job
|
||||
run: |
|
||||
envsubst < ${{ env.JOB_MANIFEST }} | kubectl apply -f -
|
||||
|
||||
- name: Tests Identification
|
||||
run: |
|
||||
echo "----"
|
||||
echo "Repository: ${{ env.SOURCE }}"
|
||||
echo "Branch: ${{ env.BRANCH }}"
|
||||
echo "Runner job: ${{ env.NAMEPREFIX }}-${{ env.RUNID }}"
|
||||
echo "Runner namespace: ${{ env.NAMESPACE }}"
|
||||
echo "----"
|
||||
echo "Tests runid: ${{ env.RUNID }}"
|
||||
echo "Tests command: `jq -r '. | join(" ")' <<< '${{ env.COMMAND }}'`"
|
||||
echo "----"
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
.vs
|
||||
obj
|
||||
bin
|
||||
.vscode
|
||||
.vscode
|
||||
Tools/AutoClient/datapath
|
||||
|
||||
+86
-14
@@ -1,10 +1,26 @@
|
||||
# Distributed System Tests for Nim-Codex
|
||||
|
||||
## Contributing plugins
|
||||
The testing framework was created for testing Codex. However, it's been designed such that other distributed/containerized projects can 'easily' be added. In order to add your project to the framework you must:
|
||||
The testing framework was created for testing Codex. However, it's been designed such that other containerized projects can 'easily' be added.
|
||||
|
||||
In this file, you'll see 'users' (in quote) mentioned once or twice. This refers to code/projects/tests which end up making use of your plugin. 'Users' come in many shapes and sizes and tend to have many differen use-cases in mind. Please consider this when reading this document and writing your plugin.
|
||||
|
||||
## Checklist
|
||||
Your application must pass this checklist to be compatible with the framework:
|
||||
- It runs in a docker container.
|
||||
- It can be configured via environment variables. (You may need to create a docker image which contains a shell script, to pass some env-vars as CLI arguments to your application. Container command overrides do work, but are not equally reliable across container platforms. When in doubt: use env-var!)
|
||||
- It has network interaction:
|
||||
- It exposes one or more APIs via one or more ports, OR
|
||||
- It makes calls to other services. (OR both.)
|
||||
|
||||
If your application's use-cases rely primarily on shell interaction, this framework might not be for you. The framework allows you to execute commands in containers AND read stdout/stderr responses. However, its focus during development has always been webservice API interactions.
|
||||
|
||||
## Steps
|
||||
In order to add your project to the framework you must:
|
||||
1. Create a library assembly in the project plugins folder.
|
||||
1. It must contain a type that implements the `IProjectPlugin` interface from the `Core` assembly.
|
||||
1. If your plugin wants to expose any specific methods or objects to the code using the framework (the tests and tools), it must implement extensions for the `CoreInterface` type.
|
||||
1. If your plugin wants to expose any specific methods or objects to 'users', it must implement extensions for the `CoreInterface` type.
|
||||
1. If your plugin wants to run containers of its own project, it must provide a recipe.
|
||||
|
||||
## Constructors & Tools
|
||||
Your implementation of `IProjectPlugin` must have a public constructor with a single argument of type `IPluginTools`, for example:
|
||||
@@ -20,19 +36,34 @@ Your implementation of `IProjectPlugin` must have a public constructor with a si
|
||||
}
|
||||
```
|
||||
|
||||
`IPluginTools` provides your plugin access to all framework functionality, such as logging, tracked file management, container lifecycle management, and a means to create HTTP clients for containers. (Without having to figure out addresses manually.)
|
||||
|
||||
## Plugin Interfaces
|
||||
The `IProjectPlugin` interface requires the implementation of two methods.
|
||||
1. `Announce` - It is considered polite to use the logging functionality provided by the `IPluginTools` to announce that your plugin has been loaded. You may also want to log some manner of version information at this time if applicable.
|
||||
1. `Decommission` - Should your plugin have any active system resources, free them in this method.
|
||||
1. `Announce` - It is considered polite to use the logging functionality provided by the `IPluginTools` to announce that your plugin has been loaded. You may also want to log some manner of version and/or configuration information at this time if applicable.
|
||||
1. `Decommission` - Should your plugin have any active system resources, free them in this method. Please note that resources managed by the framework (such as running containers and tracked data files) do *not* need to be manually disposed in this method. `Decommission` is to be used for resources not managed by the framework.
|
||||
|
||||
There are a few optional interfaces your plugin may choose to implement. The framework will automatically use these interfaces.
|
||||
1. `IHasLogPrefix` - Implementing this interface allows you to provide a string with will be prepended to all log statements made by your plugin.
|
||||
1. `IHasMetadata` - This allows you to provide metadata in the form of key/value pairs. This metadata can be accessed by code that uses your plugin.
|
||||
1. `IHasLogPrefix` - Implementing this interface allows you to provide a string which will be prepended to all log statements made by your plugin. A polite thing to do.
|
||||
1. `IHasMetadata` - This allows you to provide metadata in the form of key/value pairs. This metadata can be accessed by 'users' of your plugin. Often this data finds its way into log files and container-descriptors in order to help track versions/tests/deployments, etc.
|
||||
|
||||
## IPluginTools
|
||||
`IPluginTools` provides your plugin access to all framework functionality, such as logging, tracked file management, container lifecycle management, and a means to create HTTP clients to make calls to containers. (Figure out addresses and ports for containers is handled by the framework.)
|
||||
|
||||
It is possible and allowed for your plugin to depend on and use other plugins. (For example, maybe your project wants to interact with Ethereum and wants to use the GethPlugin to talk to a Geth node.) `IPluginTools` is *not* what is used for accessing functionality of other plugins. See 'Core Interface' section.
|
||||
|
||||
ILog GetLog();
|
||||
IHttp CreateHttp(Action<HttpClient> onClientCreated);
|
||||
IHttp CreateHttp(Action<HttpClient> onClientCreated, ITimeSet timeSet);
|
||||
IHttp CreateHttp();
|
||||
IFileManager GetFileManager();
|
||||
|
||||
The plugin tools provide:
|
||||
1. `Workflow` - This tool allows you to start and stop containers using "container recipes". (More on those below.) It also allows you to execute commands inside a container, access stdout/stderr, detect crashes, and access pod deployment information. The workflow tool also lets you inspect the locations available in the cluster, and decide where you want to run containers. (More on that below as well.)
|
||||
1. `Log` - Good logging is priceless. Use this tool to get a log object handle, and write useful debug/info/error statements.
|
||||
1. `Http` - This tool gives you a convenient way to access a standard dotnet HttpClient, and takes care of timeouts and retries (in accordance with the config). Additionally, it combos nicely with container objects created by `Workflow`, such that you never have to spend any time figuring out the addresses and ports of your containers.
|
||||
1. `FileManager` - Lets you use tracked temporary files. Even if the 'user' tests/application start crashing, the framework will make sure these are cleaned up.
|
||||
|
||||
## Core Interface
|
||||
Any functionality your plugin wants to expose to code which uses the framework will have to be added on to the `CoreInterface` type. You can accomplish this by using C# extension methods. The framework provides a `GetPlugin` method to access your plugin instance from the `CoreInterface` type:
|
||||
Any functionality your plugin wants to expose to 'users' will have to be added on to the `CoreInterface` type. You can accomplish this by using C# extension methods. The framework provides a `GetPlugin` method to access your plugin instance from the `CoreInterface` type:
|
||||
```C#
|
||||
public static class CoreInterfaceExtensions
|
||||
{
|
||||
@@ -48,12 +79,14 @@ Any functionality your plugin wants to expose to code which uses the framework w
|
||||
}
|
||||
```
|
||||
|
||||
If your plugin wants to access the functionality exposed by other plugins, then you can pass the argument `CoreInterface ci` to your plugin code in order to do so. (For example, if you want to start a Geth node, the Geth plugin adds `IGethNode StartGethNode(this CoreInterface ci, Action<IGethSetup> setup)` to the core interface.) Don't forget you'll need to add a project reference to each plugin project you wish to use.
|
||||
|
||||
While technically you can build whatever you like on top of the `CoreInterface` and your own plugin types, I recommend that you follow the approach explained below.
|
||||
|
||||
## Deploying, Wrapping, and Starting
|
||||
When building a plugin, it is important to make as few assumptions as possible about how it will be used by whoever is going to use the framework. For this reason, I recommend you expose three kinds of methods using your `CoreInterface` extensions:
|
||||
1. Deploy - This kind of method should deploy your project, creating and configuring containers as needed and returning containers as a result. If your project requires additional information, you can create a new class type to contain both it and the containers created.
|
||||
1. Wrap - This kind of method should, when given the previously mentioned container information, create some kind of convenient accessor or interactor object. This object should abstract away for example details of a REST API of your project, allowing users of your plugin to write their code using a set of methods and types that nicely model your project's domain.
|
||||
1. Deploy - This kind of method should deploy your project, creating and configuring containers as needed and returning container objects as a result. If your project requires additional information, you can create a new class type to contain both it and the container objects created.
|
||||
1. Wrap - This kind of method should, when given the previously mentioned container information, create some kind of convenient accessor or interactor object. This object should abstract away for example details of a REST API of your project, allowing users of your plugin to write their code using a set of methods and types that nicely model your project's domain. (For example, if my project has a REST API call that allows users to fetch some state information, the object returned by Wrap should have a convenient method to call that API and receive that state information.)
|
||||
1. Start - This kind of method does both, simply calling a Deploy method first, then a Wrap method, and returns the result.
|
||||
|
||||
Here's an example:
|
||||
@@ -69,8 +102,8 @@ public static class CoreInterfaceExtensions
|
||||
|
||||
public static IMyProjectNode WrapMyProjectContainer(this CoreInterface ci, RunningContainers container)
|
||||
{
|
||||
return Plugin(ci).WrapMyContainerProject(container); // <-- This method probably will use the 'PluginTools.CreateHttp()` tool to create an HTTP client for the container, then wrap it in an object that
|
||||
// represents the API of your project.
|
||||
return Plugin(ci).WrapMyContainerProject(container); // <-- This method probably will use the 'PluginTools.CreateHttp()` to create an HTTP client for the container, then wrap it in an object that
|
||||
// represents the API of your project, in this case 'IMyProjectNode'.
|
||||
}
|
||||
|
||||
public static IMyProjectNode StartMyProject(this CoreInterface ci, string someArgument)
|
||||
@@ -82,5 +115,44 @@ public static class CoreInterfaceExtensions
|
||||
}
|
||||
```
|
||||
|
||||
The primary reason to decouple deploying and wrapping functionalities is that some use cases require these steps to be performed by separate applications, and different moments in time. For this reason, whatever is returned by the deploy methods should be serializable. After deserialization at some later time, it should then be valid input for the wrap method. The Codex continuous tests system is a clear example of this use case: The `CodexNetDeployer` tool uses deploy methods to create Codex nodes. Then it writes the returned objects to a JSON file. Some time later, the `CodexContinousTests` application uses this JSON file to reconstruct the objects created by the deploy methods. It then uses the wrap methods to create accessors and interactors, which are used for testing.
|
||||
Should your deploy methods not return framework-types like RunningContainers, please make sure that your custom times are serializable. (Decorate them with the `[Serializable]` attribute.) Tools have been built using this framework which rely on the ability to serialize and store deployment information for later use. Please don't break this possibility. (Consider using the `SerializeGate` type to help ensure compatibility.)
|
||||
|
||||
The primary reason to decouple deploying and wrapping functionalities is that some use cases require these steps to be performed by separate applications, and different moments in time. For this reason, whatever is returned by the deploy methods should be serializable. After deserialization at some later time, it should then be valid input for the wrap method. The Codex continuous tests system is a clear example of this use case: The `CodexNetDeployer` tool uses deploy methods to create Codex nodes. Then it writes the returned objects to a JSON file. Some time later, the `CodexContinuousTests` application uses this JSON file to reconstruct the objects created by the deploy methods. It then uses the wrap methods to create accessors and interactors, which are used for testing.
|
||||
|
||||
## Container Recipes
|
||||
In order to run a container of your application, the framework needs to know how to create that container. Think of a container recipe as being similar to a docker-compose.yaml file: You specify the docker image, ports, environment variables, persistent volumes, and secrets. However, container recipes are code. This allows you to add conditional behaviour to how your container is constructed. For example: The 'user' of your plugin specifies in their call input that they want to run your application in a certain mode. This would cause your container recipe to set certain environment variables, which cause the application to behave in the requested way.
|
||||
|
||||
### Addresses and ports
|
||||
In a docker-compose.yaml file, it is perfectly normal to specify which ports should be exposed on your container. However, in the framework there's more to consider. When your application container starts, who knows on what kind of machine it runs, and what other processes it's sharing space with? Well, Kubernetes knows. Therefore, it is recommended that container recipes *do not* specify exact port numbers. The framework allows container recipes to declare "a port" without specifying its port number. This allows the framework and Kubernetes to figure out which ports are available when it's time to deploy. In order to find out which port numbers were assigned post-deployment, you can look up the port by tag (which is just an identifying string). When you specify a port to be mapped in your container recipe, you must specify:
|
||||
1. `Tag` - An identifier.
|
||||
1. `Internal` or `External` - Whether this port should be accessible only inside the cluster (for other containers (k8s: "ClusterIP")) or outside the cluster as well (for external tools/applications (k8s: "NodePort")).
|
||||
1. `Protocol` - TCP or UDP. Both protocols on the same port is not universally supported by all container engines, and is therefore not supported by the framework.
|
||||
|
||||
If your application wants to listen for incoming traffic from inside its container, be sure to bind it to address "0.0.0.0".
|
||||
|
||||
Reminder: If you don't want to worry about addresses, and internal or external ports, you don't have to! The container objects returned by the `workflow` plugin tool have a method called `GetAddress`. Given a port tag, it returns and address object. The `Http` plugin tool can use that address object to set up connections.
|
||||
|
||||
## Locations
|
||||
The framework is designed to allow you to control instances of your application in multiple (physical) locations. It accomplishes this by using kubernetes, and the ability to deploy containers to specific hosts (nodes) inside a kubernetes cluster. Since Kubernetes allows you to build clusters cross-site, this framework in theory enables you to deploy and interact with containers running anywhere.
|
||||
|
||||
The `workflow` plugin tool provides you a list of all available locations in the cluster. When starting a container, you are able to pick one of those locations. If no location is selected, one will be chosen by kubernetes. Locations can be chosen explicitly by kubernetes node name, or, they can be picked from the array of available locations.
|
||||
|
||||
Example:
|
||||
```C#
|
||||
{
|
||||
var location = Ci.GetKnownLocations().Get("kbnode_euwest_paris1");
|
||||
var codex = Ci.StartCodexNode(s => s.At(location));
|
||||
}
|
||||
```
|
||||
In this example, 'Ci' is an instance of the core interface. The CodexPlugin exposes a function 'StartCodexNode', which allows its user to specify a location. This location is then passed to the `workflow` tool when the Codex plugin starts its container.
|
||||
|
||||
The available locations array guarantees that each entry corresponds to a different kubernetes host.
|
||||
```C#
|
||||
{
|
||||
var knownLocations = Ci.GetKnownLocations();
|
||||
// I don't care where exactly, as long as they are different locations.
|
||||
var codexAtZero = Ci.StartCodexNode(s => s.At(knownLocations.Get(0)));
|
||||
var codexAtOne = Ci.StartCodexNode(s => s.At(knownLocations.Get(1)));
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Do you want to write some tests using this distributed test setup? Great! Here's
|
||||
1. When using the auto-bootstrap, you have no control over the bootstrap node from your tests. You can't (for example) shut it down during the course of the test. If you need this level of control for your scenario, use the `CodexDistTest` instead.
|
||||
1. If your test needs a long time to run, add the `[UseLongTimeouts]` function attribute. This will greatly increase maximum time-out values for operations like for example uploading and downloading files.
|
||||
### Continuous tests
|
||||
1. Add new code files to `Tests/CodexContinousTests/Tests`
|
||||
1. Add new code files to `Tests/CodexContinuousTests/Tests`
|
||||
1. Inherrit from `ContinuousTest`
|
||||
1. Define one or more methods and decorate them with the `[TestMoment(...)]` attribute.
|
||||
1. The TestMoment takes a number of seconds as argument. Each moment will be executed by the continuous test runner applying the given seconds as delay. (Non-cumulative. So two moments at T:10 will be executed one after another without delay, in this case the order of execution should not be depended upon.)
|
||||
|
||||
@@ -4,9 +4,8 @@ namespace ArgsUniform
|
||||
{
|
||||
public class ArgsUniform<T>
|
||||
{
|
||||
private readonly Assigner<T> assigner;
|
||||
private readonly Action printAppInfo;
|
||||
private readonly object? defaultsProvider;
|
||||
private readonly IEnv.IEnv env;
|
||||
private readonly string[] args;
|
||||
private const int cliStart = 8;
|
||||
private const int shortStart = 38;
|
||||
@@ -31,9 +30,9 @@ namespace ArgsUniform
|
||||
public ArgsUniform(Action printAppInfo, object defaultsProvider, IEnv.IEnv env, params string[] args)
|
||||
{
|
||||
this.printAppInfo = printAppInfo;
|
||||
this.defaultsProvider = defaultsProvider;
|
||||
this.env = env;
|
||||
this.args = args;
|
||||
|
||||
assigner = new Assigner<T>(env, args, defaultsProvider);
|
||||
}
|
||||
|
||||
public T Parse(bool printResult = false)
|
||||
@@ -42,7 +41,7 @@ namespace ArgsUniform
|
||||
{
|
||||
printAppInfo();
|
||||
PrintHelp();
|
||||
throw new Exception();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
var result = Activator.CreateInstance<T>();
|
||||
@@ -53,18 +52,16 @@ namespace ArgsUniform
|
||||
var attr = uniformProperty.GetCustomAttribute<UniformAttribute>();
|
||||
if (attr != null)
|
||||
{
|
||||
if (!UniformAssign(result, attr, uniformProperty) && attr.Required)
|
||||
if (!assigner.UniformAssign(result, attr, uniformProperty) && attr.Required)
|
||||
{
|
||||
{
|
||||
missingRequired.Add(uniformProperty);
|
||||
}
|
||||
missingRequired.Add(uniformProperty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingRequired.Any())
|
||||
{
|
||||
PrintResults(result, uniformProperties);
|
||||
PrintResults(printResult,result, uniformProperties);
|
||||
Print("");
|
||||
foreach (var missing in missingRequired)
|
||||
{
|
||||
@@ -75,37 +72,39 @@ namespace ArgsUniform
|
||||
}
|
||||
|
||||
PrintHelp();
|
||||
throw new ArgumentException("Unable to assemble all required arguments");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (printResult)
|
||||
{
|
||||
PrintResults(result, uniformProperties);
|
||||
}
|
||||
PrintResults(printResult, result, uniformProperties);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void PrintResults(T result, PropertyInfo[] uniformProperties)
|
||||
{
|
||||
Print("");
|
||||
foreach (var p in uniformProperties)
|
||||
{
|
||||
Print($"\t{p.Name} = {p.GetValue(result)}");
|
||||
}
|
||||
Print("");
|
||||
}
|
||||
|
||||
public void PrintHelp()
|
||||
{
|
||||
Print("");
|
||||
PrintAligned("CLI option:", "(short)", "Environment variable:", "Description");
|
||||
var attrs = typeof(T).GetProperties().Where(m => m.GetCustomAttributes(typeof(UniformAttribute), false).Length == 1).Select(p => p.GetCustomAttribute<UniformAttribute>()).Where(a => a != null).ToArray();
|
||||
foreach (var attr in attrs)
|
||||
PrintAligned("CLI option:", "(short)", "Environment variable:", "Description", "(default)");
|
||||
var props = typeof(T).GetProperties().Where(m => m.GetCustomAttributes(typeof(UniformAttribute), false).Length == 1).ToArray();
|
||||
foreach (var prop in props)
|
||||
{
|
||||
var a = attr!;
|
||||
var optional = !a.Required ? " *" : "";
|
||||
PrintAligned($"--{a.Arg}=...", $"({a.ArgShort})", a.EnvVar, a.Description + optional);
|
||||
var a = prop.GetCustomAttribute<UniformAttribute>();
|
||||
if (a != null)
|
||||
{
|
||||
var optional = !a.Required ? " (optional)" : "";
|
||||
var def = assigner.DescribeDefaultFor(prop);
|
||||
PrintAligned($"--{a.Arg}=...", $"({a.ArgShort})", a.EnvVar, a.Description + optional, $"({def})");
|
||||
}
|
||||
}
|
||||
Print("");
|
||||
}
|
||||
|
||||
private void PrintResults(bool printResult, T result, PropertyInfo[] uniformProperties)
|
||||
{
|
||||
if (!printResult) return;
|
||||
Print("");
|
||||
foreach (var p in uniformProperties)
|
||||
{
|
||||
Print($"\t{p.Name} = {p.GetValue(result)}");
|
||||
}
|
||||
Print("");
|
||||
}
|
||||
@@ -115,7 +114,7 @@ namespace ArgsUniform
|
||||
Console.WriteLine(msg);
|
||||
}
|
||||
|
||||
private void PrintAligned(string cli, string s, string env, string desc)
|
||||
private void PrintAligned(string cli, string s, string env, string desc, string def)
|
||||
{
|
||||
Console.CursorLeft = cliStart;
|
||||
Console.Write(cli);
|
||||
@@ -124,132 +123,8 @@ namespace ArgsUniform
|
||||
Console.CursorLeft = envStart;
|
||||
Console.Write(env);
|
||||
Console.CursorLeft = descStart;
|
||||
Console.Write(desc + Environment.NewLine);
|
||||
}
|
||||
|
||||
private object GetDefaultValue(Type t)
|
||||
{
|
||||
if (t.IsValueType) return Activator.CreateInstance(t)!;
|
||||
return null!;
|
||||
}
|
||||
|
||||
private bool UniformAssign(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
if (AssignFromArgsIfAble(result, attr, uniformProperty)) return true;
|
||||
if (AssignFromEnvVarIfAble(result, attr, uniformProperty)) return true;
|
||||
if (AssignFromDefaultsIfAble(result, uniformProperty)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignFromDefaultsIfAble(T result, PropertyInfo uniformProperty)
|
||||
{
|
||||
var currentValue = uniformProperty.GetValue(result);
|
||||
var isEmptryString = (currentValue as string) == string.Empty;
|
||||
if (currentValue != GetDefaultValue(uniformProperty.PropertyType) && !isEmptryString) return true;
|
||||
|
||||
if (defaultsProvider == null) return false;
|
||||
|
||||
var defaultProperty = defaultsProvider.GetType().GetProperties().SingleOrDefault(p => p.Name == uniformProperty.Name);
|
||||
if (defaultProperty == null) return false;
|
||||
|
||||
var value = defaultProperty.GetValue(defaultsProvider);
|
||||
if (value != null)
|
||||
{
|
||||
return Assign(result, uniformProperty, value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignFromEnvVarIfAble(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
var e = env.GetEnvVarOrDefault(attr.EnvVar, string.Empty);
|
||||
if (!string.IsNullOrEmpty(e))
|
||||
{
|
||||
return Assign(result, uniformProperty, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignFromArgsIfAble(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
var fromArg = GetFromArgs(attr.Arg);
|
||||
if (fromArg != null)
|
||||
{
|
||||
return Assign(result, uniformProperty, fromArg);
|
||||
}
|
||||
var fromShort = GetFromArgs(attr.ArgShort);
|
||||
if (fromShort != null)
|
||||
{
|
||||
return Assign(result, uniformProperty, fromShort);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool Assign(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (uniformProperty.PropertyType == value.GetType())
|
||||
{
|
||||
uniformProperty.SetValue(result, value);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (uniformProperty.PropertyType == typeof(string) || uniformProperty.PropertyType == typeof(int))
|
||||
{
|
||||
uniformProperty.SetValue(result, Convert.ChangeType(value, uniformProperty.PropertyType));
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (uniformProperty.PropertyType == typeof(int?)) return AssignOptionalInt(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType.IsEnum) return AssignEnum(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType == typeof(bool)) return AssignBool(result, uniformProperty, value);
|
||||
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AssignEnum(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
var s = value.ToString();
|
||||
if (Enum.TryParse(uniformProperty.PropertyType, s, out var e))
|
||||
{
|
||||
uniformProperty.SetValue(result, e);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AssignOptionalInt(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (int.TryParse(value.ToString(), out int i))
|
||||
{
|
||||
uniformProperty.SetValue(result, i);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AssignBool(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
var s = value.ToString();
|
||||
if (s == "1" || (s != null && s.ToLowerInvariant() == "true"))
|
||||
{
|
||||
uniformProperty.SetValue(result, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetFromArgs(string key)
|
||||
{
|
||||
var argKey = $"--{key}=";
|
||||
var arg = args.FirstOrDefault(a => a.StartsWith(argKey));
|
||||
if (arg != null)
|
||||
{
|
||||
return arg.Substring(argKey.Length);
|
||||
}
|
||||
return null;
|
||||
Console.Write(desc + " ");
|
||||
Console.Write(def + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ArgsUniform
|
||||
{
|
||||
public class Assigner<T>
|
||||
{
|
||||
private readonly IEnv.IEnv env;
|
||||
private readonly string[] args;
|
||||
private readonly object? defaultsProvider;
|
||||
|
||||
public Assigner(IEnv.IEnv env, string[] args, object? defaultsProvider)
|
||||
{
|
||||
this.env = env;
|
||||
this.args = args;
|
||||
this.defaultsProvider = defaultsProvider;
|
||||
}
|
||||
|
||||
public bool UniformAssign(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
if (AssignFromArgsIfAble(result, attr, uniformProperty)) return true;
|
||||
if (AssignFromEnvVarIfAble(result, attr, uniformProperty)) return true;
|
||||
if (AssignFromDefaultsIfAble(result, uniformProperty)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string DescribeDefaultFor(PropertyInfo property)
|
||||
{
|
||||
var obj = Activator.CreateInstance<T>();
|
||||
var defaultValue = GetDefaultValue(obj, property);
|
||||
if (defaultValue == null) return "";
|
||||
if (defaultValue is string str)
|
||||
{
|
||||
return "\"" + str + "\"";
|
||||
}
|
||||
return defaultValue.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private object? GetDefaultValue(T result, PropertyInfo uniformProperty)
|
||||
{
|
||||
// Get value from object's static initializer if it's there.
|
||||
var currentValue = uniformProperty.GetValue(result);
|
||||
if (currentValue != null) return currentValue;
|
||||
|
||||
// Get value from defaults-provider object if it's there.
|
||||
if (defaultsProvider == null) return null;
|
||||
var defaultProperty = defaultsProvider.GetType().GetProperties().SingleOrDefault(p => p.Name == uniformProperty.Name);
|
||||
if (defaultProperty == null) return null;
|
||||
return defaultProperty.GetValue(defaultsProvider);
|
||||
}
|
||||
|
||||
private bool AssignFromDefaultsIfAble(T result, PropertyInfo uniformProperty)
|
||||
{
|
||||
var defaultValue = GetDefaultValue(result, uniformProperty);
|
||||
var isEmptryString = (defaultValue as string) == string.Empty;
|
||||
if (defaultValue != null && defaultValue != GetDefaultValueForType(uniformProperty.PropertyType) && !isEmptryString)
|
||||
{
|
||||
return Assign(result, uniformProperty, defaultValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignFromEnvVarIfAble(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
var e = env.GetEnvVarOrDefault(attr.EnvVar, string.Empty);
|
||||
if (!string.IsNullOrEmpty(e))
|
||||
{
|
||||
return Assign(result, uniformProperty, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignFromArgsIfAble(T result, UniformAttribute attr, PropertyInfo uniformProperty)
|
||||
{
|
||||
var fromArg = GetFromArgs(attr.Arg);
|
||||
if (fromArg != null)
|
||||
{
|
||||
return Assign(result, uniformProperty, fromArg);
|
||||
}
|
||||
var fromShort = GetFromArgs(attr.ArgShort);
|
||||
if (fromShort != null)
|
||||
{
|
||||
return Assign(result, uniformProperty, fromShort);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool Assign(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (uniformProperty.PropertyType == value.GetType())
|
||||
{
|
||||
uniformProperty.SetValue(result, value);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (uniformProperty.PropertyType == typeof(string) || uniformProperty.PropertyType == typeof(int))
|
||||
{
|
||||
uniformProperty.SetValue(result, Convert.ChangeType(value, uniformProperty.PropertyType));
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (uniformProperty.PropertyType == typeof(int?)) return AssignOptionalInt(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType.IsEnum) return AssignEnum(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType == typeof(bool)) return AssignBool(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType == typeof(ulong)) return AssignUlong(result, uniformProperty, value);
|
||||
if (uniformProperty.PropertyType == typeof(BigInteger)) return AssignBigInt(result, uniformProperty, value);
|
||||
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported property type '${uniformProperty.PropertyType}' " +
|
||||
$"for property '${uniformProperty.Name}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AssignEnum(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
var s = value.ToString();
|
||||
if (Enum.TryParse(uniformProperty.PropertyType, s, out var e))
|
||||
{
|
||||
uniformProperty.SetValue(result, e);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AssignOptionalInt(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (int.TryParse(value.ToString(), CultureInfo.InvariantCulture, out int i))
|
||||
{
|
||||
uniformProperty.SetValue(result, i);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignUlong(T? result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (ulong.TryParse(value.ToString(), CultureInfo.InvariantCulture, out ulong i))
|
||||
{
|
||||
uniformProperty.SetValue(result, i);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AssignBigInt(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
if (BigInteger.TryParse(value.ToString(), CultureInfo.InvariantCulture, out BigInteger i))
|
||||
{
|
||||
uniformProperty.SetValue(result, i);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AssignBool(T result, PropertyInfo uniformProperty, object value)
|
||||
{
|
||||
var s = value.ToString();
|
||||
if (s == "1" || (s != null && s.ToLowerInvariant() == "true"))
|
||||
{
|
||||
uniformProperty.SetValue(result, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetFromArgs(string key)
|
||||
{
|
||||
var argKey = $"--{key}=";
|
||||
var arg = args.FirstOrDefault(a => a.StartsWith(argKey));
|
||||
if (arg != null)
|
||||
{
|
||||
return arg.Substring(argKey.Length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object GetDefaultValueForType(Type t)
|
||||
{
|
||||
if (t.IsValueType) return Activator.CreateInstance(t)!;
|
||||
return null!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using KubernetesWorkflow;
|
||||
using KubernetesWorkflow.Types;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
@@ -29,11 +30,7 @@ namespace Core
|
||||
public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null)
|
||||
{
|
||||
var workflow = entryPoint.Tools.CreateWorkflow();
|
||||
var file = entryPoint.Tools.GetLog().CreateSubfile();
|
||||
entryPoint.Tools.GetLog().Log($"Downloading container log for '{container.Name}' to file '{file.FullFilename}'...");
|
||||
var logHandler = new LogDownloadHandler(container.Name, file);
|
||||
workflow.DownloadContainerLog(container, logHandler, tailLines);
|
||||
return logHandler.DownloadLog();
|
||||
return workflow.DownloadContainerLog(container, tailLines);
|
||||
}
|
||||
|
||||
public string ExecuteContainerCommand(IHasContainer containerSource, string command, params string[] args)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
using Logging;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
public interface IDownloadedLog
|
||||
{
|
||||
bool DoesLogContain(string expectedString);
|
||||
string[] FindLinesThatContain(params string[] tags);
|
||||
void DeleteFile();
|
||||
}
|
||||
|
||||
internal class DownloadedLog : IDownloadedLog
|
||||
{
|
||||
private readonly LogFile logFile;
|
||||
|
||||
internal DownloadedLog(LogFile logFile)
|
||||
{
|
||||
this.logFile = logFile;
|
||||
}
|
||||
|
||||
public bool DoesLogContain(string expectedString)
|
||||
{
|
||||
using var file = File.OpenRead(logFile.FullFilename);
|
||||
using var streamReader = new StreamReader(file);
|
||||
|
||||
var line = streamReader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
if (line.Contains(expectedString)) return true;
|
||||
line = streamReader.ReadLine();
|
||||
}
|
||||
|
||||
//Assert.Fail($"{owner} Unable to find string '{expectedString}' in CodexNode log file {logFile.FullFilename}");
|
||||
return false;
|
||||
}
|
||||
|
||||
public string[] FindLinesThatContain(params string[] tags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
using var file = File.OpenRead(logFile.FullFilename);
|
||||
using var streamReader = new StreamReader(file);
|
||||
|
||||
var line = streamReader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
if (tags.All(line.Contains))
|
||||
{
|
||||
result.Add(line);
|
||||
}
|
||||
|
||||
line = streamReader.ReadLine();
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
public void DeleteFile()
|
||||
{
|
||||
File.Delete(logFile.FullFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Serialization = Newtonsoft.Json.Serialization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Utils;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
public interface IEndpoint
|
||||
{
|
||||
string HttpGetString(string route);
|
||||
T HttpGetJson<T>(string route);
|
||||
TResponse HttpPostJson<TRequest, TResponse>(string route, TRequest body);
|
||||
string HttpPostJson<TRequest>(string route, TRequest body);
|
||||
TResponse HttpPostString<TResponse>(string route, string body);
|
||||
string HttpPostStream(string route, Stream stream);
|
||||
Stream HttpGetStream(string route);
|
||||
T Deserialize<T>(string json);
|
||||
}
|
||||
|
||||
internal class Endpoint : IEndpoint
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly IHttp http;
|
||||
private readonly Address address;
|
||||
private readonly string baseUrl;
|
||||
private readonly string? logAlias;
|
||||
|
||||
public Endpoint(ILog log, IHttp http, Address address, string baseUrl, string? logAlias)
|
||||
{
|
||||
this.log = log;
|
||||
this.http = http;
|
||||
this.address = address;
|
||||
this.baseUrl = baseUrl;
|
||||
this.logAlias = logAlias;
|
||||
}
|
||||
|
||||
public string HttpGetString(string route)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
return GetString(client, route);
|
||||
}, $"HTTP-GET:{route}");
|
||||
}
|
||||
|
||||
public T HttpGetJson<T>(string route)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var json = GetString(client, route);
|
||||
return Deserialize<T>(json);
|
||||
}, $"HTTP-GET:{route}");
|
||||
}
|
||||
|
||||
public TResponse HttpPostJson<TRequest, TResponse>(string route, TRequest body)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var response = PostJson(client, route, body);
|
||||
var json = Time.Wait(response.Content.ReadAsStringAsync());
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException(json);
|
||||
}
|
||||
Log(GetUrl() + route, json);
|
||||
return Deserialize<TResponse>(json);
|
||||
}, $"HTTP-POST-JSON: {route}");
|
||||
}
|
||||
|
||||
public string HttpPostJson<TRequest>(string route, TRequest body)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var response = PostJson(client, route, body);
|
||||
return Time.Wait(response.Content.ReadAsStringAsync());
|
||||
}, $"HTTP-POST-JSON: {route}");
|
||||
}
|
||||
|
||||
public TResponse HttpPostString<TResponse>(string route, string body)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var response = PostJsonString(client, route, body);
|
||||
if (response == null) throw new Exception("Received no response.");
|
||||
var result = Deserialize<TResponse>(response);
|
||||
if (result == null) throw new Exception("Failed to deserialize response");
|
||||
return result;
|
||||
}, $"HTTO-POST-JSON: {route}");
|
||||
}
|
||||
|
||||
public string HttpPostStream(string route, Stream stream)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "~ STREAM ~");
|
||||
var content = new StreamContent(stream);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
var response = Time.Wait(client.PostAsync(url, content));
|
||||
var str = Time.Wait(response.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}, $"HTTP-POST-STREAM: {route}");
|
||||
}
|
||||
|
||||
public Stream HttpGetStream(string route)
|
||||
{
|
||||
return http.OnClient(client =>
|
||||
{
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "~ STREAM ~");
|
||||
return Time.Wait(client.GetStreamAsync(url));
|
||||
}, $"HTTP-GET-STREAM: {route}");
|
||||
}
|
||||
|
||||
public T Deserialize<T>(string json)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var deserialized = JsonConvert.DeserializeObject<T>(json, new JsonSerializerSettings()
|
||||
{
|
||||
Error = delegate (object? sender, Serialization.ErrorEventArgs args)
|
||||
{
|
||||
if (args.CurrentObject == args.ErrorContext.OriginalObject)
|
||||
{
|
||||
errors.Add($"""
|
||||
Member: '{args.ErrorContext.Member?.ToString() ?? "<null>"}'
|
||||
Path: {args.ErrorContext.Path}
|
||||
Error: {args.ErrorContext.Error.Message}
|
||||
""");
|
||||
args.ErrorContext.Handled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new JsonSerializationException($"Failed to deserialize JSON '{json}' with exception(s): \n{string.Join("\n", errors)}");
|
||||
}
|
||||
else if (deserialized == null)
|
||||
{
|
||||
throw new JsonSerializationException($"Failed to deserialize JSON '{json}': resulting deserialized object is null");
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
private string GetString(HttpClient client, string route)
|
||||
{
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "");
|
||||
var result = Time.Wait(client.GetAsync(url));
|
||||
var str = Time.Wait(result.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}
|
||||
|
||||
private HttpResponseMessage PostJson<TRequest>(HttpClient client, string route, TRequest body)
|
||||
{
|
||||
var url = GetUrl() + route;
|
||||
using var content = JsonContent.Create(body);
|
||||
Log(url, JsonConvert.SerializeObject(body));
|
||||
return Time.Wait(client.PostAsync(url, content));
|
||||
}
|
||||
|
||||
private string PostJsonString(HttpClient client, string route, string body)
|
||||
{
|
||||
var url = GetUrl() + route;
|
||||
Log(url, body);
|
||||
var content = new StringContent(body);
|
||||
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
|
||||
var result = Time.Wait(client.PostAsync(url, content));
|
||||
var str = Time.Wait(result.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}
|
||||
|
||||
private string GetUrl()
|
||||
{
|
||||
return $"{address.Host}:{address.Port}{baseUrl}";
|
||||
}
|
||||
|
||||
private void Log(string url, string message)
|
||||
{
|
||||
if (logAlias != null)
|
||||
{
|
||||
log.Debug($"({logAlias})({url}) = '{message}'", 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Debug($"({url}) = '{message}'", 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,14 @@ namespace Core
|
||||
return new CoreInterface(this);
|
||||
}
|
||||
|
||||
public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles)
|
||||
/// <summary>
|
||||
/// Deletes kubernetes and tracked file resources.
|
||||
/// when `waitTillDone` is true, this function will block until resources are deleted.
|
||||
/// </summary>
|
||||
public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles, bool waitTillDone)
|
||||
{
|
||||
manager.DecommissionPlugins(deleteKubernetesResources, deleteTrackedFiles);
|
||||
Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles);
|
||||
manager.DecommissionPlugins(deleteKubernetesResources, deleteTrackedFiles, waitTillDone);
|
||||
Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles, waitTillDone);
|
||||
}
|
||||
|
||||
internal T GetPlugin<T>() where T : IProjectPlugin
|
||||
|
||||
+43
-174
@@ -1,215 +1,84 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Serialization = Newtonsoft.Json.Serialization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Utils;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
public interface IHttp
|
||||
{
|
||||
string HttpGetString(string route);
|
||||
T HttpGetJson<T>(string route);
|
||||
TResponse HttpPostJson<TRequest, TResponse>(string route, TRequest body);
|
||||
string HttpPostJson<TRequest>(string route, TRequest body);
|
||||
TResponse HttpPostString<TResponse>(string route, string body);
|
||||
string HttpPostStream(string route, Stream stream);
|
||||
Stream HttpGetStream(string route);
|
||||
T Deserialize<T>(string json);
|
||||
T OnClient<T>(Func<HttpClient, T> action);
|
||||
T OnClient<T>(Func<HttpClient, T> action, string description);
|
||||
T OnClient<T>(Func<HttpClient, T> action, Retry retry);
|
||||
IEndpoint CreateEndpoint(Address address, string baseUrl, string? logAlias = null);
|
||||
}
|
||||
|
||||
internal class Http : IHttp
|
||||
{
|
||||
private static readonly object httpLock = new object();
|
||||
private static object lockLock = new object();
|
||||
private static readonly Dictionary<string, object> httpLocks = new Dictionary<string, object>();
|
||||
private readonly ILog log;
|
||||
private readonly ITimeSet timeSet;
|
||||
private readonly Address address;
|
||||
private readonly string baseUrl;
|
||||
private readonly Action<HttpClient> onClientCreated;
|
||||
private readonly string? logAlias;
|
||||
private readonly string id;
|
||||
|
||||
internal Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, string? logAlias = null)
|
||||
: this(log, timeSet, address, baseUrl, DoNothing, logAlias)
|
||||
internal Http(string id, ILog log, ITimeSet timeSet)
|
||||
: this(id, log, timeSet, DoNothing)
|
||||
{
|
||||
}
|
||||
|
||||
internal Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, Action<HttpClient> onClientCreated, string? logAlias = null)
|
||||
internal Http(string id, ILog log, ITimeSet timeSet, Action<HttpClient> onClientCreated)
|
||||
{
|
||||
this.id = id;
|
||||
this.log = log;
|
||||
this.timeSet = timeSet;
|
||||
this.address = address;
|
||||
this.baseUrl = baseUrl;
|
||||
this.onClientCreated = onClientCreated;
|
||||
this.logAlias = logAlias;
|
||||
if (!this.baseUrl.StartsWith("/")) this.baseUrl = "/" + this.baseUrl;
|
||||
if (!this.baseUrl.EndsWith("/")) this.baseUrl += "/";
|
||||
}
|
||||
|
||||
public string HttpGetString(string route)
|
||||
public T OnClient<T>(Func<HttpClient, T> action)
|
||||
{
|
||||
return OnClient(action, GetDescription());
|
||||
}
|
||||
|
||||
public T OnClient<T>(Func<HttpClient, T> action, string description)
|
||||
{
|
||||
var retry = new Retry(description, timeSet.HttpRetryTimeout(), timeSet.HttpCallRetryDelay(), f => { });
|
||||
return OnClient(action, retry);
|
||||
}
|
||||
|
||||
public T OnClient<T>(Func<HttpClient, T> action, Retry retry)
|
||||
{
|
||||
var client = GetClient();
|
||||
|
||||
return LockRetry(() =>
|
||||
{
|
||||
return GetString(route);
|
||||
}, $"HTTP-GET:{route}");
|
||||
return action(client);
|
||||
}, retry);
|
||||
}
|
||||
|
||||
public T HttpGetJson<T>(string route)
|
||||
public IEndpoint CreateEndpoint(Address address, string baseUrl, string? logAlias = null)
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
var json = GetString(route);
|
||||
return Deserialize<T>(json);
|
||||
}, $"HTTP-GET:{route}");
|
||||
return new Endpoint(log, this, address, baseUrl, logAlias);
|
||||
}
|
||||
|
||||
public TResponse HttpPostJson<TRequest, TResponse>(string route, TRequest body)
|
||||
private string GetDescription()
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
var response = PostJson(route, body);
|
||||
var json = Time.Wait(response.Content.ReadAsStringAsync());
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException(json);
|
||||
}
|
||||
Log(GetUrl() + route, json);
|
||||
return Deserialize<TResponse>(json);
|
||||
}, $"HTTP-POST-JSON: {route}");
|
||||
return DebugStack.GetCallerName(skipFrames: 2);
|
||||
}
|
||||
|
||||
public string HttpPostJson<TRequest>(string route, TRequest body)
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
var response = PostJson(route, body);
|
||||
return Time.Wait(response.Content.ReadAsStringAsync());
|
||||
}, $"HTTP-POST-JSON: {route}");
|
||||
}
|
||||
|
||||
public TResponse HttpPostString<TResponse>(string route, string body)
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
var response = PostJsonString(route, body);
|
||||
if (response == null) throw new Exception("Received no response.");
|
||||
var result = Deserialize<TResponse>(response);
|
||||
if (result == null) throw new Exception("Failed to deserialize response");
|
||||
return result;
|
||||
}, $"HTTO-POST-JSON: {route}");
|
||||
}
|
||||
|
||||
public string HttpPostStream(string route, Stream stream)
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
using var client = GetClient();
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "~ STREAM ~");
|
||||
var content = new StreamContent(stream);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
var response = Time.Wait(client.PostAsync(url, content));
|
||||
var str = Time.Wait(response.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}, $"HTTP-POST-STREAM: {route}");
|
||||
}
|
||||
|
||||
public Stream HttpGetStream(string route)
|
||||
{
|
||||
return LockRetry(() =>
|
||||
{
|
||||
var client = GetClient();
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "~ STREAM ~");
|
||||
return Time.Wait(client.GetStreamAsync(url));
|
||||
}, $"HTTP-GET-STREAM: {route}");
|
||||
}
|
||||
|
||||
public T Deserialize<T>(string json)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var deserialized = JsonConvert.DeserializeObject<T>(json, new JsonSerializerSettings()
|
||||
{
|
||||
Error = delegate(object? sender, Serialization.ErrorEventArgs args)
|
||||
{
|
||||
if (args.CurrentObject == args.ErrorContext.OriginalObject)
|
||||
{
|
||||
errors.Add($"""
|
||||
Member: '{args.ErrorContext.Member?.ToString() ?? "<null>"}'
|
||||
Path: {args.ErrorContext.Path}
|
||||
Error: {args.ErrorContext.Error.Message}
|
||||
""");
|
||||
args.ErrorContext.Handled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new JsonSerializationException($"Failed to deserialize JSON '{json}' with exception(s): \n{string.Join("\n", errors)}");
|
||||
}
|
||||
else if (deserialized == null)
|
||||
{
|
||||
throw new JsonSerializationException($"Failed to deserialize JSON '{json}': resulting deserialized object is null");
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
private string GetString(string route)
|
||||
{
|
||||
using var client = GetClient();
|
||||
var url = GetUrl() + route;
|
||||
Log(url, "");
|
||||
var result = Time.Wait(client.GetAsync(url));
|
||||
var str = Time.Wait(result.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}
|
||||
|
||||
private HttpResponseMessage PostJson<TRequest>(string route, TRequest body)
|
||||
{
|
||||
using var client = GetClient();
|
||||
var url = GetUrl() + route;
|
||||
using var content = JsonContent.Create(body);
|
||||
Log(url, JsonConvert.SerializeObject(body));
|
||||
return Time.Wait(client.PostAsync(url, content));
|
||||
}
|
||||
|
||||
private string PostJsonString(string route, string body)
|
||||
{
|
||||
using var client = GetClient();
|
||||
var url = GetUrl() + route;
|
||||
Log(url, body);
|
||||
var content = new StringContent(body);
|
||||
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
|
||||
var result = Time.Wait(client.PostAsync(url, content));
|
||||
var str = Time.Wait(result.Content.ReadAsStringAsync());
|
||||
Log(url, str);
|
||||
return str;
|
||||
}
|
||||
|
||||
private string GetUrl()
|
||||
{
|
||||
return $"{address.Host}:{address.Port}{baseUrl}";
|
||||
}
|
||||
|
||||
private void Log(string url, string message)
|
||||
{
|
||||
if (logAlias != null)
|
||||
{
|
||||
log.Debug($"({logAlias})({url}) = '{message}'", 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Debug($"({url}) = '{message}'", 3);
|
||||
}
|
||||
}
|
||||
|
||||
private T LockRetry<T>(Func<T> operation, string description)
|
||||
private T LockRetry<T>(Func<T> operation, Retry retry)
|
||||
{
|
||||
var httpLock = GetLock();
|
||||
lock (httpLock)
|
||||
{
|
||||
return Time.Retry(operation, timeSet.HttpMaxNumberOfRetries(), timeSet.HttpCallRetryDelay(), description);
|
||||
return retry.Run(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private object GetLock()
|
||||
{
|
||||
lock (lockLock) // I had to.
|
||||
{
|
||||
if (!httpLocks.ContainsKey(id)) httpLocks.Add(id, new object());
|
||||
return httpLocks[id];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using KubernetesWorkflow;
|
||||
using Logging;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
internal class LogDownloadHandler : LogHandler, ILogHandler
|
||||
{
|
||||
private readonly LogFile log;
|
||||
|
||||
internal LogDownloadHandler(string description, LogFile log)
|
||||
{
|
||||
this.log = log;
|
||||
|
||||
log.Write($"{description} -->> {log.FullFilename}");
|
||||
log.WriteRaw(description);
|
||||
}
|
||||
|
||||
internal IDownloadedLog DownloadLog()
|
||||
{
|
||||
return new DownloadedLog(log);
|
||||
}
|
||||
|
||||
protected override void ProcessLine(string line)
|
||||
{
|
||||
log.WriteRaw(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@
|
||||
return metadata;
|
||||
}
|
||||
|
||||
internal void DecommissionPlugins(bool deleteKubernetesResources, bool deleteTrackedFiles)
|
||||
internal void DecommissionPlugins(bool deleteKubernetesResources, bool deleteTrackedFiles, bool waitTillDone)
|
||||
{
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
pair.Plugin.Decommission();
|
||||
pair.Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles);
|
||||
pair.Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles, waitTillDone);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using FileUtils;
|
||||
using KubernetesWorkflow;
|
||||
using Logging;
|
||||
using Utils;
|
||||
|
||||
namespace Core
|
||||
{
|
||||
public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool
|
||||
{
|
||||
void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles);
|
||||
ITimeSet TimeSet { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Deletes kubernetes and tracked file resources.
|
||||
/// when `waitTillDone` is true, this function will block until resources are deleted.
|
||||
/// </summary>
|
||||
void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles, bool waitTillDone);
|
||||
}
|
||||
|
||||
public interface IWorkflowTool
|
||||
@@ -22,9 +27,9 @@ namespace Core
|
||||
|
||||
public interface IHttpFactoryTool
|
||||
{
|
||||
IHttp CreateHttp(Address address, string baseUrl, Action<HttpClient> onClientCreated, string? logAlias = null);
|
||||
IHttp CreateHttp(Address address, string baseUrl, Action<HttpClient> onClientCreated, ITimeSet timeSet, string? logAlias = null);
|
||||
IHttp CreateHttp(Address address, string baseUrl, string? logAlias = null);
|
||||
IHttp CreateHttp(string id, Action<HttpClient> onClientCreated);
|
||||
IHttp CreateHttp(string id, Action<HttpClient> onClientCreated, ITimeSet timeSet);
|
||||
IHttp CreateHttp(string id);
|
||||
}
|
||||
|
||||
public interface IFileTool
|
||||
@@ -34,37 +39,38 @@ namespace Core
|
||||
|
||||
internal class PluginTools : IPluginTools
|
||||
{
|
||||
private readonly ITimeSet timeSet;
|
||||
private readonly WorkflowCreator workflowCreator;
|
||||
private readonly IFileManager fileManager;
|
||||
private ILog log;
|
||||
private readonly LogPrefixer log;
|
||||
|
||||
internal PluginTools(ILog log, WorkflowCreator workflowCreator, string fileManagerRootFolder, ITimeSet timeSet)
|
||||
{
|
||||
this.log = log;
|
||||
this.log = new LogPrefixer(log);
|
||||
this.workflowCreator = workflowCreator;
|
||||
this.timeSet = timeSet;
|
||||
TimeSet = timeSet;
|
||||
fileManager = new FileManager(log, fileManagerRootFolder);
|
||||
}
|
||||
|
||||
public ITimeSet TimeSet { get; }
|
||||
|
||||
public void ApplyLogPrefix(string prefix)
|
||||
{
|
||||
log = new LogPrefixer(log, prefix);
|
||||
log.Prefix = prefix;
|
||||
}
|
||||
|
||||
public IHttp CreateHttp(Address address, string baseUrl, Action<HttpClient> onClientCreated, string? logAlias = null)
|
||||
public IHttp CreateHttp(string id, Action<HttpClient> onClientCreated)
|
||||
{
|
||||
return CreateHttp(address, baseUrl, onClientCreated, timeSet, logAlias);
|
||||
return CreateHttp(id, onClientCreated, TimeSet);
|
||||
}
|
||||
|
||||
public IHttp CreateHttp(Address address, string baseUrl, Action<HttpClient> onClientCreated, ITimeSet ts, string? logAlias = null)
|
||||
public IHttp CreateHttp(string id, Action<HttpClient> onClientCreated, ITimeSet ts)
|
||||
{
|
||||
return new Http(log, ts, address, baseUrl, onClientCreated, logAlias);
|
||||
return new Http(id, log, ts, onClientCreated);
|
||||
}
|
||||
|
||||
public IHttp CreateHttp(Address address, string baseUrl, string? logAlias = null)
|
||||
public IHttp CreateHttp(string id)
|
||||
{
|
||||
return new Http(log, timeSet, address, baseUrl, logAlias);
|
||||
return new Http(id, log, TimeSet);
|
||||
}
|
||||
|
||||
public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null)
|
||||
@@ -72,9 +78,9 @@ namespace Core
|
||||
return workflowCreator.CreateWorkflow(namespaceOverride);
|
||||
}
|
||||
|
||||
public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles)
|
||||
public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles, bool waitTillDone)
|
||||
{
|
||||
if (deleteKubernetesResources) CreateWorkflow().DeleteNamespace();
|
||||
if (deleteKubernetesResources) CreateWorkflow().DeleteNamespace(waitTillDone);
|
||||
if (deleteTrackedFiles) fileManager.DeleteAllFiles();
|
||||
}
|
||||
|
||||
|
||||
+34
-13
@@ -2,10 +2,31 @@
|
||||
{
|
||||
public interface ITimeSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Timeout for a single HTTP call.
|
||||
/// </summary>
|
||||
TimeSpan HttpCallTimeout();
|
||||
int HttpMaxNumberOfRetries();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total time to attempt to make a successful HTTP call to a service.
|
||||
/// When HTTP calls time out during this timespan, retries will be made.
|
||||
/// </summary>
|
||||
TimeSpan HttpRetryTimeout();
|
||||
|
||||
/// <summary>
|
||||
/// After a failed HTTP call, wait this long before trying again.
|
||||
/// </summary>
|
||||
TimeSpan HttpCallRetryDelay();
|
||||
TimeSpan WaitForK8sServiceDelay();
|
||||
|
||||
/// <summary>
|
||||
/// After a failed K8s operation, wait this long before trying again.
|
||||
/// </summary>
|
||||
TimeSpan K8sOperationRetryDelay();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total time to attempt to perform a successful k8s operation.
|
||||
/// If k8s operations fail during this timespan, retries will be made.
|
||||
/// </summary>
|
||||
TimeSpan K8sOperationTimeout();
|
||||
}
|
||||
|
||||
@@ -13,12 +34,12 @@
|
||||
{
|
||||
public TimeSpan HttpCallTimeout()
|
||||
{
|
||||
return TimeSpan.FromMinutes(3);
|
||||
return TimeSpan.FromMinutes(2);
|
||||
}
|
||||
|
||||
public int HttpMaxNumberOfRetries()
|
||||
public TimeSpan HttpRetryTimeout()
|
||||
{
|
||||
return 3;
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public TimeSpan HttpCallRetryDelay()
|
||||
@@ -26,7 +47,7 @@
|
||||
return TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
public TimeSpan WaitForK8sServiceDelay()
|
||||
public TimeSpan K8sOperationRetryDelay()
|
||||
{
|
||||
return TimeSpan.FromSeconds(10);
|
||||
}
|
||||
@@ -41,27 +62,27 @@
|
||||
{
|
||||
public TimeSpan HttpCallTimeout()
|
||||
{
|
||||
return TimeSpan.FromHours(2);
|
||||
return TimeSpan.FromMinutes(30);
|
||||
}
|
||||
|
||||
public int HttpMaxNumberOfRetries()
|
||||
public TimeSpan HttpRetryTimeout()
|
||||
{
|
||||
return 1;
|
||||
return TimeSpan.FromHours(2.2);
|
||||
}
|
||||
|
||||
public TimeSpan HttpCallRetryDelay()
|
||||
{
|
||||
return TimeSpan.FromSeconds(2);
|
||||
return TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public TimeSpan WaitForK8sServiceDelay()
|
||||
public TimeSpan K8sOperationRetryDelay()
|
||||
{
|
||||
return TimeSpan.FromSeconds(10);
|
||||
return TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public TimeSpan K8sOperationTimeout()
|
||||
{
|
||||
return TimeSpan.FromMinutes(15);
|
||||
return TimeSpan.FromHours(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Utils;
|
||||
|
||||
namespace DiscordRewards
|
||||
{
|
||||
public class CheckConfig
|
||||
{
|
||||
public CheckType Type { get; set; }
|
||||
public ulong MinNumberOfHosts { get; set; }
|
||||
public ByteSize MinSlotSize { get; set; } = 0.Bytes();
|
||||
public TimeSpan MinDuration { get; set; } = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
public enum CheckType
|
||||
{
|
||||
Uninitialized,
|
||||
HostFilledSlot,
|
||||
HostFinishedSlot,
|
||||
ClientPostedContract,
|
||||
ClientStartedContract,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Framework\Utils\Utils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DiscordRewards
|
||||
{
|
||||
public class GiveRewardsCommand
|
||||
{
|
||||
public RewardUsersCommand[] Rewards { get; set; } = Array.Empty<RewardUsersCommand>();
|
||||
public string[] EventsOverview { get; set; } = Array.Empty<string>();
|
||||
|
||||
public bool HasAny()
|
||||
{
|
||||
return Rewards.Any() || EventsOverview.Any();
|
||||
}
|
||||
}
|
||||
|
||||
public class RewardUsersCommand
|
||||
{
|
||||
public ulong RewardId { get; set; }
|
||||
public string[] UserAddresses { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DiscordRewards
|
||||
{
|
||||
public class RewardConfig
|
||||
{
|
||||
public const string UsernameTag = "<USER>";
|
||||
|
||||
public RewardConfig(ulong roleId, string message, CheckConfig checkConfig)
|
||||
{
|
||||
RoleId = roleId;
|
||||
Message = message;
|
||||
CheckConfig = checkConfig;
|
||||
}
|
||||
|
||||
public ulong RoleId { get; }
|
||||
public string Message { get; }
|
||||
public CheckConfig CheckConfig { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace DiscordRewards
|
||||
{
|
||||
public class RewardRepo
|
||||
{
|
||||
private static string Tag => RewardConfig.UsernameTag;
|
||||
|
||||
public RewardConfig[] Rewards { get; } = new RewardConfig[0];
|
||||
|
||||
// Example configuration, from test server:
|
||||
//{
|
||||
// // Filled any slot
|
||||
// new RewardConfig(1187039439558541498, $"{Tag} successfully filled their first slot!", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.HostFilledSlot
|
||||
// }),
|
||||
|
||||
// // Finished any slot
|
||||
// new RewardConfig(1202286165630390339, $"{Tag} successfully finished their first slot!", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.HostFinishedSlot
|
||||
// }),
|
||||
|
||||
// // Finished a sizable slot
|
||||
// new RewardConfig(1202286218738405418, $"{Tag} finished their first 1GB-24h slot! (10mb/5mins for test)", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.HostFinishedSlot,
|
||||
// MinSlotSize = 10.MB(),
|
||||
// MinDuration = TimeSpan.FromMinutes(5.0),
|
||||
// }),
|
||||
|
||||
// // Posted any contract
|
||||
// new RewardConfig(1202286258370383913, $"{Tag} posted their first contract!", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.ClientPostedContract
|
||||
// }),
|
||||
|
||||
// // Started any contract
|
||||
// new RewardConfig(1202286330873126992, $"A contract created by {Tag} reached Started state for the first time!", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.ClientStartedContract
|
||||
// }),
|
||||
|
||||
// // Started a sizable contract
|
||||
// new RewardConfig(1202286381670608909, $"A large contract created by {Tag} reached Started state for the first time! (10mb/5mins for test)", new CheckConfig
|
||||
// {
|
||||
// Type = CheckType.ClientStartedContract,
|
||||
// MinNumberOfHosts = 4,
|
||||
// MinSlotSize = 10.MB(),
|
||||
// MinDuration = TimeSpan.FromMinutes(5.0),
|
||||
// })
|
||||
//};
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,35 @@ namespace FileUtils
|
||||
{
|
||||
TrackedFile CreateEmptyFile(string label = "");
|
||||
TrackedFile GenerateFile(ByteSize size, string label = "");
|
||||
TrackedFile GenerateFile(Action<IGenerateOption> options, string label = "");
|
||||
void DeleteAllFiles();
|
||||
void ScopedFiles(Action action);
|
||||
T ScopedFiles<T>(Func<T> action);
|
||||
}
|
||||
|
||||
public interface IGenerateOption
|
||||
{
|
||||
IGenerateOption Random(ByteSize size);
|
||||
IGenerateOption StringRepeat(string str, ByteSize size);
|
||||
IGenerateOption StringRepeat(string str, int times);
|
||||
IGenerateOption ByteRepeat(byte[] bytes, ByteSize size);
|
||||
IGenerateOption ByteRepeat(byte[] bytes, int times);
|
||||
}
|
||||
|
||||
public class FileManager : IFileManager
|
||||
{
|
||||
public const int ChunkSize = 1024 * 1024 * 100;
|
||||
private static NumberSource folderNumberSource = new NumberSource(0);
|
||||
private readonly Random random = new Random();
|
||||
private static readonly NumberSource folderNumberSource = new NumberSource(0);
|
||||
private readonly ILog log;
|
||||
private readonly string rootFolder;
|
||||
private readonly string folder;
|
||||
private readonly List<List<TrackedFile>> fileSetStack = new List<List<TrackedFile>>();
|
||||
|
||||
public const int ChunkSize = 1024 * 1024 * 100;
|
||||
|
||||
public FileManager(ILog log, string rootFolder)
|
||||
{
|
||||
folder = Path.Combine(rootFolder, folderNumberSource.GetNextNumber().ToString("D5"));
|
||||
|
||||
this.log = log;
|
||||
this.rootFolder = rootFolder;
|
||||
}
|
||||
|
||||
public TrackedFile CreateEmptyFile(string label = "")
|
||||
@@ -41,11 +49,16 @@ namespace FileUtils
|
||||
return result;
|
||||
}
|
||||
|
||||
public TrackedFile GenerateFile(ByteSize size, string label)
|
||||
public TrackedFile GenerateFile(ByteSize size, string label = "")
|
||||
{
|
||||
return GenerateFile(o => o.Random(size), label);
|
||||
}
|
||||
|
||||
public TrackedFile GenerateFile(Action<IGenerateOption> options, string label = "")
|
||||
{
|
||||
var sw = Stopwatch.Begin(log);
|
||||
var result = GenerateRandomFile(size, label);
|
||||
sw.End($"Generated file '{result.Describe()}'.");
|
||||
var result = RunGenerators(options, label);
|
||||
sw.End($"Generated file {result.Describe()}.");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -57,16 +70,27 @@ namespace FileUtils
|
||||
public void ScopedFiles(Action action)
|
||||
{
|
||||
PushFileSet();
|
||||
action();
|
||||
PopFileSet();
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
PopFileSet();
|
||||
}
|
||||
}
|
||||
|
||||
public T ScopedFiles<T>(Func<T> action)
|
||||
{
|
||||
PushFileSet();
|
||||
var result = action();
|
||||
PopFileSet();
|
||||
return result;
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
PopFileSet();
|
||||
}
|
||||
}
|
||||
|
||||
private void PushFileSet()
|
||||
@@ -89,26 +113,35 @@ namespace FileUtils
|
||||
if (!Directory.GetFiles(folder).Any()) DeleteDirectory();
|
||||
}
|
||||
|
||||
private TrackedFile GenerateRandomFile(ByteSize size, string label)
|
||||
private TrackedFile RunGenerators(Action<IGenerateOption> options, string label)
|
||||
{
|
||||
var result = CreateEmptyFile(label);
|
||||
CheckSpaceAvailable(result, size);
|
||||
var generators = GetGenerators(options);
|
||||
CheckSpaceAvailable(result, generators.GetRequiredSpace());
|
||||
|
||||
GenerateFileBytes(result, size);
|
||||
using var stream = new FileStream(result.Filename, FileMode.Append);
|
||||
generators.Run(stream);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CheckSpaceAvailable(TrackedFile testFile, ByteSize size)
|
||||
private GeneratorCollection GetGenerators(Action<IGenerateOption> options)
|
||||
{
|
||||
var result = new GeneratorCollection();
|
||||
options(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CheckSpaceAvailable(TrackedFile testFile, long requiredSize)
|
||||
{
|
||||
var file = new FileInfo(testFile.Filename);
|
||||
var drive = new DriveInfo(file.Directory!.Root.FullName);
|
||||
|
||||
var spaceAvailable = drive.TotalFreeSpace;
|
||||
|
||||
if (spaceAvailable < size.SizeInBytes)
|
||||
if (spaceAvailable < requiredSize)
|
||||
{
|
||||
var msg = $"Not enough disk space. " +
|
||||
$"{Formatter.FormatByteSize(size.SizeInBytes)} required. " +
|
||||
$"{Formatter.FormatByteSize(requiredSize)} required. " +
|
||||
$"{Formatter.FormatByteSize(spaceAvailable)} available.";
|
||||
|
||||
log.Log(msg);
|
||||
@@ -116,34 +149,6 @@ namespace FileUtils
|
||||
}
|
||||
}
|
||||
|
||||
private void GenerateFileBytes(TrackedFile result, ByteSize size)
|
||||
{
|
||||
long bytesLeft = size.SizeInBytes;
|
||||
int chunkSize = ChunkSize;
|
||||
while (bytesLeft > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var length = Math.Min(bytesLeft, chunkSize);
|
||||
AppendRandomBytesToFile(result, length);
|
||||
bytesLeft -= length;
|
||||
}
|
||||
catch
|
||||
{
|
||||
chunkSize = chunkSize / 2;
|
||||
if (chunkSize < 1024) throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendRandomBytesToFile(TrackedFile result, long length)
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
random.NextBytes(bytes);
|
||||
using var stream = new FileStream(result.Filename, FileMode.Append);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
private void EnsureDirectory()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using Utils;
|
||||
|
||||
namespace FileUtils
|
||||
{
|
||||
public class GeneratorCollection : IGenerateOption
|
||||
{
|
||||
private readonly List<IGenerator> generators = new List<IGenerator>();
|
||||
|
||||
public IGenerateOption ByteRepeat(byte[] bytes, ByteSize size)
|
||||
{
|
||||
var times = size.SizeInBytes / bytes.Length;
|
||||
generators.Add(new ByteRepeater(bytes, times));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IGenerateOption ByteRepeat(byte[] bytes, int times)
|
||||
{
|
||||
generators.Add(new ByteRepeater(bytes, times));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IGenerateOption Random(ByteSize size)
|
||||
{
|
||||
generators.Add(new RandomGenerator(size));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IGenerateOption StringRepeat(string str, ByteSize size)
|
||||
{
|
||||
var times = size.SizeInBytes / str.Length;
|
||||
generators.Add(new StringRepeater(str, times));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IGenerateOption StringRepeat(string str, int times)
|
||||
{
|
||||
generators.Add(new StringRepeater(str, times));
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Run(FileStream file)
|
||||
{
|
||||
foreach (var generator in generators)
|
||||
{
|
||||
generator.Generate(file);
|
||||
}
|
||||
}
|
||||
|
||||
public long GetRequiredSpace()
|
||||
{
|
||||
return generators.Sum(g => g.GetRequiredSpace());
|
||||
}
|
||||
}
|
||||
|
||||
public interface IGenerator
|
||||
{
|
||||
void Generate(FileStream file);
|
||||
long GetRequiredSpace();
|
||||
}
|
||||
|
||||
public class ByteRepeater : IGenerator
|
||||
{
|
||||
private readonly byte[] bytes;
|
||||
private readonly long times;
|
||||
|
||||
public ByteRepeater(byte[] bytes, long times)
|
||||
{
|
||||
this.bytes = bytes;
|
||||
this.times = times;
|
||||
}
|
||||
|
||||
public void Generate(FileStream file)
|
||||
{
|
||||
for (var i = 0; i < times; i++)
|
||||
{
|
||||
file.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public long GetRequiredSpace()
|
||||
{
|
||||
return bytes.Length * times;
|
||||
}
|
||||
}
|
||||
|
||||
public class StringRepeater : IGenerator
|
||||
{
|
||||
private readonly string str;
|
||||
private readonly long times;
|
||||
|
||||
public StringRepeater(string str, long times)
|
||||
{
|
||||
this.str = str;
|
||||
this.times = times;
|
||||
}
|
||||
|
||||
public void Generate(FileStream file)
|
||||
{
|
||||
using var writer = new StreamWriter(file);
|
||||
for (var i = 0; i < times; i++)
|
||||
{
|
||||
writer.Write(str);
|
||||
}
|
||||
}
|
||||
|
||||
public long GetRequiredSpace()
|
||||
{
|
||||
return Encoding.ASCII.GetBytes(str).Length * times;
|
||||
}
|
||||
}
|
||||
|
||||
public class RandomGenerator : IGenerator
|
||||
{
|
||||
private readonly Random random = new Random();
|
||||
private readonly ByteSize size;
|
||||
|
||||
public RandomGenerator(ByteSize size)
|
||||
{
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public void Generate(FileStream file)
|
||||
{
|
||||
var bytesLeft = size.SizeInBytes;
|
||||
while (bytesLeft > 0)
|
||||
{
|
||||
var size = Math.Min(bytesLeft, FileManager.ChunkSize);
|
||||
var bytes = new byte[size];
|
||||
random.NextBytes(bytes);
|
||||
file.Write(bytes, 0, bytes.Length);
|
||||
bytesLeft -= size;
|
||||
}
|
||||
}
|
||||
|
||||
public long GetRequiredSpace()
|
||||
{
|
||||
return size.SizeInBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ namespace FileUtils
|
||||
return $"'{Filename}'{sizePostfix}";
|
||||
}
|
||||
|
||||
public ByteSize GetFilesize()
|
||||
{
|
||||
return new ByteSize(GetFileSize());
|
||||
}
|
||||
|
||||
private void AssertEqual(TrackedFile? actual)
|
||||
{
|
||||
if (actual == null) FrameworkAssert.Fail("TestFile is null.");
|
||||
@@ -60,7 +65,7 @@ namespace FileUtils
|
||||
|
||||
if (readExpected == 0 && readActual == 0)
|
||||
{
|
||||
log.Log($"OK: '{Describe()}' is equal to '{actual.Describe()}'.");
|
||||
log.Log($"OK: {Describe()} is equal to {actual.Describe()}.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using CodexContractsPlugin;
|
||||
using GethPlugin;
|
||||
using Logging;
|
||||
|
||||
namespace GethConnector
|
||||
{
|
||||
public class GethConnector
|
||||
{
|
||||
public IGethNode GethNode { get; }
|
||||
public ICodexContracts CodexContracts { get; }
|
||||
|
||||
public static GethConnector? Initialize(ILog log)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(GethInput.LoadError))
|
||||
{
|
||||
var msg = "Geth input incorrect: " + GethInput.LoadError;
|
||||
log.Error(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
var contractsDeployment = new CodexContractsDeployment(
|
||||
marketplaceAddress: GethInput.MarketplaceAddress,
|
||||
abi: GethInput.ABI,
|
||||
tokenAddress: GethInput.TokenAddress
|
||||
);
|
||||
|
||||
var gethNode = new CustomGethNode(log, GethInput.GethHost, GethInput.GethPort, GethInput.PrivateKey);
|
||||
var contracts = new CodexContractsAccess(log, gethNode, contractsDeployment);
|
||||
|
||||
return new GethConnector(gethNode, contracts);
|
||||
}
|
||||
|
||||
private GethConnector(IGethNode gethNode, ICodexContracts codexContracts)
|
||||
{
|
||||
GethNode = gethNode;
|
||||
CodexContracts = codexContracts;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Framework\Logging\Logging.csproj" />
|
||||
<ProjectReference Include="..\..\ProjectPlugins\CodexContractsPlugin\CodexContractsPlugin.csproj" />
|
||||
<ProjectReference Include="..\..\ProjectPlugins\GethPlugin\GethPlugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace GethConnector
|
||||
{
|
||||
public static class GethInput
|
||||
{
|
||||
private const string GethHostVar = "GETH_HOST";
|
||||
private const string GethPortVar = "GETH_HTTP_PORT";
|
||||
private const string GethPrivKeyVar = "GETH_PRIVATE_KEY";
|
||||
private const string MarketplaceAddressVar = "CODEXCONTRACTS_MARKETPLACEADDRESS";
|
||||
private const string TokenAddressVar = "CODEXCONTRACTS_TOKENADDRESS";
|
||||
private const string AbiVar = "CODEXCONTRACTS_ABI";
|
||||
|
||||
static GethInput()
|
||||
{
|
||||
var error = new List<string>();
|
||||
var gethHost = GetEnvVar(error, GethHostVar);
|
||||
var gethPort = Convert.ToInt32(GetEnvVar(error, GethPortVar));
|
||||
var privateKey = GetEnvVar(error, GethPrivKeyVar);
|
||||
var marketplaceAddress = GetEnvVar(error, MarketplaceAddressVar);
|
||||
var tokenAddress = GetEnvVar(error, TokenAddressVar);
|
||||
var abi = GetEnvVar(error, AbiVar);
|
||||
|
||||
if (error.Any())
|
||||
{
|
||||
LoadError = string.Join(", ", error);
|
||||
}
|
||||
else
|
||||
{
|
||||
GethHost = gethHost!;
|
||||
GethPort = gethPort;
|
||||
PrivateKey = privateKey!;
|
||||
MarketplaceAddress = marketplaceAddress!;
|
||||
TokenAddress = tokenAddress!;
|
||||
ABI = abi!;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GethHost { get; } = string.Empty;
|
||||
public static int GethPort { get; }
|
||||
public static string PrivateKey { get; } = string.Empty;
|
||||
public static string MarketplaceAddress { get; } = string.Empty;
|
||||
public static string TokenAddress { get; } = string.Empty;
|
||||
public static string ABI { get; } = string.Empty;
|
||||
public static string LoadError { get; } = string.Empty;
|
||||
|
||||
private static string? GetEnvVar(List<string> error, string name)
|
||||
{
|
||||
var result = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrEmpty(result)) error.Add($"'{name}' is not set.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public static class ByteSizeExtensions
|
||||
{
|
||||
public static string ToSuffixNotation(this ByteSize b)
|
||||
{
|
||||
long x = 1024;
|
||||
var map = new Dictionary<long, string>
|
||||
{
|
||||
{ Pow(x, 4), "Ti" },
|
||||
{ Pow(x, 3), "Gi" },
|
||||
{ Pow(x, 2), "Mi" },
|
||||
{ (x), "Ki" },
|
||||
};
|
||||
|
||||
var bytes = b.SizeInBytes;
|
||||
foreach (var pair in map)
|
||||
{
|
||||
if (bytes > pair.Key)
|
||||
{
|
||||
double bytesD = bytes;
|
||||
double divD = pair.Key;
|
||||
double numD = Math.Ceiling(bytesD / divD);
|
||||
var v = Convert.ToInt64(numD);
|
||||
return $"{v}{pair.Value}";
|
||||
}
|
||||
}
|
||||
|
||||
return $"{bytes}";
|
||||
}
|
||||
|
||||
private static long Pow(long x, int v)
|
||||
{
|
||||
long result = 1;
|
||||
for (var i = 0; i < v; i++) result *= x;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,17 @@ namespace KubernetesWorkflow
|
||||
{
|
||||
private readonly K8sClient client;
|
||||
private readonly string k8sNamespace;
|
||||
private readonly RunningPod pod;
|
||||
private readonly string podName;
|
||||
private readonly string containerName;
|
||||
private readonly string command;
|
||||
private readonly string[] arguments;
|
||||
private readonly List<string> lines = new List<string>();
|
||||
|
||||
public CommandRunner(K8sClient client, string k8sNamespace, RunningPod pod, string containerName, string command, string[] arguments)
|
||||
public CommandRunner(K8sClient client, string k8sNamespace, string podName, string containerName, string command, string[] arguments)
|
||||
{
|
||||
this.client = client;
|
||||
this.k8sNamespace = k8sNamespace;
|
||||
this.pod = pod;
|
||||
this.podName = podName;
|
||||
this.containerName = containerName;
|
||||
this.command = command;
|
||||
this.arguments = arguments;
|
||||
@@ -27,7 +27,7 @@ namespace KubernetesWorkflow
|
||||
var input = new[] { command }.Concat(arguments).ToArray();
|
||||
|
||||
Time.Wait(client.Run(c => c.NamespacedPodExecAsync(
|
||||
pod.PodInfo.Name, k8sNamespace, containerName, input, false, Callback, new CancellationToken())));
|
||||
podName, k8sNamespace, containerName, input, false, Callback, new CancellationToken())));
|
||||
}
|
||||
|
||||
public string GetStdOut()
|
||||
|
||||
@@ -7,27 +7,29 @@ namespace KubernetesWorkflow
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly KubernetesClientConfiguration config;
|
||||
private readonly string containerName;
|
||||
private readonly string podName;
|
||||
private readonly string recipeName;
|
||||
private readonly string k8sNamespace;
|
||||
private readonly RunningContainer container;
|
||||
private ILogHandler? logHandler;
|
||||
private CancellationTokenSource cts;
|
||||
private Task? worker;
|
||||
private Exception? workerException;
|
||||
|
||||
public CrashWatcher(ILog log, KubernetesClientConfiguration config, string k8sNamespace, RunningContainer container)
|
||||
public CrashWatcher(ILog log, KubernetesClientConfiguration config, string containerName, string podName, string recipeName, string k8sNamespace)
|
||||
{
|
||||
this.log = log;
|
||||
this.config = config;
|
||||
this.containerName = containerName;
|
||||
this.podName = podName;
|
||||
this.recipeName = recipeName;
|
||||
this.k8sNamespace = k8sNamespace;
|
||||
this.container = container;
|
||||
cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public void Start(ILogHandler logHandler)
|
||||
public void Start()
|
||||
{
|
||||
if (worker != null) throw new InvalidOperationException();
|
||||
|
||||
this.logHandler = logHandler;
|
||||
cts = new CancellationTokenSource();
|
||||
worker = Task.Run(Worker);
|
||||
}
|
||||
@@ -46,7 +48,9 @@ namespace KubernetesWorkflow
|
||||
public bool HasContainerCrashed()
|
||||
{
|
||||
using var client = new Kubernetes(config);
|
||||
return HasContainerBeenRestarted(client, container.Pod.PodInfo.Name);
|
||||
var result = HasContainerBeenRestarted(client);
|
||||
if (result) DownloadCrashedContainerLogs(client);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void Worker()
|
||||
@@ -66,30 +70,29 @@ namespace KubernetesWorkflow
|
||||
using var client = new Kubernetes(config);
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
token.WaitHandle.WaitOne(TimeSpan.FromSeconds(1));
|
||||
token.WaitHandle.WaitOne(TimeSpan.FromSeconds(10));
|
||||
|
||||
var pod = container.Pod;
|
||||
var recipe = container.Recipe;
|
||||
var podName = pod.PodInfo.Name;
|
||||
if (HasContainerBeenRestarted(client, podName))
|
||||
if (HasContainerBeenRestarted(client))
|
||||
{
|
||||
DownloadCrashedContainerLogs(client, podName, recipe);
|
||||
DownloadCrashedContainerLogs(client);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasContainerBeenRestarted(Kubernetes client, string podName)
|
||||
private bool HasContainerBeenRestarted(Kubernetes client)
|
||||
{
|
||||
var podInfo = client.ReadNamespacedPod(podName, k8sNamespace);
|
||||
return podInfo.Status.ContainerStatuses.Any(c => c.RestartCount > 0);
|
||||
var result = podInfo.Status.ContainerStatuses.Any(c => c.RestartCount > 0);
|
||||
if (result) log.Log("Pod crash detected for " + containerName);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void DownloadCrashedContainerLogs(Kubernetes client, string podName, ContainerRecipe recipe)
|
||||
private void DownloadCrashedContainerLogs(Kubernetes client)
|
||||
{
|
||||
log.Log("Pod crash detected for " + container.Name);
|
||||
using var stream = client.ReadNamespacedPodLog(podName, k8sNamespace, recipe.Name, previous: true);
|
||||
logHandler!.Log(stream);
|
||||
using var stream = client.ReadNamespacedPodLog(podName, k8sNamespace, recipeName, previous: true);
|
||||
var handler = new WriteToFileLogHandler(log, "Crash detected for " + containerName);
|
||||
handler.Log(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using Logging;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public interface IDownloadedLog
|
||||
{
|
||||
string ContainerName { get; }
|
||||
|
||||
void IterateLines(Action<string> action, params string[] thatContain);
|
||||
string[] GetLinesContaining(string expectedString);
|
||||
string[] FindLinesThatContain(params string[] tags);
|
||||
string GetFilepath();
|
||||
void DeleteFile();
|
||||
}
|
||||
|
||||
internal class DownloadedLog : IDownloadedLog
|
||||
{
|
||||
private readonly LogFile logFile;
|
||||
|
||||
internal DownloadedLog(WriteToFileLogHandler logHandler, string containerName)
|
||||
{
|
||||
logFile = logHandler.LogFile;
|
||||
ContainerName = containerName;
|
||||
}
|
||||
|
||||
public string ContainerName { get; }
|
||||
|
||||
public void IterateLines(Action<string> action, params string[] thatContain)
|
||||
{
|
||||
using var file = File.OpenRead(logFile.FullFilename);
|
||||
using var streamReader = new StreamReader(file);
|
||||
|
||||
var line = streamReader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
if (thatContain.All(line.Contains))
|
||||
{
|
||||
action(line);
|
||||
}
|
||||
line = streamReader.ReadLine();
|
||||
}
|
||||
}
|
||||
|
||||
public string[] GetLinesContaining(string expectedString)
|
||||
{
|
||||
using var file = File.OpenRead(logFile.FullFilename);
|
||||
using var streamReader = new StreamReader(file);
|
||||
var lines = new List<string>();
|
||||
|
||||
var line = streamReader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
if (line.Contains(expectedString))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
line = streamReader.ReadLine();
|
||||
}
|
||||
|
||||
return lines.ToArray(); ;
|
||||
}
|
||||
|
||||
public string[] FindLinesThatContain(params string[] tags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
using var file = File.OpenRead(logFile.FullFilename);
|
||||
using var streamReader = new StreamReader(file);
|
||||
|
||||
var line = streamReader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
if (tags.All(line.Contains))
|
||||
{
|
||||
result.Add(line);
|
||||
}
|
||||
|
||||
line = streamReader.ReadLine();
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
public string GetFilepath()
|
||||
{
|
||||
return logFile.FullFilename;
|
||||
}
|
||||
|
||||
public void DeleteFile()
|
||||
{
|
||||
File.Delete(logFile.FullFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ namespace KubernetesWorkflow
|
||||
{
|
||||
var config = GetConfig();
|
||||
UpdateHostAddress(config);
|
||||
config.SkipTlsVerify = true; // Required for operation on Wings cluster.
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -54,16 +55,4 @@ namespace KubernetesWorkflow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class K8sNodeLabel
|
||||
{
|
||||
public K8sNodeLabel(string key, string value)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string Value { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using k8s;
|
||||
using k8s.Models;
|
||||
using KubernetesWorkflow.Recipe;
|
||||
using KubernetesWorkflow.Types;
|
||||
using Logging;
|
||||
using Utils;
|
||||
|
||||
@@ -11,7 +13,7 @@ namespace KubernetesWorkflow
|
||||
private readonly K8sCluster cluster;
|
||||
private readonly WorkflowNumberSource workflowNumberSource;
|
||||
private readonly K8sClient client;
|
||||
private const string podLabelKey = "pod-uuid";
|
||||
public const string PodLabelKey = "pod-uuid";
|
||||
|
||||
public K8sController(ILog log, K8sCluster cluster, WorkflowNumberSource workflowNumberSource, string k8sNamespace)
|
||||
{
|
||||
@@ -28,57 +30,59 @@ namespace KubernetesWorkflow
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
public RunningPod BringOnline(ContainerRecipe[] containerRecipes, ILocation location)
|
||||
public StartResult BringOnline(ContainerRecipe[] containerRecipes, ILocation location)
|
||||
{
|
||||
log.Debug();
|
||||
EnsureNamespace();
|
||||
|
||||
var podLabel = K8sNameUtils.Format(Guid.NewGuid().ToString());
|
||||
var deploymentName = CreateDeployment(containerRecipes, location, podLabel);
|
||||
var (serviceName, servicePortsMap) = CreateService(containerRecipes);
|
||||
var deployment = CreateDeployment(containerRecipes, location, podLabel);
|
||||
var internalService = CreateInternalService(containerRecipes);
|
||||
var externalService = CreateExternalService(containerRecipes);
|
||||
|
||||
var pod = FindPodByLabel(podLabel);
|
||||
var podInfo = CreatePodInfo(pod);
|
||||
|
||||
return new RunningPod(cluster, podInfo, deploymentName, serviceName, servicePortsMap.ToArray());
|
||||
return new StartResult(cluster, containerRecipes, deployment, internalService, externalService);
|
||||
}
|
||||
|
||||
private V1Pod FindPodByLabel(string podLabel)
|
||||
public void WaitUntilOnline(RunningContainer container)
|
||||
{
|
||||
var pods = client.Run(c => c.ListNamespacedPod(K8sNamespace));
|
||||
foreach (var pod in pods.Items)
|
||||
{
|
||||
var label = pod.GetLabel(podLabelKey);
|
||||
if (label == podLabel)
|
||||
{
|
||||
return pod;
|
||||
}
|
||||
}
|
||||
throw new Exception("Unable to find pod by label.");
|
||||
WaitUntilDeploymentOnline(container);
|
||||
}
|
||||
|
||||
public void Stop(RunningPod pod)
|
||||
public PodInfo GetPodInfo(RunningDeployment deployment)
|
||||
{
|
||||
var pod = GetPodForDeployment(deployment);
|
||||
return CreatePodInfo(pod);
|
||||
}
|
||||
|
||||
public void Stop(StartResult startResult, bool waitTillStopped)
|
||||
{
|
||||
log.Debug();
|
||||
if (!string.IsNullOrEmpty(pod.ServiceName)) DeleteService(pod.ServiceName);
|
||||
DeleteDeployment(pod.DeploymentName);
|
||||
WaitUntilDeploymentOffline(pod.DeploymentName);
|
||||
WaitUntilPodOffline(pod.PodInfo.Name);
|
||||
if (startResult.InternalService != null) DeleteService(startResult.InternalService);
|
||||
if (startResult.ExternalService != null) DeleteService(startResult.ExternalService);
|
||||
DeleteDeployment(startResult.Deployment);
|
||||
|
||||
if (waitTillStopped) WaitUntilPodsForDeploymentAreOffline(startResult.Deployment);
|
||||
}
|
||||
|
||||
public void DownloadPodLog(RunningPod pod, ContainerRecipe recipe, ILogHandler logHandler, int? tailLines)
|
||||
public void DownloadPodLog(RunningContainer container, ILogHandler logHandler, int? tailLines, bool? previous)
|
||||
{
|
||||
log.Debug();
|
||||
using var stream = client.Run(c => c.ReadNamespacedPodLog(pod.PodInfo.Name, K8sNamespace, recipe.Name, tailLines: tailLines));
|
||||
|
||||
var podName = GetPodName(container);
|
||||
var recipeName = container.Recipe.Name;
|
||||
|
||||
using var stream = client.Run(c => c.ReadNamespacedPodLog(podName, K8sNamespace, recipeName, tailLines: tailLines, previous: previous));
|
||||
logHandler.Log(stream);
|
||||
}
|
||||
|
||||
public string ExecuteCommand(RunningPod pod, string containerName, string command, params string[] args)
|
||||
public string ExecuteCommand(RunningContainer container, string command, params string[] args)
|
||||
{
|
||||
var containerName = container.Recipe.Name;
|
||||
var cmdAndArgs = $"{containerName}: {command} ({string.Join(",", args)})";
|
||||
log.Debug(cmdAndArgs);
|
||||
|
||||
var runner = new CommandRunner(client, K8sNamespace, pod, containerName, command, args);
|
||||
var podName = GetPodName(container);
|
||||
var runner = new CommandRunner(client, K8sNamespace, podName, containerName, command, args);
|
||||
runner.Run();
|
||||
var result = runner.GetStdOut();
|
||||
|
||||
@@ -86,7 +90,32 @@ namespace KubernetesWorkflow
|
||||
return result;
|
||||
}
|
||||
|
||||
public void DeleteAllNamespacesStartingWith(string prefix)
|
||||
public int[] GetUsedExternalPorts()
|
||||
{
|
||||
return client.Run(c =>
|
||||
{
|
||||
var result = new List<int>();
|
||||
|
||||
var services = c.ListServiceForAllNamespaces();
|
||||
var nodePorts = services.Items.Where(s => s.Spec.Type == "NodePort").ToArray();
|
||||
if (!nodePorts.Any()) return result.ToArray();
|
||||
|
||||
foreach (var service in nodePorts)
|
||||
{
|
||||
foreach (var port in service.Spec.Ports)
|
||||
{
|
||||
if (port.NodePort.HasValue)
|
||||
{
|
||||
result.Add(port.NodePort.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
});
|
||||
}
|
||||
|
||||
public void DeleteAllNamespacesStartingWith(string prefix, bool wait)
|
||||
{
|
||||
log.Debug();
|
||||
|
||||
@@ -95,25 +124,28 @@ namespace KubernetesWorkflow
|
||||
|
||||
foreach (var ns in namespaces)
|
||||
{
|
||||
DeleteNamespace(ns);
|
||||
DeleteNamespace(ns, wait);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteNamespace()
|
||||
public void DeleteNamespace(bool wait)
|
||||
{
|
||||
log.Debug();
|
||||
if (IsNamespaceOnline(K8sNamespace))
|
||||
{
|
||||
client.Run(c => c.DeleteNamespace(K8sNamespace, null, null, gracePeriodSeconds: 0));
|
||||
|
||||
if (wait) WaitUntilNamespaceDeleted(K8sNamespace);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteNamespace(string ns)
|
||||
public void DeleteNamespace(string ns, bool wait)
|
||||
{
|
||||
log.Debug();
|
||||
if (IsNamespaceOnline(ns))
|
||||
{
|
||||
client.Run(c => c.DeleteNamespace(ns, null, null, gracePeriodSeconds: 0));
|
||||
if (wait) WaitUntilNamespaceDeleted(ns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +347,7 @@ namespace KubernetesWorkflow
|
||||
|
||||
#region Deployment management
|
||||
|
||||
private string CreateDeployment(ContainerRecipe[] containerRecipes, ILocation location, string podLabel)
|
||||
private RunningDeployment CreateDeployment(ContainerRecipe[] containerRecipes, ILocation location, string podLabel)
|
||||
{
|
||||
var deploymentSpec = new V1Deployment
|
||||
{
|
||||
@@ -337,6 +369,8 @@ namespace KubernetesWorkflow
|
||||
},
|
||||
Spec = new V1PodSpec
|
||||
{
|
||||
PriorityClassName = GetPriorityClassName(containerRecipes),
|
||||
Affinity = CreatePodAffinity(containerRecipes),
|
||||
NodeSelector = CreateNodeSelector(location),
|
||||
Containers = CreateDeploymentContainers(containerRecipes),
|
||||
Volumes = CreateVolumes(containerRecipes)
|
||||
@@ -346,15 +380,15 @@ namespace KubernetesWorkflow
|
||||
};
|
||||
|
||||
client.Run(c => c.CreateNamespacedDeployment(deploymentSpec, K8sNamespace));
|
||||
WaitUntilDeploymentOnline(deploymentSpec.Metadata.Name);
|
||||
|
||||
return deploymentSpec.Metadata.Name;
|
||||
var name = deploymentSpec.Metadata.Name;
|
||||
return new RunningDeployment(name, podLabel);
|
||||
}
|
||||
|
||||
private void DeleteDeployment(string deploymentName)
|
||||
private void DeleteDeployment(RunningDeployment deployment)
|
||||
{
|
||||
client.Run(c => c.DeleteNamespacedDeployment(deploymentName, K8sNamespace));
|
||||
WaitUntilDeploymentOffline(deploymentName);
|
||||
client.Run(c => c.DeleteNamespacedDeployment(deployment.Name, K8sNamespace));
|
||||
WaitUntilDeploymentOffline(deployment.Name);
|
||||
}
|
||||
|
||||
private IDictionary<string, string> CreateNodeSelector(ILocation location)
|
||||
@@ -368,12 +402,57 @@ namespace KubernetesWorkflow
|
||||
};
|
||||
}
|
||||
|
||||
private V1Affinity? CreatePodAffinity(ContainerRecipe[] recipes)
|
||||
{
|
||||
var notIns = recipes
|
||||
.Select(r => r.SchedulingAffinity.NotIn)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (!notIns.Any()) return null;
|
||||
|
||||
return new V1Affinity
|
||||
{
|
||||
NodeAffinity = new V1NodeAffinity
|
||||
{
|
||||
RequiredDuringSchedulingIgnoredDuringExecution = new V1NodeSelector
|
||||
{
|
||||
NodeSelectorTerms = new List<V1NodeSelectorTerm>
|
||||
{
|
||||
new V1NodeSelectorTerm
|
||||
{
|
||||
MatchExpressions = new List<V1NodeSelectorRequirement>
|
||||
{
|
||||
new V1NodeSelectorRequirement
|
||||
{
|
||||
Key = "allow-tests-pods",
|
||||
OperatorProperty = "NotIn",
|
||||
Values = notIns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private K8sNodeLabel? GetNodeLabelForLocation(ILocation location)
|
||||
{
|
||||
var l = (Location)location;
|
||||
return l.NodeLabel;
|
||||
}
|
||||
|
||||
private string GetPriorityClassName(ContainerRecipe[] containerRecipes)
|
||||
{
|
||||
if (containerRecipes.Any(c => c.SetCriticalPriority))
|
||||
{
|
||||
return "system-node-critical";
|
||||
}
|
||||
return null!;
|
||||
}
|
||||
|
||||
private IDictionary<string, string> GetSelector(ContainerRecipe[] containerRecipes)
|
||||
{
|
||||
return containerRecipes.First().PodLabels.GetLabels();
|
||||
@@ -382,7 +461,7 @@ namespace KubernetesWorkflow
|
||||
private IDictionary<string, string> GetSelector(ContainerRecipe[] containerRecipes, string podLabel)
|
||||
{
|
||||
var labels = containerRecipes.First().PodLabels.Clone();
|
||||
labels.Add(podLabelKey, podLabel);
|
||||
labels.Add(PodLabelKey, podLabel);
|
||||
return labels.GetLabels();
|
||||
}
|
||||
|
||||
@@ -427,10 +506,17 @@ namespace KubernetesWorkflow
|
||||
Ports = CreateContainerPorts(recipe),
|
||||
Env = CreateEnv(recipe),
|
||||
VolumeMounts = CreateContainerVolumeMounts(recipe),
|
||||
Resources = CreateResourceLimits(recipe)
|
||||
Resources = CreateResourceLimits(recipe),
|
||||
Command = CreateCommandList(recipe)
|
||||
};
|
||||
}
|
||||
|
||||
private IList<string> CreateCommandList(ContainerRecipe recipe)
|
||||
{
|
||||
if (recipe.CommandOverride == null || !recipe.CommandOverride.Command.Any()) return null!;
|
||||
return recipe.CommandOverride.Command.ToList();
|
||||
}
|
||||
|
||||
private V1ResourceRequirements CreateResourceLimits(ContainerRecipe recipe)
|
||||
{
|
||||
return new V1ResourceRequirements
|
||||
@@ -449,7 +535,7 @@ namespace KubernetesWorkflow
|
||||
}
|
||||
if (set.Memory.SizeInBytes != 0)
|
||||
{
|
||||
result.Add("memory", new ResourceQuantity(set.Memory.ToSuffixNotation()));
|
||||
result.Add("memory", new ResourceQuantity(set.Memory.SizeInBytes.ToString()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -609,118 +695,174 @@ namespace KubernetesWorkflow
|
||||
|
||||
private string GetNameForPort(ContainerRecipe recipe, Port port)
|
||||
{
|
||||
return $"p{workflowNumberSource.WorkflowNumber}-{recipe.Number}-{port.Number}-{port.Protocol.ToString().ToLowerInvariant()}";
|
||||
var inputs = new[]
|
||||
{
|
||||
$"p{workflowNumberSource.WorkflowNumber}",
|
||||
recipe.Number.ToString(),
|
||||
port.Number.ToString(),
|
||||
port.Protocol.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
return K8sNameUtils.FormatPortName(string.Join(",", inputs));
|
||||
}
|
||||
|
||||
private string GetPodName(RunningContainer container)
|
||||
{
|
||||
return GetPodForDeployment(container.RunningPod.StartResult.Deployment).Metadata.Name;
|
||||
}
|
||||
|
||||
private V1Pod GetPodForDeployment(RunningDeployment deployment)
|
||||
{
|
||||
return Time.Retry(() => GetPodForDeplomentInternal(deployment),
|
||||
// We will wait up to 1 minute, k8s might be moving pods around.
|
||||
maxTimeout: TimeSpan.FromMinutes(1),
|
||||
retryTime: TimeSpan.FromSeconds(10),
|
||||
description: "Find pod by label for deployment.");
|
||||
}
|
||||
|
||||
private V1Pod GetPodForDeplomentInternal(RunningDeployment deployment)
|
||||
{
|
||||
var allPods = client.Run(c => c.ListNamespacedPod(K8sNamespace));
|
||||
var pods = allPods.Items.Where(p => p.GetLabel(PodLabelKey) == deployment.PodLabel).ToArray();
|
||||
|
||||
if (pods.Length != 1)
|
||||
{
|
||||
var allLabels = allPods.Items.Select(p =>
|
||||
{
|
||||
var labels = string.Join(",", p.Labels().Select(l => $"{l.Key}={l.Value}"));
|
||||
return $"pod:'{p.Name()}' has labels: [{labels}]";
|
||||
});
|
||||
throw new Exception($"Expected to find 1 pod by podLabel '{deployment.PodLabel}'. Found: {pods.Length}. " +
|
||||
$"Total number of pods: {allPods.Items.Count}. Their labels: {string.Join(Environment.NewLine, allLabels)}");
|
||||
}
|
||||
return pods[0];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service management
|
||||
|
||||
private (string, List<ContainerRecipePortMapEntry>) CreateService(ContainerRecipe[] containerRecipes)
|
||||
private RunningService? CreateInternalService(ContainerRecipe[] recipes)
|
||||
{
|
||||
var result = new List<ContainerRecipePortMapEntry>();
|
||||
return CreateService(recipes, r => r.InternalPorts.Concat(r.ExposedPorts).ToArray(), "ClusterIP", "int", false);
|
||||
}
|
||||
|
||||
var ports = CreateServicePorts(containerRecipes);
|
||||
private RunningService? CreateExternalService(ContainerRecipe[] recipes)
|
||||
{
|
||||
return CreateService(recipes, r => r.ExposedPorts, "NodePort", "ext", true);
|
||||
}
|
||||
|
||||
if (!ports.Any())
|
||||
{
|
||||
// None of these container-recipes wish to expose anything via a service port.
|
||||
// So, we don't have to create a service.
|
||||
return (string.Empty, result);
|
||||
}
|
||||
private RunningService? CreateService(ContainerRecipe[] recipes, Func<ContainerRecipe, Port[]> portSelector, string serviceType, string namePostfix, bool isNodePort)
|
||||
{
|
||||
var ports = CreateServicePorts(recipes, portSelector, isNodePort);
|
||||
if (!ports.Any()) return null;
|
||||
|
||||
var serviceSpec = new V1Service
|
||||
{
|
||||
ApiVersion = "v1",
|
||||
Metadata = CreateServiceMetadata(),
|
||||
Metadata = CreateServiceMetadata(recipes, namePostfix),
|
||||
Spec = new V1ServiceSpec
|
||||
{
|
||||
Type = "NodePort",
|
||||
Selector = GetSelector(containerRecipes),
|
||||
Ports = ports
|
||||
Type = serviceType,
|
||||
Selector = GetSelector(recipes),
|
||||
Ports = ports,
|
||||
}
|
||||
};
|
||||
|
||||
client.Run(c => c.CreateNamespacedService(serviceSpec, K8sNamespace));
|
||||
|
||||
ReadBackServiceAndMapPorts(serviceSpec, containerRecipes, result);
|
||||
var result = ReadBackServiceAndMapPorts(serviceSpec, recipes);
|
||||
var name = serviceSpec.Metadata.Name;
|
||||
|
||||
return (serviceSpec.Metadata.Name, result);
|
||||
return new RunningService(name, result);
|
||||
}
|
||||
|
||||
private void ReadBackServiceAndMapPorts(V1Service serviceSpec, ContainerRecipe[] containerRecipes, List<ContainerRecipePortMapEntry> result)
|
||||
private List<ContainerRecipePortMapEntry> ReadBackServiceAndMapPorts(V1Service serviceSpec, ContainerRecipe[] containerRecipes)
|
||||
{
|
||||
// For each container-recipe, we need to figure out which service-ports it was assigned by K8s.
|
||||
var result = new List<ContainerRecipePortMapEntry>();
|
||||
|
||||
// For each container-recipe-port, we need to figure out which service-ports it was assigned by K8s.
|
||||
var readback = client.Run(c => c.ReadNamespacedService(serviceSpec.Metadata.Name, K8sNamespace));
|
||||
foreach (var r in containerRecipes)
|
||||
{
|
||||
foreach (var port in r.ExposedPorts)
|
||||
var recipePorts = r.ExposedPorts.Concat(r.InternalPorts).ToArray();
|
||||
foreach (var port in recipePorts)
|
||||
{
|
||||
var portName = GetNameForPort(r, port);
|
||||
|
||||
var matchingServicePorts = readback.Spec.Ports.Where(p => p.Name == portName);
|
||||
if (matchingServicePorts.Any())
|
||||
{
|
||||
// These service ports belongs to this recipe.
|
||||
var optionals = matchingServicePorts.Select(p => MapNodePortIfAble(p, port.Tag, port.Protocol));
|
||||
var ports = optionals.Where(p => p != null).Select(p => p!).ToArray();
|
||||
var ports = matchingServicePorts.Select(p => MapPortIfAble(p, port.Tag, port.Protocol)).ToArray();
|
||||
|
||||
if (ports.Any())
|
||||
{
|
||||
result.Add(new ContainerRecipePortMapEntry(r.Number, ports));
|
||||
log.Debug($"Service Readback: {portName} found: {string.Join(",", ports.Select(p => p.ToString()))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Port? MapNodePortIfAble(V1ServicePort p, string tag, PortProtocol protocol)
|
||||
private Port MapPortIfAble(V1ServicePort p, string tag, PortProtocol protocol)
|
||||
{
|
||||
if (p.NodePort == null) return null;
|
||||
return new Port(p.NodePort.Value, tag, protocol);
|
||||
if (p.NodePort != null) return new Port(p.NodePort.Value, tag, protocol);
|
||||
if (p.Port > 0) return new Port(p.Port, tag, protocol);
|
||||
throw new Exception("Unable to map port.");
|
||||
}
|
||||
|
||||
private void DeleteService(string serviceName)
|
||||
private void DeleteService(RunningService service)
|
||||
{
|
||||
client.Run(c => c.DeleteNamespacedService(serviceName, K8sNamespace));
|
||||
client.Run(c => c.DeleteNamespacedService(service.Name, K8sNamespace));
|
||||
}
|
||||
|
||||
private V1ObjectMeta CreateServiceMetadata()
|
||||
private V1ObjectMeta CreateServiceMetadata(ContainerRecipe[] containerRecipes, string namePostfix)
|
||||
{
|
||||
var recipeName = containerRecipes.First().Name;
|
||||
var name = K8sNameUtils.Format($"{recipeName}-{workflowNumberSource.WorkflowNumber}-{namePostfix}");
|
||||
log.Debug("Creating service: " + name);
|
||||
return new V1ObjectMeta
|
||||
{
|
||||
Name = "service-" + workflowNumberSource.WorkflowNumber,
|
||||
NamespaceProperty = K8sNamespace
|
||||
Name = name,
|
||||
NamespaceProperty = K8sNamespace,
|
||||
};
|
||||
}
|
||||
|
||||
private List<V1ServicePort> CreateServicePorts(ContainerRecipe[] recipes)
|
||||
private List<V1ServicePort> CreateServicePorts(ContainerRecipe[] recipes, Func<ContainerRecipe, Port[]> portSelector, bool isNodePort)
|
||||
{
|
||||
var result = new List<V1ServicePort>();
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
result.AddRange(CreateServicePorts(recipe));
|
||||
var ports = portSelector(recipe);
|
||||
foreach (var port in ports)
|
||||
{
|
||||
result.AddRange(CreateServicePorts(recipe, port, isNodePort));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<V1ServicePort> CreateServicePorts(ContainerRecipe recipe)
|
||||
private List<V1ServicePort> CreateServicePorts(ContainerRecipe recipe, Port recipePort, bool isNodePort)
|
||||
{
|
||||
var result = new List<V1ServicePort>();
|
||||
foreach (var port in recipe.ExposedPorts)
|
||||
{
|
||||
if (port.IsTcp()) CreateServicePort(result, recipe, port, "TCP");
|
||||
if (port.IsUdp()) CreateServicePort(result, recipe, port, "UDP");
|
||||
}
|
||||
if (recipePort.IsTcp()) CreateServicePort(result, recipe, recipePort, "TCP", isNodePort);
|
||||
if (recipePort.IsUdp()) CreateServicePort(result, recipe, recipePort, "UDP", isNodePort);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CreateServicePort(List<V1ServicePort> result, ContainerRecipe recipe, Port port, string protocol)
|
||||
private void CreateServicePort(List<V1ServicePort> result, ContainerRecipe recipe, Port port, string protocol, bool isNodePort)
|
||||
{
|
||||
result.Add(new V1ServicePort
|
||||
var p = new V1ServicePort
|
||||
{
|
||||
Name = GetNameForPort(recipe, port),
|
||||
Protocol = protocol,
|
||||
Port = port.Number,
|
||||
TargetPort = GetNameForPort(recipe, port),
|
||||
});
|
||||
TargetPort = GetNameForPort(recipe, port)
|
||||
};
|
||||
|
||||
if (isNodePort) p.NodePort = port.Number;
|
||||
|
||||
result.Add(p);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -729,16 +871,45 @@ namespace KubernetesWorkflow
|
||||
|
||||
private void WaitUntilNamespaceCreated()
|
||||
{
|
||||
WaitUntil(() => IsNamespaceOnline(K8sNamespace));
|
||||
WaitUntil(() => IsNamespaceOnline(K8sNamespace), nameof(WaitUntilNamespaceCreated));
|
||||
}
|
||||
|
||||
private void WaitUntilDeploymentOnline(string deploymentName)
|
||||
private void WaitUntilNamespaceDeleted(string @namespace)
|
||||
{
|
||||
WaitUntil(() => !IsNamespaceOnline(@namespace), nameof(WaitUntilNamespaceDeleted));
|
||||
}
|
||||
|
||||
private void WaitUntilDeploymentOnline(RunningContainer container)
|
||||
{
|
||||
WaitUntil(() =>
|
||||
{
|
||||
var deployment = client.Run(c => c.ReadNamespacedDeployment(deploymentName, K8sNamespace));
|
||||
CheckForCrash(container);
|
||||
|
||||
var deployment = client.Run(c => c.ReadNamespacedDeployment(container.Recipe.Name, K8sNamespace));
|
||||
return deployment?.Status.AvailableReplicas != null && deployment.Status.AvailableReplicas > 0;
|
||||
});
|
||||
}, nameof(WaitUntilDeploymentOnline));
|
||||
}
|
||||
|
||||
private void CheckForCrash(RunningContainer container)
|
||||
{
|
||||
var deploymentName = container.Recipe.Name;
|
||||
var podName = GetPodName(container);
|
||||
|
||||
var podInfo = client.Run(c => c.ReadNamespacedPod(podName, K8sNamespace));
|
||||
if (podInfo == null) return;
|
||||
if (podInfo.Status == null) return;
|
||||
if (podInfo.Status.ContainerStatuses == null) return;
|
||||
|
||||
var result = podInfo.Status.ContainerStatuses.Any(c => c.RestartCount > 0);
|
||||
if (result)
|
||||
{
|
||||
var msg = $"Pod crash detected for deployment {deploymentName} (pod:{podName})";
|
||||
log.Error(msg);
|
||||
|
||||
DownloadPodLog(container, new WriteToFileLogHandler(log, msg), tailLines: null, previous: true);
|
||||
|
||||
throw new Exception(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private void WaitUntilDeploymentOffline(string deploymentName)
|
||||
@@ -748,29 +919,28 @@ namespace KubernetesWorkflow
|
||||
var deployments = client.Run(c => c.ListNamespacedDeployment(K8sNamespace));
|
||||
var deployment = deployments.Items.SingleOrDefault(d => d.Metadata.Name == deploymentName);
|
||||
return deployment == null || deployment.Status.AvailableReplicas == 0;
|
||||
});
|
||||
}, nameof(WaitUntilDeploymentOffline));
|
||||
}
|
||||
|
||||
private void WaitUntilPodOffline(string podName)
|
||||
private void WaitUntilPodsForDeploymentAreOffline(RunningDeployment deployment)
|
||||
{
|
||||
WaitUntil(() =>
|
||||
{
|
||||
var pods = client.Run(c => c.ListNamespacedPod(K8sNamespace)).Items;
|
||||
var pod = pods.SingleOrDefault(p => p.Metadata.Name == podName);
|
||||
return pod == null;
|
||||
});
|
||||
var pods = FindPodsByLabel(deployment.PodLabel);
|
||||
return !pods.Any();
|
||||
}, nameof(WaitUntilPodsForDeploymentAreOffline));
|
||||
}
|
||||
|
||||
private void WaitUntil(Func<bool> predicate)
|
||||
private void WaitUntil(Func<bool> predicate, string msg)
|
||||
{
|
||||
var sw = Stopwatch.Begin(log, true);
|
||||
try
|
||||
{
|
||||
Time.WaitUntil(predicate, cluster.K8sOperationTimeout(), cluster.K8sOperationRetryDelay());
|
||||
Time.WaitUntil(predicate, cluster.K8sOperationTimeout(), cluster.K8sOperationRetryDelay(), msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.End("", 1);
|
||||
sw.End(msg, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,7 +948,17 @@ namespace KubernetesWorkflow
|
||||
|
||||
public CrashWatcher CreateCrashWatcher(RunningContainer container)
|
||||
{
|
||||
return new CrashWatcher(log, cluster.GetK8sClientConfig(), K8sNamespace, container);
|
||||
var containerName = container.Name;
|
||||
var podName = GetPodName(container);
|
||||
var recipeName = container.Recipe.Name;
|
||||
|
||||
return new CrashWatcher(log, cluster.GetK8sClientConfig(), containerName, podName, recipeName, K8sNamespace);
|
||||
}
|
||||
|
||||
private V1Pod[] FindPodsByLabel(string podLabel)
|
||||
{
|
||||
var pods = client.Run(c => c.ListNamespacedPod(K8sNamespace));
|
||||
return pods.Items.Where(p => p.GetLabel(PodLabelKey) == podLabel).ToArray();
|
||||
}
|
||||
|
||||
private PodInfo CreatePodInfo(V1Pod pod)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
namespace KubernetesWorkflow
|
||||
using KubernetesWorkflow.Recipe;
|
||||
using KubernetesWorkflow.Types;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public interface IK8sHooks
|
||||
{
|
||||
void OnContainersStarted(RunningContainers runningContainers);
|
||||
void OnContainersStopped(RunningContainers runningContainers);
|
||||
void OnContainersStarted(RunningPod runningPod);
|
||||
void OnContainersStopped(RunningPod runningPod);
|
||||
void OnContainerRecipeCreated(ContainerRecipe recipe);
|
||||
}
|
||||
|
||||
public class DoNothingK8sHooks : IK8sHooks
|
||||
{
|
||||
public void OnContainersStarted(RunningContainers runningContainers)
|
||||
public void OnContainersStarted(RunningPod runningPod)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnContainersStopped(RunningContainers runningContainers)
|
||||
public void OnContainersStopped(RunningPod runningPod)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
public static class K8sNameUtils
|
||||
{
|
||||
public static string Format(string s)
|
||||
{
|
||||
return Format(s, 62);
|
||||
}
|
||||
|
||||
public static string FormatPortName(string s)
|
||||
{
|
||||
return Format(s, 15);
|
||||
}
|
||||
|
||||
private static string Format(string s, int maxLength)
|
||||
{
|
||||
var result = s.ToLowerInvariant()
|
||||
.Replace("_", "-")
|
||||
.Replace(" ", "-")
|
||||
.Replace(":", "-")
|
||||
.Replace("/", "-")
|
||||
@@ -14,7 +25,7 @@
|
||||
.Replace(",", "-");
|
||||
|
||||
result = result.Trim('-');
|
||||
if (result.Length > 62) result = result.Substring(0, 62);
|
||||
if (result.Length > maxLength) result = result.Substring(0, maxLength);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace KubernetesWorkflow
|
||||
using KubernetesWorkflow.Types;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public interface ILocation
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Logging;
|
||||
using KubernetesWorkflow.Types;
|
||||
using Logging;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Logging;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public interface ILogHandler
|
||||
{
|
||||
void Log(Stream log);
|
||||
}
|
||||
|
||||
public abstract class LogHandler : ILogHandler
|
||||
{
|
||||
public void Log(Stream log)
|
||||
{
|
||||
using var reader = new StreamReader(log);
|
||||
var line = reader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
ProcessLine(line);
|
||||
line = reader.ReadLine();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void ProcessLine(string line);
|
||||
}
|
||||
|
||||
public class WriteToFileLogHandler : LogHandler, ILogHandler
|
||||
{
|
||||
public WriteToFileLogHandler(ILog sourceLog, string description)
|
||||
{
|
||||
LogFile = sourceLog.CreateSubfile();
|
||||
|
||||
var msg = $"{description} -->> {LogFile.FullFilename}";
|
||||
sourceLog.Log(msg);
|
||||
|
||||
LogFile.Write(msg);
|
||||
LogFile.WriteRaw(description);
|
||||
}
|
||||
|
||||
public LogFile LogFile { get; }
|
||||
|
||||
protected override void ProcessLine(string line)
|
||||
{
|
||||
LogFile.WriteRaw(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public static class PublicIpService
|
||||
{
|
||||
public static string Address { get; } = "ip.codex.storage";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class CommandOverride
|
||||
{
|
||||
public CommandOverride(params string[] command)
|
||||
{
|
||||
Command = command;
|
||||
}
|
||||
|
||||
public string[] Command { get; }
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,6 +1,7 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class ContainerAdditionals
|
||||
{
|
||||
@@ -20,15 +21,14 @@ namespace KubernetesWorkflow
|
||||
{
|
||||
var typeName = GetTypeName(typeof(T));
|
||||
var userData = Additionals.SingleOrDefault(a => a.Type == typeName);
|
||||
if (userData == null) return default(T);
|
||||
var jobject = (JObject)userData.UserData;
|
||||
return jobject.ToObject<T>();
|
||||
if (userData == null) return default;
|
||||
return JsonConvert.DeserializeObject<T>(userData.UserData);
|
||||
}
|
||||
|
||||
private static Additional ConvertToAdditional(object userData)
|
||||
{
|
||||
var typeName = GetTypeName(userData.GetType());
|
||||
return new Additional(typeName, userData);
|
||||
return new Additional(typeName, JsonConvert.SerializeObject(userData));
|
||||
}
|
||||
|
||||
private static string GetTypeName(Type type)
|
||||
@@ -41,13 +41,13 @@ namespace KubernetesWorkflow
|
||||
|
||||
public class Additional
|
||||
{
|
||||
public Additional(string type, object userData)
|
||||
public Additional(string type, string userData)
|
||||
{
|
||||
Type = type;
|
||||
UserData = userData;
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
public object UserData { get; }
|
||||
public string UserData { get; }
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -1,13 +1,17 @@
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class ContainerRecipe
|
||||
{
|
||||
public ContainerRecipe(int number, string? nameOverride, string image, ContainerResources resources, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, ContainerAdditionals additionals)
|
||||
public ContainerRecipe(DateTime recipeCreatedUtc, int number, string? nameOverride, string image, ContainerResources resources, SchedulingAffinity schedulingAffinity, CommandOverride commandOverride, bool setCriticalPriority, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, ContainerAdditionals additionals)
|
||||
{
|
||||
RecipeCreatedUtc = recipeCreatedUtc;
|
||||
Number = number;
|
||||
NameOverride = nameOverride;
|
||||
Image = image;
|
||||
Resources = resources;
|
||||
SchedulingAffinity = schedulingAffinity;
|
||||
CommandOverride = commandOverride;
|
||||
SetCriticalPriority = setCriticalPriority;
|
||||
ExposedPorts = exposedPorts;
|
||||
InternalPorts = internalPorts;
|
||||
EnvVars = envVars;
|
||||
@@ -28,10 +32,14 @@
|
||||
if (exposedPorts.Any(p => string.IsNullOrEmpty(p.Tag))) throw new Exception("Port tags are required for all exposed ports.");
|
||||
}
|
||||
|
||||
public DateTime RecipeCreatedUtc { get; }
|
||||
public string Name { get; }
|
||||
public int Number { get; }
|
||||
public string? NameOverride { get; }
|
||||
public ContainerResources Resources { get; }
|
||||
public SchedulingAffinity SchedulingAffinity { get; }
|
||||
public CommandOverride CommandOverride { get; }
|
||||
public bool SetCriticalPriority { get; }
|
||||
public string Image { get; }
|
||||
public Port[] ExposedPorts { get; }
|
||||
public Port[] InternalPorts { get; }
|
||||
@@ -53,6 +61,7 @@
|
||||
$"internalPorts: {string.Join(",", InternalPorts.Select(p => p.Number))}, " +
|
||||
$"envVars: {string.Join(",", EnvVars.Select(v => v.ToString()))}, " +
|
||||
$"limits: {Resources}, " +
|
||||
$"affinity: {SchedulingAffinity}, " +
|
||||
$"volumes: {string.Join(",", Volumes.Select(v => $"'{v.MountPath}'"))}";
|
||||
}
|
||||
}
|
||||
@@ -64,6 +73,11 @@
|
||||
Number = number;
|
||||
Tag = tag;
|
||||
Protocol = protocol;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Tag))
|
||||
{
|
||||
throw new Exception("A unique port tag is required");
|
||||
}
|
||||
}
|
||||
|
||||
public int Number { get; }
|
||||
+32
-9
@@ -1,6 +1,6 @@
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public abstract class ContainerRecipeFactory
|
||||
{
|
||||
@@ -13,6 +13,9 @@ namespace KubernetesWorkflow
|
||||
private readonly List<object> additionals = new List<object>();
|
||||
private RecipeComponentFactory factory = null!;
|
||||
private ContainerResources resources = new ContainerResources();
|
||||
private SchedulingAffinity schedulingAffinity = new SchedulingAffinity();
|
||||
private CommandOverride commandOverride = new CommandOverride();
|
||||
private bool setCriticalPriority;
|
||||
|
||||
public ContainerRecipe CreateRecipe(int index, int containerNumber, RecipeComponentFactory factory, StartupConfig config)
|
||||
{
|
||||
@@ -22,7 +25,7 @@ namespace KubernetesWorkflow
|
||||
|
||||
Initialize(config);
|
||||
|
||||
var recipe = new ContainerRecipe(containerNumber, config.NameOverride, Image, resources,
|
||||
var recipe = new ContainerRecipe(DateTime.UtcNow, containerNumber, config.NameOverride, Image, resources, schedulingAffinity, commandOverride, setCriticalPriority,
|
||||
exposedPorts.ToArray(),
|
||||
internalPorts.ToArray(),
|
||||
envVars.ToArray(),
|
||||
@@ -40,6 +43,9 @@ namespace KubernetesWorkflow
|
||||
additionals.Clear();
|
||||
this.factory = null!;
|
||||
resources = new ContainerResources();
|
||||
schedulingAffinity = new SchedulingAffinity();
|
||||
commandOverride = new CommandOverride();
|
||||
setCriticalPriority = false;
|
||||
|
||||
return recipe;
|
||||
}
|
||||
@@ -52,17 +58,17 @@ namespace KubernetesWorkflow
|
||||
|
||||
protected Port AddExposedPort(string tag, PortProtocol protocol = PortProtocol.TCP)
|
||||
{
|
||||
return AddExposedPort(factory.CreatePort(tag, protocol));
|
||||
return AddExposedPort(factory.CreateExternalPort(tag, protocol));
|
||||
}
|
||||
|
||||
protected Port AddExposedPort(int number, string tag, PortProtocol protocol = PortProtocol.TCP)
|
||||
{
|
||||
return AddExposedPort(factory.CreatePort(number, tag, protocol));
|
||||
return AddExposedPort(factory.CreateExternalPort(number, tag, protocol));
|
||||
}
|
||||
|
||||
protected Port AddInternalPort(string tag = "", PortProtocol protocol = PortProtocol.TCP)
|
||||
{
|
||||
var p = factory.CreatePort(tag, protocol);
|
||||
var p = factory.CreateInternalPort(tag, protocol);
|
||||
internalPorts.Add(p);
|
||||
return p;
|
||||
}
|
||||
@@ -99,7 +105,7 @@ namespace KubernetesWorkflow
|
||||
|
||||
protected void AddVolume(string name, string mountPath, string? subPath = null, string? secret = null, string? hostPath = null)
|
||||
{
|
||||
var size = 10.MB().ToSuffixNotation();
|
||||
var size = 10.MB().SizeInBytes.ToString();
|
||||
volumeMounts.Add(new VolumeMount(name, mountPath, subPath, size, secret, hostPath));
|
||||
}
|
||||
|
||||
@@ -108,7 +114,7 @@ namespace KubernetesWorkflow
|
||||
volumeMounts.Add(new VolumeMount(
|
||||
$"autovolume-{Guid.NewGuid().ToString().ToLowerInvariant()}",
|
||||
mountPath,
|
||||
resourceQuantity: volumeSize.ToSuffixNotation()));
|
||||
resourceQuantity: volumeSize.SizeInBytes.ToString()));
|
||||
}
|
||||
|
||||
protected void Additional(object userData)
|
||||
@@ -121,11 +127,28 @@ namespace KubernetesWorkflow
|
||||
SetResourcesRequest(new ContainerResourceSet(milliCPUs, memory));
|
||||
}
|
||||
|
||||
protected void SetResourceLimits(int milliCPUs, ByteSize memory)
|
||||
protected void SetSchedulingAffinity(string notIn)
|
||||
{
|
||||
SetResourceLimits(new ContainerResourceSet(milliCPUs, memory));
|
||||
schedulingAffinity = new SchedulingAffinity(notIn);
|
||||
}
|
||||
|
||||
protected void OverrideCommand(params string[] command)
|
||||
{
|
||||
commandOverride = new CommandOverride(command);
|
||||
}
|
||||
|
||||
protected void SetSystemCriticalPriority()
|
||||
{
|
||||
setCriticalPriority = true;
|
||||
}
|
||||
|
||||
// Disabled following a possible bug in the k8s cluster that will throttle containers much more than is
|
||||
// called for if they have resource limits defined.
|
||||
//protected void SetResourceLimits(int milliCPUs, ByteSize memory)
|
||||
//{
|
||||
// SetResourceLimits(new ContainerResourceSet(milliCPUs, memory));
|
||||
//}
|
||||
|
||||
protected void SetResourcesRequest(ContainerResourceSet requests)
|
||||
{
|
||||
resources.Requests = requests;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class ContainerResources
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class PodAnnotations
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class PodLabels
|
||||
{
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Globalization;
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class RecipeComponentFactory
|
||||
{
|
||||
private readonly NumberSource internalNumberSource = new NumberSource(8080);
|
||||
private static readonly NumberSource externalNumberSource = new NumberSource(30000);
|
||||
private static int[] usedExternalPorts = Array.Empty<int>();
|
||||
|
||||
public void Update(K8sController controller)
|
||||
{
|
||||
usedExternalPorts = controller.GetUsedExternalPorts();
|
||||
}
|
||||
|
||||
public Port CreateInternalPort(string tag, PortProtocol protocol)
|
||||
{
|
||||
return new Port(internalNumberSource.GetNextNumber(), tag, protocol);
|
||||
}
|
||||
|
||||
public Port CreateExternalPort(int number, string tag, PortProtocol protocol)
|
||||
{
|
||||
if (usedExternalPorts.Contains(number)) throw new Exception($"External port number {number} is already in use by the cluster.");
|
||||
return new Port(number, tag, protocol);
|
||||
}
|
||||
|
||||
public Port CreateExternalPort(string tag, PortProtocol protocol)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var number = externalNumberSource.GetNextNumber();
|
||||
if (!usedExternalPorts.Contains(number))
|
||||
{
|
||||
return new Port(number, tag, protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public EnvVar CreateEnvVar(string name, int value)
|
||||
{
|
||||
return CreateEnvVar(name, value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public EnvVar CreateEnvVar(string name, string value)
|
||||
{
|
||||
return new EnvVar(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace KubernetesWorkflow.Recipe
|
||||
{
|
||||
public class SchedulingAffinity
|
||||
{
|
||||
public SchedulingAffinity(string? notIn = null)
|
||||
{
|
||||
NotIn = notIn;
|
||||
}
|
||||
|
||||
public string? NotIn { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (string.IsNullOrEmpty(NotIn)) return "none";
|
||||
return "notIn:" + NotIn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public class RecipeComponentFactory
|
||||
{
|
||||
private NumberSource portNumberSource = new NumberSource(8080);
|
||||
|
||||
public Port CreatePort(int number, string tag, PortProtocol protocol)
|
||||
{
|
||||
return new Port(number, tag, protocol);
|
||||
}
|
||||
|
||||
public Port CreatePort(string tag, PortProtocol protocol)
|
||||
{
|
||||
return new Port(portNumberSource.GetNextNumber(), tag, protocol);
|
||||
}
|
||||
|
||||
public EnvVar CreateEnvVar(string name, int value)
|
||||
{
|
||||
return CreateEnvVar(name, value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public EnvVar CreateEnvVar(string name, string value)
|
||||
{
|
||||
return new EnvVar(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,38 @@
|
||||
using System.Net.NetworkInformation;
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
internal enum RunnerLocation
|
||||
{
|
||||
Unknown,
|
||||
ExternalToCluster,
|
||||
InternalToCluster,
|
||||
}
|
||||
|
||||
internal static class RunnerLocationUtils
|
||||
{
|
||||
private static RunnerLocation? knownLocation = null;
|
||||
private static RunnerLocation location = RunnerLocation.Unknown;
|
||||
|
||||
internal static RunnerLocation DetermineRunnerLocation(RunningContainer container)
|
||||
internal static RunnerLocation GetRunnerLocation()
|
||||
{
|
||||
if (knownLocation != null) return knownLocation.Value;
|
||||
knownLocation = PingForLocation(container);
|
||||
return knownLocation.Value;
|
||||
DetermineRunnerLocation();
|
||||
if (location == RunnerLocation.Unknown) throw new Exception("Runner location is unknown.");
|
||||
return location;
|
||||
}
|
||||
|
||||
private static RunnerLocation PingForLocation(RunningContainer container)
|
||||
private static void DetermineRunnerLocation()
|
||||
{
|
||||
if (PingHost(container.Pod.PodInfo.Ip))
|
||||
if (location != RunnerLocation.Unknown) return;
|
||||
|
||||
var port = Environment.GetEnvironmentVariable("KUBERNETES_PORT");
|
||||
var host = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST");
|
||||
|
||||
if (string.IsNullOrEmpty(port) || string.IsNullOrEmpty(host))
|
||||
{
|
||||
return RunnerLocation.InternalToCluster;
|
||||
location = RunnerLocation.ExternalToCluster;
|
||||
}
|
||||
|
||||
foreach (var port in container.ContainerPorts)
|
||||
else
|
||||
{
|
||||
if (port.ExternalAddress.IsValid() && PingHost(Format(port.ExternalAddress)))
|
||||
{
|
||||
return RunnerLocation.ExternalToCluster;
|
||||
}
|
||||
location = RunnerLocation.InternalToCluster;
|
||||
}
|
||||
|
||||
throw new Exception("Unable to determine location relative to kubernetes cluster.");
|
||||
}
|
||||
|
||||
private static string Format(Address host)
|
||||
{
|
||||
return host.Host
|
||||
.Replace("http://", "")
|
||||
.Replace("https://", "");
|
||||
}
|
||||
|
||||
private static bool PingHost(string host)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var pinger = new Ping();
|
||||
PingReply reply = pinger.Send(host);
|
||||
return reply.Status == IPStatus.Success;
|
||||
}
|
||||
catch (PingException)
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public class RunningContainers
|
||||
{
|
||||
public RunningContainers(StartupConfig startupConfig, RunningPod runningPod, RunningContainer[] containers)
|
||||
{
|
||||
StartupConfig = startupConfig;
|
||||
RunningPod = runningPod;
|
||||
Containers = containers;
|
||||
}
|
||||
|
||||
public StartupConfig StartupConfig { get; }
|
||||
public RunningPod RunningPod { get; }
|
||||
public RunningContainer[] Containers { get; }
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
return string.Join(",", Containers.Select(c => c.Name));
|
||||
}
|
||||
}
|
||||
|
||||
public class RunningContainer
|
||||
{
|
||||
public RunningContainer(RunningPod pod, ContainerRecipe recipe, Port[] servicePorts, string name, ContainerPort[] containerPorts)
|
||||
{
|
||||
Pod = pod;
|
||||
Recipe = recipe;
|
||||
ServicePorts = servicePorts;
|
||||
Name = name;
|
||||
ContainerPorts = containerPorts;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public RunningPod Pod { get; }
|
||||
public ContainerRecipe Recipe { get; }
|
||||
public Port[] ServicePorts { get; }
|
||||
public ContainerPort[] ContainerPorts { get; }
|
||||
|
||||
public ContainerPort GetContainerPort(string portTag)
|
||||
{
|
||||
return ContainerPorts.Single(c => c.Port.Tag == portTag);
|
||||
}
|
||||
|
||||
public Address GetAddress(string portTag)
|
||||
{
|
||||
var containerPort = GetContainerPort(portTag);
|
||||
if (RunnerLocationUtils.DetermineRunnerLocation(this) == RunnerLocation.InternalToCluster)
|
||||
{
|
||||
return containerPort.InternalAddress;
|
||||
}
|
||||
if (!containerPort.ExternalAddress.IsValid()) throw new Exception($"Getting address by tag {portTag} resulted in an invalid address.");
|
||||
return containerPort.ExternalAddress;
|
||||
}
|
||||
}
|
||||
|
||||
public class ContainerPort
|
||||
{
|
||||
public ContainerPort(Port port, Address externalAddress, Address internalAddress)
|
||||
{
|
||||
Port = port;
|
||||
ExternalAddress = externalAddress;
|
||||
InternalAddress = internalAddress;
|
||||
}
|
||||
|
||||
public Port Port { get; }
|
||||
public Address ExternalAddress { get; }
|
||||
public Address InternalAddress { get; }
|
||||
}
|
||||
|
||||
public static class RunningContainersExtensions
|
||||
{
|
||||
public static RunningContainer[] Containers(this RunningContainers[] runningContainers)
|
||||
{
|
||||
return runningContainers.SelectMany(c => c.Containers).ToArray();
|
||||
}
|
||||
|
||||
public static string Describe(this RunningContainers[] runningContainers)
|
||||
{
|
||||
return string.Join(",", runningContainers.Select(c => c.Describe()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,66 @@
|
||||
namespace KubernetesWorkflow
|
||||
using KubernetesWorkflow.Recipe;
|
||||
using KubernetesWorkflow.Types;
|
||||
|
||||
namespace KubernetesWorkflow
|
||||
{
|
||||
public class RunningPod
|
||||
public class StartResult
|
||||
{
|
||||
public RunningPod(K8sCluster cluster, PodInfo podInfo, string deploymentName, string serviceName, ContainerRecipePortMapEntry[] portMapEntries)
|
||||
public StartResult(
|
||||
K8sCluster cluster,
|
||||
ContainerRecipe[] containerRecipes,
|
||||
RunningDeployment deployment,
|
||||
RunningService? internalService,
|
||||
RunningService? externalService)
|
||||
{
|
||||
Cluster = cluster;
|
||||
PodInfo = podInfo;
|
||||
DeploymentName = deploymentName;
|
||||
ServiceName = serviceName;
|
||||
PortMapEntries = portMapEntries;
|
||||
ContainerRecipes = containerRecipes;
|
||||
Deployment = deployment;
|
||||
InternalService = internalService;
|
||||
ExternalService = externalService;
|
||||
}
|
||||
|
||||
public K8sCluster Cluster { get; }
|
||||
public PodInfo PodInfo { get; }
|
||||
public ContainerRecipePortMapEntry[] PortMapEntries { get; }
|
||||
public string DeploymentName { get; }
|
||||
public string ServiceName { get; }
|
||||
public ContainerRecipe[] ContainerRecipes { get; }
|
||||
public RunningDeployment Deployment { get; }
|
||||
public RunningService? InternalService { get; }
|
||||
public RunningService? ExternalService { get; }
|
||||
|
||||
public Port[] GetServicePortsForContainerRecipe(ContainerRecipe containerRecipe)
|
||||
public Port GetInternalServicePorts(ContainerRecipe recipe, string tag)
|
||||
{
|
||||
return PortMapEntries
|
||||
.Where(p => p.ContainerNumber == containerRecipe.Number)
|
||||
.SelectMany(p => p.Ports)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
if (InternalService != null)
|
||||
{
|
||||
var p = InternalService.GetServicePortForRecipeAndTag(recipe, tag);
|
||||
if (p != null) return p;
|
||||
}
|
||||
|
||||
public class ContainerRecipePortMapEntry
|
||||
{
|
||||
public ContainerRecipePortMapEntry(int containerNumber, Port[] ports)
|
||||
{
|
||||
ContainerNumber = containerNumber;
|
||||
Ports = ports;
|
||||
throw new Exception($"Unable to find internal port by tag '{tag}' for recipe '{recipe.Name}'.");
|
||||
}
|
||||
|
||||
public int ContainerNumber { get; }
|
||||
public Port[] Ports { get; }
|
||||
}
|
||||
|
||||
public class PodInfo
|
||||
{
|
||||
public PodInfo(string name, string ip, string k8sNodeName)
|
||||
public Port GetExternalServicePorts(ContainerRecipe recipe, string tag)
|
||||
{
|
||||
Name = name;
|
||||
Ip = ip;
|
||||
K8SNodeName = k8sNodeName;
|
||||
if (ExternalService != null)
|
||||
{
|
||||
var p = ExternalService.GetServicePortForRecipeAndTag(recipe, tag);
|
||||
if (p != null) return p;
|
||||
}
|
||||
|
||||
throw new Exception($"Unable to find external port by tag '{tag}' for recipe '{recipe.Name}'.");
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Ip { get; }
|
||||
public string K8SNodeName { get; }
|
||||
public Port[] GetServicePortsForContainer(ContainerRecipe recipe)
|
||||
{
|
||||
if (InternalService != null)
|
||||
{
|
||||
var p = InternalService.GetServicePortsForRecipe(recipe);
|
||||
if (p.Any()) return p;
|
||||
}
|
||||
if (ExternalService != null)
|
||||
{
|
||||
var p = ExternalService.GetServicePortsForRecipe(recipe);
|
||||
if (p.Any()) return p;
|
||||
}
|
||||
|
||||
return Array.Empty<Port>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Logging;
|
||||
using KubernetesWorkflow.Recipe;
|
||||
using KubernetesWorkflow.Types;
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Utils;
|
||||
|
||||
@@ -7,14 +9,17 @@ namespace KubernetesWorkflow
|
||||
public interface IStartupWorkflow
|
||||
{
|
||||
IKnownLocations GetAvailableLocations();
|
||||
RunningContainers Start(int numberOfContainers, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig);
|
||||
RunningContainers Start(int numberOfContainers, ILocation location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig);
|
||||
FutureContainers Start(int numberOfContainers, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig);
|
||||
FutureContainers Start(int numberOfContainers, ILocation location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig);
|
||||
PodInfo GetPodInfo(RunningContainer container);
|
||||
PodInfo GetPodInfo(RunningPod pod);
|
||||
CrashWatcher CreateCrashWatcher(RunningContainer container);
|
||||
void Stop(RunningContainers runningContainers);
|
||||
void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null);
|
||||
void Stop(RunningPod pod, bool waitTillStopped);
|
||||
void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null, bool? previous = null);
|
||||
IDownloadedLog DownloadContainerLog(RunningContainer container, int? tailLines = null, bool? previous = null);
|
||||
string ExecuteCommand(RunningContainer container, string command, params string[] args);
|
||||
void DeleteNamespace();
|
||||
void DeleteNamespacesStartingWith(string namespacePrefix);
|
||||
void DeleteNamespace(bool wait);
|
||||
void DeleteNamespacesStartingWith(string namespacePrefix, bool wait);
|
||||
}
|
||||
|
||||
public class StartupWorkflow : IStartupWorkflow
|
||||
@@ -41,83 +46,131 @@ namespace KubernetesWorkflow
|
||||
return locationProvider.GetAvailableLocations();
|
||||
}
|
||||
|
||||
public RunningContainers Start(int numberOfContainers, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig)
|
||||
public FutureContainers Start(int numberOfContainers, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig)
|
||||
{
|
||||
return Start(numberOfContainers, KnownLocations.UnspecifiedLocation, recipeFactory, startupConfig);
|
||||
}
|
||||
|
||||
public RunningContainers Start(int numberOfContainers, ILocation location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig)
|
||||
public FutureContainers Start(int numberOfContainers, ILocation location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig)
|
||||
{
|
||||
return K8s(controller =>
|
||||
{
|
||||
var recipes = CreateRecipes(numberOfContainers, recipeFactory, startupConfig);
|
||||
var runningPod = controller.BringOnline(recipes, location);
|
||||
var containers = CreateContainers(runningPod, recipes, startupConfig);
|
||||
componentFactory.Update(controller);
|
||||
|
||||
var rc = new RunningContainers(startupConfig, runningPod, containers);
|
||||
var recipes = CreateRecipes(numberOfContainers, recipeFactory, startupConfig);
|
||||
var startResult = controller.BringOnline(recipes, location);
|
||||
var containers = CreateContainers(startResult, recipes, startupConfig);
|
||||
|
||||
var rc = new RunningPod(Guid.NewGuid().ToString(), startupConfig, startResult, containers);
|
||||
cluster.Configuration.Hooks.OnContainersStarted(rc);
|
||||
return rc;
|
||||
|
||||
if (startResult.ExternalService != null)
|
||||
{
|
||||
componentFactory.Update(controller);
|
||||
}
|
||||
return new FutureContainers(rc, this);
|
||||
});
|
||||
}
|
||||
|
||||
public void WaitUntilOnline(RunningPod rc)
|
||||
{
|
||||
K8s(controller =>
|
||||
{
|
||||
foreach (var c in rc.Containers)
|
||||
{
|
||||
controller.WaitUntilOnline(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PodInfo GetPodInfo(RunningContainer container)
|
||||
{
|
||||
return K8s(c => c.GetPodInfo(container.RunningPod.StartResult.Deployment));
|
||||
}
|
||||
|
||||
public PodInfo GetPodInfo(RunningPod pod)
|
||||
{
|
||||
return K8s(c => c.GetPodInfo(pod.StartResult.Deployment));
|
||||
}
|
||||
|
||||
public CrashWatcher CreateCrashWatcher(RunningContainer container)
|
||||
{
|
||||
return K8s(c => c.CreateCrashWatcher(container));
|
||||
}
|
||||
|
||||
public void Stop(RunningContainers runningContainers)
|
||||
public void Stop(RunningPod runningPod, bool waitTillStopped)
|
||||
{
|
||||
if (runningPod.IsStopped) return;
|
||||
foreach (var c in runningPod.Containers)
|
||||
{
|
||||
c.StopLog = DownloadContainerLog(c);
|
||||
}
|
||||
runningPod.IsStopped = true;
|
||||
|
||||
K8s(controller =>
|
||||
{
|
||||
controller.Stop(runningPod.StartResult, waitTillStopped);
|
||||
});
|
||||
|
||||
cluster.Configuration.Hooks.OnContainersStopped(runningPod);
|
||||
}
|
||||
|
||||
public void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null, bool? previous = null)
|
||||
{
|
||||
K8s(controller =>
|
||||
{
|
||||
controller.Stop(runningContainers.RunningPod);
|
||||
cluster.Configuration.Hooks.OnContainersStopped(runningContainers);
|
||||
controller.DownloadPodLog(container, logHandler, tailLines, previous);
|
||||
});
|
||||
}
|
||||
|
||||
public void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null)
|
||||
public IDownloadedLog DownloadContainerLog(RunningContainer container, int? tailLines = null, bool? previous = null)
|
||||
{
|
||||
var msg = $"Downloading container log for '{container.Name}'";
|
||||
log.Log(msg);
|
||||
var logHandler = new WriteToFileLogHandler(log, msg);
|
||||
|
||||
K8s(controller =>
|
||||
{
|
||||
controller.DownloadPodLog(container.Pod, container.Recipe, logHandler, tailLines);
|
||||
controller.DownloadPodLog(container, logHandler, tailLines, previous);
|
||||
});
|
||||
|
||||
return new DownloadedLog(logHandler, container.Name);
|
||||
}
|
||||
|
||||
public string ExecuteCommand(RunningContainer container, string command, params string[] args)
|
||||
{
|
||||
return K8s(controller =>
|
||||
{
|
||||
return controller.ExecuteCommand(container.Pod, container.Recipe.Name, command, args);
|
||||
return controller.ExecuteCommand(container, command, args);
|
||||
});
|
||||
}
|
||||
|
||||
public void DeleteNamespace()
|
||||
public void DeleteNamespace(bool wait)
|
||||
{
|
||||
K8s(controller =>
|
||||
{
|
||||
controller.DeleteNamespace();
|
||||
controller.DeleteNamespace(wait);
|
||||
});
|
||||
}
|
||||
|
||||
public void DeleteNamespacesStartingWith(string namespacePrefix)
|
||||
public void DeleteNamespacesStartingWith(string namespacePrefix, bool wait)
|
||||
{
|
||||
K8s(controller =>
|
||||
{
|
||||
controller.DeleteAllNamespacesStartingWith(namespacePrefix);
|
||||
controller.DeleteAllNamespacesStartingWith(namespacePrefix, wait);
|
||||
});
|
||||
}
|
||||
|
||||
private RunningContainer[] CreateContainers(RunningPod runningPod, ContainerRecipe[] recipes, StartupConfig startupConfig)
|
||||
private RunningContainer[] CreateContainers(StartResult startResult, ContainerRecipe[] recipes, StartupConfig startupConfig)
|
||||
{
|
||||
log.Debug();
|
||||
return recipes.Select(r =>
|
||||
{
|
||||
var servicePorts = runningPod.GetServicePortsForContainerRecipe(r);
|
||||
log.Debug($"{r} -> service ports: {string.Join(",", servicePorts.Select(p => p.Number))}");
|
||||
|
||||
var name = GetContainerName(r, startupConfig);
|
||||
var addresses = CreateContainerAddresses(startResult, r);
|
||||
log.Debug($"{r}={name} -> container addresses: {string.Join(Environment.NewLine, addresses.Select(a => a.ToString()))}");
|
||||
|
||||
return new RunningContainer(runningPod, r, servicePorts, name,
|
||||
CreateContainerPorts(runningPod, r, servicePorts));
|
||||
return new RunningContainer(Guid.NewGuid().ToString(), name, r, addresses);
|
||||
|
||||
}).ToArray();
|
||||
}
|
||||
@@ -135,43 +188,39 @@ namespace KubernetesWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
private ContainerPort[] CreateContainerPorts(RunningPod pod, ContainerRecipe recipe, Port[] servicePorts)
|
||||
private ContainerAddress[] CreateContainerAddresses(StartResult startResult, ContainerRecipe recipe)
|
||||
{
|
||||
var result = new List<ContainerPort>();
|
||||
var result = new List<ContainerAddress>();
|
||||
foreach (var exposedPort in recipe.ExposedPorts)
|
||||
{
|
||||
result.Add(new ContainerPort(
|
||||
exposedPort,
|
||||
GetContainerExternalAddress(pod, servicePorts, exposedPort),
|
||||
GetContainerInternalAddress(pod, exposedPort)));
|
||||
result.Add(new ContainerAddress(exposedPort.Tag, GetContainerExternalAddress(startResult, recipe, exposedPort.Tag), false));
|
||||
result.Add(new ContainerAddress(exposedPort.Tag, GetContainerInternalAddress(startResult, recipe, exposedPort.Tag), true));
|
||||
}
|
||||
foreach (var internalPort in recipe.InternalPorts)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(internalPort.Tag))
|
||||
{
|
||||
result.Add(new ContainerPort(
|
||||
internalPort,
|
||||
new Address(string.Empty, 0),
|
||||
GetContainerInternalAddress(pod, internalPort)));
|
||||
}
|
||||
result.Add(new ContainerAddress(internalPort.Tag, GetContainerInternalAddress(startResult, recipe, internalPort.Tag), true));
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static Address GetContainerExternalAddress(RunningPod pod, Port[] servicePorts, Port exposedPort)
|
||||
private static Address GetContainerExternalAddress(StartResult startResult, ContainerRecipe recipe, string tag)
|
||||
{
|
||||
var servicePort = servicePorts.Single(p => p.Tag == exposedPort.Tag);
|
||||
var port = startResult.GetExternalServicePorts(recipe, tag);
|
||||
|
||||
return new Address(
|
||||
pod.Cluster.HostAddress,
|
||||
servicePort.Number);
|
||||
startResult.Cluster.HostAddress,
|
||||
port.Number);
|
||||
}
|
||||
|
||||
private Address GetContainerInternalAddress(RunningPod pod, Port port)
|
||||
private Address GetContainerInternalAddress(StartResult startResult, ContainerRecipe recipe, string tag)
|
||||
{
|
||||
var namespaceName = startResult.Cluster.Configuration.KubernetesNamespace;
|
||||
var serviceName = startResult.InternalService!.Name;
|
||||
var port = startResult.GetInternalServicePorts(recipe, tag);
|
||||
|
||||
return new Address(
|
||||
$"http://{pod.PodInfo.Ip}",
|
||||
$"http://{serviceName}.{namespaceName}.svc.cluster.local",
|
||||
port.Number);
|
||||
}
|
||||
|
||||
@@ -182,6 +231,8 @@ namespace KubernetesWorkflow
|
||||
for (var i = 0; i < numberOfContainers; i++)
|
||||
{
|
||||
var recipe = recipeFactory.CreateRecipe(i, numberSource.GetContainerNumber(), componentFactory, startupConfig);
|
||||
CheckPorts(recipe);
|
||||
|
||||
if (cluster.Configuration.AddAppPodLabel) recipe.PodLabels.Add("app", recipeFactory.AppName);
|
||||
cluster.Configuration.Hooks.OnContainerRecipeCreated(recipe);
|
||||
result.Add(recipe);
|
||||
@@ -190,6 +241,18 @@ namespace KubernetesWorkflow
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private void CheckPorts(ContainerRecipe recipe)
|
||||
{
|
||||
var allTags =
|
||||
recipe.ExposedPorts.Concat(recipe.InternalPorts)
|
||||
.Select(p => K8sNameUtils.Format(p.Tag)).ToArray();
|
||||
|
||||
if (allTags.Length != allTags.Distinct().Count())
|
||||
{
|
||||
throw new Exception("Duplicate port tags found in recipe for " + recipe.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private void K8s(Action<K8sController> action)
|
||||
{
|
||||
try
|
||||
@@ -200,7 +263,7 @@ namespace KubernetesWorkflow
|
||||
}
|
||||
catch (k8s.Autorest.HttpOperationException ex)
|
||||
{
|
||||
log.Error(JsonConvert.SerializeObject(ex));
|
||||
log.Error(JsonConvert.SerializeObject(ex.Response));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -216,30 +279,9 @@ namespace KubernetesWorkflow
|
||||
}
|
||||
catch (k8s.Autorest.HttpOperationException ex)
|
||||
{
|
||||
log.Error(JsonConvert.SerializeObject(ex));
|
||||
log.Error(JsonConvert.SerializeObject(ex.Response));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface ILogHandler
|
||||
{
|
||||
void Log(Stream log);
|
||||
}
|
||||
|
||||
public abstract class LogHandler : ILogHandler
|
||||
{
|
||||
public void Log(Stream log)
|
||||
{
|
||||
using var reader = new StreamReader(log);
|
||||
var line = reader.ReadLine();
|
||||
while (line != null)
|
||||
{
|
||||
ProcessLine(line);
|
||||
line = reader.ReadLine();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void ProcessLine(string line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class ContainerAddress
|
||||
{
|
||||
public ContainerAddress(string portTag, Address address, bool isInteral)
|
||||
{
|
||||
PortTag = portTag;
|
||||
Address = address;
|
||||
IsInteral = isInteral;
|
||||
}
|
||||
|
||||
public string PortTag { get; }
|
||||
public Address Address { get; }
|
||||
public bool IsInteral { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var indicator = IsInteral ? "int" : "ext";
|
||||
return $"{indicator} {PortTag} -> '{Address}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using KubernetesWorkflow.Recipe;
|
||||
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class ContainerRecipePortMapEntry
|
||||
{
|
||||
public ContainerRecipePortMapEntry(int recipeNumber, Port[] ports)
|
||||
{
|
||||
RecipeNumber = recipeNumber;
|
||||
Ports = ports;
|
||||
}
|
||||
|
||||
public int RecipeNumber { get; }
|
||||
public Port[] Ports { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class FutureContainers
|
||||
{
|
||||
private readonly RunningPod runningPod;
|
||||
private readonly StartupWorkflow workflow;
|
||||
|
||||
public FutureContainers(RunningPod runningPod, StartupWorkflow workflow)
|
||||
{
|
||||
this.runningPod = runningPod;
|
||||
this.workflow = workflow;
|
||||
}
|
||||
|
||||
public RunningPod WaitForOnline()
|
||||
{
|
||||
workflow.WaitUntilOnline(runningPod);
|
||||
return runningPod;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class K8sNodeLabel
|
||||
{
|
||||
public K8sNodeLabel(string key, string value)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string Value { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class PodInfo
|
||||
{
|
||||
public PodInfo(string name, string ip, string k8sNodeName)
|
||||
{
|
||||
Name = name;
|
||||
Ip = ip;
|
||||
K8SNodeName = k8sNodeName;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Ip { get; }
|
||||
public string K8SNodeName { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using KubernetesWorkflow.Recipe;
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Utils;
|
||||
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class RunningContainer
|
||||
{
|
||||
public RunningContainer(string id, string name, ContainerRecipe recipe, ContainerAddress[] addresses)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Recipe = recipe;
|
||||
Addresses = addresses;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public ContainerRecipe Recipe { get; }
|
||||
public ContainerAddress[] Addresses { get; }
|
||||
public IDownloadedLog? StopLog { get; internal set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public RunningPod RunningPod { get; internal set; } = null!;
|
||||
|
||||
public Address GetAddress(ILog log, string portTag)
|
||||
{
|
||||
var addresses = Addresses.Where(a => a.PortTag == portTag).ToArray();
|
||||
if (!addresses.Any()) throw new Exception("No addresses found for portTag: " + portTag);
|
||||
|
||||
var select = SelectAddress(addresses);
|
||||
log.Debug($"Container '{Name}' selected for tag '{portTag}' address: '{select}'");
|
||||
return select.Address;
|
||||
}
|
||||
|
||||
public Address GetInternalAddress(string portTag)
|
||||
{
|
||||
var containerAddress = Addresses.Single(a => a.PortTag == portTag && a.IsInteral);
|
||||
return containerAddress.Address;
|
||||
}
|
||||
|
||||
private ContainerAddress SelectAddress(ContainerAddress[] addresses)
|
||||
{
|
||||
var location = RunnerLocationUtils.GetRunnerLocation();
|
||||
if (location == RunnerLocation.InternalToCluster)
|
||||
{
|
||||
return addresses.Single(a => a.IsInteral);
|
||||
}
|
||||
if (location == RunnerLocation.ExternalToCluster)
|
||||
{
|
||||
return addresses.Single(a => !a.IsInteral);
|
||||
}
|
||||
throw new Exception("Running location not known.");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is RunningContainer container &&
|
||||
Id == container.Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class RunningDeployment
|
||||
{
|
||||
public RunningDeployment(string name, string podLabel)
|
||||
{
|
||||
Name = name;
|
||||
PodLabel = podLabel;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string PodLabel { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class RunningPod
|
||||
{
|
||||
public RunningPod(string id, StartupConfig startupConfig, StartResult startResult, RunningContainer[] containers)
|
||||
{
|
||||
Id = id;
|
||||
StartupConfig = startupConfig;
|
||||
StartResult = startResult;
|
||||
Containers = containers;
|
||||
|
||||
foreach (var c in containers) c.RunningPod = this;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public StartupConfig StartupConfig { get; }
|
||||
public StartResult StartResult { get; }
|
||||
public RunningContainer[] Containers { get; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string Name
|
||||
{
|
||||
get { return $"'{string.Join("&", Containers.Select(c => c.Name).ToArray())}'"; }
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsStopped { get; internal set; }
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
return string.Join(",", Containers.Select(c => c.Name));
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is RunningPod pod &&
|
||||
Id == pod.Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Id);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (IsStopped) return Name + " (*)";
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RunningContainersExtensions
|
||||
{
|
||||
public static string Describe(this RunningPod[] runningContainers)
|
||||
{
|
||||
return string.Join(",", runningContainers.Select(c => c.Describe()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using KubernetesWorkflow.Recipe;
|
||||
|
||||
namespace KubernetesWorkflow.Types
|
||||
{
|
||||
public class RunningService
|
||||
{
|
||||
public RunningService(string name, List<ContainerRecipePortMapEntry> result)
|
||||
{
|
||||
Name = name;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public List<ContainerRecipePortMapEntry> Result { get; }
|
||||
|
||||
public Port? GetServicePortForRecipeAndTag(ContainerRecipe recipe, string tag)
|
||||
{
|
||||
return GetServicePortsForRecipe(recipe).SingleOrDefault(p => p.Tag == tag);
|
||||
}
|
||||
|
||||
public Port[] GetServicePortsForRecipe(ContainerRecipe recipe)
|
||||
{
|
||||
return Result
|
||||
.Where(p => p.RecipeNumber == recipe.Number)
|
||||
.SelectMany(p => p.Ports)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,21 @@ namespace Logging
|
||||
|
||||
public abstract class BaseLog : ILog
|
||||
{
|
||||
public static bool EnableDebugLogging { get; set; } = false;
|
||||
|
||||
private readonly NumberSource subfileNumberSource = new NumberSource(0);
|
||||
private readonly bool debug;
|
||||
private readonly List<BaseLogStringReplacement> replacements = new List<BaseLogStringReplacement>();
|
||||
private LogFile? logFile;
|
||||
|
||||
protected BaseLog(bool debug)
|
||||
public BaseLog()
|
||||
{
|
||||
this.debug = debug;
|
||||
IsDebug =
|
||||
EnableDebugLogging ||
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("LOGDEBUG")) ||
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DEBUGLOG"));
|
||||
}
|
||||
|
||||
protected bool IsDebug { get; private set; }
|
||||
protected abstract string GetFullName();
|
||||
|
||||
public LogFile LogFile
|
||||
@@ -41,7 +46,7 @@ namespace Logging
|
||||
|
||||
public void Debug(string message = "", int skipFrames = 0)
|
||||
{
|
||||
if (debug)
|
||||
if (IsDebug)
|
||||
{
|
||||
var callerName = DebugStack.GetCallerName(skipFrames);
|
||||
Log($"(debug)({callerName}) {message}");
|
||||
@@ -50,7 +55,9 @@ namespace Logging
|
||||
|
||||
public virtual void Error(string message)
|
||||
{
|
||||
Log($"[ERROR] {message}");
|
||||
var msg = $"[ERROR] {message}";
|
||||
Console.WriteLine(msg);
|
||||
Log(msg);
|
||||
}
|
||||
|
||||
public virtual void AddStringReplace(string from, string to)
|
||||
@@ -70,9 +77,9 @@ namespace Logging
|
||||
return new LogFile($"{GetFullName()}_{GetSubfileNumber()}", ext);
|
||||
}
|
||||
|
||||
private string ApplyReplacements(string str)
|
||||
protected string ApplyReplacements(string str)
|
||||
{
|
||||
if (debug) return str;
|
||||
if (IsDebug) return str;
|
||||
foreach (var replacement in replacements)
|
||||
{
|
||||
str = replacement.Apply(str);
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
{
|
||||
public class ConsoleLog : BaseLog
|
||||
{
|
||||
public ConsoleLog() : base(false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetFullName()
|
||||
{
|
||||
return "CONSOLE";
|
||||
@@ -13,7 +9,7 @@
|
||||
|
||||
public override void Log(string message)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
Console.WriteLine(ApplyReplacements(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Logging
|
||||
{
|
||||
public class FileLog : BaseLog
|
||||
{
|
||||
public FileLog(string fullFilename)
|
||||
{
|
||||
FullFilename = fullFilename;
|
||||
}
|
||||
|
||||
public string FullFilename { get; }
|
||||
|
||||
protected override string GetFullName()
|
||||
{
|
||||
return FullFilename;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@
|
||||
{
|
||||
public class LogConfig
|
||||
{
|
||||
public LogConfig(string logRoot, bool debugEnabled)
|
||||
public LogConfig(string logRoot)
|
||||
{
|
||||
LogRoot = logRoot;
|
||||
DebugEnabled = debugEnabled;
|
||||
}
|
||||
|
||||
public string LogRoot { get; }
|
||||
public bool DebugEnabled { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
public class LogPrefixer : ILog
|
||||
{
|
||||
private readonly ILog backingLog;
|
||||
private readonly string prefix;
|
||||
|
||||
public LogPrefixer(ILog backingLog)
|
||||
{
|
||||
this.backingLog = backingLog;
|
||||
}
|
||||
|
||||
public LogPrefixer(ILog backingLog, string prefix)
|
||||
{
|
||||
this.backingLog = backingLog;
|
||||
this.prefix = prefix;
|
||||
Prefix = prefix;
|
||||
}
|
||||
|
||||
public string Prefix { get; set; } = string.Empty;
|
||||
|
||||
|
||||
public LogFile CreateSubfile(string ext = "log")
|
||||
{
|
||||
return backingLog.CreateSubfile(ext);
|
||||
@@ -18,17 +25,17 @@
|
||||
|
||||
public void Debug(string message = "", int skipFrames = 0)
|
||||
{
|
||||
backingLog.Debug(prefix + message, skipFrames);
|
||||
backingLog.Debug(Prefix + message, skipFrames);
|
||||
}
|
||||
|
||||
public void Error(string message)
|
||||
{
|
||||
backingLog.Error(prefix + message);
|
||||
backingLog.Error(Prefix + message);
|
||||
}
|
||||
|
||||
public void Log(string message)
|
||||
{
|
||||
backingLog.Log(prefix + message);
|
||||
backingLog.Log(Prefix + message);
|
||||
}
|
||||
|
||||
public void AddStringReplace(string from, string to)
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
{
|
||||
public class NullLog : BaseLog
|
||||
{
|
||||
public NullLog() : base(false)
|
||||
{
|
||||
}
|
||||
|
||||
public string FullFilename { get; set; } = "NULL";
|
||||
|
||||
protected override string GetFullName()
|
||||
@@ -15,11 +11,13 @@
|
||||
|
||||
public override void Log(string message)
|
||||
{
|
||||
if (IsDebug) base.Log(message);
|
||||
}
|
||||
|
||||
public override void Error(string message)
|
||||
{
|
||||
Console.WriteLine("Error: " + message);
|
||||
base.Error(message);
|
||||
}
|
||||
|
||||
public override void AddStringReplace(string from, string to)
|
||||
|
||||
@@ -16,19 +16,19 @@ namespace Logging
|
||||
this.debug = debug;
|
||||
}
|
||||
|
||||
public static void Measure(ILog log, string name, Action action, bool debug = false)
|
||||
public static TimeSpan Measure(ILog log, string name, Action action, bool debug = false)
|
||||
{
|
||||
var sw = Begin(log, name, debug);
|
||||
action();
|
||||
sw.End();
|
||||
return sw.End();
|
||||
}
|
||||
|
||||
public static T Measure<T>(ILog log, string name, Func<T> action, bool debug = false)
|
||||
public static StopwatchResult<T> Measure<T>(ILog log, string name, Func<T> action, bool debug = false)
|
||||
{
|
||||
var sw = Begin(log, name, debug);
|
||||
var result = action();
|
||||
sw.End();
|
||||
return result;
|
||||
var duration = sw.End();
|
||||
return new StopwatchResult<T>(result, duration);
|
||||
}
|
||||
|
||||
public static Stopwatch Begin(ILog log)
|
||||
@@ -68,4 +68,16 @@ namespace Logging
|
||||
return duration;
|
||||
}
|
||||
}
|
||||
|
||||
public class StopwatchResult<T>
|
||||
{
|
||||
public StopwatchResult(T value, TimeSpan duration)
|
||||
{
|
||||
Value = value;
|
||||
Duration = duration;
|
||||
}
|
||||
|
||||
public T Value { get; }
|
||||
public TimeSpan Duration { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace NethereumWorkflow.BlockUtils
|
||||
{
|
||||
public class BlockCache
|
||||
{
|
||||
public delegate void CacheClearedEvent();
|
||||
|
||||
private const int MaxEntries = 1024 * 1024 * 5;
|
||||
private readonly Dictionary<ulong, BlockTimeEntry> entries = new Dictionary<ulong, BlockTimeEntry>();
|
||||
|
||||
public event CacheClearedEvent? OnCacheCleared;
|
||||
|
||||
public BlockTimeEntry Add(ulong number, DateTime dateTime)
|
||||
{
|
||||
return Add(new BlockTimeEntry(number, dateTime));
|
||||
}
|
||||
|
||||
public BlockTimeEntry Add(BlockTimeEntry entry)
|
||||
{
|
||||
if (!entries.ContainsKey(entry.BlockNumber))
|
||||
{
|
||||
if (entries.Count > MaxEntries)
|
||||
{
|
||||
entries.Clear();
|
||||
var e = OnCacheCleared;
|
||||
if (e != null) e();
|
||||
}
|
||||
entries.Add(entry.BlockNumber, entry);
|
||||
}
|
||||
|
||||
return entries[entry.BlockNumber];
|
||||
}
|
||||
|
||||
public BlockTimeEntry? Get(ulong number)
|
||||
{
|
||||
if (!entries.TryGetValue(number, out BlockTimeEntry? value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
public int Size { get { return entries.Count; } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace NethereumWorkflow.BlockUtils
|
||||
{
|
||||
public class BlockTimeEntry
|
||||
{
|
||||
public BlockTimeEntry(ulong blockNumber, DateTime utc)
|
||||
{
|
||||
BlockNumber = blockNumber;
|
||||
Utc = utc;
|
||||
}
|
||||
|
||||
public ulong BlockNumber { get; }
|
||||
public DateTime Utc { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[{BlockNumber}] @ {Utc.ToString("o")}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Logging;
|
||||
|
||||
namespace NethereumWorkflow.BlockUtils
|
||||
{
|
||||
public class BlockTimeFinder
|
||||
{
|
||||
private readonly BlockCache cache;
|
||||
private readonly BlockchainBounds bounds;
|
||||
private readonly IWeb3Blocks web3;
|
||||
private readonly ILog log;
|
||||
|
||||
public BlockTimeFinder(BlockCache cache, IWeb3Blocks web3, ILog log)
|
||||
{
|
||||
this.web3 = web3;
|
||||
this.log = log;
|
||||
|
||||
this.cache = cache;
|
||||
bounds = new BlockchainBounds(cache, web3);
|
||||
}
|
||||
|
||||
public BlockTimeEntry Get(ulong blockNumber)
|
||||
{
|
||||
bounds.Initialize();
|
||||
var b = cache.Get(blockNumber);
|
||||
if (b != null) return b;
|
||||
return GetBlock(blockNumber);
|
||||
}
|
||||
|
||||
public ulong? GetHighestBlockNumberBefore(DateTime moment)
|
||||
{
|
||||
bounds.Initialize();
|
||||
if (moment <= bounds.Genesis.Utc) return null;
|
||||
if (moment >= bounds.Current.Utc) return bounds.Current.BlockNumber;
|
||||
|
||||
return Log(() => Search(bounds.Genesis, bounds.Current, moment, HighestBeforeSelector));
|
||||
}
|
||||
|
||||
public ulong? GetLowestBlockNumberAfter(DateTime moment)
|
||||
{
|
||||
bounds.Initialize();
|
||||
if (moment >= bounds.Current.Utc) return null;
|
||||
if (moment <= bounds.Genesis.Utc) return bounds.Genesis.BlockNumber;
|
||||
|
||||
return Log(()=> Search(bounds.Genesis, bounds.Current, moment, LowestAfterSelector)); ;
|
||||
}
|
||||
|
||||
private ulong Log(Func<ulong> operation)
|
||||
{
|
||||
var sw = Stopwatch.Begin(log, nameof(BlockTimeFinder), true);
|
||||
var result = operation();
|
||||
sw.End($"(Bounds: [{bounds.Genesis.BlockNumber}-{bounds.Current.BlockNumber}] Cache: {cache.Size})");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ulong Search(BlockTimeEntry lower, BlockTimeEntry upper, DateTime target, Func<DateTime, BlockTimeEntry, bool> isWhatIwant)
|
||||
{
|
||||
var middle = GetMiddle(lower, upper);
|
||||
if (middle.BlockNumber == lower.BlockNumber)
|
||||
{
|
||||
if (isWhatIwant(target, upper)) return upper.BlockNumber;
|
||||
}
|
||||
|
||||
if (isWhatIwant(target, middle))
|
||||
{
|
||||
return middle.BlockNumber;
|
||||
}
|
||||
|
||||
if (middle.Utc > target)
|
||||
{
|
||||
return Search(lower, middle, target, isWhatIwant);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Search(middle, upper, target, isWhatIwant);
|
||||
}
|
||||
}
|
||||
|
||||
private BlockTimeEntry GetMiddle(BlockTimeEntry lower, BlockTimeEntry upper)
|
||||
{
|
||||
ulong range = upper.BlockNumber - lower.BlockNumber;
|
||||
ulong number = lower.BlockNumber + range / 2;
|
||||
return GetBlock(number);
|
||||
}
|
||||
|
||||
private bool HighestBeforeSelector(DateTime target, BlockTimeEntry entry)
|
||||
{
|
||||
var next = GetBlock(entry.BlockNumber + 1);
|
||||
return
|
||||
entry.Utc <= target &&
|
||||
next.Utc > target;
|
||||
}
|
||||
|
||||
private bool LowestAfterSelector(DateTime target, BlockTimeEntry entry)
|
||||
{
|
||||
var previous = GetBlock(entry.BlockNumber - 1);
|
||||
return
|
||||
entry.Utc >= target &&
|
||||
previous.Utc < target;
|
||||
}
|
||||
|
||||
private BlockTimeEntry GetBlock(ulong number)
|
||||
{
|
||||
if (number < bounds.Genesis.BlockNumber) throw new Exception("Can't fetch block before genesis.");
|
||||
if (number > bounds.Current.BlockNumber) throw new Exception("Can't fetch block after current.");
|
||||
|
||||
var dateTime = web3.GetTimestampForBlock(number);
|
||||
if (dateTime == null) throw new Exception("Failed to get dateTime for block that should exist.");
|
||||
return cache.Add(number, dateTime.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace NethereumWorkflow.BlockUtils
|
||||
{
|
||||
public class BlockchainBounds
|
||||
{
|
||||
private readonly BlockCache cache;
|
||||
private readonly IWeb3Blocks web3;
|
||||
|
||||
public BlockTimeEntry Genesis { get; private set; } = null!;
|
||||
public BlockTimeEntry Current { get; private set; } = null!;
|
||||
|
||||
public BlockchainBounds(BlockCache cache, IWeb3Blocks web3)
|
||||
{
|
||||
this.cache = cache;
|
||||
this.web3 = web3;
|
||||
|
||||
cache.OnCacheCleared += Initialize;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
AddCurrentBlock();
|
||||
LookForGenesisBlock();
|
||||
|
||||
if (Current.BlockNumber == Genesis.BlockNumber)
|
||||
{
|
||||
throw new Exception("Unsupported condition: Current block is genesis block.");
|
||||
}
|
||||
}
|
||||
|
||||
private void LookForGenesisBlock()
|
||||
{
|
||||
if (Genesis != null)
|
||||
{
|
||||
cache.Add(Genesis);
|
||||
return;
|
||||
}
|
||||
|
||||
var blockTime = web3.GetTimestampForBlock(0);
|
||||
if (blockTime != null)
|
||||
{
|
||||
AddGenesisBlock(0, blockTime.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
LookForGenesisBlock(0, Current);
|
||||
}
|
||||
|
||||
private void LookForGenesisBlock(ulong lower, BlockTimeEntry upper)
|
||||
{
|
||||
if (Genesis != null) return;
|
||||
|
||||
var range = upper.BlockNumber - lower;
|
||||
if (range == 1)
|
||||
{
|
||||
var lowTime = web3.GetTimestampForBlock(lower);
|
||||
if (lowTime != null)
|
||||
{
|
||||
AddGenesisBlock(lower, lowTime.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddGenesisBlock(upper);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var current = lower + range / 2;
|
||||
|
||||
var blockTime = web3.GetTimestampForBlock(current);
|
||||
if (blockTime != null)
|
||||
{
|
||||
var newUpper = cache.Add(current, blockTime.Value);
|
||||
LookForGenesisBlock(lower, newUpper);
|
||||
}
|
||||
else
|
||||
{
|
||||
LookForGenesisBlock(current, upper);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCurrentBlock()
|
||||
{
|
||||
var currentBlockNumber = web3.GetCurrentBlockNumber();
|
||||
var blockTime = web3.GetTimestampForBlock(currentBlockNumber);
|
||||
if (blockTime == null) throw new Exception("Unable to get dateTime for current block.");
|
||||
AddCurrentBlock(currentBlockNumber, blockTime.Value);
|
||||
}
|
||||
|
||||
private void AddCurrentBlock(ulong currentBlockNumber, DateTime dateTime)
|
||||
{
|
||||
Current = new BlockTimeEntry(currentBlockNumber, dateTime);
|
||||
cache.Add(Current);
|
||||
}
|
||||
|
||||
private void AddGenesisBlock(ulong number, DateTime dateTime)
|
||||
{
|
||||
AddGenesisBlock(new BlockTimeEntry(number, dateTime));
|
||||
}
|
||||
|
||||
private void AddGenesisBlock(BlockTimeEntry entry)
|
||||
{
|
||||
Genesis = entry;
|
||||
cache.Add(Genesis);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
using Logging;
|
||||
using Nethereum.ABI.FunctionEncoding.Attributes;
|
||||
using Nethereum.Contracts;
|
||||
using Nethereum.RPC.Eth.DTOs;
|
||||
using Nethereum.Web3;
|
||||
using NethereumWorkflow.BlockUtils;
|
||||
using Utils;
|
||||
|
||||
namespace NethereumWorkflow
|
||||
{
|
||||
public class NethereumInteraction
|
||||
{
|
||||
// BlockCache is a static instance: It stays alive for the duration of the application runtime.
|
||||
private readonly static BlockCache blockCache = new BlockCache();
|
||||
|
||||
private readonly ILog log;
|
||||
private readonly Web3 web3;
|
||||
|
||||
@@ -17,11 +22,12 @@ namespace NethereumWorkflow
|
||||
this.web3 = web3;
|
||||
}
|
||||
|
||||
public void SendEth(string toAddress, decimal ethAmount)
|
||||
public string SendEth(string toAddress, decimal ethAmount)
|
||||
{
|
||||
log.Debug();
|
||||
var receipt = Time.Wait(web3.Eth.GetEtherTransferService().TransferEtherAndWaitForReceiptAsync(toAddress, ethAmount));
|
||||
if (!receipt.Succeeded()) throw new Exception("Unable to send Eth");
|
||||
return receipt.TransactionHash;
|
||||
}
|
||||
|
||||
public decimal GetEthBalance()
|
||||
@@ -44,12 +50,19 @@ namespace NethereumWorkflow
|
||||
return Time.Wait(handler.QueryAsync<TResult>(contractAddress, function));
|
||||
}
|
||||
|
||||
public void SendTransaction<TFunction>(string contractAddress, TFunction function) where TFunction : FunctionMessage, new()
|
||||
public string SendTransaction<TFunction>(string contractAddress, TFunction function) where TFunction : FunctionMessage, new()
|
||||
{
|
||||
log.Debug();
|
||||
var handler = web3.Eth.GetContractTransactionHandler<TFunction>();
|
||||
var receipt = Time.Wait(handler.SendRequestAndWaitForReceiptAsync(contractAddress, function));
|
||||
if (!receipt.Succeeded()) throw new Exception("Unable to perform contract transaction.");
|
||||
return receipt.TransactionHash;
|
||||
}
|
||||
|
||||
public Transaction GetTransaction(string transactionHash)
|
||||
{
|
||||
log.Debug();
|
||||
return Time.Wait(web3.Eth.Transactions.GetTransactionByHash.SendRequestAsync(transactionHash));
|
||||
}
|
||||
|
||||
public decimal? GetSyncedBlockNumber()
|
||||
@@ -75,5 +88,46 @@ namespace NethereumWorkflow
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, BlockInterval blockRange) where TEvent : IEventDTO, new()
|
||||
{
|
||||
return GetEvents<TEvent>(address, blockRange.From, blockRange.To);
|
||||
}
|
||||
|
||||
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new()
|
||||
{
|
||||
var eventHandler = web3.Eth.GetEvent<TEvent>(address);
|
||||
var from = new BlockParameter(fromBlockNumber);
|
||||
var to = new BlockParameter(toBlockNumber);
|
||||
var blockFilter = Time.Wait(eventHandler.CreateFilterBlockRangeAsync(from, to));
|
||||
return Time.Wait(eventHandler.GetAllChangesAsync(blockFilter));
|
||||
}
|
||||
|
||||
public BlockInterval ConvertTimeRangeToBlockRange(TimeRange timeRange)
|
||||
{
|
||||
var wrapper = new Web3Wrapper(web3, log);
|
||||
var blockTimeFinder = new BlockTimeFinder(blockCache, wrapper, log);
|
||||
|
||||
var fromBlock = blockTimeFinder.GetLowestBlockNumberAfter(timeRange.From);
|
||||
var toBlock = blockTimeFinder.GetHighestBlockNumberBefore(timeRange.To);
|
||||
|
||||
if (fromBlock == null || toBlock == null)
|
||||
{
|
||||
throw new Exception("Failed to convert time range to block range.");
|
||||
}
|
||||
|
||||
return new BlockInterval(
|
||||
timeRange: timeRange,
|
||||
from: fromBlock.Value,
|
||||
to: toBlock.Value
|
||||
);
|
||||
}
|
||||
|
||||
public BlockTimeEntry GetBlockForNumber(ulong number)
|
||||
{
|
||||
var wrapper = new Web3Wrapper(web3, log);
|
||||
var blockTimeFinder = new BlockTimeFinder(blockCache, wrapper, log);
|
||||
return blockTimeFinder.Get(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Logging;
|
||||
using Nethereum.RPC.Eth.DTOs;
|
||||
using Nethereum.Web3;
|
||||
using Utils;
|
||||
|
||||
namespace NethereumWorkflow
|
||||
{
|
||||
public interface IWeb3Blocks
|
||||
{
|
||||
ulong GetCurrentBlockNumber();
|
||||
DateTime? GetTimestampForBlock(ulong blockNumber);
|
||||
}
|
||||
|
||||
public class Web3Wrapper : IWeb3Blocks
|
||||
{
|
||||
private readonly Web3 web3;
|
||||
private readonly ILog log;
|
||||
|
||||
public Web3Wrapper(Web3 web3, ILog log)
|
||||
{
|
||||
this.web3 = web3;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
public ulong GetCurrentBlockNumber()
|
||||
{
|
||||
var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync());
|
||||
return Convert.ToUInt64(number.ToDecimal());
|
||||
}
|
||||
|
||||
public DateTime? GetTimestampForBlock(ulong blockNumber)
|
||||
{
|
||||
try
|
||||
{
|
||||
var block = Time.Wait(web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new BlockParameter(blockNumber)));
|
||||
if (block == null) return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(block.Timestamp.ToDecimal())).UtcDateTime;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("Exception while getting timestamp for block: " + ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public class ActionQueue
|
||||
{
|
||||
// Using ConcurrentQueue<> here would make this process slower.
|
||||
private readonly object queueLock = new object();
|
||||
private readonly AutoResetEvent signal = new AutoResetEvent(false);
|
||||
private List<Action> queue = new List<Action>();
|
||||
private Task queueWorker = null!;
|
||||
private bool stopping = false;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
queueWorker = Task.Run(QueueWorker);
|
||||
}
|
||||
|
||||
public int Count { get; private set; }
|
||||
|
||||
public void StopAndJoin()
|
||||
{
|
||||
stopping = true;
|
||||
queueWorker.Wait();
|
||||
if (queue.Count > 0) throw new Exception("not all acions handled");
|
||||
queueWorker.Dispose();
|
||||
}
|
||||
|
||||
public void Add(Action action)
|
||||
{
|
||||
if (stopping) throw new Exception("queue stopping");
|
||||
|
||||
lock (queueLock)
|
||||
{
|
||||
queue.Add(action);
|
||||
Count = queue.Count;
|
||||
}
|
||||
signal.Set();
|
||||
}
|
||||
|
||||
private void QueueWorker()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
signal.WaitOne(10);
|
||||
|
||||
List<Action> work = null!;
|
||||
lock (queueLock)
|
||||
{
|
||||
work = queue;
|
||||
queue = new List<Action>();
|
||||
Count = 0;
|
||||
}
|
||||
if (stopping && !work.Any()) return;
|
||||
|
||||
foreach (var action in work)
|
||||
{
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public class BucketSet
|
||||
{
|
||||
private const int numberOfActiveBuckets = 10;
|
||||
private readonly ILog log;
|
||||
private readonly string workingDir;
|
||||
private readonly object _bucketLock = new object();
|
||||
private readonly List<EventBucketWriter> fullBuckets = new List<EventBucketWriter>();
|
||||
private readonly List<EventBucketWriter> activeBuckets = new List<EventBucketWriter>();
|
||||
private readonly ActionQueue queue = new ActionQueue();
|
||||
private int activeBucketIndex = 0;
|
||||
private bool closed = false;
|
||||
private string internalErrors = string.Empty;
|
||||
|
||||
public BucketSet(ILog log, string workingDir)
|
||||
{
|
||||
this.log = log;
|
||||
this.workingDir = workingDir;
|
||||
|
||||
for (var i = 0; i < numberOfActiveBuckets;i++)
|
||||
{
|
||||
AddNewBucket();
|
||||
}
|
||||
|
||||
queue.Start();
|
||||
}
|
||||
|
||||
public void Add(DateTime utc, object payload)
|
||||
{
|
||||
if (closed) throw new Exception("Buckets already closed!");
|
||||
queue.Add(() => AddInternal(utc, payload));
|
||||
|
||||
if (queue.Count > 1000)
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
public IFinalizedBucket[] FinalizeBuckets()
|
||||
{
|
||||
closed = true;
|
||||
queue.StopAndJoin();
|
||||
|
||||
if (IsEmpty()) throw new Exception("No entries have been added.");
|
||||
if (!string.IsNullOrEmpty(internalErrors)) throw new Exception(internalErrors);
|
||||
|
||||
var buckets = fullBuckets.Concat(activeBuckets).ToArray();
|
||||
log.Debug($"Finalizing {buckets.Length} buckets...");
|
||||
|
||||
var finalized = new ConcurrentBag<IFinalizedBucket>();
|
||||
var tasks = Parallel.ForEach(buckets, b => finalized.Add(b.FinalizeBucket()));
|
||||
if (!tasks.IsCompleted) throw new Exception("Failed to finalize buckets: " + tasks);
|
||||
|
||||
return finalized.ToArray();
|
||||
}
|
||||
|
||||
private bool IsEmpty()
|
||||
{
|
||||
return fullBuckets.All(b => b.Count == 0) && activeBuckets.All(b => b.Count == 0);
|
||||
}
|
||||
|
||||
private void AddInternal(DateTime utc, object payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_bucketLock)
|
||||
{
|
||||
var current = activeBuckets[activeBucketIndex];
|
||||
current.Add(utc, payload);
|
||||
activeBucketIndex = (activeBucketIndex + 1) % numberOfActiveBuckets;
|
||||
|
||||
if (current.IsFull)
|
||||
{
|
||||
log.Debug("Bucket is full. New bucket...");
|
||||
fullBuckets.Add(current);
|
||||
activeBuckets.Remove(current);
|
||||
AddNewBucket();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
internalErrors += ex.ToString();
|
||||
log.Error(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static int bucketSizeIndex = 0;
|
||||
private static int[] bucketSizes = new[]
|
||||
{
|
||||
10000,
|
||||
15000,
|
||||
20000,
|
||||
};
|
||||
|
||||
private void AddNewBucket()
|
||||
{
|
||||
lock (_bucketLock)
|
||||
{
|
||||
var size = bucketSizes[bucketSizeIndex];
|
||||
bucketSizeIndex = (bucketSizeIndex + 1) % bucketSizes.Length;
|
||||
activeBuckets.Add(new EventBucketWriter(log, Path.Combine(workingDir, Guid.NewGuid().ToString()), size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public interface IFinalizedBucket
|
||||
{
|
||||
bool IsEmpty { get; }
|
||||
DateTime? SeeTopUtc();
|
||||
BucketTop? TakeTop();
|
||||
}
|
||||
|
||||
public class BucketTop
|
||||
{
|
||||
public BucketTop(DateTime utc, OverwatchEvent[] events)
|
||||
{
|
||||
Utc = utc;
|
||||
Events = events;
|
||||
}
|
||||
|
||||
public DateTime Utc { get; }
|
||||
public OverwatchEvent[] Events { get; }
|
||||
}
|
||||
|
||||
public class EventBucketReader : IFinalizedBucket
|
||||
{
|
||||
private readonly string bucketFile;
|
||||
private readonly ConcurrentQueue<BucketTop> topQueue = new ConcurrentQueue<BucketTop>();
|
||||
private readonly AutoResetEvent itemDequeued = new AutoResetEvent(false);
|
||||
private bool stopping;
|
||||
|
||||
public EventBucketReader(ILog log, string bucketFile)
|
||||
{
|
||||
this.bucketFile = bucketFile;
|
||||
if (!File.Exists(bucketFile)) throw new Exception("Doesn't exist: " + bucketFile);
|
||||
|
||||
log.Debug("Read Bucket open: " + bucketFile);
|
||||
|
||||
Task.Run(ReadBucket);
|
||||
}
|
||||
|
||||
public bool IsEmpty { get; private set; }
|
||||
|
||||
public DateTime? SeeTopUtc()
|
||||
{
|
||||
if (IsEmpty) return null;
|
||||
while (true)
|
||||
{
|
||||
UpdateIsEmpty();
|
||||
if (IsEmpty) return null;
|
||||
if (topQueue.TryPeek(out BucketTop? top))
|
||||
{
|
||||
return top.Utc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BucketTop? TakeTop()
|
||||
{
|
||||
if (IsEmpty) return null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
UpdateIsEmpty();
|
||||
if (IsEmpty) return null;
|
||||
if (topQueue.TryDequeue(out BucketTop? top))
|
||||
{
|
||||
itemDequeued.Set();
|
||||
return top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadBucket()
|
||||
{
|
||||
using var file = File.OpenRead(bucketFile);
|
||||
using var reader = new StreamReader(file);
|
||||
|
||||
while (true)
|
||||
{
|
||||
while (topQueue.Count < 5)
|
||||
{
|
||||
var top = CreateNewTop(reader);
|
||||
if (top != null)
|
||||
{
|
||||
topQueue.Enqueue(top);
|
||||
}
|
||||
else
|
||||
{
|
||||
stopping = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
itemDequeued.Reset();
|
||||
itemDequeued.WaitOne();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIsEmpty()
|
||||
{
|
||||
var empty = stopping && topQueue.IsEmpty;
|
||||
if (!IsEmpty && empty)
|
||||
{
|
||||
File.Delete(bucketFile);
|
||||
IsEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private EventBucketEntry? nextEntry = null;
|
||||
private BucketTop? CreateNewTop(StreamReader reader)
|
||||
{
|
||||
if (nextEntry == null)
|
||||
{
|
||||
nextEntry = ReadEntry(reader);
|
||||
if (nextEntry == null) return null;
|
||||
}
|
||||
|
||||
var topEntry = nextEntry;
|
||||
var entries = new List<EventBucketEntry>
|
||||
{
|
||||
topEntry
|
||||
};
|
||||
|
||||
nextEntry = ReadEntry(reader);
|
||||
while (nextEntry != null && nextEntry.Utc == topEntry.Utc)
|
||||
{
|
||||
entries.Add(nextEntry);
|
||||
nextEntry = ReadEntry(reader);
|
||||
}
|
||||
|
||||
return new BucketTop(topEntry.Utc, entries.Select(e => e.Event).ToArray());
|
||||
}
|
||||
|
||||
private EventBucketEntry? ReadEntry(StreamReader reader)
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (string.IsNullOrEmpty(line)) return null;
|
||||
return JsonConvert.DeserializeObject<EventBucketEntry>(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public class EventBucketWriter
|
||||
{
|
||||
private const int MaxBuffer = 1000;
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private bool closed = false;
|
||||
private readonly ILog log;
|
||||
private readonly string bucketFile;
|
||||
private readonly int maxCount;
|
||||
private readonly List<EventBucketEntry> buffer = new List<EventBucketEntry>();
|
||||
|
||||
public EventBucketWriter(ILog log, string bucketFile, int maxCount)
|
||||
{
|
||||
this.log = log;
|
||||
this.bucketFile = bucketFile;
|
||||
this.maxCount = maxCount;
|
||||
if (File.Exists(bucketFile)) throw new Exception("Already exists");
|
||||
|
||||
log.Debug("Write Bucket open: " + bucketFile);
|
||||
}
|
||||
|
||||
public int Count { get; private set; }
|
||||
public bool IsFull { get; private set; }
|
||||
|
||||
public void Add(DateTime utc, object payload)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (closed) throw new Exception("Already closed");
|
||||
AddToBuffer(utc, payload);
|
||||
BufferToFile(emptyBuffer: false);
|
||||
}
|
||||
}
|
||||
|
||||
public IFinalizedBucket FinalizeBucket()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
closed = true;
|
||||
BufferToFile(emptyBuffer: true);
|
||||
SortFileByTimestamps();
|
||||
}
|
||||
log.Debug($"Finalized bucket with {Count} entries");
|
||||
return new EventBucketReader(log, bucketFile);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"EventBucket: " + Count;
|
||||
}
|
||||
|
||||
private void AddToBuffer(DateTime utc, object payload)
|
||||
{
|
||||
var typeName = payload.GetType().FullName;
|
||||
if (string.IsNullOrEmpty(typeName)) throw new Exception("Empty typename for payload");
|
||||
if (utc == default) throw new Exception("DateTimeUtc not set");
|
||||
|
||||
var entry = new EventBucketEntry
|
||||
{
|
||||
Utc = utc,
|
||||
Event = new OverwatchEvent
|
||||
{
|
||||
Type = typeName,
|
||||
Payload = Json.Serialize(payload)
|
||||
}
|
||||
};
|
||||
|
||||
Count++;
|
||||
IsFull = Count > maxCount;
|
||||
|
||||
buffer.Add(entry);
|
||||
}
|
||||
|
||||
private void BufferToFile(bool emptyBuffer)
|
||||
{
|
||||
if (emptyBuffer || buffer.Count > MaxBuffer)
|
||||
{
|
||||
using var file = File.Open(bucketFile, FileMode.Append);
|
||||
using var writer = new StreamWriter(file);
|
||||
foreach (var entry in buffer)
|
||||
{
|
||||
writer.WriteLine(Json.Serialize(entry));
|
||||
}
|
||||
log.Debug($"Bucket wrote {buffer.Count} entries to file.");
|
||||
buffer.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void SortFileByTimestamps()
|
||||
{
|
||||
var lines = File.ReadAllLines(bucketFile);
|
||||
var entries = lines.Select(Json.Deserialize<EventBucketEntry>)
|
||||
.Cast<EventBucketEntry>()
|
||||
.OrderBy(e => e.Utc)
|
||||
.ToArray();
|
||||
|
||||
File.Delete(bucketFile);
|
||||
File.WriteAllLines(bucketFile, entries.Select(e => Json.Serialize(e)));
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class EventBucketEntry
|
||||
{
|
||||
public DateTime Utc { get; set; }
|
||||
public OverwatchEvent Event { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Globalization;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public static class Json
|
||||
{
|
||||
private static JsonSerializerSettings settings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.None,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Culture = CultureInfo.InvariantCulture,
|
||||
DateFormatHandling = DateFormatHandling.IsoDateFormat,
|
||||
FloatFormatHandling = FloatFormatHandling.Symbol
|
||||
};
|
||||
|
||||
public static string Serialize(object obj, Formatting formatting = Formatting.None)
|
||||
{
|
||||
return JsonConvert.SerializeObject(obj, formatting, settings);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<T>(json)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
[Serializable]
|
||||
public class OverwatchTranscript
|
||||
{
|
||||
public OverwatchHeader Header { get; set; } = new();
|
||||
public OverwatchMomentReference[] MomentReferences { get; set; } = Array.Empty<OverwatchMomentReference>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchMomentReference
|
||||
{
|
||||
public string MomentsFile { get; set; } = string.Empty;
|
||||
public int NumberOfMoments { get; set; }
|
||||
public int NumberOfEvents { get; set; }
|
||||
public DateTime EarliestUtc { get; set; }
|
||||
public DateTime LatestUtc { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchHeader
|
||||
{
|
||||
public OverwatchCommonHeader Common { get; set; } = new();
|
||||
public OverwatchHeaderEntry[] Entries { get; set; } = Array.Empty<OverwatchHeaderEntry>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchCommonHeader
|
||||
{
|
||||
public long NumberOfMoments { get; set; }
|
||||
public long NumberOfEvents { get; set; }
|
||||
public DateTime EarliestUtc { get; set; }
|
||||
public DateTime LatestUtc { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchHeaderEntry
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchMoment
|
||||
{
|
||||
public DateTime Utc { get; set; }
|
||||
public OverwatchEvent[] Events { get; set; } = Array.Empty<OverwatchEvent>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverwatchEvent
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public class MomentReader
|
||||
{
|
||||
private readonly OverwatchTranscript model;
|
||||
private readonly string workingDir;
|
||||
private int referenceIndex = 0;
|
||||
private int momentsRead = 0;
|
||||
private OpenReference currentRef;
|
||||
|
||||
public MomentReader(OverwatchTranscript model, string workingDir)
|
||||
{
|
||||
this.model = model;
|
||||
this.workingDir = workingDir;
|
||||
|
||||
currentRef = CreateOpenReference();
|
||||
}
|
||||
|
||||
public OverwatchMoment? Next()
|
||||
{
|
||||
if (referenceIndex >= model.MomentReferences.Length) return null;
|
||||
|
||||
var moment = currentRef.ReadNext();
|
||||
if (moment == null)
|
||||
{
|
||||
Close();
|
||||
|
||||
// This reference file ran out.
|
||||
// The number of moments read should match exactly the number of moments
|
||||
// describe in the reference. If not, error:
|
||||
var expected = model.MomentReferences[referenceIndex].NumberOfMoments;
|
||||
if (momentsRead != expected)
|
||||
{
|
||||
throw new Exception("Number of moments read from referenced file does not match number of moments value in model. " +
|
||||
$"Reads: { momentsRead} - model.MomentReferences[{referenceIndex}].NumberOfMoment: {expected}");
|
||||
}
|
||||
|
||||
referenceIndex++;
|
||||
if (referenceIndex < model.MomentReferences.Length)
|
||||
{
|
||||
// Proceed to next reference file.
|
||||
currentRef = CreateOpenReference();
|
||||
momentsRead = 0;
|
||||
return Next();
|
||||
}
|
||||
else
|
||||
{
|
||||
// That was the last one.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
momentsRead++;
|
||||
return moment;
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (currentRef != null)
|
||||
{
|
||||
currentRef.Close();
|
||||
currentRef = null!;
|
||||
}
|
||||
}
|
||||
|
||||
private OpenReference CreateOpenReference()
|
||||
{
|
||||
var filepath = Path.Combine(workingDir, model.MomentReferences[referenceIndex].MomentsFile);
|
||||
return new OpenReference(filepath);
|
||||
}
|
||||
|
||||
private class OpenReference
|
||||
{
|
||||
private readonly FileStream file;
|
||||
private readonly StreamReader reader;
|
||||
|
||||
public OpenReference(string filePath)
|
||||
{
|
||||
file = File.OpenRead(filePath);
|
||||
reader = new StreamReader(file);
|
||||
}
|
||||
|
||||
public OverwatchMoment? ReadNext()
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (string.IsNullOrEmpty(line)) return null;
|
||||
return JsonConvert.DeserializeObject<OverwatchMoment>(line);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
reader.Close();
|
||||
file.Close();
|
||||
|
||||
reader.Dispose();
|
||||
file.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public class MomentReferenceBuilder
|
||||
{
|
||||
private const int MaxMomentsPerReference = 10000;
|
||||
private readonly ILog log;
|
||||
private readonly string workingDir;
|
||||
|
||||
public MomentReferenceBuilder(ILog log, string workingDir)
|
||||
{
|
||||
this.log = log;
|
||||
this.workingDir = workingDir;
|
||||
}
|
||||
|
||||
public OverwatchMomentReference[] Build(IFinalizedBucket[] finalizedBuckets)
|
||||
{
|
||||
var result = new List<OverwatchMomentReference>();
|
||||
var currentBuilder = new Builder(log, workingDir);
|
||||
|
||||
var buckets = finalizedBuckets.ToList();
|
||||
log.Debug($"Building references for {buckets.Count} buckets.");
|
||||
while (buckets.Any())
|
||||
{
|
||||
buckets.RemoveAll(b => b.IsEmpty);
|
||||
if (!buckets.Any()) break;
|
||||
|
||||
var earliestUtc = GetEarliestUtc(buckets);
|
||||
if (earliestUtc == null) continue;
|
||||
|
||||
var tops = CollectAllTopsForUtc(earliestUtc.Value, buckets);
|
||||
var moment = ConvertTopsToMoment(tops);
|
||||
currentBuilder.Add(moment);
|
||||
if (currentBuilder.NumberOfMoments == MaxMomentsPerReference)
|
||||
{
|
||||
result.Add(currentBuilder.Build());
|
||||
currentBuilder = new Builder(log, workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBuilder.NumberOfMoments > 0)
|
||||
{
|
||||
result.Add(currentBuilder.Build());
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private OverwatchMoment ConvertTopsToMoment(List<BucketTop> tops)
|
||||
{
|
||||
var discintUtc = tops.Select(e => e.Utc).Distinct().ToArray();
|
||||
if (discintUtc.Length != 1) throw new Exception("UTC mixing in moment construction.");
|
||||
|
||||
return new OverwatchMoment
|
||||
{
|
||||
Utc = tops[0].Utc,
|
||||
Events = tops.SelectMany(e => e.Events).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private List<BucketTop> CollectAllTopsForUtc(DateTime earliestUtc, List<IFinalizedBucket> buckets)
|
||||
{
|
||||
var result = new List<BucketTop>();
|
||||
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
if (bucket.IsEmpty) continue;
|
||||
|
||||
var utc = bucket.SeeTopUtc();
|
||||
if (utc == null) continue;
|
||||
|
||||
if (utc.Value == earliestUtc)
|
||||
{
|
||||
var top = bucket.TakeTop();
|
||||
if (top == null) throw new Exception("top was null after top utc was not");
|
||||
result.Add(top);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private DateTime? GetEarliestUtc(List<IFinalizedBucket> buckets)
|
||||
{
|
||||
var earliest = DateTime.MaxValue;
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
var utc = bucket.SeeTopUtc();
|
||||
if (utc == null) return null;
|
||||
|
||||
if (utc.Value < earliest) earliest = utc.Value;
|
||||
}
|
||||
return earliest;
|
||||
}
|
||||
|
||||
public class Builder
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly string workingDir;
|
||||
private OverwatchMomentReference reference;
|
||||
private readonly ActionQueue queue = new ActionQueue();
|
||||
|
||||
public Builder(ILog log, string workingDir)
|
||||
{
|
||||
reference = new OverwatchMomentReference
|
||||
{
|
||||
MomentsFile = Guid.NewGuid().ToString(),
|
||||
EarliestUtc = DateTime.MaxValue,
|
||||
LatestUtc = DateTime.MinValue,
|
||||
NumberOfEvents = 0,
|
||||
NumberOfMoments = 0,
|
||||
};
|
||||
this.log = log;
|
||||
this.workingDir = workingDir;
|
||||
queue.Start();
|
||||
}
|
||||
|
||||
public int NumberOfMoments => reference.NumberOfMoments;
|
||||
|
||||
public void Add(OverwatchMoment moment)
|
||||
{
|
||||
if (moment.Utc < reference.EarliestUtc) reference.EarliestUtc = moment.Utc;
|
||||
if (moment.Utc > reference.LatestUtc) reference.LatestUtc = moment.Utc;
|
||||
reference.NumberOfMoments++;
|
||||
reference.NumberOfEvents += moment.Events.Length;
|
||||
|
||||
var filePath = Path.Combine(workingDir, reference.MomentsFile);
|
||||
|
||||
queue.Add(() =>
|
||||
{
|
||||
File.AppendAllLines(filePath, new[]
|
||||
{
|
||||
Json.Serialize(moment)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public OverwatchMomentReference Build()
|
||||
{
|
||||
queue.StopAndJoin();
|
||||
|
||||
log.Debug($"Created reference with {reference.NumberOfMoments} moments and {reference.NumberOfEvents} events...");
|
||||
var result = reference;
|
||||
reference = null!;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Logging\Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using Logging;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public static class Transcript
|
||||
{
|
||||
public static ITranscriptWriter NewWriter(ILog log)
|
||||
{
|
||||
log = new LogPrefixer(log, "(TranscriptWriter) ");
|
||||
return new TranscriptWriter(log, NewWorkDir());
|
||||
}
|
||||
|
||||
public static ITranscriptReader NewReader(string transcriptFile)
|
||||
{
|
||||
return new TranscriptReader(NewWorkDir(), transcriptFile);
|
||||
}
|
||||
|
||||
private static string NewWorkDir()
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public static class TranscriptConstants
|
||||
{
|
||||
public const string TranscriptFilename = "transcript.json";
|
||||
public const string ArtifactFolderName = "artifacts";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public interface ITranscriptReader
|
||||
{
|
||||
OverwatchCommonHeader Header { get; }
|
||||
T GetHeader<T>(string key);
|
||||
void AddMomentHandler(Action<ActivateMoment> handler);
|
||||
void AddEventHandler<T>(Action<ActivateEvent<T>> handler);
|
||||
bool Next();
|
||||
void Close();
|
||||
}
|
||||
|
||||
public class TranscriptReader : ITranscriptReader
|
||||
{
|
||||
private readonly object handlersLock = new object();
|
||||
private readonly string transcriptFile;
|
||||
private readonly string artifactsFolder;
|
||||
private readonly List<Action<ActivateMoment>> momentHandlers = new List<Action<ActivateMoment>>();
|
||||
private readonly Dictionary<string, List<Action<ActivateMoment, string>>> eventHandlers = new Dictionary<string, List<Action<ActivateMoment, string>>>();
|
||||
private readonly string workingDir;
|
||||
private readonly OverwatchTranscript model;
|
||||
private bool closed;
|
||||
private long momentCounter;
|
||||
private readonly ConcurrentQueue<OverwatchMoment> queue = new ConcurrentQueue<OverwatchMoment>();
|
||||
private readonly Task queueFiller;
|
||||
|
||||
public TranscriptReader(string workingDir, string inputFilename)
|
||||
{
|
||||
closed = false;
|
||||
this.workingDir = workingDir;
|
||||
transcriptFile = Path.Combine(workingDir, TranscriptConstants.TranscriptFilename);
|
||||
artifactsFolder = Path.Combine(workingDir, TranscriptConstants.ArtifactFolderName);
|
||||
|
||||
if (!Directory.Exists(workingDir)) Directory.CreateDirectory(workingDir);
|
||||
if (File.Exists(transcriptFile) || Directory.Exists(artifactsFolder)) throw new Exception("workingdir not clean");
|
||||
|
||||
model = LoadModel(inputFilename);
|
||||
|
||||
queueFiller = Task.Run(() => FillQueue(model, workingDir));
|
||||
}
|
||||
|
||||
public OverwatchCommonHeader Header
|
||||
{
|
||||
get
|
||||
{
|
||||
CheckClosed();
|
||||
return model.Header.Common;
|
||||
}
|
||||
}
|
||||
|
||||
public T GetHeader<T>(string key)
|
||||
{
|
||||
CheckClosed();
|
||||
var value = model.Header.Entries.First(e => e.Key == key).Value;
|
||||
return JsonConvert.DeserializeObject<T>(value)!;
|
||||
}
|
||||
|
||||
public void AddMomentHandler(Action<ActivateMoment> handler)
|
||||
{
|
||||
CheckClosed();
|
||||
lock (handlersLock)
|
||||
{
|
||||
momentHandlers.Add(handler);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddEventHandler<T>(Action<ActivateEvent<T>> handler)
|
||||
{
|
||||
CheckClosed();
|
||||
|
||||
var typeName = typeof(T).FullName;
|
||||
if (string.IsNullOrEmpty(typeName)) throw new Exception("Empty typename for payload");
|
||||
|
||||
lock (handlersLock)
|
||||
{
|
||||
if (eventHandlers.ContainsKey(typeName))
|
||||
{
|
||||
eventHandlers[typeName].Add(CreateEventAction(handler));
|
||||
}
|
||||
else
|
||||
{
|
||||
eventHandlers.Add(typeName, new List<Action<ActivateMoment, string>>
|
||||
{
|
||||
CreateEventAction(handler)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly object nextLock = new object();
|
||||
private OverwatchMoment? moment = null;
|
||||
private OverwatchMoment? next = null;
|
||||
|
||||
public bool Next()
|
||||
{
|
||||
CheckClosed();
|
||||
|
||||
OverwatchMoment? m = null;
|
||||
TimeSpan? duration = null;
|
||||
lock (nextLock)
|
||||
{
|
||||
if (next == null)
|
||||
{
|
||||
if (!queue.TryDequeue(out moment)) return false;
|
||||
queue.TryDequeue(out next);
|
||||
}
|
||||
else
|
||||
{
|
||||
moment = next;
|
||||
next = null;
|
||||
queue.TryDequeue(out next);
|
||||
}
|
||||
|
||||
m = moment;
|
||||
duration = GetMomentDuration();
|
||||
}
|
||||
|
||||
ActivateMoment(moment, duration);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
CheckClosed();
|
||||
closed = true;
|
||||
|
||||
queueFiller.Wait();
|
||||
|
||||
Directory.Delete(workingDir, true);
|
||||
}
|
||||
|
||||
private Action<ActivateMoment, string> CreateEventAction<T>(Action<ActivateEvent<T>> handler)
|
||||
{
|
||||
return (m, s) =>
|
||||
{
|
||||
handler(new ActivateEvent<T>(m, JsonConvert.DeserializeObject<T>(s)!));
|
||||
};
|
||||
}
|
||||
|
||||
private void FillQueue(OverwatchTranscript model, string workingDir)
|
||||
{
|
||||
var reader = new MomentReader(model, workingDir);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (closed)
|
||||
{
|
||||
reader.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
while (queue.Count < 10)
|
||||
{
|
||||
var moment = reader.Next();
|
||||
if (moment == null)
|
||||
{
|
||||
reader.Close();
|
||||
return;
|
||||
}
|
||||
queue.Enqueue(moment);
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan? GetMomentDuration()
|
||||
{
|
||||
if (moment == null) return null;
|
||||
if (next == null) return null;
|
||||
|
||||
return next.Utc - moment.Utc;
|
||||
}
|
||||
|
||||
private void ActivateMoment(OverwatchMoment moment, TimeSpan? duration)
|
||||
{
|
||||
var m = new ActivateMoment(moment.Utc, duration, momentCounter);
|
||||
|
||||
lock (handlersLock)
|
||||
{
|
||||
ActivateMomentHandlers(m);
|
||||
|
||||
foreach (var @event in moment.Events)
|
||||
{
|
||||
ActivateEventHandlers(m, @event);
|
||||
}
|
||||
}
|
||||
|
||||
momentCounter++;
|
||||
}
|
||||
|
||||
private void ActivateMomentHandlers(ActivateMoment m)
|
||||
{
|
||||
foreach (var handler in momentHandlers)
|
||||
{
|
||||
handler(m);
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivateEventHandlers(ActivateMoment m, OverwatchEvent @event)
|
||||
{
|
||||
if (!eventHandlers.ContainsKey(@event.Type)) return;
|
||||
var handlers = eventHandlers[@event.Type];
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
handler(m, @event.Payload);
|
||||
}
|
||||
}
|
||||
|
||||
private OverwatchTranscript LoadModel(string inputFilename)
|
||||
{
|
||||
ZipFile.ExtractToDirectory(inputFilename, workingDir);
|
||||
|
||||
if (!File.Exists(transcriptFile))
|
||||
{
|
||||
closed = true;
|
||||
throw new Exception("Is not a transcript file. Unzipped to: " + workingDir);
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<OverwatchTranscript>(File.ReadAllText(transcriptFile))!;
|
||||
}
|
||||
|
||||
private void CheckClosed()
|
||||
{
|
||||
if (closed) throw new Exception("Transcript has already been closed.");
|
||||
}
|
||||
}
|
||||
|
||||
public class ActivateMoment
|
||||
{
|
||||
public ActivateMoment(DateTime utc, TimeSpan? duration, long index)
|
||||
{
|
||||
Utc = utc;
|
||||
Duration = duration;
|
||||
Index = index;
|
||||
}
|
||||
|
||||
public DateTime Utc { get; }
|
||||
public TimeSpan? Duration { get; }
|
||||
public long Index { get; }
|
||||
}
|
||||
|
||||
public class ActivateEvent<T>
|
||||
{
|
||||
public ActivateEvent(ActivateMoment moment, T payload)
|
||||
{
|
||||
Moment = moment;
|
||||
Payload = payload;
|
||||
}
|
||||
|
||||
public ActivateMoment Moment { get; }
|
||||
public T Payload { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Logging;
|
||||
using Newtonsoft.Json;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace OverwatchTranscript
|
||||
{
|
||||
public interface ITranscriptWriter
|
||||
{
|
||||
void AddHeader(string key, object value);
|
||||
void Add(DateTime utc, object payload);
|
||||
void IncludeArtifact(string filePath);
|
||||
void Write(string outputFilename);
|
||||
}
|
||||
|
||||
public class TranscriptWriter : ITranscriptWriter
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly MomentReferenceBuilder builder;
|
||||
private readonly string transcriptFile;
|
||||
private readonly string artifactsFolder;
|
||||
private readonly Dictionary<string, string> header = new Dictionary<string, string>();
|
||||
private readonly BucketSet bucketSet;
|
||||
private readonly ILog log;
|
||||
private readonly string workingDir;
|
||||
private bool closed;
|
||||
|
||||
public TranscriptWriter(ILog log, string workingDir)
|
||||
{
|
||||
closed = false;
|
||||
this.log = log;
|
||||
this.workingDir = workingDir;
|
||||
bucketSet = new BucketSet(log, workingDir);
|
||||
builder = new MomentReferenceBuilder(log, workingDir);
|
||||
transcriptFile = Path.Combine(workingDir, TranscriptConstants.TranscriptFilename);
|
||||
artifactsFolder = Path.Combine(workingDir, TranscriptConstants.ArtifactFolderName);
|
||||
|
||||
if (!Directory.Exists(workingDir)) Directory.CreateDirectory(workingDir);
|
||||
if (File.Exists(transcriptFile) || Directory.Exists(artifactsFolder)) throw new Exception("workingdir not clean");
|
||||
}
|
||||
|
||||
public void Add(DateTime utc, object payload)
|
||||
{
|
||||
CheckClosed();
|
||||
bucketSet.Add(utc, payload);
|
||||
}
|
||||
|
||||
public void AddHeader(string key, object value)
|
||||
{
|
||||
CheckClosed();
|
||||
lock (_lock)
|
||||
{
|
||||
header.Add(key, Json.Serialize(value));
|
||||
}
|
||||
}
|
||||
|
||||
public void IncludeArtifact(string filePath)
|
||||
{
|
||||
CheckClosed();
|
||||
if (!File.Exists(filePath)) throw new Exception("File not found: " + filePath);
|
||||
if (!Directory.Exists(artifactsFolder)) Directory.CreateDirectory(artifactsFolder);
|
||||
var name = Path.GetFileName(filePath);
|
||||
File.Copy(filePath, Path.Combine(artifactsFolder, name), overwrite: false);
|
||||
}
|
||||
|
||||
public void Write(string outputFilename)
|
||||
{
|
||||
CheckClosed();
|
||||
closed = true;
|
||||
|
||||
var momentReferences = builder.Build(bucketSet.FinalizeBuckets());
|
||||
var model = CreateModel(momentReferences);
|
||||
|
||||
File.WriteAllText(transcriptFile, Json.Serialize(model, Formatting.Indented));
|
||||
|
||||
ZipFile.CreateFromDirectory(workingDir, outputFilename);
|
||||
log.Debug($"Transcript written to {outputFilename}");
|
||||
log.Debug($"Common header: {Json.Serialize(model.Header.Common, Formatting.Indented)}");
|
||||
|
||||
Directory.Delete(workingDir, true);
|
||||
log.Debug($"Workdir {workingDir} deleted");
|
||||
}
|
||||
|
||||
private OverwatchTranscript CreateModel(OverwatchMomentReference[] momentReferences)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var model = new OverwatchTranscript
|
||||
{
|
||||
Header = new OverwatchHeader
|
||||
{
|
||||
Common = CreateCommonHeader(momentReferences),
|
||||
Entries = header.Select(h =>
|
||||
{
|
||||
return new OverwatchHeaderEntry
|
||||
{
|
||||
Key = h.Key,
|
||||
Value = h.Value
|
||||
};
|
||||
}).ToArray()
|
||||
},
|
||||
MomentReferences = momentReferences
|
||||
};
|
||||
|
||||
header.Clear();
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
private OverwatchCommonHeader CreateCommonHeader(OverwatchMomentReference[] momentReferences)
|
||||
{
|
||||
var moments = momentReferences.Sum(m => m.NumberOfMoments);
|
||||
var events = momentReferences.Sum(m => m.NumberOfEvents);
|
||||
var earliest = momentReferences.Min(m => m.EarliestUtc);
|
||||
var latest = momentReferences.Max(m => m.LatestUtc);
|
||||
|
||||
return new OverwatchCommonHeader
|
||||
{
|
||||
NumberOfMoments = moments,
|
||||
NumberOfEvents = events,
|
||||
EarliestUtc = earliest,
|
||||
LatestUtc = latest
|
||||
};
|
||||
}
|
||||
|
||||
private void CheckClosed()
|
||||
{
|
||||
if (closed) throw new Exception("Transcript has already been written. Cannot modify or write again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Utils
|
||||
{
|
||||
public class BlockInterval
|
||||
{
|
||||
public BlockInterval(TimeRange timeRange, ulong from, ulong to)
|
||||
{
|
||||
if (from < to)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
else
|
||||
{
|
||||
From = to;
|
||||
To = from;
|
||||
}
|
||||
TimeRange = timeRange;
|
||||
}
|
||||
|
||||
public ulong From { get; }
|
||||
public ulong To { get; }
|
||||
public TimeRange TimeRange { get; }
|
||||
public ulong NumberOfBlocks => To - From;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[{From} - {To}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user