From fd061499c17a97dab8f6302f963577d743fb2d05 Mon Sep 17 00:00:00 2001 From: fengcaiwen Date: Tue, 6 Jun 2023 10:52:30 +0800 Subject: [PATCH] feat: update README.md --- .github/workflows/test.yml | 33 ++++--- README.md | 24 +++++ README_ZH.md | 24 +++++ cmd/kubevpn/cmds/version.go | 4 +- pkg/handler/function_test.go | 164 +++++++++++++++++++++-------------- pkg/handler/remote.go | 79 +++++++++-------- pkg/util/pod.go | 9 ++ pkg/util/util.go | 4 +- samples/flat_log.png | Bin 0 -> 4111 bytes 9 files changed, 224 insertions(+), 117 deletions(-) create mode 100644 samples/flat_log.png diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45e3d4d3..ceaaa204 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USER }} --password-stdin docker buildx create --use export VERSION=test - make container + # make container linux: runs-on: ubuntu-latest needs: [ "image" ] @@ -36,8 +36,11 @@ jobs: check-latest: true cache: true - name: Setup Minikube + id: minikube timeout-minutes: 30 uses: medyagh/setup-minikube@master + with: + cache: true - name: Kubernetes info run: | @@ -69,6 +72,8 @@ jobs: run: | kubectl wait pods -l app=reviews --for=condition=Ready --timeout=3600s kubectl wait pods -l app=productpage --for=condition=Ready --timeout=3600s + kubectl get svc -A -o wide + kubectl get pod -A -o wide kubectl get all -o wide kubectl get nodes -o yaml ifconfig @@ -76,10 +81,10 @@ jobs: sudo ln /usr/bin/resolvectl /usr/bin/systemd-resolve - name: Test - run: go test -v ./... -timeout=60m + run: go test -v -failfast ./... -timeout=60m macos: - runs-on: macos-10.15 + runs-on: macos-latest needs: [ "image" ] steps: - uses: actions/checkout@v2 @@ -90,19 +95,17 @@ jobs: go-version: 1.19 check-latest: true cache: true - - uses: docker-practice/actions-setup-docker@master - - name: Pull image in advance - run: | - rm '/usr/local/bin/kubectl' - set -x - docker version + - name: Setup Docker on macOS + uses: douglascamata/setup-docker-macos-action@v1-alpha - name: Install minikube run: | + set -x + docker version brew install minikube minikube start --driver=docker - kubectl get po -A - minikube kubectl -- get po -A + kubectl get pod -A -o wide + minikube kubectl -- get pod -A -o wide - name: Kubernetes info run: | @@ -135,13 +138,15 @@ jobs: run: | kubectl wait pods -l app=reviews --for=condition=Ready --timeout=3600s kubectl wait pods -l app=productpage --for=condition=Ready --timeout=3600s - kubectl get all -o wide - kubectl get nodes -o yaml + kubectl get svc -A -o wide || true + kubectl get pod -A -o wide || true + kubectl get all -o wide || true + kubectl get nodes -o yaml || true ifconfig netstat -anr - name: Test - run: go test -v ./... -timeout=60m + run: go test -v -failfast ./... -timeout=60m # windows: # runs-on: windows-latest diff --git a/README.md b/README.md index f59d84a1..e7352e4b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ +![kubevpn](samples/flat_log.png) + +[![GitHub Workflow][1]](https://github.com/wencaiwulue/kubevpn/actions) +[![Go Version][2]](https://github.com/wencaiwulue/kubevpn/blob/master/go.mod) +[![Go Report][3]](https://goreportcard.com/badge/github.com/wencaiwulue/kubevpn) +[![Maintainability][4]](https://codeclimate.com/github/wencaiwulue/kubevpn/maintainability) +[![GitHub License][5]](https://github.com/wencaiwulue/kubevpn/blob/main/LICENSE) +[![Docker Pulls][6]](https://hub.docker.com/r/naison/kubevpn) +[![Releases][7]](https://github.com/wencaiwulue/kubevpn/releases) + +[1]: https://img.shields.io/github/actions/workflow/status/wencaiwulue/kubevpn/release.yml?logo=github + +[2]: https://img.shields.io/github/go-mod/go-version/wencaiwulue/kubevpn?logo=go + +[3]: https://goreportcard.com/badge/github.com/wencaiwulue/kubevpn + +[4]: https://api.codeclimate.com/v1/badges/b5b30239174fc6603aca/maintainability + +[5]: https://img.shields.io/github/license/wencaiwulue/kubevpn + +[6]: https://img.shields.io/docker/pulls/naison/kubevpn?logo=docker + +[7]: https://img.shields.io/github/v/release/wencaiwulue/kubevpn?logo=smartthings + # KubeVPN [中文](README_ZH.md) | [English](README.md) | [Wiki](https://github.com/wencaiwulue/kubevpn/wiki/Architecture) diff --git a/README_ZH.md b/README_ZH.md index 36e0181a..939ea02e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,3 +1,27 @@ +![kubevpn](samples/flat_log.png) + +[![GitHub Workflow][1]](https://github.com/wencaiwulue/kubevpn/actions) +[![Go Version][2]](https://github.com/wencaiwulue/kubevpn/blob/master/go.mod) +[![Go Report][3]](https://goreportcard.com/badge/github.com/wencaiwulue/kubevpn) +[![Maintainability][4]](https://codeclimate.com/github/wencaiwulue/kubevpn/maintainability) +[![GitHub License][5]](https://github.com/wencaiwulue/kubevpn/blob/main/LICENSE) +[![Docker Pulls][6]](https://hub.docker.com/r/naison/kubevpn) +[![Releases][7]](https://github.com/wencaiwulue/kubevpn/releases) + +[1]: https://img.shields.io/github/actions/workflow/status/wencaiwulue/kubevpn/release.yml?logo=github + +[2]: https://img.shields.io/github/go-mod/go-version/wencaiwulue/kubevpn?logo=go + +[3]: https://goreportcard.com/badge/github.com/wencaiwulue/kubevpn + +[4]: https://api.codeclimate.com/v1/badges/b5b30239174fc6603aca/maintainability + +[5]: https://img.shields.io/github/license/wencaiwulue/kubevpn + +[6]: https://img.shields.io/docker/pulls/naison/kubevpn?logo=docker + +[7]: https://img.shields.io/github/v/release/wencaiwulue/kubevpn?logo=smartthings + # KubeVPN [English](README.md) | [中文](README_ZH.md) | [维基](https://github.com/wencaiwulue/kubevpn/wiki/%E6%9E%B6%E6%9E%84) diff --git a/cmd/kubevpn/cmds/version.go b/cmd/kubevpn/cmds/version.go index 767027b2..98ff97e6 100644 --- a/cmd/kubevpn/cmds/version.go +++ b/cmd/kubevpn/cmds/version.go @@ -31,8 +31,8 @@ func reformatDate(buildTime string) string { func CmdVersion(cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "version", - Short: "Print the version number of KubeVPN", - Long: `This is the version of KubeVPN`, + Short: "Print the client version information", + Long: `Print the client version information`, Run: func(cmd *cobra.Command, args []string) { fmt.Printf("KubeVPN: CLI\n") fmt.Printf(" Version: %s\n", config.Version) diff --git a/pkg/handler/function_test.go b/pkg/handler/function_test.go index 8f38dc73..294f12eb 100644 --- a/pkg/handler/function_test.go +++ b/pkg/handler/function_test.go @@ -13,10 +13,9 @@ import ( "testing" "time" - "github.com/docker/distribution/reference" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -33,7 +32,7 @@ var ( namespace string clientset *kubernetes.Clientset restclient *rest.RESTClient - c *rest.Config + restconfig *rest.Config ) func TestFunctions(t *testing.T) { @@ -47,9 +46,7 @@ func TestFunctions(t *testing.T) { } func pingPodIP(t *testing.T) { - ctx, f := context.WithTimeout(context.Background(), time.Second*60) - defer f() - list, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + list, err := clientset.CoreV1().Pods(namespace).List(context.Background(), v1.ListOptions{}) if err != nil { t.Error(err) } @@ -72,44 +69,69 @@ func pingPodIP(t *testing.T) { } func healthCheckPod(t *testing.T) { - podList, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{ - LabelSelector: fields.OneTermEqualSelector("app", "productpage").String(), + var app = "authors" + podList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: fields.OneTermEqualSelector("app", app).String(), }) if err != nil { t.Error(err) } if len(podList.Items) == 0 { - t.Error("can not found pods of product page") + t.Error("can not found pods of authors") } - endpoint := fmt.Sprintf("http://%s:%v/health", podList.Items[0].Status.PodIP, podList.Items[0].Spec.Containers[0].Ports[0].ContainerPort) - req, _ := http.NewRequest("GET", endpoint, nil) - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Error(err) - return - } - if res == nil || res.StatusCode != 200 { - t.Errorf("health check not pass") - return + for _, pod := range podList.Items { + pod := pod + if pod.Status.Phase != corev1.PodRunning { + continue + } + endpoint := fmt.Sprintf("http://%s:%v/health", pod.Status.PodIP, pod.Spec.Containers[0].Ports[0].ContainerPort) + req, _ := http.NewRequest("GET", endpoint, nil) + var res *http.Response + err = retry.OnError( + wait.Backoff{Duration: time.Second, Factor: 2, Jitter: 0.2, Steps: 5}, + func(err error) bool { + return err != nil + }, + func() error { + res, err = http.DefaultClient.Do(req) + return err + }, + ) + if err != nil { + t.Error(err) + } + if res == nil || res.StatusCode != 200 { + t.Errorf("health check not pass") + } } } func healthCheckService(t *testing.T) { - serviceList, err := clientset.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{ - LabelSelector: fields.OneTermEqualSelector("app", "productpage").String(), + var app = "authors" + serviceList, err := clientset.CoreV1().Services(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: fields.OneTermEqualSelector("app", app).String(), }) if err != nil { t.Error(err) } if len(serviceList.Items) == 0 { - t.Error("can not found pods of product page") + t.Error("can not found pods of authors") } endpoint := fmt.Sprintf("http://%s:%v/health", serviceList.Items[0].Spec.ClusterIP, serviceList.Items[0].Spec.Ports[0].Port) req, _ := http.NewRequest("GET", endpoint, nil) - res, err := http.DefaultClient.Do(req) + var res *http.Response + err = retry.OnError( + wait.Backoff{Duration: time.Second, Factor: 2, Jitter: 0.2, Steps: 5}, + func(err error) bool { + return err != nil + }, + func() error { + res, err = http.DefaultClient.Do(req) + return err + }, + ) if err != nil { t.Error(err) - return } if res == nil || res.StatusCode != 200 { t.Errorf("health check not pass") @@ -118,8 +140,8 @@ func healthCheckService(t *testing.T) { } func shortDomain(t *testing.T) { - var app = "productpage" - serviceList, err := clientset.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{ + var app = "authors" + serviceList, err := clientset.CoreV1().Services(namespace).List(context.TODO(), v1.ListOptions{ LabelSelector: fields.OneTermEqualSelector("app", app).String(), }) if err != nil { @@ -130,20 +152,28 @@ func shortDomain(t *testing.T) { } endpoint := fmt.Sprintf("http://%s:%v/health", app, serviceList.Items[0].Spec.Ports[0].Port) req, _ := http.NewRequest("GET", endpoint, nil) - res, err := http.DefaultClient.Do(req) + var res *http.Response + err = retry.OnError( + wait.Backoff{Duration: time.Second, Factor: 2, Jitter: 0.2, Steps: 5}, + func(err error) bool { + return err != nil + }, + func() error { + res, err = http.DefaultClient.Do(req) + return err + }, + ) if err != nil { t.Error(err) - return } if res == nil || res.StatusCode != 200 { t.Errorf("health check not pass") - return } } func fullDomain(t *testing.T) { - var app = "productpage" - serviceList, err := clientset.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{ + var app = "authors" + serviceList, err := clientset.CoreV1().Services(namespace).List(context.TODO(), v1.ListOptions{ LabelSelector: fields.OneTermEqualSelector("app", app).String(), }) if err != nil { @@ -154,10 +184,19 @@ func fullDomain(t *testing.T) { } endpoint := fmt.Sprintf("http://%s:%v/health", fmt.Sprintf("%s.%s.svc.cluster.local", app, namespace), serviceList.Items[0].Spec.Ports[0].Port) req, _ := http.NewRequest("GET", endpoint, nil) - res, err := http.DefaultClient.Do(req) + var res *http.Response + err = retry.OnError( + wait.Backoff{Duration: time.Second, Factor: 2, Jitter: 0.2, Steps: 5}, + func(err error) bool { + return err != nil + }, + func() error { + res, err = http.DefaultClient.Do(req) + return err + }, + ) if err != nil { t.Error(err) - return } if res == nil || res.StatusCode != 200 { t.Errorf("health check not pass") @@ -167,9 +206,9 @@ func fullDomain(t *testing.T) { func dialUDP(t *testing.T) { port := util.GetAvailableUDPPortOrDie() - go UDPServer(port) + go server(port) - list, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{ + list, err := clientset.CoreV1().Pods(namespace).List(context.Background(), v1.ListOptions{ LabelSelector: fields.OneTermEqualSelector("app", "reviews").String(), }) if err != nil { @@ -184,6 +223,7 @@ func dialUDP(t *testing.T) { } if len(ip) == 0 { t.Errorf("can not found pods for service reviews") + return } log.Printf("dail udp to ip: %s", ip) if err = retry.OnError( @@ -191,13 +231,13 @@ func dialUDP(t *testing.T) { func(err error) bool { return err != nil }, func() error { - return UDPClient(ip, port) + return udpclient(ip, port) }); err != nil { t.Errorf("can not access pod ip: %s, port: %v", ip, port) } } -func UDPClient(ip string, port int) error { +func udpclient(ip string, port int) error { udpConn, err := net.DialUDP("udp4", nil, &net.UDPAddr{ IP: net.ParseIP(ip), Port: port, @@ -217,7 +257,7 @@ func UDPClient(ip string, port int) error { sendData := []byte("hello server!") _, err = udpConn.Write(sendData) if err != nil { - fmt.Println("[client] 发送数据失败!", err) + fmt.Println("发送数据失败!", err) return err } @@ -225,7 +265,7 @@ func UDPClient(ip string, port int) error { data := make([]byte, 4096) read, remoteAddr, err := udpConn.ReadFromUDP(data) if err != nil { - fmt.Println("[client] 读取数据失败!", err) + fmt.Println("读取数据失败!", err) return err } fmt.Println(read, remoteAddr) @@ -233,7 +273,7 @@ func UDPClient(ip string, port int) error { return nil } -func UDPServer(port int) { +func server(port int) { // 创建监听 udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{ IP: net.IPv4(0, 0, 0, 0), @@ -248,7 +288,7 @@ func UDPServer(port int) { data := make([]byte, 4096) read, remoteAddr, err := udpConn.ReadFromUDP(data) if err != nil { - fmt.Println("[server] 读取数据失败!", err) + fmt.Println("读取数据失败!", err) continue } fmt.Println(read, remoteAddr) @@ -257,27 +297,32 @@ func UDPServer(port int) { sendData := []byte("hello client!") _, err = udpConn.WriteToUDP(sendData, remoteAddr) if err != nil { - fmt.Println("[server] 发送数据失败!", err) + fmt.Println("发送数据失败!", err) return } } } func kubevpnConnect(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) - cmd := exec.CommandContext(context.Background(), "kubevpn", "proxy", "deployments/reviews", "--debug") + ctx2, timeoutFunc := context.WithTimeout(context.Background(), 2*time.Hour) + + cmd := exec.Command("kubevpn", "proxy", "--debug", "deployments/reviews") go func() { - var checker = func(log string) { - if strings.Contains(log, "dns service ok") { - cancel() + stdout, stderr, err := util.RunWithRollingOutWithChecker(cmd, func(log string) { + ok := strings.Contains(log, "dns service ok") + if ok { + timeoutFunc() } - } - _, _, err := util.RunWithRollingOutWithChecker(cmd, checker) + }) + defer timeoutFunc() if err != nil { - t.Log(err) + t.Log(stdout, stderr) + t.Error(err) + t.Fail() + return } }() - <-ctx.Done() + <-ctx2.Done() } func init() { @@ -287,27 +332,16 @@ func init() { configFlags.KubeConfig = &clientcmd.RecommendedHomeFile f := cmdutil.NewFactory(cmdutil.NewMatchVersionFlags(configFlags)) - if c, err = f.ToRESTConfig(); err != nil { + if restconfig, err = f.ToRESTConfig(); err != nil { log.Fatal(err) } - if restclient, err = rest.RESTClientFor(c); err != nil { + if restclient, err = rest.RESTClientFor(restconfig); err != nil { log.Fatal(err) } - if clientset, err = kubernetes.NewForConfig(c); err != nil { + if clientset, err = kubernetes.NewForConfig(restconfig); err != nil { log.Fatal(err) } if namespace, _, err = f.ToRawKubeConfigLoader().Namespace(); err != nil { log.Fatal(err) } } - -func TestName(t *testing.T) { - name := "docker.io/naison/alpine@sha256:b733d4a32c4da6a00a84df2ca32791bb03df95400243648d8c539e7b4cce329c" - named, err := reference.ParseNormalizedNamed(name) - if err != nil { - t.Error(err) - } - domain := reference.Domain(named) - path := reference.Path(named) - fmt.Println(domain, path) -} diff --git a/pkg/handler/remote.go b/pkg/handler/remote.go index b57c4550..6c18eedb 100644 --- a/pkg/handler/remote.go +++ b/pkg/handler/remote.go @@ -177,11 +177,11 @@ func createOutboundPod(ctx context.Context, factory cmdutil.Factory, clientset * var Resources = v1.ResourceRequirements{ Requests: map[v1.ResourceName]resource.Quantity{ - v1.ResourceCPU: resource.MustParse("500m"), + v1.ResourceCPU: resource.MustParse("250m"), v1.ResourceMemory: resource.MustParse("512Mi"), }, Limits: map[v1.ResourceName]resource.Quantity{ - v1.ResourceCPU: resource.MustParse("2000m"), + v1.ResourceCPU: resource.MustParse("1000m"), v1.ResourceMemory: resource.MustParse("2048Mi"), }, } @@ -371,41 +371,50 @@ kubevpn serve -L "tcp://:10800" -L "tun://:8422?net=${TunIPv4}" --debug=true`, if _, err = clientset.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}); err != nil { return err } - var last string -out: - for { - select { - case e, ok := <-watchStream.ResultChan(): - if !ok { - return fmt.Errorf("can not wait pod to be ready because of watch chan has closed") - } - if podT, ok := e.Object.(*v1.Pod); ok { - if podT.DeletionTimestamp != nil { - continue - } - var sb = bytes.NewBuffer(nil) - sb.WriteString(fmt.Sprintf("pod [%s] status is %s\n", config.ConfigMapPodTrafficManager, podT.Status.Phase)) - util.PrintStatus(podT, sb) - - if last != sb.String() { - log.Infof(sb.String()) - } - if podutils.IsPodReady(podT) && func() bool { - for _, status := range podT.Status.ContainerStatuses { - if !status.Ready { - return false - } - } - return true - }() { - break out - } - last = sb.String() - } - case <-time.Tick(time.Minute * 60): - return errors.New(fmt.Sprintf("wait pod %s to be ready timeout", config.ConfigMapPodTrafficManager)) + var ok bool + ctx2, cancelFunc := context.WithTimeout(ctx, time.Minute*60) + defer cancelFunc() + wait.UntilWithContext(ctx2, func(ctx context.Context) { + podList, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fields.OneTermEqualSelector("app", config.ConfigMapPodTrafficManager).String(), + }) + if err != nil { + return } + + for _, podT := range podList.Items { + podT := &podT + if podT.DeletionTimestamp != nil { + continue + } + var sb = bytes.NewBuffer(nil) + sb.WriteString(fmt.Sprintf("pod %s is %s\n", podT.Name, podT.Status.Phase)) + if podT.Status.Reason != "" { + sb.WriteString(fmt.Sprintf(" reason %s", podT.Status.Reason)) + } + if podT.Status.Message != "" { + sb.WriteString(fmt.Sprintf(" message %s", podT.Status.Message)) + } + util.PrintStatus(podT, sb) + log.Infof(sb.String()) + + if podutils.IsPodReady(podT) && func() bool { + for _, status := range podT.Status.ContainerStatuses { + if !status.Ready { + return false + } + } + return true + }() { + cancelFunc() + ok = true + } + } + }, time.Second*3) + if !ok { + return errors.New(fmt.Sprintf("wait pod %s to be ready timeout", config.ConfigMapPodTrafficManager)) } + _, err = clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(ctx, &admissionv1.MutatingWebhookConfiguration{ ObjectMeta: metav1.ObjectMeta{ Name: config.ConfigMapPodTrafficManager + "." + namespace, diff --git a/pkg/util/pod.go b/pkg/util/pod.go index 20679c52..d961fb96 100644 --- a/pkg/util/pod.go +++ b/pkg/util/pod.go @@ -31,6 +31,15 @@ func PrintStatus(pod *corev1.Pod, writer io.Writer) { _, _ = fmt.Fprintf(w, "%s\t%v\t%v\n", name, v1, v2) } + if len(pod.Status.ContainerStatuses) == 0 { + show("Type", "Reason", "Message") + for _, condition := range pod.Status.Conditions { + if condition.Status == corev1.ConditionFalse { + show(string(condition.Type), condition.Reason, condition.Message) + } + } + return + } show("Container", "Reason", "Message") for _, status := range pod.Status.ContainerStatuses { if status.State.Waiting != nil { diff --git a/pkg/util/util.go b/pkg/util/util.go index 46307d43..3abf00f2 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -460,7 +460,9 @@ func RunWithRollingOutWithChecker(cmd *osexec.Cmd, checker func(log string)) (st } return stdoutBuf.String(), stderrBuf.String(), err } - _ = cmd.Wait() + if err := cmd.Wait(); err != nil { + return "", "", err + } var err error if !cmd.ProcessState.Success() { err = errors.New("exit code is not 0") diff --git a/samples/flat_log.png b/samples/flat_log.png new file mode 100644 index 0000000000000000000000000000000000000000..057a61bf368459e9b6481c61e14916e9e9513a8c GIT binary patch literal 4111 zcmb_eX;@QNyT$9&I#HyS4-}|Fp<0DX7{oAKtDqo*1CuBLWkw)`N&>_rZBbE!L<@+B zkqZh42#5g^ID}L&AY()XB*{rMGRTkx|av=;&;8J9gxhj?QYKj?Rj?RV$H5f5ogRGOUU`HKPBzT)>& zM}O0)>e)7qOm>~{@NrpQUiR@X`E?H=Bq$G`%*@MgU%7H+D|c{wTxx4;8yXr)qtRBg zYz=jEbZ@vFIpmYfp8|Q_ZM{c5c~DM5F&OhDnt_kM%9Sa9yf>@Y{jA6*aPorY=MhE! zpHGOx{As7sbJ?8)HR;~;#!YHM#P^!po4K65xzxahbC}eztZdU<=S5l90g0ycaVl04 z)Jm}x+q*5y^0@Rnuhj9U*zi@%H9s1<v0_$M5?sB1uu!V$`-g*A+necXGFY2 zmyK9J8M`4jg(k@^?4_Zn+O`1!oa8!Iie#{VE+q4TBt1_g4+CW98JuX-XC)?`*U_O= z;^GjIc&1KRh$Q2hk-Kce8+@^w#+{0hQh?s z8Wfiys2Z+g3Y+5=1espm1!dSkF79m0j)bexOszr#csRAX&7&jh-hdWpiRd%I<_=XN zxEZdadWyuhh`8juuHR5k%)j#5B+55T!4k@ zV43%;dbBVrh#*rtiIW&w?XBzuQu8E|xk^HYhv9{iwaCWUnL#MrQ6P<#wtpqxxE38n zcmrJr@JEGpXh{{=z7tv-$*1S*!_F58@g73B6Rsdgd$RfL%be6f$fFz-SBvVhJ}*+`0)8r5MZ$1APcNDBf${BV6Ys1l!K4FDJ9btm|OrKStMY;ultgT5H$@!046Z5BM3kZ;l-6-!fx?! z5fV1;xsr(JsRaoR59|Y@2_-{Np@?vhNE@m8LW~|%^|}M7etf|oMVc60w-0f#i%EP< zOl0#i5gldqM7@D72W3LZFUZOf0sH#u0w3AYUl6ZM;t=Mddl}q}v_wA^E}H6a9O=m; zH(Vr`4MCnC#%hMEt6*J(BbrHkI-iY>jHo~?bVxCC(VWGE(E=@pfGxa-ifsQXCLO&a z|C(i$(*tD);`%bc+!(w(`(uNa9)t=%cBTZ)=$_J=zbO@gdWO}u;BgB?)b|4RBs@KO zC{bJaJtS8qG&vvBF3j;sB5>yqShMRmXW_g;g%lk2ju>s3F=47&hFM5<9VqBEv9zfZ zkdmH&?F>+$4=1AzWvCvd^XU#uVz0q)h6T*TA+U`T>a9{Q3#k*0 z*=Qhtak#$`98@$@5a4K7gvblNy|7QJ>8`<>7i8s4*RpMAa+$;+h)+LPPb`>Ah^xcG z=MjfiF|e|UGpWW1Al-DNZ|zS(JBSeMIMG3x*Mb>R%q2V5dI5=n^sS+mxV&RknhG53 zR4)VoS^faw^ysV_Qnm5v5BO|Vu#7vHOp}rezvk9BrN}H%^tihK-aH>Kli@i_&)t#K zCO%0moKkZAcf5BVfWrDAITR0nWaL?7YZJ5biYQ={X2`<;Ie=N6c*_pOCGj>Cf}27% zz#G00+I3m$vJy-On|mQ7zQiX5NflzaKIbd`0ueC1NJxx@4>cI13$!fV?Oh2?l;?Gb zE@iDMN9%Jetb|pL%nj>QzF+4NWZ$n1sn9}t+dXhKLe3yGEP!ym+b}zk)G1)G`5W~9;ox)Uo2pCmX zX%l0C>Ri>+chGP%I5;9|1rZjhlWDf<6p)nyRCkh2@}Xfivef)@9Ki|;zeo&z4-JD* z8WJ$z)1L}REa7p%*U`W$;xz!;-6ci?S-%S-TShPzv4q4-qD=XG$f_P~w;f_w#Tt%5 z!YeFiY&mIMNYDQc9{nqZPWT$DHZ2}VVS)`Sa0UDLcmm|5!BH^RLi4WvPMD>so~GP3 z%8aN*54EXNSTB90O^hCY@LQ5kSMna01h#>Gg2=o(65AoVkJK zn_G`Q)8;4~6TMUY24s1WsSDMHlY5}d5V5fidb^ezwC#eczT;~LWO!4@xvy58c=J6X&T6N1MO z18m<9ks8{hWM}Kk%kHb{(e_++Ff=J`eglOW!AT7SfumDFJKmhbq6jTuZV1r!i6HgD zPSvslv*NRRe{=fLwpP%&h#Y*l7>k9scPJkom5lILXEP--{w3pfzi0} z0>Zbnm8IQeg&i)k3%9%@V|tFod->nk!yuVo`An0~a@HfbHi5c}r(fGPRyP_q!)Fzz zO@+(NjNdeJ$JHf`0_Y;j8N-JlS3yK|aAY`^R}`q3@b;-3?-XN95c z0$t~C+$ee!cS(B}VWzD}^TA7f^bZz_FBK)tk4QCB!|C$m|H0@gO2sBi1Ddca#(kLB z#$?E;qVx_6CBah`Gx5&r?J35KOYLVqFMKyM{j*Fy#fe9&5&@awkaMNQUS;qmzWl*_=IiA-@dvyDaV*6< z6ZLoj^$!495}~z8DltKk1KCT@&^bbu(Uz)SCw+pe0P6N!71_e_Z^6@4Bh+q|xZFe> zL$kEAXuhT^DK`68q%cw)-v1q#HnE(McTd{L_ZF#h*8@;p{HgmTcwLhP>O2!MBk#wB5SDT|UT=SB}ncezl-?d3eABT5K)yA!mh|W^c z&T0AS_@`|tn&Qm!IR)Zb-)%6bG@S7wwEu~JEUH4hzB}|M18y_?eByUIY#WL7O)=mq zWnq^ad5`!QHp#QkF6GQQ+HUI!QulHf^`t@4@{!{6za}J>@^2FL=oh$~B~JIvJ}q>3 z*j7lI-Ph!&SQq*Q9X04~D-F9W$|Uwa+r9DDrcWB8?L*f(#T~JQDdR=FN8w4Ce0tV% zM$zmWgCDcUIUhf0wN=tgXBr^?d!nYj=2LiKXS6Z`oNM3oNtAufbvx9(@Orir%`3-j z+gqC=;i6G8<$92iSG51CtBcU`X6}e`K%9%9g8_egwt%ai{S|r?t(;X`c^0&?Kcg%B zgyYcoaaA4h=QtzdRV}l1ZS$rO5Z)-H66#BNV~#^Sdo>iYC#Z}E~}^l3JCimQiG^@VT`>oKPcKy%##d) z-sRn0pRtQ#1Xn1lYNIdhf(q-mz~F_FEpWoU1CVUV!M?2PB&jTn>ATPBz2*F$w4k&7 z!+R>_1e>xJBXQw7`MoyAJ$`w8>+8^2;VZE<*Ag!*a28ZXF~ zsjG^$F;LIOE+xIiZMQNkPKw5?vEmh>il1Qo#vQyDQ!0207oR}8gFnC}S_xNQ`w2Tq zQ;HoEMr&5KDP$FZjhp=3B#tB>wGH;YL+p~>t^7RSSAHo8U1sEOm;G{Q4R&HkagfJx zA-$cmpt@rvyALe63|lag;;Ng&vu%0b$G2Yk4kkaWE*f&&u;xG9q5o@WAy}#VCdA63 T(eq)lmdnk>>qymaA%FcFlKI)r literal 0 HcmV?d00001