blob: 4a54d2fac603dcbdf2b4d22771221a9568fb7aff [file] [log] [blame]
Lorenz Brunfc5dbc62020-05-28 12:18:07 +02001// Copyright 2020 The Monogon Project Authors.
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16
17package e2e
18
19import (
20 "context"
21 "errors"
22 "fmt"
Serge Bazanski2cfafc92023-03-21 16:42:47 +010023 "io"
Leopold Schabele28e6d72020-06-03 11:39:25 +020024 "net"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020025 "net/http"
26 _ "net/http"
27 _ "net/http/pprof"
Lorenz Brun3ff5af32020-06-24 16:34:11 +020028 "os"
Lorenz Brun5e4fc2d2020-09-22 18:35:15 +020029 "strings"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020030 "testing"
31 "time"
32
Serge Bazanskibe742842022-04-04 13:18:50 +020033 "google.golang.org/grpc"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020034 corev1 "k8s.io/api/core/v1"
Lorenz Brun30167f52021-03-17 17:49:01 +010035 "k8s.io/apimachinery/pkg/api/resource"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020036 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
37 podv1 "k8s.io/kubernetes/pkg/api/v1/pod"
38
Serge Bazanski31370b02021-01-07 16:31:14 +010039 common "source.monogon.dev/metropolis/node"
Serge Bazanski6dff6d62022-01-28 18:15:14 +010040 "source.monogon.dev/metropolis/node/core/identity"
Serge Bazanskibe742842022-04-04 13:18:50 +020041 "source.monogon.dev/metropolis/node/core/rpc"
Serge Bazanski31370b02021-01-07 16:31:14 +010042 apb "source.monogon.dev/metropolis/proto/api"
Serge Bazanski05f813b2023-03-16 17:58:39 +010043 "source.monogon.dev/metropolis/test/launch"
Serge Bazanski66e58952021-10-05 17:06:56 +020044 "source.monogon.dev/metropolis/test/launch/cluster"
Mateusz Zalegaddf19b42022-06-22 12:27:37 +020045 "source.monogon.dev/metropolis/test/util"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020046)
47
Leopold Schabeld603f842020-06-09 17:48:09 +020048const (
49 // Timeout for the global test context.
50 //
Serge Bazanski216fe7b2021-05-21 18:36:16 +020051 // Bazel would eventually time out the test after 900s ("large") if, for
52 // some reason, the context cancellation fails to abort it.
Leopold Schabeld603f842020-06-09 17:48:09 +020053 globalTestTimeout = 600 * time.Second
54
55 // Timeouts for individual end-to-end tests of different sizes.
Serge Bazanski1ebd1e12020-07-13 19:17:16 +020056 smallTestTimeout = 60 * time.Second
Leopold Schabeld603f842020-06-09 17:48:09 +020057 largeTestTimeout = 120 * time.Second
58)
59
Serge Bazanski216fe7b2021-05-21 18:36:16 +020060// TestE2E is the main E2E test entrypoint for single-node freshly-bootstrapped
61// E2E tests. It starts a full Metropolis node in bootstrap mode and then runs
62// tests against it. The actual tests it performs are located in the RunGroup
63// subtest.
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020064func TestE2E(t *testing.T) {
Leopold Schabele28e6d72020-06-03 11:39:25 +020065 // Run pprof server for debugging
Serge Bazanski66e58952021-10-05 17:06:56 +020066 addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
67 if err != nil {
68 panic(err)
69 }
70
71 pprofListen, err := net.ListenTCP("tcp", addr)
72 if err != nil {
Serge Bazanski05f813b2023-03-16 17:58:39 +010073 launch.Fatal("Failed to listen on pprof port: %s", pprofListen.Addr())
Serge Bazanski66e58952021-10-05 17:06:56 +020074 }
75
Serge Bazanski05f813b2023-03-16 17:58:39 +010076 launch.Log("E2E: pprof server listening on %s", pprofListen.Addr())
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020077 go func() {
Serge Bazanski05f813b2023-03-16 17:58:39 +010078 launch.Log("E2E: pprof server returned an error: %v", http.Serve(pprofListen, nil))
Serge Bazanski66e58952021-10-05 17:06:56 +020079 pprofListen.Close()
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020080 }()
Leopold Schabele28e6d72020-06-03 11:39:25 +020081
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020082 // Set a global timeout to make sure this terminates
Leopold Schabeld603f842020-06-09 17:48:09 +020083 ctx, cancel := context.WithTimeout(context.Background(), globalTestTimeout)
Serge Bazanski1f9a03b2021-08-17 13:40:53 +020084 defer cancel()
Serge Bazanski66e58952021-10-05 17:06:56 +020085
86 // Launch cluster.
Serge Bazanskie78a0892021-10-07 17:03:49 +020087 clusterOptions := cluster.ClusterOptions{
88 NumNodes: 2,
89 }
90 cluster, err := cluster.LaunchCluster(ctx, clusterOptions)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020091 if err != nil {
Serge Bazanski66e58952021-10-05 17:06:56 +020092 t.Fatalf("LaunchCluster failed: %v", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020093 }
Serge Bazanski66e58952021-10-05 17:06:56 +020094 defer func() {
95 err := cluster.Close()
96 if err != nil {
97 t.Fatalf("cluster Close failed: %v", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020098 }
99 }()
Serge Bazanski1f9a03b2021-08-17 13:40:53 +0200100
Serge Bazanski05f813b2023-03-16 17:58:39 +0100101 launch.Log("E2E: Cluster running, starting tests...")
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200102
Serge Bazanskibe742842022-04-04 13:18:50 +0200103 // Dial first node's curator.
Serge Bazanski8535cb52023-03-29 14:15:08 +0200104 creds := rpc.NewAuthenticatedCredentials(cluster.Owner, rpc.WantInsecure())
Serge Bazanskibe742842022-04-04 13:18:50 +0200105 remote := net.JoinHostPort(cluster.NodeIDs[0], common.CuratorServicePort.PortString())
106 cl, err := grpc.Dial(remote, grpc.WithContextDialer(cluster.DialNode), grpc.WithTransportCredentials(creds))
107 if err != nil {
108 t.Fatalf("failed to dial first node's curator: %v", err)
109 }
110 defer cl.Close()
111 mgmt := apb.NewManagementClient(cl)
112
Serge Bazanski216fe7b2021-05-21 18:36:16 +0200113 // This exists to keep the parent around while all the children race.
114 // It currently tests both a set of OS-level conditions and Kubernetes
115 // Deployments and StatefulSets
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200116 t.Run("RunGroup", func(t *testing.T) {
Serge Bazanski6dff6d62022-01-28 18:15:14 +0100117 t.Run("Cluster", func(t *testing.T) {
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200118 util.TestEventual(t, "Retrieving cluster directory sucessful", ctx, 60*time.Second, func(ctx context.Context) error {
Serge Bazanskibe742842022-04-04 13:18:50 +0200119 res, err := mgmt.GetClusterInfo(ctx, &apb.GetClusterInfoRequest{})
Serge Bazanskibf68fa92021-10-05 17:53:58 +0200120 if err != nil {
121 return fmt.Errorf("GetClusterInfo: %w", err)
122 }
123
Serge Bazanskie78a0892021-10-07 17:03:49 +0200124 // Ensure that the expected node count is present.
Serge Bazanskibf68fa92021-10-05 17:53:58 +0200125 nodes := res.ClusterDirectory.Nodes
Serge Bazanskie78a0892021-10-07 17:03:49 +0200126 if want, got := clusterOptions.NumNodes, len(nodes); want != got {
Serge Bazanskibf68fa92021-10-05 17:53:58 +0200127 return fmt.Errorf("wanted %d nodes in cluster directory, got %d", want, got)
128 }
Serge Bazanski6dff6d62022-01-28 18:15:14 +0100129
130 // Ensure the nodes have the expected addresses.
131 addresses := make(map[string]bool)
132 for _, n := range nodes {
133 if len(n.Addresses) != 1 {
134 return fmt.Errorf("node %s has no addresss", identity.NodeID(n.PublicKey))
135 }
136 address := n.Addresses[0].Host
137 addresses[address] = true
138 }
139
140 for _, address := range []string{"10.1.0.2", "10.1.0.3"} {
141 if !addresses[address] {
142 return fmt.Errorf("address %q not found in directory", address)
143 }
144 }
Serge Bazanski1f9a03b2021-08-17 13:40:53 +0200145 return nil
146 })
Serge Bazanski630fb5c2023-04-06 10:50:24 +0200147 util.TestEventual(t, "Heartbeat test successful", ctx, 20*time.Second, cluster.AllNodesHealthy)
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200148 util.TestEventual(t, "Node rejoin successful", ctx, 60*time.Second, func(ctx context.Context) error {
Mateusz Zalega0246f5e2022-04-22 17:29:04 +0200149 // Ensure nodes rejoin the cluster after a reboot by reboting the 1st node.
150 if err := cluster.RebootNode(ctx, 1); err != nil {
151 return fmt.Errorf("while rebooting a node: %w", err)
152 }
153 return nil
154 })
Serge Bazanski630fb5c2023-04-06 10:50:24 +0200155 util.TestEventual(t, "Heartbeat test successful", ctx, 20*time.Second, cluster.AllNodesHealthy)
Serge Bazanski1f9a03b2021-08-17 13:40:53 +0200156 })
Serge Bazanski6dff6d62022-01-28 18:15:14 +0100157 t.Run("Kubernetes", func(t *testing.T) {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200158 t.Parallel()
Serge Bazanskibe742842022-04-04 13:18:50 +0200159 // TODO(q3k): use SOCKS proxy.
160 clientSet, err := GetKubeClientSet(cluster, cluster.Ports[uint16(common.KubernetesAPIWrappedPort)])
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200161 if err != nil {
162 t.Fatal(err)
163 }
Serge Bazanski2cfafc92023-03-21 16:42:47 +0100164 util.TestEventual(t, "Add KubernetesWorker roles", ctx, smallTestTimeout, func(ctx context.Context) error {
165 // Find all nodes that are non-controllers.
166 var ids []string
167 srvN, err := mgmt.GetNodes(ctx, &apb.GetNodesRequest{})
168 if err != nil {
169 return fmt.Errorf("GetNodes: %w", err)
170 }
171 defer srvN.CloseSend()
172 for {
173 node, err := srvN.Recv()
174 if err == io.EOF {
175 break
176 }
177 if err != nil {
178 return fmt.Errorf("GetNodes.Recv: %w", err)
179 }
180 if node.Roles.KubernetesController != nil {
181 continue
182 }
183 if node.Roles.ConsensusMember != nil {
184 continue
185 }
186 ids = append(ids, identity.NodeID(node.Pubkey))
187 }
188
189 if len(ids) < 1 {
190 return fmt.Errorf("no appropriate nodes found")
191 }
192
193 // Make all these nodes as KubernetesWorker.
194 for _, id := range ids {
195 tr := true
196 _, err := mgmt.UpdateNodeRoles(ctx, &apb.UpdateNodeRolesRequest{
197 Node: &apb.UpdateNodeRolesRequest_Id{
198 Id: id,
199 },
200 KubernetesWorker: &tr,
201 })
202 if err != nil {
203 return fmt.Errorf("could not make node %q into kubernetes worker: %w", id, err)
204 }
205 }
206 return nil
207 })
208 util.TestEventual(t, "Node is registered and ready", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200209 nodes, err := clientSet.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
210 if err != nil {
211 return err
212 }
Serge Bazanski2cfafc92023-03-21 16:42:47 +0100213 if len(nodes.Items) < 1 {
214 return errors.New("node not yet registered")
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200215 }
216 node := nodes.Items[0]
217 for _, cond := range node.Status.Conditions {
218 if cond.Type != corev1.NodeReady {
219 continue
220 }
221 if cond.Status != corev1.ConditionTrue {
222 return fmt.Errorf("node not ready: %v", cond.Message)
223 }
224 }
225 return nil
226 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200227 util.TestEventual(t, "Simple deployment", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200228 _, err := clientSet.AppsV1().Deployments("default").Create(ctx, makeTestDeploymentSpec("test-deploy-1"), metav1.CreateOptions{})
229 return err
230 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200231 util.TestEventual(t, "Simple deployment is running", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200232 res, err := clientSet.CoreV1().Pods("default").List(ctx, metav1.ListOptions{LabelSelector: "name=test-deploy-1"})
233 if err != nil {
234 return err
235 }
236 if len(res.Items) == 0 {
237 return errors.New("pod didn't get created")
238 }
239 pod := res.Items[0]
240 if podv1.IsPodAvailable(&pod, 1, metav1.NewTime(time.Now())) {
241 return nil
242 }
243 events, err := clientSet.CoreV1().Events("default").List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=default", pod.Name)})
244 if err != nil || len(events.Items) == 0 {
245 return fmt.Errorf("pod is not ready: %v", pod.Status.Phase)
246 } else {
247 return fmt.Errorf("pod is not ready: %v", events.Items[0].Message)
248 }
249 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200250 util.TestEventual(t, "Simple deployment with runc", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brun5e4fc2d2020-09-22 18:35:15 +0200251 deployment := makeTestDeploymentSpec("test-deploy-2")
252 var runcStr = "runc"
253 deployment.Spec.Template.Spec.RuntimeClassName = &runcStr
254 _, err := clientSet.AppsV1().Deployments("default").Create(ctx, deployment, metav1.CreateOptions{})
255 return err
256 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200257 util.TestEventual(t, "Simple deployment is running on runc", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brun5e4fc2d2020-09-22 18:35:15 +0200258 res, err := clientSet.CoreV1().Pods("default").List(ctx, metav1.ListOptions{LabelSelector: "name=test-deploy-2"})
259 if err != nil {
260 return err
261 }
262 if len(res.Items) == 0 {
263 return errors.New("pod didn't get created")
264 }
265 pod := res.Items[0]
266 if podv1.IsPodAvailable(&pod, 1, metav1.NewTime(time.Now())) {
267 return nil
268 }
269 events, err := clientSet.CoreV1().Events("default").List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=default", pod.Name)})
270 if err != nil || len(events.Items) == 0 {
271 return fmt.Errorf("pod is not ready: %v", pod.Status.Phase)
272 } else {
273 var errorMsg strings.Builder
274 for _, msg := range events.Items {
275 errorMsg.WriteString(" | ")
276 errorMsg.WriteString(msg.Message)
277 }
278 return fmt.Errorf("pod is not ready: %v", errorMsg.String())
279 }
280 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200281 util.TestEventual(t, "Simple StatefulSet with PVC", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brun37050122021-03-30 14:00:27 +0200282 _, err := clientSet.AppsV1().StatefulSets("default").Create(ctx, makeTestStatefulSet("test-statefulset-1", corev1.PersistentVolumeFilesystem), metav1.CreateOptions{})
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200283 return err
284 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200285 util.TestEventual(t, "Simple StatefulSet with PVC is running", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200286 res, err := clientSet.CoreV1().Pods("default").List(ctx, metav1.ListOptions{LabelSelector: "name=test-statefulset-1"})
287 if err != nil {
288 return err
289 }
290 if len(res.Items) == 0 {
291 return errors.New("pod didn't get created")
292 }
293 pod := res.Items[0]
294 if podv1.IsPodAvailable(&pod, 1, metav1.NewTime(time.Now())) {
295 return nil
296 }
297 events, err := clientSet.CoreV1().Events("default").List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=default", pod.Name)})
298 if err != nil || len(events.Items) == 0 {
299 return fmt.Errorf("pod is not ready: %v", pod.Status.Phase)
300 } else {
301 return fmt.Errorf("pod is not ready: %v", events.Items[0].Message)
302 }
303 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200304 util.TestEventual(t, "Simple StatefulSet with Block PVC", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brun37050122021-03-30 14:00:27 +0200305 _, err := clientSet.AppsV1().StatefulSets("default").Create(ctx, makeTestStatefulSet("test-statefulset-2", corev1.PersistentVolumeBlock), metav1.CreateOptions{})
306 return err
307 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200308 util.TestEventual(t, "Simple StatefulSet with Block PVC is running", ctx, largeTestTimeout, func(ctx context.Context) error {
Lorenz Brun37050122021-03-30 14:00:27 +0200309 res, err := clientSet.CoreV1().Pods("default").List(ctx, metav1.ListOptions{LabelSelector: "name=test-statefulset-2"})
310 if err != nil {
311 return err
312 }
313 if len(res.Items) == 0 {
314 return errors.New("pod didn't get created")
315 }
316 pod := res.Items[0]
317 if podv1.IsPodAvailable(&pod, 1, metav1.NewTime(time.Now())) {
318 return nil
319 }
320 events, err := clientSet.CoreV1().Events("default").List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=default", pod.Name)})
321 if err != nil || len(events.Items) == 0 {
322 return fmt.Errorf("pod is not ready: %v", pod.Status.Phase)
323 } else {
324 return fmt.Errorf("pod is not ready: %v", events.Items[0].Message)
325 }
326 })
Serge Bazanski9104e382023-04-04 20:08:21 +0200327 util.TestEventual(t, "In-cluster self-test job", ctx, smallTestTimeout, func(ctx context.Context) error {
328 _, err := clientSet.BatchV1().Jobs("default").Create(ctx, makeSelftestSpec("selftest"), metav1.CreateOptions{})
329 return err
330 })
331 util.TestEventual(t, "In-cluster self-test job passed", ctx, smallTestTimeout, func(ctx context.Context) error {
332 res, err := clientSet.BatchV1().Jobs("default").Get(ctx, "selftest", metav1.GetOptions{})
333 if err != nil {
334 return err
335 }
336 if res.Status.Failed > 0 {
337 pods, err := clientSet.CoreV1().Pods("default").List(ctx, metav1.ListOptions{
338 LabelSelector: "job-name=selftest",
339 })
340 if err != nil {
341 return util.Permanent(fmt.Errorf("job failed but failed to find pod: %w", err))
342 }
343 if len(pods.Items) < 1 {
344 return fmt.Errorf("job failed but pod does not exist")
345 }
346 lines, err := getPodLogLines(ctx, clientSet, pods.Items[0].Name, 1)
347 if err != nil {
348 return fmt.Errorf("job failed but could not get logs: %w", err)
349 }
350 if len(lines) > 0 {
351 return util.Permanent(fmt.Errorf("job failed, last log line: %s", lines[0]))
352 }
353 return util.Permanent(fmt.Errorf("job failed, empty log"))
354 }
355 if res.Status.Succeeded > 0 {
356 return nil
357 }
358 return fmt.Errorf("job still running")
359 })
Lorenz Brun30167f52021-03-17 17:49:01 +0100360 if os.Getenv("HAVE_NESTED_KVM") != "" {
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200361 util.TestEventual(t, "Pod for KVM/QEMU smoke test", ctx, smallTestTimeout, func(ctx context.Context) error {
Lorenz Brun30167f52021-03-17 17:49:01 +0100362 runcRuntimeClass := "runc"
363 _, err := clientSet.CoreV1().Pods("default").Create(ctx, &corev1.Pod{
364 ObjectMeta: metav1.ObjectMeta{
365 Name: "vm-smoketest",
366 },
367 Spec: corev1.PodSpec{
368 Containers: []corev1.Container{{
369 Name: "vm-smoketest",
370 ImagePullPolicy: corev1.PullNever,
371 Image: "bazel/metropolis/vm/smoketest:smoketest_container",
372 Resources: corev1.ResourceRequirements{
373 Limits: corev1.ResourceList{
374 "devices.monogon.dev/kvm": *resource.NewQuantity(1, ""),
375 },
376 },
377 }},
378 RuntimeClassName: &runcRuntimeClass,
379 RestartPolicy: corev1.RestartPolicyNever,
380 },
381 }, metav1.CreateOptions{})
382 return err
383 })
Mateusz Zalegaddf19b42022-06-22 12:27:37 +0200384 util.TestEventual(t, "KVM/QEMU smoke test completion", ctx, smallTestTimeout, func(ctx context.Context) error {
Lorenz Brun30167f52021-03-17 17:49:01 +0100385 pod, err := clientSet.CoreV1().Pods("default").Get(ctx, "vm-smoketest", metav1.GetOptions{})
386 if err != nil {
387 return fmt.Errorf("failed to get pod: %w", err)
388 }
389 if pod.Status.Phase == corev1.PodSucceeded {
390 return nil
391 }
392 events, err := clientSet.CoreV1().Events("default").List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=default", pod.Name)})
393 if err != nil || len(events.Items) == 0 {
394 return fmt.Errorf("pod is not ready: %v", pod.Status.Phase)
395 } else {
396 return fmt.Errorf("pod is not ready: %v", events.Items[len(events.Items)-1].Message)
397 }
398 })
399 }
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200400 })
401 })
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200402}