From 6726f01d8dc215e85d720f0e159e447008d1d1b3 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Thu, 14 Dec 2023 11:37:42 +0700 Subject: [PATCH 01/12] develop --- argocd/helm-udaconnect-prod.yaml | 17 +++ db/2020-08-15_init-db.sql | 3 +- deployment/udaconnect-app.yaml | 2 +- deployment/udaconnect-connection.yaml | 63 ++++++++ deployment/udaconnect-location.yaml | 63 ++++++++ deployment/udaconnect-person.yaml | 63 ++++++++ docs/system-architecture.md | 28 ++++ docs/system-architecture.png | Bin 0 -> 10911 bytes docs/system-architecture.puml | 39 +++++ helm/Chart.yaml | 8 ++ helm/templates/db-configmap.yaml | 9 ++ helm/templates/db-secret.yaml | 7 + helm/templates/deploy.yaml | 36 +++++ helm/templates/kafka.yaml | 54 +++++++ helm/templates/namespace.yaml | 4 + helm/templates/postgres.yaml | 86 +++++++++++ helm/templates/service. yaml | 17 +++ helm/templates/udaconnect-api.yaml | 63 ++++++++ helm/templates/udaconnect-app.yaml | 44 ++++++ helm/templates/udaconnect-connection.yaml | 100 +++++++++++++ helm/templates/udaconnect-location.yaml | 63 ++++++++ helm/templates/udaconnect-person.yaml | 63 ++++++++ helm/templates/zookeeper.yaml | 49 +++++++ helm/values-prod.yaml | 18 +++ modules/{api => connection}/Dockerfile | 0 modules/{api => connection}/app/__init__.py | 0 modules/{api => connection}/app/config.py | 0 modules/{api => connection}/app/routes.py | 0 .../app/udaconnect/__init__.py | 0 .../connection/app/udaconnect/controllers.py | 42 ++++++ .../app/udaconnect/models.py | 0 .../app/udaconnect/schemas.py | 0 .../app/udaconnect/services.py | 0 modules/{api => connection}/requirements.txt | 0 modules/{api => connection}/wsgi.py | 0 modules/frontend/src/components/Connection.js | 2 +- modules/frontend/src/components/Persons.js | 2 +- modules/location/Dockerfile | 12 ++ modules/location/app/__init__.py | 26 ++++ modules/location/app/config.py | 58 ++++++++ modules/location/app/routes.py | 5 + modules/location/app/udaconnect/__init__.py | 8 ++ .../app/udaconnect/controllers.py | 0 modules/location/app/udaconnect/models.py | 64 +++++++++ modules/location/app/udaconnect/schemas.py | 30 ++++ modules/location/app/udaconnect/services.py | 134 ++++++++++++++++++ modules/location/db/script.sql | 48 +++++++ modules/location/requirements.txt | 26 ++++ modules/location/wsgi.py | 7 + modules/person/Dockerfile | 12 ++ modules/person/app/__init__.py | 26 ++++ modules/person/app/config.py | 58 ++++++++ modules/person/app/routes.py | 5 + modules/person/app/udaconnect/__init__.py | 8 ++ modules/person/app/udaconnect/controllers.py | 43 ++++++ modules/person/app/udaconnect/models.py | 27 ++++ modules/person/app/udaconnect/schemas.py | 15 ++ modules/person/app/udaconnect/services.py | 35 +++++ modules/person/db/script.sql | 12 ++ modules/person/deployment/db-configmap.yaml | 9 ++ modules/person/deployment/db-secret.yaml | 7 + modules/person/deployment/person-api.yaml | 63 ++++++++ modules/person/deployment/postgres.yaml | 86 +++++++++++ modules/person/requirements.txt | 26 ++++ modules/person/scripts/run_db_command.sh | 14 ++ modules/person/wsgi.py | 7 + notes.md | 90 ++++++++++++ 67 files changed, 1901 insertions(+), 5 deletions(-) create mode 100644 argocd/helm-udaconnect-prod.yaml create mode 100644 deployment/udaconnect-connection.yaml create mode 100644 deployment/udaconnect-location.yaml create mode 100644 deployment/udaconnect-person.yaml create mode 100644 docs/system-architecture.md create mode 100644 docs/system-architecture.png create mode 100644 docs/system-architecture.puml create mode 100644 helm/Chart.yaml create mode 100644 helm/templates/db-configmap.yaml create mode 100644 helm/templates/db-secret.yaml create mode 100644 helm/templates/deploy.yaml create mode 100644 helm/templates/kafka.yaml create mode 100644 helm/templates/namespace.yaml create mode 100644 helm/templates/postgres.yaml create mode 100644 helm/templates/service. yaml create mode 100644 helm/templates/udaconnect-api.yaml create mode 100644 helm/templates/udaconnect-app.yaml create mode 100644 helm/templates/udaconnect-connection.yaml create mode 100644 helm/templates/udaconnect-location.yaml create mode 100644 helm/templates/udaconnect-person.yaml create mode 100644 helm/templates/zookeeper.yaml create mode 100644 helm/values-prod.yaml rename modules/{api => connection}/Dockerfile (100%) rename modules/{api => connection}/app/__init__.py (100%) rename modules/{api => connection}/app/config.py (100%) rename modules/{api => connection}/app/routes.py (100%) rename modules/{api => connection}/app/udaconnect/__init__.py (100%) create mode 100644 modules/connection/app/udaconnect/controllers.py rename modules/{api => connection}/app/udaconnect/models.py (100%) rename modules/{api => connection}/app/udaconnect/schemas.py (100%) rename modules/{api => connection}/app/udaconnect/services.py (100%) rename modules/{api => connection}/requirements.txt (100%) rename modules/{api => connection}/wsgi.py (100%) create mode 100644 modules/location/Dockerfile create mode 100644 modules/location/app/__init__.py create mode 100644 modules/location/app/config.py create mode 100644 modules/location/app/routes.py create mode 100644 modules/location/app/udaconnect/__init__.py rename modules/{api => location}/app/udaconnect/controllers.py (100%) create mode 100644 modules/location/app/udaconnect/models.py create mode 100644 modules/location/app/udaconnect/schemas.py create mode 100644 modules/location/app/udaconnect/services.py create mode 100644 modules/location/db/script.sql create mode 100644 modules/location/requirements.txt create mode 100644 modules/location/wsgi.py create mode 100644 modules/person/Dockerfile create mode 100644 modules/person/app/__init__.py create mode 100644 modules/person/app/config.py create mode 100644 modules/person/app/routes.py create mode 100644 modules/person/app/udaconnect/__init__.py create mode 100644 modules/person/app/udaconnect/controllers.py create mode 100644 modules/person/app/udaconnect/models.py create mode 100644 modules/person/app/udaconnect/schemas.py create mode 100644 modules/person/app/udaconnect/services.py create mode 100644 modules/person/db/script.sql create mode 100644 modules/person/deployment/db-configmap.yaml create mode 100644 modules/person/deployment/db-secret.yaml create mode 100644 modules/person/deployment/person-api.yaml create mode 100644 modules/person/deployment/postgres.yaml create mode 100644 modules/person/requirements.txt create mode 100644 modules/person/scripts/run_db_command.sh create mode 100644 modules/person/wsgi.py create mode 100644 notes.md diff --git a/argocd/helm-udaconnect-prod.yaml b/argocd/helm-udaconnect-prod.yaml new file mode 100644 index 000000000..6ef1410d7 --- /dev/null +++ b/argocd/helm-udaconnect-prod.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: techtrends-prod + namespace: argocd +spec: + destination: + namespace: default + server: https://kubernetes.default.svc + project: default + source: + helm: + valueFiles: + - values-prod.yaml + path: helm + repoURL: https://github.com/kydq2022/nd064-c2-message-passing-projects-starter + targetRevision: HEAD \ No newline at end of file diff --git a/db/2020-08-15_init-db.sql b/db/2020-08-15_init-db.sql index bd0e6abb4..1a869533f 100644 --- a/db/2020-08-15_init-db.sql +++ b/db/2020-08-15_init-db.sql @@ -10,8 +10,7 @@ CREATE TABLE location ( id SERIAL PRIMARY KEY, person_id INT NOT NULL, coordinate GEOMETRY NOT NULL, - creation_time TIMESTAMP NOT NULL DEFAULT NOW(), - FOREIGN KEY (person_id) REFERENCES person(id) + creation_time TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX coordinate_idx ON location (coordinate); CREATE INDEX creation_time_idx ON location (creation_time); diff --git a/deployment/udaconnect-app.yaml b/deployment/udaconnect-app.yaml index 26a0ca5dc..d572e0c37 100644 --- a/deployment/udaconnect-app.yaml +++ b/deployment/udaconnect-app.yaml @@ -31,7 +31,7 @@ spec: service: udaconnect-app spec: containers: - - image: udacity/nd064-udaconnect-app:latest + - image: kydq2022/nd064-c2-udaconnect-app:latest name: udaconnect-app imagePullPolicy: Always resources: diff --git a/deployment/udaconnect-connection.yaml b/deployment/udaconnect-connection.yaml new file mode 100644 index 000000000..933320bd3 --- /dev/null +++ b/deployment/udaconnect-connection.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: connection-api + name: connection-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: connection-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: connection-api + name: connection-api +spec: + replicas: 1 + selector: + matchLabels: + service: connection-api + template: + metadata: + labels: + service: connection-api + spec: + containers: + - image: kydq2022/nd064-c2-connection-api:latest + name: connection-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/deployment/udaconnect-location.yaml b/deployment/udaconnect-location.yaml new file mode 100644 index 000000000..3ae342d57 --- /dev/null +++ b/deployment/udaconnect-location.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: location-api + name: location-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: location-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: location-api + name: location-api +spec: + replicas: 1 + selector: + matchLabels: + service: location-api + template: + metadata: + labels: + service: location-api + spec: + containers: + - image: udacity/nd064-c2-location-api:latest + name: location-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/deployment/udaconnect-person.yaml b/deployment/udaconnect-person.yaml new file mode 100644 index 000000000..e02bfeee7 --- /dev/null +++ b/deployment/udaconnect-person.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: person-api + name: person-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: person-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: person-api + name: person-api +spec: + replicas: 1 + selector: + matchLabels: + service: person-api + template: + metadata: + labels: + service: person-api + spec: + containers: + - image: udacity/nd064-c2-person-api:latest + name: person-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 000000000..c59b9d2df --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,28 @@ +``` + +-------------------+ +---------------------+ +------------------------+ + | Client | | API Gateway | | Kafka Cluster | + | Applications | | | | | + +-------------------+ +---------------------+ | | + | | | | + v v | | + +-------------------+ +---------------------+ | | + | Microservice | | Microservice | | Kafka Topics | + | - Location | | - Person | | - location_created | + | - Person | | - Connection | | - person_created | + | - Connection | +---------------------+ +------------------------+ + +-------------------+ | | | | + | | | | | + | +-----------------+| v v v + +--------->| RPC Server || +---------------------+ + | - Person || | RPC Server | + | - Connection |+------------------>| - Connection | + +-----------------+| +---------------------+ + | + v + +---------------------+ + | Microservice | + | - Connection | + | (continued) | + +---------------------+ + +``` \ No newline at end of file diff --git a/docs/system-architecture.png b/docs/system-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..24678ba15b113ccb2699e1c215c9d5810cb901db GIT binary patch literal 10911 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 + +package "Client Apps" { + RECTANGLE ClientApplications +} + +cloud APIGateway { + RECTANGLE ApiGateway +} + +package "Microservices" { + [Location] + [Person] + [Connection] +} + +database "Kafka Cluster" { + [Kafka Topics] +} + +package "RPC Servers" { + [RPC Server - Person] + [RPC Server - Connection] +} + +ClientApplications --down-> ApiGateway +ApiGateway --down-> Location : RESTful API +ApiGateway --down-> Person : RESTful API +ApiGateway --down-> Connection : RESTful API +Location --down-> "Kafka Topics" : Event +Person --down-> "Kafka Topics" : Event +Connection --down-> "Kafka Topics" : Event +"Kafka Topics" --down-> "RPC Server - Person" : Notification +"Kafka Topics" --down-> "RPC Server - Connection" : Notification + +@enduml diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 000000000..384a6462c --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +name: udaconnect +version: 1.0.0 +keywords: + - udaconnect +maintainers: + - name: kydq2022 + email: kydq2022@outlook.com \ No newline at end of file diff --git a/helm/templates/db-configmap.yaml b/helm/templates/db-configmap.yaml new file mode 100644 index 000000000..19579cdee --- /dev/null +++ b/helm/templates/db-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +data: + DB_USERNAME: "ct_admin" + DB_NAME: "geoconnections" + DB_HOST: "postgres" + DB_PORT: "5432" +metadata: + name: db-env \ No newline at end of file diff --git a/helm/templates/db-secret.yaml b/helm/templates/db-secret.yaml new file mode 100644 index 000000000..5ee171d3f --- /dev/null +++ b/helm/templates/db-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: db-secret +type: Opaque +data: + DB_PASSWORD: d293aW1zb3NlY3VyZQ== \ No newline at end of file diff --git a/helm/templates/deploy.yaml b/helm/templates/deploy.yaml new file mode 100644 index 000000000..89ad52497 --- /dev/null +++ b/helm/templates/deploy.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: techtrends + namespace: {{ .Values.namespace.name }} + labels: + app: techtrends +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: techtrends + template: + metadata: + labels: + app: techtrends + spec: + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + ports: + - containerPort: {{ .Values.containerPort }} + resources: +{{ toYaml .Values.resources | indent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: {{ .Values.containerPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: {{ .Values.containerPort }} + initialDelaySeconds: 5 + periodSeconds: 10 \ No newline at end of file diff --git a/helm/templates/kafka.yaml b/helm/templates/kafka.yaml new file mode 100644 index 000000000..c25cefd20 --- /dev/null +++ b/helm/templates/kafka.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: kafka + name: kafka +spec: + ports: + - name: "9092" + port: 9092 + targetPort: 9092 + nodePort: 30005 + selector: + service: kafka + type: NodePort + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: kafka + name: kafka +spec: + replicas: 2 + selector: + matchLabels: + service: kafka + template: + metadata: + labels: + service: kafka + spec: + containers: + - image: bitnami/kafka:latest + name: kafka + imagePullPolicy: Always + env: + - name: KAFKA_CFG_ZOOKEEPER_CONNECT + value: "zookeeper.default.svc.cluster.local:2181" + - name: KAFKA_CFG_ADVERTISED_LISTENERS + value: "PLAINTEXT://kafka.default.svc.cluster.local:9092" + - name: ALLOW_PLAINTEXT_LISTENER + value: "yes" + - name: "zookeeper.enabled" + value: "false" + resources: + requests: + memory: "128Mi" + cpu: "64m" + limits: + memory: "256Mi" + cpu: "256m" + restartPolicy: Always \ No newline at end of file diff --git a/helm/templates/namespace.yaml b/helm/templates/namespace.yaml new file mode 100644 index 000000000..3703e335e --- /dev/null +++ b/helm/templates/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace.name }} \ No newline at end of file diff --git a/helm/templates/postgres.yaml b/helm/templates/postgres.yaml new file mode 100644 index 000000000..debd18781 --- /dev/null +++ b/helm/templates/postgres.yaml @@ -0,0 +1,86 @@ +kind: PersistentVolume +apiVersion: v1 +metadata: + name: postgres-volume + labels: + type: local + app: postgres +spec: + storageClassName: manual + capacity: + storage: 256Mi + accessModes: + - ReadWriteMany + hostPath: + path: "/mnt/data" +--- +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: postgres-pv-claim + labels: + app: postgres +spec: + storageClassName: manual + accessModes: + - ReadWriteMany + resources: + requests: + storage: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + labels: + app: postgres +spec: + type: NodePort + selector: + app: postgres + ports: + - port: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgis/postgis:12-2.5-alpine + imagePullPolicy: "IfNotPresent" + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgresdb + volumes: + - name: postgresdb + persistentVolumeClaim: + claimName: postgres-pv-claim diff --git a/helm/templates/service. yaml b/helm/templates/service. yaml new file mode 100644 index 000000000..7149f0d26 --- /dev/null +++ b/helm/templates/service. yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: techtrends + tag: latest + name: techtrends + namespace: {{ .Values.namespace.name }} +spec: + ports: + - port: {{ .Values.service.port }} + protocol: TCP + targetPort: {{ .Values.service.port }} + selector: + app: techtrends + tag: latest + type: {{ .Values.service.type }} \ No newline at end of file diff --git a/helm/templates/udaconnect-api.yaml b/helm/templates/udaconnect-api.yaml new file mode 100644 index 000000000..e62dcbd0e --- /dev/null +++ b/helm/templates/udaconnect-api.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: udaconnect-api + name: udaconnect-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: udaconnect-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: udaconnect-api + name: udaconnect-api +spec: + replicas: 1 + selector: + matchLabels: + service: udaconnect-api + template: + metadata: + labels: + service: udaconnect-api + spec: + containers: + - image: udacity/nd064-udaconnect-api:latest + name: udaconnect-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/helm/templates/udaconnect-app.yaml b/helm/templates/udaconnect-app.yaml new file mode 100644 index 000000000..d572e0c37 --- /dev/null +++ b/helm/templates/udaconnect-app.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: udaconnect-app + name: udaconnect-app +spec: + ports: + - name: "3000" + port: 3000 + targetPort: 3000 + nodePort: 30000 + selector: + service: udaconnect-app + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: udaconnect-app + name: udaconnect-app +spec: + replicas: 1 + selector: + matchLabels: + service: udaconnect-app + template: + metadata: + labels: + service: udaconnect-app + spec: + containers: + - image: kydq2022/nd064-c2-udaconnect-app:latest + name: udaconnect-app + imagePullPolicy: Always + resources: + requests: + memory: "128Mi" + cpu: "64m" + limits: + memory: "256Mi" + cpu: "256m" + restartPolicy: Always diff --git a/helm/templates/udaconnect-connection.yaml b/helm/templates/udaconnect-connection.yaml new file mode 100644 index 000000000..df8e43936 --- /dev/null +++ b/helm/templates/udaconnect-connection.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: connection-api + namespace: {{ .Values.namespace.name }} + labels: + app: connection-api +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: connection-api + template: + metadata: + labels: + app: connection-api + spec: + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + ports: + - containerPort: {{ .Values.containerPort }} + resources: +{{ toYaml .Values.resources | indent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: {{ .Values.containerPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: {{ .Values.containerPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + +apiVersion: v1 +kind: Service +metadata: + labels: + service: connection-api + name: connection-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: connection-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: connection-api + name: connection-api +spec: + replicas: 1 + selector: + matchLabels: + service: connection-api + template: + metadata: + labels: + service: connection-api + spec: + containers: + - image: kydq2022/nd064-c2-connection-api:latest + name: connection-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/helm/templates/udaconnect-location.yaml b/helm/templates/udaconnect-location.yaml new file mode 100644 index 000000000..3ae342d57 --- /dev/null +++ b/helm/templates/udaconnect-location.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: location-api + name: location-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: location-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: location-api + name: location-api +spec: + replicas: 1 + selector: + matchLabels: + service: location-api + template: + metadata: + labels: + service: location-api + spec: + containers: + - image: udacity/nd064-c2-location-api:latest + name: location-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/helm/templates/udaconnect-person.yaml b/helm/templates/udaconnect-person.yaml new file mode 100644 index 000000000..e02bfeee7 --- /dev/null +++ b/helm/templates/udaconnect-person.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: person-api + name: person-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30001 + selector: + service: person-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: person-api + name: person-api +spec: + replicas: 1 + selector: + matchLabels: + service: person-api + template: + metadata: + labels: + service: person-api + spec: + containers: + - image: udacity/nd064-c2-person-api:latest + name: person-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/helm/templates/zookeeper.yaml b/helm/templates/zookeeper.yaml new file mode 100644 index 000000000..a40a16104 --- /dev/null +++ b/helm/templates/zookeeper.yaml @@ -0,0 +1,49 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: zookeeper + name: zookeeper +spec: + ports: + - name: "2181" + port: 2181 + targetPort: 2181 + nodePort: 30010 + selector: + service: zookeeper + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: zookeeper + name: zookeeper +spec: + replicas: 2 + selector: + matchLabels: + service: zookeeper + template: + metadata: + labels: + service: zookeeper + spec: + containers: + - image: bitnami/zookeeper:latest + name: zookeeper + env: + - name: ALLOW_ANONYMOUS_LOGIN + value: "yes" + - name: "auth.enabled" + value: "false" + imagePullPolicy: Always + resources: + requests: + memory: "128Mi" + cpu: "64m" + limits: + memory: "256Mi" + cpu: "256m" + restartPolicy: Always \ No newline at end of file diff --git a/helm/values-prod.yaml b/helm/values-prod.yaml new file mode 100644 index 000000000..9ce0e8f77 --- /dev/null +++ b/helm/values-prod.yaml @@ -0,0 +1,18 @@ +namespace: + name: prod + +service: + port: 7111 + +image: + pullPolicy: Always + +replicaCount: 5 + +resources: + requests: + memory: 128Mi + cpu: 350m + limits: + memory: 256Mi + cpu: 500m \ No newline at end of file diff --git a/modules/api/Dockerfile b/modules/connection/Dockerfile similarity index 100% rename from modules/api/Dockerfile rename to modules/connection/Dockerfile diff --git a/modules/api/app/__init__.py b/modules/connection/app/__init__.py similarity index 100% rename from modules/api/app/__init__.py rename to modules/connection/app/__init__.py diff --git a/modules/api/app/config.py b/modules/connection/app/config.py similarity index 100% rename from modules/api/app/config.py rename to modules/connection/app/config.py diff --git a/modules/api/app/routes.py b/modules/connection/app/routes.py similarity index 100% rename from modules/api/app/routes.py rename to modules/connection/app/routes.py diff --git a/modules/api/app/udaconnect/__init__.py b/modules/connection/app/udaconnect/__init__.py similarity index 100% rename from modules/api/app/udaconnect/__init__.py rename to modules/connection/app/udaconnect/__init__.py diff --git a/modules/connection/app/udaconnect/controllers.py b/modules/connection/app/udaconnect/controllers.py new file mode 100644 index 000000000..9993639b5 --- /dev/null +++ b/modules/connection/app/udaconnect/controllers.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from app.udaconnect.models import Connection, Location, Person +from app.udaconnect.schemas import ( + ConnectionSchema, + LocationSchema, + PersonSchema, +) +from app.udaconnect.services import ConnectionService, LocationService, PersonService +from flask import request +from flask_accepts import accepts, responds +from flask_restx import Namespace, Resource +from typing import Optional, List + +DATE_FORMAT = "%Y-%m-%d" + +api = Namespace("UdaConnect", description="Connections via geolocation.") # noqa + + +# TODO: This needs better exception handling + + +@api.route("/persons//connection") +@api.param("start_date", "Lower bound of date range", _in="query") +@api.param("end_date", "Upper bound of date range", _in="query") +@api.param("distance", "Proximity to a given user in meters", _in="query") +class ConnectionDataResource(Resource): + @responds(schema=ConnectionSchema, many=True) + def get(self, person_id) -> ConnectionSchema: + start_date: datetime = datetime.strptime( + request.args["start_date"], DATE_FORMAT + ) + end_date: datetime = datetime.strptime(request.args["end_date"], DATE_FORMAT) + distance: Optional[int] = request.args.get("distance", 5) + + results = ConnectionService.find_contacts( + person_id=person_id, + start_date=start_date, + end_date=end_date, + meters=distance, + ) + return results diff --git a/modules/api/app/udaconnect/models.py b/modules/connection/app/udaconnect/models.py similarity index 100% rename from modules/api/app/udaconnect/models.py rename to modules/connection/app/udaconnect/models.py diff --git a/modules/api/app/udaconnect/schemas.py b/modules/connection/app/udaconnect/schemas.py similarity index 100% rename from modules/api/app/udaconnect/schemas.py rename to modules/connection/app/udaconnect/schemas.py diff --git a/modules/api/app/udaconnect/services.py b/modules/connection/app/udaconnect/services.py similarity index 100% rename from modules/api/app/udaconnect/services.py rename to modules/connection/app/udaconnect/services.py diff --git a/modules/api/requirements.txt b/modules/connection/requirements.txt similarity index 100% rename from modules/api/requirements.txt rename to modules/connection/requirements.txt diff --git a/modules/api/wsgi.py b/modules/connection/wsgi.py similarity index 100% rename from modules/api/wsgi.py rename to modules/connection/wsgi.py diff --git a/modules/frontend/src/components/Connection.js b/modules/frontend/src/components/Connection.js index d23d1d3f2..b2fe22c0f 100644 --- a/modules/frontend/src/components/Connection.js +++ b/modules/frontend/src/components/Connection.js @@ -22,7 +22,7 @@ class Connection extends Component { if (personId) { // TODO: endpoint should be abstracted into a config variable fetch( - `http://localhost:30001/api/persons/${personId}/connection?start_date=2020-01-01&end_date=2020-12-30&distance=5` + `${process.env.PERSON_API}/persons/${personId}/connection?start_date=2020-01-01&end_date=2020-12-30&distance=5` ) .then((response) => response.json()) .then((connections) => diff --git a/modules/frontend/src/components/Persons.js b/modules/frontend/src/components/Persons.js index d75500bbb..24bed0cf4 100644 --- a/modules/frontend/src/components/Persons.js +++ b/modules/frontend/src/components/Persons.js @@ -5,7 +5,7 @@ class Persons extends Component { constructor(props) { super(props); // TODO: endpoint should be abstracted into a config variable - this.endpoint_url = "http://localhost:30001/api/persons"; + this.endpoint_url = `${process.env.PERSON_API}/persons`; this.state = { persons: [], display: null, diff --git a/modules/location/Dockerfile b/modules/location/Dockerfile new file mode 100644 index 000000000..1ef643ff1 --- /dev/null +++ b/modules/location/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.7-alpine + +WORKDIR . + +RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +EXPOSE 5000 + +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/location/app/__init__.py b/modules/location/app/__init__.py new file mode 100644 index 000000000..627a5c5f6 --- /dev/null +++ b/modules/location/app/__init__.py @@ -0,0 +1,26 @@ +from flask import Flask, jsonify +from flask_cors import CORS +from flask_restx import Api +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def create_app(env=None): + from app.config import config_by_name + from app.routes import register_routes + + app = Flask(__name__) + app.config.from_object(config_by_name[env or "test"]) + api = Api(app, title="UdaConnect API", version="0.1.0") + + CORS(app) # Set CORS for development + + register_routes(api, app) + db.init_app(app) + + @app.route("/health") + def health(): + return jsonify("healthy") + + return app diff --git a/modules/location/app/config.py b/modules/location/app/config.py new file mode 100644 index 000000000..827b6a14a --- /dev/null +++ b/modules/location/app/config.py @@ -0,0 +1,58 @@ +import os +from typing import List, Type + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + + +class BaseConfig: + CONFIG_NAME = "base" + USE_MOCK_EQUIVALENCY = False + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class DevelopmentConfig(BaseConfig): + CONFIG_NAME = "dev" + SECRET_KEY = os.getenv( + "DEV_SECRET_KEY", "You can't see California without Marlon Widgeto's eyes" + ) + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class TestingConfig(BaseConfig): + CONFIG_NAME = "test" + SECRET_KEY = os.getenv("TEST_SECRET_KEY", "Thanos did nothing wrong") + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = True + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class ProductionConfig(BaseConfig): + CONFIG_NAME = "prod" + SECRET_KEY = os.getenv("PROD_SECRET_KEY", "I'm Ron Burgundy?") + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +EXPORT_CONFIGS: List[Type[BaseConfig]] = [ + DevelopmentConfig, + TestingConfig, + ProductionConfig, +] +config_by_name = {cfg.CONFIG_NAME: cfg for cfg in EXPORT_CONFIGS} diff --git a/modules/location/app/routes.py b/modules/location/app/routes.py new file mode 100644 index 000000000..c6b1c20eb --- /dev/null +++ b/modules/location/app/routes.py @@ -0,0 +1,5 @@ +def register_routes(api, app, root="api"): + from app.udaconnect import register_routes as attach_udaconnect + + # Add routes + attach_udaconnect(api, app) diff --git a/modules/location/app/udaconnect/__init__.py b/modules/location/app/udaconnect/__init__.py new file mode 100644 index 000000000..5cef3c90b --- /dev/null +++ b/modules/location/app/udaconnect/__init__.py @@ -0,0 +1,8 @@ +from app.udaconnect.models import Connection, Location, Person # noqa +from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema # noqa + + +def register_routes(api, app, root="api"): + from app.udaconnect.controllers import api as udaconnect_api + + api.add_namespace(udaconnect_api, path=f"/{root}") diff --git a/modules/api/app/udaconnect/controllers.py b/modules/location/app/udaconnect/controllers.py similarity index 100% rename from modules/api/app/udaconnect/controllers.py rename to modules/location/app/udaconnect/controllers.py diff --git a/modules/location/app/udaconnect/models.py b/modules/location/app/udaconnect/models.py new file mode 100644 index 000000000..94673a9e6 --- /dev/null +++ b/modules/location/app/udaconnect/models.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from app import db # noqa +from geoalchemy2 import Geometry +from geoalchemy2.shape import to_shape +from shapely.geometry.point import Point +from sqlalchemy import BigInteger, Column, Date, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.hybrid import hybrid_property + + +class Person(db.Model): + __tablename__ = "person" + + id = Column(Integer, primary_key=True) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) + company_name = Column(String, nullable=False) + + +class Location(db.Model): + __tablename__ = "location" + + id = Column(BigInteger, primary_key=True) + person_id = Column(Integer, ForeignKey(Person.id), nullable=False) + coordinate = Column(Geometry("POINT"), nullable=False) + creation_time = Column(DateTime, nullable=False, default=datetime.utcnow) + _wkt_shape: str = None + + @property + def wkt_shape(self) -> str: + # Persist binary form into readable text + if not self._wkt_shape: + point: Point = to_shape(self.coordinate) + # normalize WKT returned by to_wkt() from shapely and ST_AsText() from DB + self._wkt_shape = point.to_wkt().replace("POINT ", "ST_POINT") + return self._wkt_shape + + @wkt_shape.setter + def wkt_shape(self, v: str) -> None: + self._wkt_shape = v + + def set_wkt_with_coords(self, lat: str, long: str) -> str: + self._wkt_shape = f"ST_POINT({lat} {long})" + return self._wkt_shape + + @hybrid_property + def longitude(self) -> str: + coord_text = self.wkt_shape + return coord_text[coord_text.find(" ") + 1 : coord_text.find(")")] + + @hybrid_property + def latitude(self) -> str: + coord_text = self.wkt_shape + return coord_text[coord_text.find("(") + 1 : coord_text.find(" ")] + + +@dataclass +class Connection: + location: Location + person: Person diff --git a/modules/location/app/udaconnect/schemas.py b/modules/location/app/udaconnect/schemas.py new file mode 100644 index 000000000..b2ce23961 --- /dev/null +++ b/modules/location/app/udaconnect/schemas.py @@ -0,0 +1,30 @@ +from app.udaconnect.models import Connection, Location, Person +from geoalchemy2.types import Geometry as GeometryType +from marshmallow import Schema, fields +from marshmallow_sqlalchemy.convert import ModelConverter as BaseModelConverter + + +class LocationSchema(Schema): + id = fields.Integer() + person_id = fields.Integer() + longitude = fields.String(attribute="longitude") + latitude = fields.String(attribute="latitude") + creation_time = fields.DateTime() + + class Meta: + model = Location + + +class PersonSchema(Schema): + id = fields.Integer() + first_name = fields.String() + last_name = fields.String() + company_name = fields.String() + + class Meta: + model = Person + + +class ConnectionSchema(Schema): + location = fields.Nested(LocationSchema) + person = fields.Nested(PersonSchema) diff --git a/modules/location/app/udaconnect/services.py b/modules/location/app/udaconnect/services.py new file mode 100644 index 000000000..c248c31b2 --- /dev/null +++ b/modules/location/app/udaconnect/services.py @@ -0,0 +1,134 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List + +from app import db +from app.udaconnect.models import Connection, Location, Person +from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema +from geoalchemy2.functions import ST_AsText, ST_Point +from sqlalchemy.sql import text + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("udaconnect-api") + + +class ConnectionService: + @staticmethod + def find_contacts(person_id: int, start_date: datetime, end_date: datetime, meters=5 + ) -> List[Connection]: + """ + Finds all Person who have been within a given distance of a given Person within a date range. + + This will run rather quickly locally, but this is an expensive method and will take a bit of time to run on + large datasets. This is by design: what are some ways or techniques to help make this data integrate more + smoothly for a better user experience for API consumers? + """ + locations: List = db.session.query(Location).filter( + Location.person_id == person_id + ).filter(Location.creation_time < end_date).filter( + Location.creation_time >= start_date + ).all() + + # Cache all users in memory for quick lookup + person_map: Dict[str, Person] = {person.id: person for person in PersonService.retrieve_all()} + + # Prepare arguments for queries + data = [] + for location in locations: + data.append( + { + "person_id": person_id, + "longitude": location.longitude, + "latitude": location.latitude, + "meters": meters, + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": (end_date + timedelta(days=1)).strftime("%Y-%m-%d"), + } + ) + + query = text( + """ + SELECT person_id, id, ST_X(coordinate), ST_Y(coordinate), creation_time + FROM location + WHERE ST_DWithin(coordinate::geography,ST_SetSRID(ST_MakePoint(:latitude,:longitude),4326)::geography, :meters) + AND person_id != :person_id + AND TO_DATE(:start_date, 'YYYY-MM-DD') <= creation_time + AND TO_DATE(:end_date, 'YYYY-MM-DD') > creation_time; + """ + ) + result: List[Connection] = [] + for line in tuple(data): + for ( + exposed_person_id, + location_id, + exposed_lat, + exposed_long, + exposed_time, + ) in db.engine.execute(query, **line): + location = Location( + id=location_id, + person_id=exposed_person_id, + creation_time=exposed_time, + ) + location.set_wkt_with_coords(exposed_lat, exposed_long) + + result.append( + Connection( + person=person_map[exposed_person_id], location=location, + ) + ) + + return result + + +class LocationService: + @staticmethod + def retrieve(location_id) -> Location: + location, coord_text = ( + db.session.query(Location, Location.coordinate.ST_AsText()) + .filter(Location.id == location_id) + .one() + ) + + # Rely on database to return text form of point to reduce overhead of conversion in app code + location.wkt_shape = coord_text + return location + + @staticmethod + def create(location: Dict) -> Location: + validation_results: Dict = LocationSchema().validate(location) + if validation_results: + logger.warning(f"Unexpected data format in payload: {validation_results}") + raise Exception(f"Invalid payload: {validation_results}") + + new_location = Location() + new_location.person_id = location["person_id"] + new_location.creation_time = location["creation_time"] + new_location.coordinate = ST_Point(location["latitude"], location["longitude"]) + db.session.add(new_location) + db.session.commit() + + return new_location + + +class PersonService: + @staticmethod + def create(person: Dict) -> Person: + new_person = Person() + new_person.first_name = person["first_name"] + new_person.last_name = person["last_name"] + new_person.company_name = person["company_name"] + + db.session.add(new_person) + db.session.commit() + + return new_person + + @staticmethod + def retrieve(person_id: int) -> Person: + person = db.session.query(Person).get(person_id) + return person + + @staticmethod + def retrieve_all() -> List[Person]: + return db.session.query(Person).all() diff --git a/modules/location/db/script.sql b/modules/location/db/script.sql new file mode 100644 index 000000000..cacfeee75 --- /dev/null +++ b/modules/location/db/script.sql @@ -0,0 +1,48 @@ +CREATE TABLE location ( + id SERIAL PRIMARY KEY, + person_id INT NOT NULL, + coordinate GEOMETRY NOT NULL, + creation_time TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX coordinate_idx ON location (coordinate); +CREATE INDEX creation_time_idx ON location (creation_time); + +insert into public.location (id, person_id, coordinate, creation_time) values (29, 1, '010100000000ADF9F197925EC0FDA19927D7C64240', '2020-08-18 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (30, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-08-15 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (31, 5, '010100000000ADF9F197925EC0FDA19927D7C64240', '2020-08-15 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (32, 1, '0101000000477364E597925EC0FDA19927D7C64240', '2020-08-15 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (33, 1, '0101000000477364E597925EC021787C7BD7C64240', '2020-08-19 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (34, 6, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (36, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (37, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (38, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (39, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (40, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (41, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (42, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (43, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-06 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (44, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (45, 6, '0101000000554FE61F7D87414002D9EBDD9FA45AC0', '2020-07-05 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (46, 6, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-04-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (47, 6, '0101000000895C70067F874140971128AC9EA45AC0', '2020-05-01 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (48, 6, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (49, 8, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-07-07 10:38:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (50, 8, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-07 10:38:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (51, 8, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-01 10:38:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (52, 9, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-01 10:38:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (53, 9, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (54, 9, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2019-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (55, 5, '0101000000842FA75F7D874140CEEEDAEF9A645AC0', '2019-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (56, 5, '0101000000842FA75F7D074140CEEEDAEF9A645AC0', '2019-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (57, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (58, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (59, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (60, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-06 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (61, 8, '0101000000842FA75F7D874140DA0FC2ED9AA45AC0', '2020-07-05 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (62, 8, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (63, 5, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (64, 6, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (65, 9, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (66, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (67, 8, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); +insert into public.location (id, person_id, coordinate, creation_time) values (68, 6, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-08-15 10:37:06.000000'); diff --git a/modules/location/requirements.txt b/modules/location/requirements.txt new file mode 100644 index 000000000..652e39c33 --- /dev/null +++ b/modules/location/requirements.txt @@ -0,0 +1,26 @@ +aniso8601==7.0.0 +attrs==19.1.0 +Click==7.0 +Flask==1.1.1 +flask-accepts==0.10.0 +flask-cors==3.0.8 +Flask-RESTful==0.3.7 +flask-restplus==0.12.1 +Flask-Script==2.0.6 +Flask-SQLAlchemy==2.4.0 +GeoAlchemy2==0.8.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.0.2 +MarkupSafe==1.1.1 +marshmallow==3.7.1 +marshmallow-sqlalchemy==0.23.1 +psycopg2-binary==2.8.5 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +shapely==1.7.0 +SQLAlchemy==1.3.19 +Werkzeug==0.16.1 +flask-restx==0.2.0 diff --git a/modules/location/wsgi.py b/modules/location/wsgi.py new file mode 100644 index 000000000..63fc43373 --- /dev/null +++ b/modules/location/wsgi.py @@ -0,0 +1,7 @@ +import os + +from app import create_app + +app = create_app(os.getenv("FLASK_ENV") or "test") +if __name__ == "__main__": + app.run(debug=True) diff --git a/modules/person/Dockerfile b/modules/person/Dockerfile new file mode 100644 index 000000000..1ef643ff1 --- /dev/null +++ b/modules/person/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.7-alpine + +WORKDIR . + +RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +EXPOSE 5000 + +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/person/app/__init__.py b/modules/person/app/__init__.py new file mode 100644 index 000000000..627a5c5f6 --- /dev/null +++ b/modules/person/app/__init__.py @@ -0,0 +1,26 @@ +from flask import Flask, jsonify +from flask_cors import CORS +from flask_restx import Api +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def create_app(env=None): + from app.config import config_by_name + from app.routes import register_routes + + app = Flask(__name__) + app.config.from_object(config_by_name[env or "test"]) + api = Api(app, title="UdaConnect API", version="0.1.0") + + CORS(app) # Set CORS for development + + register_routes(api, app) + db.init_app(app) + + @app.route("/health") + def health(): + return jsonify("healthy") + + return app diff --git a/modules/person/app/config.py b/modules/person/app/config.py new file mode 100644 index 000000000..827b6a14a --- /dev/null +++ b/modules/person/app/config.py @@ -0,0 +1,58 @@ +import os +from typing import List, Type + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + + +class BaseConfig: + CONFIG_NAME = "base" + USE_MOCK_EQUIVALENCY = False + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class DevelopmentConfig(BaseConfig): + CONFIG_NAME = "dev" + SECRET_KEY = os.getenv( + "DEV_SECRET_KEY", "You can't see California without Marlon Widgeto's eyes" + ) + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class TestingConfig(BaseConfig): + CONFIG_NAME = "test" + SECRET_KEY = os.getenv("TEST_SECRET_KEY", "Thanos did nothing wrong") + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = True + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class ProductionConfig(BaseConfig): + CONFIG_NAME = "prod" + SECRET_KEY = os.getenv("PROD_SECRET_KEY", "I'm Ron Burgundy?") + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +EXPORT_CONFIGS: List[Type[BaseConfig]] = [ + DevelopmentConfig, + TestingConfig, + ProductionConfig, +] +config_by_name = {cfg.CONFIG_NAME: cfg for cfg in EXPORT_CONFIGS} diff --git a/modules/person/app/routes.py b/modules/person/app/routes.py new file mode 100644 index 000000000..c6b1c20eb --- /dev/null +++ b/modules/person/app/routes.py @@ -0,0 +1,5 @@ +def register_routes(api, app, root="api"): + from app.udaconnect import register_routes as attach_udaconnect + + # Add routes + attach_udaconnect(api, app) diff --git a/modules/person/app/udaconnect/__init__.py b/modules/person/app/udaconnect/__init__.py new file mode 100644 index 000000000..5cef3c90b --- /dev/null +++ b/modules/person/app/udaconnect/__init__.py @@ -0,0 +1,8 @@ +from app.udaconnect.models import Connection, Location, Person # noqa +from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema # noqa + + +def register_routes(api, app, root="api"): + from app.udaconnect.controllers import api as udaconnect_api + + api.add_namespace(udaconnect_api, path=f"/{root}") diff --git a/modules/person/app/udaconnect/controllers.py b/modules/person/app/udaconnect/controllers.py new file mode 100644 index 000000000..276d3195f --- /dev/null +++ b/modules/person/app/udaconnect/controllers.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from app.udaconnect.models import Person +from app.udaconnect.schemas import ( + PersonSchema, +) +from app.udaconnect.services import ConnectionService, LocationService, PersonService +from flask import request +from flask_accepts import accepts, responds +from flask_restx import Namespace, Resource +from typing import Optional, List + +DATE_FORMAT = "%Y-%m-%d" + +api = Namespace("UdaConnect", description="Connections via geolocation.") # noqa + + +# TODO: This needs better exception handling + + +@api.route("/persons") +class PersonsResource(Resource): + @accepts(schema=PersonSchema) + @responds(schema=PersonSchema) + def post(self) -> Person: + payload = request.get_json() + new_person: Person = PersonService.create(payload) + return new_person + + @responds(schema=PersonSchema, many=True) + def get(self) -> List[Person]: + persons: List[Person] = PersonService.retrieve_all() + return persons + + +@api.route("/persons/") +@api.param("person_id", "Unique ID for a given Person", _in="query") +class PersonResource(Resource): + @responds(schema=PersonSchema) + def get(self, person_id) -> Person: + person: Person = PersonService.retrieve(person_id) + return person + diff --git a/modules/person/app/udaconnect/models.py b/modules/person/app/udaconnect/models.py new file mode 100644 index 000000000..8cac7f6b5 --- /dev/null +++ b/modules/person/app/udaconnect/models.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from app import db # noqa +from geoalchemy2 import Geometry +from geoalchemy2.shape import to_shape +from shapely.geometry.point import Point +from sqlalchemy import BigInteger, Column, Date, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.hybrid import hybrid_property + + +class Person(db.Model): + __tablename__ = "person" + + id = Column(Integer, primary_key=True) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) + company_name = Column(String, nullable=False) + + + +@dataclass +class Connection: + person: Person diff --git a/modules/person/app/udaconnect/schemas.py b/modules/person/app/udaconnect/schemas.py new file mode 100644 index 000000000..85f0e2c25 --- /dev/null +++ b/modules/person/app/udaconnect/schemas.py @@ -0,0 +1,15 @@ +from app.udaconnect.models import Connection, Location, Person +from geoalchemy2.types import Geometry as GeometryType +from marshmallow import Schema, fields +from marshmallow_sqlalchemy.convert import ModelConverter as BaseModelConverter + + +class PersonSchema(Schema): + id = fields.Integer() + first_name = fields.String() + last_name = fields.String() + company_name = fields.String() + + class Meta: + model = Person + diff --git a/modules/person/app/udaconnect/services.py b/modules/person/app/udaconnect/services.py new file mode 100644 index 000000000..1d6fa81c1 --- /dev/null +++ b/modules/person/app/udaconnect/services.py @@ -0,0 +1,35 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List + +from app import db +from app.udaconnect.models import Connection, Location, Person +from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema +from geoalchemy2.functions import ST_AsText, ST_Point +from sqlalchemy.sql import text + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("udaconnect-api") + + +class PersonService: + @staticmethod + def create(person: Dict) -> Person: + new_person = Person() + new_person.first_name = person["first_name"] + new_person.last_name = person["last_name"] + new_person.company_name = person["company_name"] + + db.session.add(new_person) + db.session.commit() + + return new_person + + @staticmethod + def retrieve(person_id: int) -> Person: + person = db.session.query(Person).get(person_id) + return person + + @staticmethod + def retrieve_all() -> List[Person]: + return db.session.query(Person).all() diff --git a/modules/person/db/script.sql b/modules/person/db/script.sql new file mode 100644 index 000000000..ef01b1385 --- /dev/null +++ b/modules/person/db/script.sql @@ -0,0 +1,12 @@ +CREATE TABLE person ( + id SERIAL PRIMARY KEY, + first_name VARCHAR NOT NULL, + last_name VARCHAR NOT NULL, + company_name VARCHAR NOT NULL +); + +insert into public.person (id, first_name, last_name, company_name) values (5, 'Taco', 'Fargo', 'Alpha Omega Upholstery'); +insert into public.person (id, first_name, last_name, company_name) values (6, 'Frank', 'Shader', 'USDA'); +insert into public.person (id, first_name, last_name, company_name) values (1, 'Pam', 'Trexler', 'Hampton, Hampton and McQuill'); +insert into public.person (id, first_name, last_name, company_name) values (8, 'Paul', 'Badman', 'Paul Badman & Associates'); +insert into public.person (id, first_name, last_name, company_name) values (9, 'Otto', 'Spring', 'The Chicken Sisters Restaurant'); diff --git a/modules/person/deployment/db-configmap.yaml b/modules/person/deployment/db-configmap.yaml new file mode 100644 index 000000000..cb55c9b40 --- /dev/null +++ b/modules/person/deployment/db-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +data: + DB_USERNAME: "person" + DB_NAME: "person-db" + DB_HOST: "postgres-person-db" + DB_PORT: "5432" +metadata: + name: db-env \ No newline at end of file diff --git a/modules/person/deployment/db-secret.yaml b/modules/person/deployment/db-secret.yaml new file mode 100644 index 000000000..6c4028b39 --- /dev/null +++ b/modules/person/deployment/db-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: db-secret +type: Opaque +data: + DB_PASSWORD: MTIzNDU2Cg== # 123456 \ No newline at end of file diff --git a/modules/person/deployment/person-api.yaml b/modules/person/deployment/person-api.yaml new file mode 100644 index 000000000..b4370bdfb --- /dev/null +++ b/modules/person/deployment/person-api.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: person-api + name: person-api +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + nodePort: 30002 + selector: + service: person-api + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: person-api + name: person-api +spec: + replicas: 1 + selector: + matchLabels: + service: person-api + template: + metadata: + labels: + service: person-api + spec: + containers: + - image: kydq2022/person-api:latest + name: person-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/modules/person/deployment/postgres.yaml b/modules/person/deployment/postgres.yaml new file mode 100644 index 000000000..7889e16ed --- /dev/null +++ b/modules/person/deployment/postgres.yaml @@ -0,0 +1,86 @@ +kind: PersistentVolume +apiVersion: v1 +metadata: + name: postgres-person-db-volume + labels: + type: local + app: postgres-person-db +spec: + storageClassName: manual + capacity: + storage: 256Mi + accessModes: + - ReadWriteMany + hostPath: + path: "/mnt/data" +--- +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: postgres-pv-claim + labels: + app: postgres-person-db +spec: + storageClassName: manual + accessModes: + - ReadWriteMany + resources: + requests: + storage: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-person-db + labels: + app: postgres-person-db +spec: + type: NodePort + selector: + app: postgres-person-db + ports: + - port: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-person-db +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-person-db + template: + metadata: + labels: + app: postgres-person-db + spec: + containers: + - name: postgres + image: postgis/postgis:12-2.5-alpine + imagePullPolicy: "IfNotPresent" + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgresdb + volumes: + - name: postgresdb + persistentVolumeClaim: + claimName: postgres-pv-claim diff --git a/modules/person/requirements.txt b/modules/person/requirements.txt new file mode 100644 index 000000000..652e39c33 --- /dev/null +++ b/modules/person/requirements.txt @@ -0,0 +1,26 @@ +aniso8601==7.0.0 +attrs==19.1.0 +Click==7.0 +Flask==1.1.1 +flask-accepts==0.10.0 +flask-cors==3.0.8 +Flask-RESTful==0.3.7 +flask-restplus==0.12.1 +Flask-Script==2.0.6 +Flask-SQLAlchemy==2.4.0 +GeoAlchemy2==0.8.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.0.2 +MarkupSafe==1.1.1 +marshmallow==3.7.1 +marshmallow-sqlalchemy==0.23.1 +psycopg2-binary==2.8.5 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +shapely==1.7.0 +SQLAlchemy==1.3.19 +Werkzeug==0.16.1 +flask-restx==0.2.0 diff --git a/modules/person/scripts/run_db_command.sh b/modules/person/scripts/run_db_command.sh new file mode 100644 index 000000000..c415a3ef3 --- /dev/null +++ b/modules/person/scripts/run_db_command.sh @@ -0,0 +1,14 @@ +# Usage: pass in the DB container ID as the argument + +# Set database configurations +export CT_DB_USERNAME=person +export CT_DB_NAME=person-db + + +cat ./db/ddl.sql | kubectl exec postgres-person-db-6786975574-2zs6q -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" + +cat ./db/data.sql | kubectl exec -i $1 -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" + + + +cat ./db/ddl.sql | kubectl exec postgres-person-db-6786975574-2zs6q -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" \ No newline at end of file diff --git a/modules/person/wsgi.py b/modules/person/wsgi.py new file mode 100644 index 000000000..63fc43373 --- /dev/null +++ b/modules/person/wsgi.py @@ -0,0 +1,7 @@ +import os + +from app import create_app + +app = create_app(os.getenv("FLASK_ENV") or "test") +if __name__ == "__main__": + app.run(debug=True) diff --git a/notes.md b/notes.md new file mode 100644 index 000000000..b3bcf5d31 --- /dev/null +++ b/notes.md @@ -0,0 +1,90 @@ +# k3s: Lightweight Kubernetes +```shell +# Install +curl -sfL https://get.k3s.io | sh - + +# Config kubectl +sudo cp /etc/rancher/k3s/k3s.yaml /home/${USER}/.kube/config +sudo chown ${USER} ~/.kube/config +chmod 600 ~/.kube/config + +``` + +# Argocd: Declarative continuous delivery with a fully-loaded UI + +https://argo-cd.readthedocs.io/en/stable/getting_started +## Install Argo CD¶ +```shell +# namespace argocd +kubectl create namespace argocd + +# apply +kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml + +``` +## Access The Argo CD API Server + +```shell +# Service Type Load Balancer +kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "LoadBalancer"}}' + +# Port Forwarding +kubectl port-forward svc/argocd-server -n argocd 8080:443 + +``` + +## Download Argo CD CLI +```shell +Download the latest Argo CD version from https://github.com/argoproj/argo-cd/releases/latest. More detailed installation instructions can be found via the CLI installation documentation. +``` + +## Login Using The CLI +```shell +# The initial password for the admin account is auto-generated and stored as clear text in the field password in a secret named argocd-initial-admin-secret in your Argo CD installation namespace. You can simply retrieve this password using the argocd CLI: +argocd admin initial-password -n argocd + +# Using the username admin and the password from above, login to Argo CD's IP or hostname: +argocd login + +# Change the password using the command: +argocd account update-password + +``` + +# Helm: The package manager for Kubernetes +https://helm.sh + +## Install Helm + +https://helm.sh/docs/intro/install/ + +```shell + +curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 +chmod 700 get_helm.sh +./get_helm.sh + +``` + +## Helm cheatsheet + +https://helm.sh/docs/intro/cheatsheet/ + +# Prometheus: Monitoring system & time series database + +https://prometheus.io/ + + +# Grafana: open source, distributed tracing platform + +https://grafana.com/ + + +# Jaeger: open source, distributed tracing platform + +https://www.jaegertracing.io/ + +# gRPC: A high performance, open source universal RPC framework + +https://grpc.io/ + From c7f6adfdb96303f6ddc29cf92943cec3e024a491 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Mon, 18 Dec 2023 13:22:08 +0700 Subject: [PATCH 02/12] kafka --- deployment/kafka.yaml | 32 ++++++ helm/Chart.yaml | 8 -- helm/templates/db-configmap.yaml | 9 -- helm/templates/db-secret.yaml | 7 -- helm/templates/deploy.yaml | 36 ------- helm/templates/kafka.yaml | 54 ---------- helm/templates/namespace.yaml | 4 - helm/templates/postgres.yaml | 86 --------------- helm/templates/service. yaml | 17 --- helm/templates/udaconnect-api.yaml | 63 ----------- helm/templates/udaconnect-app.yaml | 44 -------- helm/templates/udaconnect-connection.yaml | 100 ------------------ helm/templates/udaconnect-location.yaml | 63 ----------- helm/templates/udaconnect-person.yaml | 63 ----------- helm/templates/zookeeper.yaml | 49 --------- helm/values-prod.yaml | 18 ---- modules/connection/app/udaconnect/services.py | 53 ---------- .../location/app/udaconnect/controllers.py | 46 -------- modules/location/db/script.sql | 48 --------- modules/person/db/script.sql | 12 --- modules/person/scripts/run_db_command.sh | 14 --- 21 files changed, 32 insertions(+), 794 deletions(-) create mode 100644 deployment/kafka.yaml delete mode 100644 helm/Chart.yaml delete mode 100644 helm/templates/db-configmap.yaml delete mode 100644 helm/templates/db-secret.yaml delete mode 100644 helm/templates/deploy.yaml delete mode 100644 helm/templates/kafka.yaml delete mode 100644 helm/templates/namespace.yaml delete mode 100644 helm/templates/postgres.yaml delete mode 100644 helm/templates/service. yaml delete mode 100644 helm/templates/udaconnect-api.yaml delete mode 100644 helm/templates/udaconnect-app.yaml delete mode 100644 helm/templates/udaconnect-connection.yaml delete mode 100644 helm/templates/udaconnect-location.yaml delete mode 100644 helm/templates/udaconnect-person.yaml delete mode 100644 helm/templates/zookeeper.yaml delete mode 100644 helm/values-prod.yaml delete mode 100644 modules/location/db/script.sql delete mode 100644 modules/person/db/script.sql delete mode 100644 modules/person/scripts/run_db_command.sh diff --git a/deployment/kafka.yaml b/deployment/kafka.yaml new file mode 100644 index 000000000..af24caf1f --- /dev/null +++ b/deployment/kafka.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kafka-kraft +spec: + replicas: 1 + selector: + matchLabels: + app: kafka-kraft + template: + metadata: + labels: + app: kafka-kraft + spec: + containers: + - name: kafka-kraft + image: confluentinc/confluent-local:7.4.0 + ports: + - containerPort: 9092 # Adjust the port based on your Kafka configuration + +--- +apiVersion: v1 +kind: Service +metadata: + name: kafka-service +spec: + selector: + app: kafka-kraft + ports: + - protocol: TCP + port: 9092 # Port to expose on the service + targetPort: 9092 # Port your application is listening on diff --git a/helm/Chart.yaml b/helm/Chart.yaml deleted file mode 100644 index 384a6462c..000000000 --- a/helm/Chart.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -name: udaconnect -version: 1.0.0 -keywords: - - udaconnect -maintainers: - - name: kydq2022 - email: kydq2022@outlook.com \ No newline at end of file diff --git a/helm/templates/db-configmap.yaml b/helm/templates/db-configmap.yaml deleted file mode 100644 index 19579cdee..000000000 --- a/helm/templates/db-configmap.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -data: - DB_USERNAME: "ct_admin" - DB_NAME: "geoconnections" - DB_HOST: "postgres" - DB_PORT: "5432" -metadata: - name: db-env \ No newline at end of file diff --git a/helm/templates/db-secret.yaml b/helm/templates/db-secret.yaml deleted file mode 100644 index 5ee171d3f..000000000 --- a/helm/templates/db-secret.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: db-secret -type: Opaque -data: - DB_PASSWORD: d293aW1zb3NlY3VyZQ== \ No newline at end of file diff --git a/helm/templates/deploy.yaml b/helm/templates/deploy.yaml deleted file mode 100644 index 89ad52497..000000000 --- a/helm/templates/deploy.yaml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: techtrends - namespace: {{ .Values.namespace.name }} - labels: - app: techtrends -spec: - replicas: {{ .Values.replicas }} - selector: - matchLabels: - app: techtrends - template: - metadata: - labels: - app: techtrends - spec: - containers: - - name: {{ .Chart.Name }} - image: {{ .Values.image.repository }}:{{ .Values.image.tag }} - ports: - - containerPort: {{ .Values.containerPort }} - resources: -{{ toYaml .Values.resources | indent 12 }} - livenessProbe: - httpGet: - path: /healthz - port: {{ .Values.containerPort }} - initialDelaySeconds: 5 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /healthz - port: {{ .Values.containerPort }} - initialDelaySeconds: 5 - periodSeconds: 10 \ No newline at end of file diff --git a/helm/templates/kafka.yaml b/helm/templates/kafka.yaml deleted file mode 100644 index c25cefd20..000000000 --- a/helm/templates/kafka.yaml +++ /dev/null @@ -1,54 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: kafka - name: kafka -spec: - ports: - - name: "9092" - port: 9092 - targetPort: 9092 - nodePort: 30005 - selector: - service: kafka - type: NodePort - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: kafka - name: kafka -spec: - replicas: 2 - selector: - matchLabels: - service: kafka - template: - metadata: - labels: - service: kafka - spec: - containers: - - image: bitnami/kafka:latest - name: kafka - imagePullPolicy: Always - env: - - name: KAFKA_CFG_ZOOKEEPER_CONNECT - value: "zookeeper.default.svc.cluster.local:2181" - - name: KAFKA_CFG_ADVERTISED_LISTENERS - value: "PLAINTEXT://kafka.default.svc.cluster.local:9092" - - name: ALLOW_PLAINTEXT_LISTENER - value: "yes" - - name: "zookeeper.enabled" - value: "false" - resources: - requests: - memory: "128Mi" - cpu: "64m" - limits: - memory: "256Mi" - cpu: "256m" - restartPolicy: Always \ No newline at end of file diff --git a/helm/templates/namespace.yaml b/helm/templates/namespace.yaml deleted file mode 100644 index 3703e335e..000000000 --- a/helm/templates/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: {{ .Values.namespace.name }} \ No newline at end of file diff --git a/helm/templates/postgres.yaml b/helm/templates/postgres.yaml deleted file mode 100644 index debd18781..000000000 --- a/helm/templates/postgres.yaml +++ /dev/null @@ -1,86 +0,0 @@ -kind: PersistentVolume -apiVersion: v1 -metadata: - name: postgres-volume - labels: - type: local - app: postgres -spec: - storageClassName: manual - capacity: - storage: 256Mi - accessModes: - - ReadWriteMany - hostPath: - path: "/mnt/data" ---- -kind: PersistentVolumeClaim -apiVersion: v1 -metadata: - name: postgres-pv-claim - labels: - app: postgres -spec: - storageClassName: manual - accessModes: - - ReadWriteMany - resources: - requests: - storage: 256Mi ---- -apiVersion: v1 -kind: Service -metadata: - name: postgres - labels: - app: postgres -spec: - type: NodePort - selector: - app: postgres - ports: - - port: 5432 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: postgres -spec: - replicas: 1 - selector: - matchLabels: - app: postgres - template: - metadata: - labels: - app: postgres - spec: - containers: - - name: postgres - image: postgis/postgis:12-2.5-alpine - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - env: - - name: POSTGRES_USER - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: POSTGRES_DB - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - volumeMounts: - - mountPath: /var/lib/postgresql/data - name: postgresdb - volumes: - - name: postgresdb - persistentVolumeClaim: - claimName: postgres-pv-claim diff --git a/helm/templates/service. yaml b/helm/templates/service. yaml deleted file mode 100644 index 7149f0d26..000000000 --- a/helm/templates/service. yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: techtrends - tag: latest - name: techtrends - namespace: {{ .Values.namespace.name }} -spec: - ports: - - port: {{ .Values.service.port }} - protocol: TCP - targetPort: {{ .Values.service.port }} - selector: - app: techtrends - tag: latest - type: {{ .Values.service.type }} \ No newline at end of file diff --git a/helm/templates/udaconnect-api.yaml b/helm/templates/udaconnect-api.yaml deleted file mode 100644 index e62dcbd0e..000000000 --- a/helm/templates/udaconnect-api.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: udaconnect-api - name: udaconnect-api -spec: - ports: - - name: "5000" - port: 5000 - targetPort: 5000 - nodePort: 30001 - selector: - service: udaconnect-api - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: udaconnect-api - name: udaconnect-api -spec: - replicas: 1 - selector: - matchLabels: - service: udaconnect-api - template: - metadata: - labels: - service: udaconnect-api - spec: - containers: - - image: udacity/nd064-udaconnect-api:latest - name: udaconnect-api - imagePullPolicy: Always - env: - - name: DB_USERNAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: DB_NAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - - name: DB_HOST - valueFrom: - configMapKeyRef: - name: db-env - key: DB_HOST - - name: DB_PORT - valueFrom: - configMapKeyRef: - name: db-env - key: DB_PORT - restartPolicy: Always diff --git a/helm/templates/udaconnect-app.yaml b/helm/templates/udaconnect-app.yaml deleted file mode 100644 index d572e0c37..000000000 --- a/helm/templates/udaconnect-app.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: udaconnect-app - name: udaconnect-app -spec: - ports: - - name: "3000" - port: 3000 - targetPort: 3000 - nodePort: 30000 - selector: - service: udaconnect-app - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: udaconnect-app - name: udaconnect-app -spec: - replicas: 1 - selector: - matchLabels: - service: udaconnect-app - template: - metadata: - labels: - service: udaconnect-app - spec: - containers: - - image: kydq2022/nd064-c2-udaconnect-app:latest - name: udaconnect-app - imagePullPolicy: Always - resources: - requests: - memory: "128Mi" - cpu: "64m" - limits: - memory: "256Mi" - cpu: "256m" - restartPolicy: Always diff --git a/helm/templates/udaconnect-connection.yaml b/helm/templates/udaconnect-connection.yaml deleted file mode 100644 index df8e43936..000000000 --- a/helm/templates/udaconnect-connection.yaml +++ /dev/null @@ -1,100 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: connection-api - namespace: {{ .Values.namespace.name }} - labels: - app: connection-api -spec: - replicas: {{ .Values.replicas }} - selector: - matchLabels: - app: connection-api - template: - metadata: - labels: - app: connection-api - spec: - containers: - - name: {{ .Chart.Name }} - image: {{ .Values.image.repository }}:{{ .Values.image.tag }} - ports: - - containerPort: {{ .Values.containerPort }} - resources: -{{ toYaml .Values.resources | indent 12 }} - livenessProbe: - httpGet: - path: /healthz - port: {{ .Values.containerPort }} - initialDelaySeconds: 5 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /healthz - port: {{ .Values.containerPort }} - initialDelaySeconds: 5 - periodSeconds: 10 - -apiVersion: v1 -kind: Service -metadata: - labels: - service: connection-api - name: connection-api -spec: - ports: - - name: "5000" - port: 5000 - targetPort: 5000 - nodePort: 30001 - selector: - service: connection-api - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: connection-api - name: connection-api -spec: - replicas: 1 - selector: - matchLabels: - service: connection-api - template: - metadata: - labels: - service: connection-api - spec: - containers: - - image: kydq2022/nd064-c2-connection-api:latest - name: connection-api - imagePullPolicy: Always - env: - - name: DB_USERNAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: DB_NAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - - name: DB_HOST - valueFrom: - configMapKeyRef: - name: db-env - key: DB_HOST - - name: DB_PORT - valueFrom: - configMapKeyRef: - name: db-env - key: DB_PORT - restartPolicy: Always diff --git a/helm/templates/udaconnect-location.yaml b/helm/templates/udaconnect-location.yaml deleted file mode 100644 index 3ae342d57..000000000 --- a/helm/templates/udaconnect-location.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: location-api - name: location-api -spec: - ports: - - name: "5000" - port: 5000 - targetPort: 5000 - nodePort: 30001 - selector: - service: location-api - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: location-api - name: location-api -spec: - replicas: 1 - selector: - matchLabels: - service: location-api - template: - metadata: - labels: - service: location-api - spec: - containers: - - image: udacity/nd064-c2-location-api:latest - name: location-api - imagePullPolicy: Always - env: - - name: DB_USERNAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: DB_NAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - - name: DB_HOST - valueFrom: - configMapKeyRef: - name: db-env - key: DB_HOST - - name: DB_PORT - valueFrom: - configMapKeyRef: - name: db-env - key: DB_PORT - restartPolicy: Always diff --git a/helm/templates/udaconnect-person.yaml b/helm/templates/udaconnect-person.yaml deleted file mode 100644 index e02bfeee7..000000000 --- a/helm/templates/udaconnect-person.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: person-api - name: person-api -spec: - ports: - - name: "5000" - port: 5000 - targetPort: 5000 - nodePort: 30001 - selector: - service: person-api - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: person-api - name: person-api -spec: - replicas: 1 - selector: - matchLabels: - service: person-api - template: - metadata: - labels: - service: person-api - spec: - containers: - - image: udacity/nd064-c2-person-api:latest - name: person-api - imagePullPolicy: Always - env: - - name: DB_USERNAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: DB_NAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - - name: DB_HOST - valueFrom: - configMapKeyRef: - name: db-env - key: DB_HOST - - name: DB_PORT - valueFrom: - configMapKeyRef: - name: db-env - key: DB_PORT - restartPolicy: Always diff --git a/helm/templates/zookeeper.yaml b/helm/templates/zookeeper.yaml deleted file mode 100644 index a40a16104..000000000 --- a/helm/templates/zookeeper.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: zookeeper - name: zookeeper -spec: - ports: - - name: "2181" - port: 2181 - targetPort: 2181 - nodePort: 30010 - selector: - service: zookeeper - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: zookeeper - name: zookeeper -spec: - replicas: 2 - selector: - matchLabels: - service: zookeeper - template: - metadata: - labels: - service: zookeeper - spec: - containers: - - image: bitnami/zookeeper:latest - name: zookeeper - env: - - name: ALLOW_ANONYMOUS_LOGIN - value: "yes" - - name: "auth.enabled" - value: "false" - imagePullPolicy: Always - resources: - requests: - memory: "128Mi" - cpu: "64m" - limits: - memory: "256Mi" - cpu: "256m" - restartPolicy: Always \ No newline at end of file diff --git a/helm/values-prod.yaml b/helm/values-prod.yaml deleted file mode 100644 index 9ce0e8f77..000000000 --- a/helm/values-prod.yaml +++ /dev/null @@ -1,18 +0,0 @@ -namespace: - name: prod - -service: - port: 7111 - -image: - pullPolicy: Always - -replicaCount: 5 - -resources: - requests: - memory: 128Mi - cpu: 350m - limits: - memory: 256Mi - cpu: 500m \ No newline at end of file diff --git a/modules/connection/app/udaconnect/services.py b/modules/connection/app/udaconnect/services.py index c248c31b2..b29008f9f 100644 --- a/modules/connection/app/udaconnect/services.py +++ b/modules/connection/app/udaconnect/services.py @@ -79,56 +79,3 @@ def find_contacts(person_id: int, start_date: datetime, end_date: datetime, mete ) return result - - -class LocationService: - @staticmethod - def retrieve(location_id) -> Location: - location, coord_text = ( - db.session.query(Location, Location.coordinate.ST_AsText()) - .filter(Location.id == location_id) - .one() - ) - - # Rely on database to return text form of point to reduce overhead of conversion in app code - location.wkt_shape = coord_text - return location - - @staticmethod - def create(location: Dict) -> Location: - validation_results: Dict = LocationSchema().validate(location) - if validation_results: - logger.warning(f"Unexpected data format in payload: {validation_results}") - raise Exception(f"Invalid payload: {validation_results}") - - new_location = Location() - new_location.person_id = location["person_id"] - new_location.creation_time = location["creation_time"] - new_location.coordinate = ST_Point(location["latitude"], location["longitude"]) - db.session.add(new_location) - db.session.commit() - - return new_location - - -class PersonService: - @staticmethod - def create(person: Dict) -> Person: - new_person = Person() - new_person.first_name = person["first_name"] - new_person.last_name = person["last_name"] - new_person.company_name = person["company_name"] - - db.session.add(new_person) - db.session.commit() - - return new_person - - @staticmethod - def retrieve(person_id: int) -> Person: - person = db.session.query(Person).get(person_id) - return person - - @staticmethod - def retrieve_all() -> List[Person]: - return db.session.query(Person).all() diff --git a/modules/location/app/udaconnect/controllers.py b/modules/location/app/udaconnect/controllers.py index 0b714680b..a30f0688a 100644 --- a/modules/location/app/udaconnect/controllers.py +++ b/modules/location/app/udaconnect/controllers.py @@ -35,49 +35,3 @@ def post(self) -> Location: def get(self, location_id) -> Location: location: Location = LocationService.retrieve(location_id) return location - - -@api.route("/persons") -class PersonsResource(Resource): - @accepts(schema=PersonSchema) - @responds(schema=PersonSchema) - def post(self) -> Person: - payload = request.get_json() - new_person: Person = PersonService.create(payload) - return new_person - - @responds(schema=PersonSchema, many=True) - def get(self) -> List[Person]: - persons: List[Person] = PersonService.retrieve_all() - return persons - - -@api.route("/persons/") -@api.param("person_id", "Unique ID for a given Person", _in="query") -class PersonResource(Resource): - @responds(schema=PersonSchema) - def get(self, person_id) -> Person: - person: Person = PersonService.retrieve(person_id) - return person - - -@api.route("/persons//connection") -@api.param("start_date", "Lower bound of date range", _in="query") -@api.param("end_date", "Upper bound of date range", _in="query") -@api.param("distance", "Proximity to a given user in meters", _in="query") -class ConnectionDataResource(Resource): - @responds(schema=ConnectionSchema, many=True) - def get(self, person_id) -> ConnectionSchema: - start_date: datetime = datetime.strptime( - request.args["start_date"], DATE_FORMAT - ) - end_date: datetime = datetime.strptime(request.args["end_date"], DATE_FORMAT) - distance: Optional[int] = request.args.get("distance", 5) - - results = ConnectionService.find_contacts( - person_id=person_id, - start_date=start_date, - end_date=end_date, - meters=distance, - ) - return results diff --git a/modules/location/db/script.sql b/modules/location/db/script.sql deleted file mode 100644 index cacfeee75..000000000 --- a/modules/location/db/script.sql +++ /dev/null @@ -1,48 +0,0 @@ -CREATE TABLE location ( - id SERIAL PRIMARY KEY, - person_id INT NOT NULL, - coordinate GEOMETRY NOT NULL, - creation_time TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX coordinate_idx ON location (coordinate); -CREATE INDEX creation_time_idx ON location (creation_time); - -insert into public.location (id, person_id, coordinate, creation_time) values (29, 1, '010100000000ADF9F197925EC0FDA19927D7C64240', '2020-08-18 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (30, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-08-15 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (31, 5, '010100000000ADF9F197925EC0FDA19927D7C64240', '2020-08-15 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (32, 1, '0101000000477364E597925EC0FDA19927D7C64240', '2020-08-15 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (33, 1, '0101000000477364E597925EC021787C7BD7C64240', '2020-08-19 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (34, 6, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (36, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (37, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (38, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (39, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (40, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (41, 1, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (42, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (43, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-06 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (44, 6, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (45, 6, '0101000000554FE61F7D87414002D9EBDD9FA45AC0', '2020-07-05 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (46, 6, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-04-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (47, 6, '0101000000895C70067F874140971128AC9EA45AC0', '2020-05-01 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (48, 6, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (49, 8, '0101000000895C70067F874140CDB1BCAB9EA45AC0', '2020-07-07 10:38:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (50, 8, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-07 10:38:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (51, 8, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-01 10:38:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (52, 9, '0101000000895C70067F874140971128AC9EA45AC0', '2020-07-01 10:38:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (53, 9, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (54, 9, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2019-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (55, 5, '0101000000842FA75F7D874140CEEEDAEF9A645AC0', '2019-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (56, 5, '0101000000842FA75F7D074140CEEEDAEF9A645AC0', '2019-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (57, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (58, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (59, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (60, 8, '0101000000842FA75F7D874140CEEEDAEF9AA45AC0', '2020-07-06 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (61, 8, '0101000000842FA75F7D874140DA0FC2ED9AA45AC0', '2020-07-05 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (62, 8, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (63, 5, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (64, 6, '0101000000842FA75F7D8741403A18FBDC9AA45AC0', '2020-01-05 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (65, 9, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (66, 5, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (67, 8, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-07-07 10:37:06.000000'); -insert into public.location (id, person_id, coordinate, creation_time) values (68, 6, '010100000097FDBAD39D925EC0D00A0C59DDC64240', '2020-08-15 10:37:06.000000'); diff --git a/modules/person/db/script.sql b/modules/person/db/script.sql deleted file mode 100644 index ef01b1385..000000000 --- a/modules/person/db/script.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE person ( - id SERIAL PRIMARY KEY, - first_name VARCHAR NOT NULL, - last_name VARCHAR NOT NULL, - company_name VARCHAR NOT NULL -); - -insert into public.person (id, first_name, last_name, company_name) values (5, 'Taco', 'Fargo', 'Alpha Omega Upholstery'); -insert into public.person (id, first_name, last_name, company_name) values (6, 'Frank', 'Shader', 'USDA'); -insert into public.person (id, first_name, last_name, company_name) values (1, 'Pam', 'Trexler', 'Hampton, Hampton and McQuill'); -insert into public.person (id, first_name, last_name, company_name) values (8, 'Paul', 'Badman', 'Paul Badman & Associates'); -insert into public.person (id, first_name, last_name, company_name) values (9, 'Otto', 'Spring', 'The Chicken Sisters Restaurant'); diff --git a/modules/person/scripts/run_db_command.sh b/modules/person/scripts/run_db_command.sh deleted file mode 100644 index c415a3ef3..000000000 --- a/modules/person/scripts/run_db_command.sh +++ /dev/null @@ -1,14 +0,0 @@ -# Usage: pass in the DB container ID as the argument - -# Set database configurations -export CT_DB_USERNAME=person -export CT_DB_NAME=person-db - - -cat ./db/ddl.sql | kubectl exec postgres-person-db-6786975574-2zs6q -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" - -cat ./db/data.sql | kubectl exec -i $1 -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" - - - -cat ./db/ddl.sql | kubectl exec postgres-person-db-6786975574-2zs6q -- bash -c "psql -U $CT_DB_USERNAME -d $CT_DB_NAME" \ No newline at end of file From ed4cff5bfeeb4fe2d1a28f75cae923f4c7a4c95d Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Mon, 18 Dec 2023 16:49:37 +0700 Subject: [PATCH 03/12] refactor --- deployment/postgres.yaml | 3 +- env-dev | 7 ++ .../connection/app/udaconnect/controllers.py | 8 +- modules/person/app/udaconnect/__init__.py | 4 +- modules/person/app/udaconnect/controllers.py | 2 +- modules/person/app/udaconnect/schemas.py | 2 +- modules/person/app/udaconnect/services.py | 4 +- modules/person/deployment/db-configmap.yaml | 9 -- modules/person/deployment/db-secret.yaml | 7 -- modules/person/deployment/person-api.yaml | 63 -------------- modules/person/deployment/postgres.yaml | 86 ------------------- modules/person_rpc/Dockerfile | 12 +++ modules/person_rpc/app/__init__.py | 26 ++++++ modules/person_rpc/app/config.py | 58 +++++++++++++ modules/person_rpc/app/routes.py | 5 ++ modules/person_rpc/app/udaconnect/__init__.py | 8 ++ .../person_rpc/app/udaconnect/controllers.py | 27 ++++++ modules/person_rpc/app/udaconnect/models.py | 27 ++++++ modules/person_rpc/app/udaconnect/schemas.py | 15 ++++ modules/person_rpc/app/udaconnect/services.py | 35 ++++++++ modules/person_rpc/requirements.txt | 26 ++++++ modules/person_rpc/wsgi.py | 7 ++ notes.md | 19 ++++ scripts/run_db_command.sh | 0 24 files changed, 283 insertions(+), 177 deletions(-) create mode 100755 env-dev delete mode 100644 modules/person/deployment/db-configmap.yaml delete mode 100644 modules/person/deployment/db-secret.yaml delete mode 100644 modules/person/deployment/person-api.yaml delete mode 100644 modules/person/deployment/postgres.yaml create mode 100644 modules/person_rpc/Dockerfile create mode 100644 modules/person_rpc/app/__init__.py create mode 100644 modules/person_rpc/app/config.py create mode 100644 modules/person_rpc/app/routes.py create mode 100644 modules/person_rpc/app/udaconnect/__init__.py create mode 100644 modules/person_rpc/app/udaconnect/controllers.py create mode 100644 modules/person_rpc/app/udaconnect/models.py create mode 100644 modules/person_rpc/app/udaconnect/schemas.py create mode 100644 modules/person_rpc/app/udaconnect/services.py create mode 100644 modules/person_rpc/requirements.txt create mode 100644 modules/person_rpc/wsgi.py mode change 100644 => 100755 scripts/run_db_command.sh diff --git a/deployment/postgres.yaml b/deployment/postgres.yaml index debd18781..6dfa3bda3 100644 --- a/deployment/postgres.yaml +++ b/deployment/postgres.yaml @@ -58,7 +58,8 @@ spec: containers: - name: postgres image: postgis/postgis:12-2.5-alpine - imagePullPolicy: "IfNotPresent" + imagePullPolicy: Always + restartPolicy: Always ports: - containerPort: 5432 env: diff --git a/env-dev b/env-dev new file mode 100755 index 000000000..1ebfc288e --- /dev/null +++ b/env-dev @@ -0,0 +1,7 @@ +#!/bin/bash + +export DB_USERNAME="ct_admin" +export DB_NAME="geoconnections" +export DB_PASSWORD="wowimsosecure" +export DB_HOST="localhost" +export DB_PORT="5432" \ No newline at end of file diff --git a/modules/connection/app/udaconnect/controllers.py b/modules/connection/app/udaconnect/controllers.py index 9993639b5..4e2d0ff71 100644 --- a/modules/connection/app/udaconnect/controllers.py +++ b/modules/connection/app/udaconnect/controllers.py @@ -1,12 +1,10 @@ from datetime import datetime -from app.udaconnect.models import Connection, Location, Person +from app.udaconnect.models import Connection from app.udaconnect.schemas import ( - ConnectionSchema, - LocationSchema, - PersonSchema, + ConnectionSchema ) -from app.udaconnect.services import ConnectionService, LocationService, PersonService +from app.udaconnect.services import ConnectionService from flask import request from flask_accepts import accepts, responds from flask_restx import Namespace, Resource diff --git a/modules/person/app/udaconnect/__init__.py b/modules/person/app/udaconnect/__init__.py index 5cef3c90b..684e4f074 100644 --- a/modules/person/app/udaconnect/__init__.py +++ b/modules/person/app/udaconnect/__init__.py @@ -1,5 +1,5 @@ -from app.udaconnect.models import Connection, Location, Person # noqa -from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema # noqa +from app.udaconnect.models import Person # noqa +from app.udaconnect.schemas import PersonSchema # noqa def register_routes(api, app, root="api"): diff --git a/modules/person/app/udaconnect/controllers.py b/modules/person/app/udaconnect/controllers.py index 276d3195f..e0176ea6c 100644 --- a/modules/person/app/udaconnect/controllers.py +++ b/modules/person/app/udaconnect/controllers.py @@ -4,7 +4,7 @@ from app.udaconnect.schemas import ( PersonSchema, ) -from app.udaconnect.services import ConnectionService, LocationService, PersonService +from app.udaconnect.services import PersonService from flask import request from flask_accepts import accepts, responds from flask_restx import Namespace, Resource diff --git a/modules/person/app/udaconnect/schemas.py b/modules/person/app/udaconnect/schemas.py index 85f0e2c25..879b2136a 100644 --- a/modules/person/app/udaconnect/schemas.py +++ b/modules/person/app/udaconnect/schemas.py @@ -1,4 +1,4 @@ -from app.udaconnect.models import Connection, Location, Person +from app.udaconnect.models import Person from geoalchemy2.types import Geometry as GeometryType from marshmallow import Schema, fields from marshmallow_sqlalchemy.convert import ModelConverter as BaseModelConverter diff --git a/modules/person/app/udaconnect/services.py b/modules/person/app/udaconnect/services.py index 1d6fa81c1..e998bc30d 100644 --- a/modules/person/app/udaconnect/services.py +++ b/modules/person/app/udaconnect/services.py @@ -3,8 +3,8 @@ from typing import Dict, List from app import db -from app.udaconnect.models import Connection, Location, Person -from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema +from app.udaconnect.models import Person +from app.udaconnect.schemas import PersonSchema from geoalchemy2.functions import ST_AsText, ST_Point from sqlalchemy.sql import text diff --git a/modules/person/deployment/db-configmap.yaml b/modules/person/deployment/db-configmap.yaml deleted file mode 100644 index cb55c9b40..000000000 --- a/modules/person/deployment/db-configmap.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -data: - DB_USERNAME: "person" - DB_NAME: "person-db" - DB_HOST: "postgres-person-db" - DB_PORT: "5432" -metadata: - name: db-env \ No newline at end of file diff --git a/modules/person/deployment/db-secret.yaml b/modules/person/deployment/db-secret.yaml deleted file mode 100644 index 6c4028b39..000000000 --- a/modules/person/deployment/db-secret.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: db-secret -type: Opaque -data: - DB_PASSWORD: MTIzNDU2Cg== # 123456 \ No newline at end of file diff --git a/modules/person/deployment/person-api.yaml b/modules/person/deployment/person-api.yaml deleted file mode 100644 index b4370bdfb..000000000 --- a/modules/person/deployment/person-api.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - service: person-api - name: person-api -spec: - ports: - - name: "5000" - port: 5000 - targetPort: 5000 - nodePort: 30002 - selector: - service: person-api - type: NodePort ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - service: person-api - name: person-api -spec: - replicas: 1 - selector: - matchLabels: - service: person-api - template: - metadata: - labels: - service: person-api - spec: - containers: - - image: kydq2022/person-api:latest - name: person-api - imagePullPolicy: Always - env: - - name: DB_USERNAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: DB_NAME - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - - name: DB_HOST - valueFrom: - configMapKeyRef: - name: db-env - key: DB_HOST - - name: DB_PORT - valueFrom: - configMapKeyRef: - name: db-env - key: DB_PORT - restartPolicy: Always diff --git a/modules/person/deployment/postgres.yaml b/modules/person/deployment/postgres.yaml deleted file mode 100644 index 7889e16ed..000000000 --- a/modules/person/deployment/postgres.yaml +++ /dev/null @@ -1,86 +0,0 @@ -kind: PersistentVolume -apiVersion: v1 -metadata: - name: postgres-person-db-volume - labels: - type: local - app: postgres-person-db -spec: - storageClassName: manual - capacity: - storage: 256Mi - accessModes: - - ReadWriteMany - hostPath: - path: "/mnt/data" ---- -kind: PersistentVolumeClaim -apiVersion: v1 -metadata: - name: postgres-pv-claim - labels: - app: postgres-person-db -spec: - storageClassName: manual - accessModes: - - ReadWriteMany - resources: - requests: - storage: 256Mi ---- -apiVersion: v1 -kind: Service -metadata: - name: postgres-person-db - labels: - app: postgres-person-db -spec: - type: NodePort - selector: - app: postgres-person-db - ports: - - port: 5432 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: postgres-person-db -spec: - replicas: 1 - selector: - matchLabels: - app: postgres-person-db - template: - metadata: - labels: - app: postgres-person-db - spec: - containers: - - name: postgres - image: postgis/postgis:12-2.5-alpine - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - env: - - name: POSTGRES_USER - valueFrom: - configMapKeyRef: - name: db-env - key: DB_USERNAME - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: db-secret - key: DB_PASSWORD - - name: POSTGRES_DB - valueFrom: - configMapKeyRef: - name: db-env - key: DB_NAME - volumeMounts: - - mountPath: /var/lib/postgresql/data - name: postgresdb - volumes: - - name: postgresdb - persistentVolumeClaim: - claimName: postgres-pv-claim diff --git a/modules/person_rpc/Dockerfile b/modules/person_rpc/Dockerfile new file mode 100644 index 000000000..1ef643ff1 --- /dev/null +++ b/modules/person_rpc/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.7-alpine + +WORKDIR . + +RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +EXPOSE 5000 + +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/person_rpc/app/__init__.py b/modules/person_rpc/app/__init__.py new file mode 100644 index 000000000..627a5c5f6 --- /dev/null +++ b/modules/person_rpc/app/__init__.py @@ -0,0 +1,26 @@ +from flask import Flask, jsonify +from flask_cors import CORS +from flask_restx import Api +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def create_app(env=None): + from app.config import config_by_name + from app.routes import register_routes + + app = Flask(__name__) + app.config.from_object(config_by_name[env or "test"]) + api = Api(app, title="UdaConnect API", version="0.1.0") + + CORS(app) # Set CORS for development + + register_routes(api, app) + db.init_app(app) + + @app.route("/health") + def health(): + return jsonify("healthy") + + return app diff --git a/modules/person_rpc/app/config.py b/modules/person_rpc/app/config.py new file mode 100644 index 000000000..827b6a14a --- /dev/null +++ b/modules/person_rpc/app/config.py @@ -0,0 +1,58 @@ +import os +from typing import List, Type + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + + +class BaseConfig: + CONFIG_NAME = "base" + USE_MOCK_EQUIVALENCY = False + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class DevelopmentConfig(BaseConfig): + CONFIG_NAME = "dev" + SECRET_KEY = os.getenv( + "DEV_SECRET_KEY", "You can't see California without Marlon Widgeto's eyes" + ) + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class TestingConfig(BaseConfig): + CONFIG_NAME = "test" + SECRET_KEY = os.getenv("TEST_SECRET_KEY", "Thanos did nothing wrong") + DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = True + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +class ProductionConfig(BaseConfig): + CONFIG_NAME = "prod" + SECRET_KEY = os.getenv("PROD_SECRET_KEY", "I'm Ron Burgundy?") + DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + TESTING = False + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + +EXPORT_CONFIGS: List[Type[BaseConfig]] = [ + DevelopmentConfig, + TestingConfig, + ProductionConfig, +] +config_by_name = {cfg.CONFIG_NAME: cfg for cfg in EXPORT_CONFIGS} diff --git a/modules/person_rpc/app/routes.py b/modules/person_rpc/app/routes.py new file mode 100644 index 000000000..c6b1c20eb --- /dev/null +++ b/modules/person_rpc/app/routes.py @@ -0,0 +1,5 @@ +def register_routes(api, app, root="api"): + from app.udaconnect import register_routes as attach_udaconnect + + # Add routes + attach_udaconnect(api, app) diff --git a/modules/person_rpc/app/udaconnect/__init__.py b/modules/person_rpc/app/udaconnect/__init__.py new file mode 100644 index 000000000..684e4f074 --- /dev/null +++ b/modules/person_rpc/app/udaconnect/__init__.py @@ -0,0 +1,8 @@ +from app.udaconnect.models import Person # noqa +from app.udaconnect.schemas import PersonSchema # noqa + + +def register_routes(api, app, root="api"): + from app.udaconnect.controllers import api as udaconnect_api + + api.add_namespace(udaconnect_api, path=f"/{root}") diff --git a/modules/person_rpc/app/udaconnect/controllers.py b/modules/person_rpc/app/udaconnect/controllers.py new file mode 100644 index 000000000..12651d2e9 --- /dev/null +++ b/modules/person_rpc/app/udaconnect/controllers.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from app.udaconnect.models import Person +from app.udaconnect.schemas import ( + PersonSchema, +) +from app.udaconnect.services import PersonService +from flask import request +from flask_accepts import accepts, responds +from flask_restx import Namespace, Resource +from typing import Optional, List + +DATE_FORMAT = "%Y-%m-%d" + +api = Namespace("UdaConnect", description="Connections via geolocation.") # noqa + + +# TODO: This needs better exception handling + + +@api.route("/persons") +class PersonsResource(Resource): + + @responds(schema=PersonSchema, many=True) + def get(self) -> List[Person]: + persons: List[Person] = PersonService.retrieve_all() + return persons diff --git a/modules/person_rpc/app/udaconnect/models.py b/modules/person_rpc/app/udaconnect/models.py new file mode 100644 index 000000000..8cac7f6b5 --- /dev/null +++ b/modules/person_rpc/app/udaconnect/models.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from app import db # noqa +from geoalchemy2 import Geometry +from geoalchemy2.shape import to_shape +from shapely.geometry.point import Point +from sqlalchemy import BigInteger, Column, Date, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.hybrid import hybrid_property + + +class Person(db.Model): + __tablename__ = "person" + + id = Column(Integer, primary_key=True) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) + company_name = Column(String, nullable=False) + + + +@dataclass +class Connection: + person: Person diff --git a/modules/person_rpc/app/udaconnect/schemas.py b/modules/person_rpc/app/udaconnect/schemas.py new file mode 100644 index 000000000..879b2136a --- /dev/null +++ b/modules/person_rpc/app/udaconnect/schemas.py @@ -0,0 +1,15 @@ +from app.udaconnect.models import Person +from geoalchemy2.types import Geometry as GeometryType +from marshmallow import Schema, fields +from marshmallow_sqlalchemy.convert import ModelConverter as BaseModelConverter + + +class PersonSchema(Schema): + id = fields.Integer() + first_name = fields.String() + last_name = fields.String() + company_name = fields.String() + + class Meta: + model = Person + diff --git a/modules/person_rpc/app/udaconnect/services.py b/modules/person_rpc/app/udaconnect/services.py new file mode 100644 index 000000000..e998bc30d --- /dev/null +++ b/modules/person_rpc/app/udaconnect/services.py @@ -0,0 +1,35 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List + +from app import db +from app.udaconnect.models import Person +from app.udaconnect.schemas import PersonSchema +from geoalchemy2.functions import ST_AsText, ST_Point +from sqlalchemy.sql import text + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("udaconnect-api") + + +class PersonService: + @staticmethod + def create(person: Dict) -> Person: + new_person = Person() + new_person.first_name = person["first_name"] + new_person.last_name = person["last_name"] + new_person.company_name = person["company_name"] + + db.session.add(new_person) + db.session.commit() + + return new_person + + @staticmethod + def retrieve(person_id: int) -> Person: + person = db.session.query(Person).get(person_id) + return person + + @staticmethod + def retrieve_all() -> List[Person]: + return db.session.query(Person).all() diff --git a/modules/person_rpc/requirements.txt b/modules/person_rpc/requirements.txt new file mode 100644 index 000000000..652e39c33 --- /dev/null +++ b/modules/person_rpc/requirements.txt @@ -0,0 +1,26 @@ +aniso8601==7.0.0 +attrs==19.1.0 +Click==7.0 +Flask==1.1.1 +flask-accepts==0.10.0 +flask-cors==3.0.8 +Flask-RESTful==0.3.7 +flask-restplus==0.12.1 +Flask-Script==2.0.6 +Flask-SQLAlchemy==2.4.0 +GeoAlchemy2==0.8.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.0.2 +MarkupSafe==1.1.1 +marshmallow==3.7.1 +marshmallow-sqlalchemy==0.23.1 +psycopg2-binary==2.8.5 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +shapely==1.7.0 +SQLAlchemy==1.3.19 +Werkzeug==0.16.1 +flask-restx==0.2.0 diff --git a/modules/person_rpc/wsgi.py b/modules/person_rpc/wsgi.py new file mode 100644 index 000000000..63fc43373 --- /dev/null +++ b/modules/person_rpc/wsgi.py @@ -0,0 +1,7 @@ +import os + +from app import create_app + +app = create_app(os.getenv("FLASK_ENV") or "test") +if __name__ == "__main__": + app.run(debug=True) diff --git a/notes.md b/notes.md index b3bcf5d31..413b6a44b 100644 --- a/notes.md +++ b/notes.md @@ -88,3 +88,22 @@ https://www.jaegertracing.io/ https://grpc.io/ +## gRPC Python + +https://grpc.io/docs/languages/python/quickstart/ + +``` + +``` + +# Postgres + +```shell + +kubectl apply -f deployment/db-configmap.yaml + +kubectl apply -f deployment/db-secret.yaml + +kubectl apply -f deployment/postgres.yaml + +``` \ No newline at end of file diff --git a/scripts/run_db_command.sh b/scripts/run_db_command.sh old mode 100644 new mode 100755 From cc7669bc1c0d08189e404697d4a4e43bc197e9a5 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Tue, 19 Dec 2023 23:42:35 +0700 Subject: [PATCH 04/12] gRPC server & client --- docs/system-architecture.png | Bin 10911 -> 71972 bytes docs/system-architecture.puml | 39 ----------- env-dev | 5 +- modules/connection/app/udaconnect/services.py | 15 +++- modules/connection/persons_pb2.py | 32 +++++++++ modules/connection/persons_pb2_grpc.py | 66 ++++++++++++++++++ modules/connection/requirements.txt | 2 + modules/person/app/udaconnect/controllers.py | 6 -- modules/person_rpc/app/__init__.py | 26 ------- modules/person_rpc/app/client.py | 29 ++++++++ modules/person_rpc/app/config.py | 58 --------------- modules/person_rpc/app/persons_pb2.py | 32 +++++++++ modules/person_rpc/app/persons_pb2_grpc.py | 66 ++++++++++++++++++ modules/person_rpc/app/routes.py | 5 -- modules/person_rpc/app/server.py | 64 +++++++++++++++++ modules/person_rpc/app/udaconnect/__init__.py | 8 --- .../person_rpc/app/udaconnect/controllers.py | 27 ------- modules/person_rpc/app/udaconnect/models.py | 27 ------- modules/person_rpc/app/udaconnect/schemas.py | 15 ---- modules/person_rpc/app/udaconnect/services.py | 35 ---------- modules/person_rpc/requirements.txt | 1 + modules/person_rpc/wsgi.py | 2 +- protobufs/REAME.md | 21 ++++++ protobufs/persons.proto | 20 ++++++ 24 files changed, 351 insertions(+), 250 deletions(-) delete mode 100644 docs/system-architecture.puml create mode 100644 modules/connection/persons_pb2.py create mode 100644 modules/connection/persons_pb2_grpc.py delete mode 100644 modules/person_rpc/app/__init__.py create mode 100644 modules/person_rpc/app/client.py delete mode 100644 modules/person_rpc/app/config.py create mode 100644 modules/person_rpc/app/persons_pb2.py create mode 100644 modules/person_rpc/app/persons_pb2_grpc.py delete mode 100644 modules/person_rpc/app/routes.py create mode 100644 modules/person_rpc/app/server.py delete mode 100644 modules/person_rpc/app/udaconnect/__init__.py delete mode 100644 modules/person_rpc/app/udaconnect/controllers.py delete mode 100644 modules/person_rpc/app/udaconnect/models.py delete mode 100644 modules/person_rpc/app/udaconnect/schemas.py delete mode 100644 modules/person_rpc/app/udaconnect/services.py create mode 100644 protobufs/REAME.md create mode 100644 protobufs/persons.proto diff --git a/docs/system-architecture.png b/docs/system-architecture.png index 24678ba15b113ccb2699e1c215c9d5810cb901db..24bbfb513a959d6319e3da6f0cf63ad93d22255e 100644 GIT binary patch literal 71972 zcmd>n2RxPS|36YvA)}B{vS;?l%sR4nWbeJ_u|>)}WR+E%hKy`cL}e>d$d(m}aEfgI z>l{vxr+UWs8NY9R|DImu+~>Zp@tN<>xb7!hRT+YJ;Mf5S3=F)pa?DamNpjV+xKvDa`UirakFy?X>xMY^PJ&B{>915 zAs`@de*1nCD+|Y+0hOG5Z0zk#=(%P2I5@#jOgda#^gNQ_H$@vq4{z|Vw;;a}KQA-* z?ToXty~Q~TQw19^uPh&r00$30@(~3&O(jKoZYl7)y^Wm(_y=NPX6J-_M9SLL$pKuE z<>CV4BmV;zHB2l`Ty1s_A!Ba!Y1k#dE|3JLH?>RM^C zd&sI->&V$}-{WcF>Sp8QxU+k14lWL`2k(S6-I(S1|O`NTjoXjo2S~$(Uw}<88 z;}P6mnmKg)iV!dF_5~|f^gKJ$A}?sz`0Ol*YiHY59yaC{ZabrGKk4q|Wbba{{OgNm zPL7TiW;?vx8PUYm)yeDEw^=&b@9b{p9%r!eUyY8upkZcW|LxUtHs1E;PR!Owc#)-0Eg+V(#R%{XFW@Zmw+85lu~3Cnqr8E~f4N8hw+4x0HoF zlGtcE?#u(^=Rbe;3pHH7D8%0x&e1|iUB|@Mja}fZkcG0)xw953P_%e?n%H~n5LZ@a zo1<>-P&B<=oje@PQ3?!1Rnp7a#@#~0*~AR_fESQ3aLwA?!5(}u&C@PIeZ%zTr0$v@o><-)zqZ^u*l)Eu!0(oK1e&If@v+PySz+0I>ji z?d}Q%jMy2GcZYI263>hN?DdQMa3P;ZYPIz)*+JiDvLmuqUq(jW`DfS)?%9!w-(Nk_ zqJAYj-{qLl4!M5em@_cIR<1xHfsUJQfBO$HkIU4A%L16HZ<)u*$uDyT$h4%TlcW1C zrdpzY{*Ra{$nzCf(FW>|xXQot+>gUmZs4ns617Vazu}~XIk18|T4`Z#>f{9+6Z(QA z>H<(Xq#D@(QwdmL;b?vaX>Gw(GkX&^HyflH?$T5<4_D7GTK#Js2aoQUeL?Vyo4bjt z`>xNx9JxYuz_6Sge5iTAM_uGc^ap9A*8TDcsdUIsUzrAe6HZP`qyhNGH1G&;a!N^o zZ?9H zi+SQfOYV++;uic$LfxD^T+J+Yp89j9=MSb7L{EV>iRdMuHwTz*W#RrerUb%gf;7SC z0iBV123G3V*k|`Lf9rRoZTyO2d;-7LeQvZ)|FKQ}c9!pq&-?ZGXixB;jn4ycr|dQ% z{)~X2ZU?vx?PY%dnVq{)UgMAM{BzEQdj}crGD8plloUpUy9nK1nJA|KkE zd~J|-xeYEf{MhX`c+d}`S@hpWjC?zU`~@+7L+@XE|Nji(TX324SqK32{Kkz7@eA;n z@c*9T{-J_y3(H?n&_Ci67uufxIDGmR0&ZJfc6TRdfZe_}xWDiW8Fu|=JOdmPH0S*u z&jh(mdHDZJJloMdG!WTQJ+wmY<|z7>-I|9s`acds1-?-{dnYr1CY>DrKWFKegv`&! z({BNif{CRaNKt+Thd?d^q>FZkTfa7sj!uq9(6c)v+PU=S5!3f{{uirUi|`1`4q z?;@jr!tkOscej?Jeddp&rRe164~<8@1cbkbN4`hzFS+U;h~PWu{Pzg{Ycc+wpzlg# z>{cRrZXQcM81V;oo+;d%Mi=-5XKi`QLN!=%T`IXMn!;FJ}zVu{GNFpj{SvdNe%z z=L-#>Wnx=G(Nm(U@#rak>_P+g*AzqN9{y~;6`eo+sR{vTvDoE3zA-cZ^(5`rK@lGs zZ0$Dd-^JAE+80^`e}E+Ix0w1Hg8zb}kp}NS3yMrl%mmGOzGp$Xd3bsG{vPPsmX^O~ z8@ay6s4qPkKM9L^&i&0{xTQQKnrbA|HvKw zh*tL7V%ScRAK6R$&)eC5wIKGF8TlJ__9xcIZ%bmoV(WIUiPOy92HEoZ&!#8-#iY|e z%wTSGkz}{xqOtAn*byC?{6OgF+xFCN*!Ux+CyTx{m1b(=u9k?3cK5WMJW_KtK5aPFkI(8~GGn*%|C zog~Z7lwXdjp{M+@?ZMZ}=NH(a*#EXc__l1Qs-l5BT4&~JVd9Q_FU9Um=>J0Cx@`mh zf`ab^uHbMT@0V9o?8y3#S^bv-S9IQer!4sExzSqoQ%14h7CN^L^;ZWu|L~ak|A*aU zJEFI3T>sbm#CXx=h8@}eazGs|{r|jM`{KrSru=fE8$IQZ?bg15x&MFQ+&@uZ{|RE_ zKZLm6Htsq|G>iCKK~OT z_Sab^WCaaX3r1Ulf5G^CQ$+b{%D-%U{@cgx!C_t$?RHg4q|k^H1Z}9dI{Z(dO_U}2 zTWFJqlY0jr{mb|gEyq74g#5OJ<41;&U!4s!Gq*4mGzC`tn})5g&K~`4>EwSSbMY0# zjS>(XL=Suyn?;QGi`F!Zy&;NAh0-c`O zTdyYD0ctxZjCPFYx< zUzY!$86~s<{pLk$T^k3N!T9;VT=;iP z*dGR^eE$=8=}VV9+BBhM1Z^k&dDF!8m1*Kd=akS>{@AAJYl?k&soH;LntnFZvE#9S zI@9rMv++kMv26_a$6?Z+X?swe2tcVF8lDM zIqoO34=4lm8}Ellr~iI=6aQ{GM}3Qa_y6`6I}d=o1$aLg@^$m&{;M;8AR^2o2uHk^3T!CaYd3uh4V8G^#p~`C^oa zk|@h5T`E3VLelc>(6vgp$r`)rF3FggkF)VreuI-4E|rTQQEy^L;m-sP9r;rqrcE6!jtLEw<1&5`DR`qeHEXEB9Oh zK20p3ds4CQ$G1uq4<0lKJ&J8~=r3>fzzr7j`apSwK`H0F2<2rIEo&bD+ICO0_ioeH z{<1`wZP$xDU7k|gE*9C&;NW1ntDKoEhzkET&nPP4qvYJ?wf*;<2#JU;OtdD`>K2=m z@mL=6oaw>D!NFm+s@Ygd%{vrPp7nrP%wM6dyTC}CIqoEf+AN#K?L*)b%w?>BMlDOa zxIInC&77~)v_7JEb!Do9Hcik)Qk$%vY;L#)(vcLWXLj3qb1X{hQk zpv#M!XNfB+D{0Q%)9pnjE_a)ng&3GLdXlk^ zR;P1ytTf<=3cJ@FytbYDw>H;KU7_w>o5DFl)(=-pAK8mMrQ(%yQs_CZ)zRK$0Q?d2^6##w+H3h47UeNXH$1_BI!!GPSI674A%Qar4a$QW__7Xl-1naqbCjvLu zi%&C;tL@Ld3ZZ=e?G7S|rbbv!D$?nz;8Hmb8pA`aXYOkrUO%ewMzc9x1BmL;!KU?{3%f0T}+ix9zN4h8DSq*tC- z?Ld4l8C$Nl0eAj$S_QEjv~=P5^ZB#2$FP^Bx{h@U-8s7dGCX5cXDUZ4H|wUH=0|B6 z<$)(GED^R%aKY0oj^;<^4wl=z+D9*+ATjdfVn>k~1j+@Am7$4CWYM}ZM=zTUp~;YR zy!a_y$W4~o?#M}&o2rz0O7RSMmThU_@r)`hsTyYSkPhXhiIJz5;DwD#ZE$b#mxF@_ z)Ott!BS@G}?v0-lu{F8Ry$_fd4X{ee7WiG?OB=v&n$pxB&lxznq$r^|CM~0+Irikq zlW{H;HGkrhj4Yl-RC>=-_*m;?1mjfv8nx@yG}Y>f8zJ?+u+b-!3o0&YT5>twjcSyH zM-pXR)KuJ|`qe&VRb-G-0(|@=({p9ET2QH(<%O|0K^N0rqpDV>n2`nsSk2}t>pi0y z4p^P6SW)v>y0F(x#mE(4bQPc@F;|T=&q7UNL<5tZXX%Xlv6p@ECpyZ#IN^+#(S&aZ zw4&)lN2VfzVl@^P$VN>m*+m-yK83|9xtPEXD~KUYyRpLdGf7LVX7rfPiNQ0^?_9?0 z*q$kZ@V$U7Cn1Bi zo|$t}5nl%V?G0wEbqep-2&sE`v(P{!%33_&l?m!Rlk;c~Uq$i0i2U$djzS-=`y z?XVjYY^m~Nr7aOBF?gDfSO;I>%hMj)PcJGpVaQ3SfaPha^p2nZ;+?aIQIrdSfW2nR z1&M2Fg*In?`IdKk>wIN2B>i@O?(WnrU&bjC!%z+I3*)n2;4%|Ve|6m$nuDtQb z_39fUIS~mVy%?A+v=bfs%9*9;FvoCJsiUnLsHX|RmyPO?v})94SC6zdN<&LU+}f=D zfeUJZFM83?p1|Y*bEaUjT;jb7xMQrz-y4gtj?i7CnjM}-%q^Qs~Xf!d-WSC*C5WH>xcIrfZF*v&zNXoIkttz3CZC5 zr5EuQKb+)KU`v3M7Lx)HY2P-2C7Rn&tzEiG+Se=-Fx zm(%^ziBkK4V>aq{>boe;aR$^$KX)#=F9F0m$>rWT6c1L?C9odQ8FN4fpceF-{w{lj zULBEK9oMCi4uv@o9)7YlKOtVRGR0PWv+3g8M_zS*%mheRoUN?{--Jt_Q+(0f8$-7A z`$Ue8j@J#W?qtlWJ}@UQwHdnaF9I=J)a3xPH+gWv9gnh(#v$}NiSO|pRO>foF+cBx( zL^28ued56ySgai{=SmYG8*h_XBU3RZ;t)-91Vk_BG0P^@D76ON{Ha(bopKZmm#VS{ zNs}{QCNFA=K9PrZevVO>qJ|Jq;JD~(T{dg@L=F#{?YqCnj4}GjTP@)?Z{7s;MF-ZZ zP(6TqEsL6P$yor?Skcq#4JNej+N$wc(Mz()NJVo`dif_a^WK9GlZ;4#ZYRs z`Y@I-Hi&$GHam=>tsR1(dl$>@A;u%!N|S%~&C)9c@0GWN(NDc1`qL=V_UV1%o%jbjqJbk z@b~v?t_nW6GspzX$?P1Lke?{Bn^W#7cO>wf8Hx~3Vy|mBq6atUdZUXWt3nn`5ona0 z*eLw@!wsd9@~NT9*Nh0kdM~UCQWt1)VA1ad;`78_8^v=v2r{+6@fcy5sfs}eIVdmm zV<9cB^|hcrgrEzIbUJw|bAq0Se=l}-+SRb3LQSI>|S&FxxMX-BHMrB48Vi?{FIK zR$7R#X2j0#o?odmSg{5&rO$%II#Y;i+?mJY6}sN z$*s&}g4wFf~&`?)J3kp*BSn z^LZTDfnVze%Pi#x)JsiASlf}8I94S<-XmVO%p^ea%`dpCSm6(1R<;qHp}^iCpx;hEeE}s zL8w)b*vkw%-;6z0NQV;sD{IkiaY6OqG4`=zK$T%9Qyml4_TK5+{2 z08gV%{k*hHkPTM{1h*Xz)*T!?Tj2Q?&mJtOQ|GrDv6g61mtDbaFgc6zKJ=d{CQo8U zg2&DuD$5Fwm}ClmerY|9L+Z557}KmGR8#)Nqg4&+S=M2f{3Jw^zcCC&3*Ujy`XyAr zAT}tvdV32}Qc>|GuUM&Er;*7a(S`x(sLaoN7sLchQHIrD04jWlkMZSUuTkuy((AAOeSQPl@_I}<@R%% zw*(dOG@kTt_@pCfMYfu8?%qbn1qB6-dzLeopE?m*Dy3a~eo25f(N{i-;xzu;(4&xc>EkVM ztr`Wv%DZ5Lx39c9C*A^oglO&}lSLUG`#Xh4aC^uwMO(}$+W7^2X0xA3>sXT=@K+5kvoHD9JBxEk zUcj#3cN&ssKGL4#p;zd*a{8Gr7o=ruL8_5!?DIQ>9fW|o{_111B9kqj7I?J35!V0# z*vA0w1)!8`wbUp>wp1}!X(@c-1_U}YbQ{MrV}LNR+O#*)Z1w|R@fEM7P9a^71XJ>( zVVyPuMXrtV%rFIkQ)jvUkr2px~aGFqql&#Q~ zE>^f|Q9;LD8^#%-YRFPez=ThfIAkAf%;UDSH<41l1kE)*i-<dAC7^qFB0CK5p`sA0z}Q>lL48Y+Ey+^N^-X2Ya(B$NL z-IIOGc8#;smq}mWVB(^96(>Y*Sm1nk7$Q4Sku# zc6F7WcDLa)%_4Ap+eYruKOJdx>do}q2r;R(rbwum*zIGli@K~Z+dIlE&Rvdlt7FeG z4Do4(-_1~ZPjkzcB~28%FL(~iWo%l>{8`!=`NE3XM1(cYa1;X@a4%TAo<)KH+1JOp zNp435e1&-ASh(3L5-`M6*JBDjn7!KZq-zk8#KzQfPEGw5nCo$UkeUAQ^b`Z zRs!XW;)e0)ZYTiO&fZK!WT)%*$kQc2^qLd=8=G6;{*%Lo=Tv|*?|VNq`M^V2!U>W6 z(OuUcnkRBzbT1bc@T#oGqrws)A25lGC%866-y9|+%pCN6G6uWOE?0XG=;`d~4cSu} z2y0&M5>F5q9XW+_E*ygu#u7XZ{{N0ai4bjS2ivFIIz*faz{jbo+cN^MSXM@`y-t&f zINu;_9cWY_K_P-noVNS~=5r!2DL-Gzp(sRlekr&&kw8`omEvtNTlWq6;qOw_qqd@^ zjA~g)t$i+3Wcm78ts?DV62#Gmr#K+5odUTWf^>YKP^bw=c;!S}IqgxSo@E~^uQUwM zyyWmUT8)*q7B+o3_4tHZ?~thzQ6URJ@h(qSR^&41sETj3S#^21xGv%j1o+^d8WxdE zzu=%nk%q`VL_eWMF1}QL2#A`6pxaRg6u2lWqb0|~&6bvMfL0xGCilHpAO&!+Rz&Pm z>x;D?B3o78wfZ!D$T(LP%LHptRi{o|Fmq}JNa1`Kk5$J}l2j6DS+{u$2e;f=M`-7yL71bHXH{nql#X?5N|udSQUo+-X9Q14nzyCaYKZqpEv<%E&-xsFTL*!mMz#zj#9!HxfJ;F5W6;YVuz`d9TGK0icFR zY6T9na?=>NN`o(nK+M2xKjUBmEYQI{lkO=ekgrYh1zbXl49S9&3&k_?fFca#pC> z(j9C#E@6uSfpulni3dZG`&T!?Ng7{YQlm@78Hl&k+;_41x$=oOi~ebkstH!EByq`| z04`4jm+{33lyrymU!jQ$as{(b91^wch2VAH zQ`43o2QPLs8#Bg#&E|qQ2iqy6}v9a(4r~P2z5x)1Yd7(Tc?P^%51S_-# zVeM}G&nVfW5X%*%?sF_+7@3H)qBjUJY=O zzo|XJ1+ot@F)=MyNL1!pCr_CnrkLmT`1{A98KVASBzM)Xigv^H+!RSk} zHM-dPnoPv^=#_7w z-^hVH{Zb-H28}yP(Jnjmv*P zd(%#@l4ghmTsQ*{knN4IFr!FvD72-td5?by<-=7_y52XG+^WMrWY`NuMq zFFu$W9VG91uhkfxEAXPq@}RwN`4N@l;xZd8iz5%4NGDhlpC&Ep6`2navGtZN$A`|% z4+nQCbT_q(h4>Pv7F=P}cGL@I>jk;FEG?Z9clIPFgD~;+qbA*Tx;=j0JnEHq>MAnF z=0D5|FDrfuzXAbOL!mi@Lu;Iv**NM|bYiJElmT&T~>>C&lU|KOu#=SO=M2|gM=bjK4fn_=1eX^ioK$N{y1Y70_7 z(zO;#R3S63-hBOiQo+;jgfgp4P?Es=ZXfSu^PdSmwMSUx!{w9*I!mKE4}GKa2GT`6l(nhq ziI09SFskzn7m};XL-zu>-F{v_vgeZ84OPrT*E#jIN!Q$`iKX^G1qj{Z1Ly8PA^5ymO`!!%<8i=u9IUw5@k+Mwve#fnS+O}sv+)cgPkLWje~2T#_0d9d+8lS5;9hjW^1ECK%1Uxc=(i-e8#$w6|0m(C_E4P^Njf6tOX&FwQ%Q)4MW7ev2 z<;1v4Z_+Kql=UY@UOJLT#QrXeFZm9Ubr@+=*Z0IFPxG#1GN1PKki5-yZ8QP05MVaH zW-OGKZTg}25u-e{%!FE1$#VZoUBvq=S0?jwngaE&r7b$VK#)`RbPa#(+;yWO$GHb& ziH-U1@5?Nbx`o5@-|^f&SKcu?r3B?YsEwaPj53N`F+$Z+vqFR0p zjbr*@vQKMVbc(449>E~N)_1gtw?vPi8<_9~|@R!(Q$1*O~#F0WO6Xs6rUQZ<6= z$zD6sCNJ{(wTo>WZQ1=5_wqz!TgG}dthfT-`bAymGc;5$0a+9K;R-w+(&q_tBA(M- zgnS})O*zK>`)0?5VK8cu2n}lSwhHght{*-@bw+E*V8_K|(+_wQx~|DH-y0Qeo~7`5 z1H-Fn^`gF5ac`*d*tHV#C#rLq&MqE_q!==WS7{Ad@-;Hc{a`$LZ8OE(DK)BMk3MG4 z^hZ^nt))Zl_AJGvbUBIlNEGR!>9O?8Mn~*MPIq$KaunSQ^BGU~={~70+Pnk^ZT!5N zTYjrqRKGUush02wTH$A)mgv@%|}+5I{YMkq}-JNjY;FDEqMF!aL;t7 z_0G4B$H}FNIUItL2;^-}^PJJfPEVtZJ8f_*>grTTTBV!pVEO83@4<=(m09gC#1N(# zQ;nMSBFDFce2&#GoKq`0L_iSVs9!G}%M-XLBZtZ_VO+<3I`RCTB#3K8LTbH&D_j;C zxGmdG+|n7?JhoBZB7eyKLq7HLAH-mI?%U z;}q9d%<4S!EVF$K5Nc-U1CfBi+hlLOGGL|6Vt#ZGeV7m{th&$Fq`xD-MZ)gJ{aA<# zdCc;FY`(~LR@6EUx%}*U|K_W55?o;UMWbF_&h|bB>s?$h9Eg^0ubIvamMN9JK}HO= z2GsSFBHh+{TthPI)MGMquzc{(WZhA^8%I!l*Iqg~b^=oitXix7?%HDz^T~5fsOhZ~ z-nZ6)mfnwYt8PG5YNC5j*(+f2Bf%uk-Ivlg0G7Q;!?M4+X>9f3D^)s(OawuvT^p*Byt~}DC&9ST&hr@@XUvs^`lg!lOG8hd+XQT*Y5;6cuh>j!Foal&=m(DqtQ ze#UsmdZ}PY8CYe$OvcCa0I&i%dA`wDdA8ObxCcwA+}2=bKp|Eng^YQN01R}s`gM|n z3FaAVek55As!4eJfRq}wR!|eoGi5+E&$={;5R{L>b5sbE#%cizN#rTLLx>Es`L_qm z`%X@v^_Iu$h1Tqs9xVE4=~)a)$t>ijU!Q^ni*KITx~KneK`>x}A7qTjufKZ82!Syq zz8PG9m@TGCW@`hy50NLt&Ic1!hSCt)UM&UjZio#NJ7E4~>=`BMr%!3K>YlyCJ&fpq zej*ys*;452oV+(&Pat~xsh0##=Ur8~7PHCKBQLU61&X5&*X0b}Hr*`j&NeK^SSQGk zDmn9!v(k&UbCD_PMIoo+`Kp5lHweaBrU~V)vBl{oFA70PUV3|dWEkijCBVUfB&0t) zF{A_9&`z$}?*WcBV8($Qh;6I$TeiNFs3^q_!hMWLH9RQ4dF*cM){U;hy~~@aeuB_< z{DxKdby1s2BWi5->9}n-kFxdok>KOwXX|qlFhKc2Bi*p*Sci%C1m=Y;1kThBvYT<) zEe1R@^gTQ>GE)5DzO}jDcvHf$NvDDM3!lg9?J6n-%{o;))NyycM6Jg{n%OxlUsDeW~7cGb2-pPT6@-9XIDkJ=gsxc*RAe79a-6kql;rb_9-O!3|<$FCXno(Pj^h3>=1BJKi|*HcZW%Z2rK- z)R@q%1jlS`2Jxgh!QgkTpGRM;2XMV~U+LbHVE37ogtL?P94pB%vKTgb?&8y{j*Bnm zADTL=WayOFKtG4)%~bb^FV-A5rn=hbLiXbJF;`3FkYb0%k%`T_#rcAc=UeS=lf0N} zpDDvB+-RcD+bAnpx1+fh;x8cG3>Un>5~?LK7a|qMU_o@q@!TFN(VE0SQUhw*lP8^5 zr>QjGi{a%qct|fr-Hj`ID}ha&L6e)+?HwxeVaaIg%(1OpURing<;LbNJY5Q2-t1|n zJ`L(b3*ZudC$AHU(7FGaP1irtk0|+l43EO4TDzC>l%iV#SJoM=OiV`PCNmB(l5Vg~ zEHqufT;^`Qq+k)65UpEyL>OiuyHKS&B2clHTFn0zKnEU%HiYoO=ZnHgT;krv0Xp)K*;u;X(0?ghz=^vPP?^V85A~ zzA*pBIMMUY?4fvWqkx1@D_alqk|NR&*32){Ah^H<5c$m5<)RF%-r&QiRBSPY+M^Ri zerveim4Oj?_z~0Tde`pl@8QASxP9+TaN4yOxw;c7-E89vQ%^9j%bk<6Ci7cgclpp@ zpt$5n#|k_3StAABr zw&d`Z(aCgugSfI_lR4oCiA8njz)&G|&b{+i+Dk=v%Ft3lS7mD~uru5jJ@s$5{Z>tQ zB?i&u=pgnMvxTE@(58aJo2^e0d7m<*;>O!2EhatQZ`fp?^+r{fRD)D*O&QKaqiN|~ zEl_F$)k00i`g$P+NLhTN_*Rb}39DB|-R))rfb>K9v>_$7^fW^Sc>~a5k{VwXLozxT zBgdd@bOw((AD6DFfaV+*)%_3R(Hhh;7V4#>U3HzzSL#;V%sy3x%iB=RT_fusBv#5v zh&4(nHD}bG5eau|y(CK&&iC38wwT_SpGqSiYqC}SaVm|DS-!Vd7?#l3<28^c)~X8H ze%u@&F?O_ob^KSD!tS!5Tuv8W{bM-^R&0Av+j{&F(KB9^nXgu^svNFK zNwG1wwO?9>%d6Y{s(9%U?%QVxrKsy0Jw9D4K4!PHzLyaKF_%lHblZ1d`Vl30mZP5k z>L?!31EI0k8F=FWSsvGje&3UG`Ak%`{Tiq0e? zxIjklxAU~f9W8y1=f2&=xN3FwgRqAZ^&Qd8Mv43|J5Bwtz&7}vmNo;jxgKO5%fB4+ zw-b(aQpTfo;Zr?Dw4k?mwKu7p2{uYASf{4WE~b)EBinX5#KqhsaAnh^pRlGCep6$O zx7)Nrw(*k?G>THu@59*BYTwv*m!=nDRG?9$gkYYNOWmM{3BK=2?O`?mSwJ~s`Sw^S z-HlL*Ea~vWAX*LBSZ&^_r;kg3&?lJ9u@03{mN>K4ITXlzG>>bvr4`4Q-soi$&Z0qB zD+0KMOcy34{$_6XFhMZ0#%<#lH_j9%#x_0jUu$5AD`$WyRv|NPW|Qp)-ApH+9CXWD z+sOHD?Vbf&{f=^UP!NVtQnqy1rAu(o<;z8P1(AKg_91yFSUs;LsLn@c-Wo3dy78t^ z2rFw<(7fqz74#zm%M$2eJqdo~PhM11&JfWpE7MvB{z}Oyv(KASZaZyi=1GT40IxbI zKs%irE4f?X%)YfWlIsjDjpkU?)YLsSfi=w=y>ZxL5Q!#JW8*iBO8WZxZ{EENH*db8 zMqPRF5Gv<%tV$Pyj28CV@cNTm$(QE2m!I0;iuKX&z*HFka}OQ3g0Mc!!2gmQQ=58B z8D!W;EWMnaV-rt^(cRz&*O1vlWTwLG(ZNHB5ayTy0=OJM0YKl@&q(a;_HLu^S^uWw z(x@|7XQ8E%R|Ic$p^S%Re<*0*$}C$8hSz*tUU2DZ>OZ&A2AVX8;TXv7i3WQzsR@^j zK^vwk@RRi&WEI+uta}l2)!C_)kg7OU5IxlGI}LhCKx)xJPXpwoyYJz|R5@sAmceVr zmKPwNAiwC7N>=TS?Ag1jWR@+mx%rv1^AurCC>f7Hf6DdR6yjGePG3@~0__&x8|-MJ z>;fUx#@grCU6Uex9_oCB+e0}3UWS1t!CNAIhHYd;6R0S{vflx;|l~eG8HZpr$L@Db2@4NLAVk$fl~NVfa!jGx*tBEXkN9CE@IZ zJ?@|O;SWRvwO?L_-6ekjGUb^!O`RrR)L{~~midmP42>@!R$8x$_#9YIdLDMT`CNe^ zjYUpE%avs#rv;xg7Yjt4-&DQ3Ux2fB9}O=r$=NFmYV^04_hpJSKrS2Iw?f5py-VT~ z9ro5jg_t0eZ!~LEG2V&gx0hhe?J0cPV>Z}HK1r3`9w6!jV2D>_3PcPa`ca8(_|1#F z5z;p>c;RNO;;p$5OXamXSJ%m~X@!02jw`9)3eu;#;s>ev%4C~$} z7jSt$JCVSofz`nZ6Sy~2t2$@m?Y)J`ZMA|0zXL;-q!Sz}U_Kfknr(jUzTIfipLAO! z1&BJ-lqLQ0cO6PJR(UgB-u zHLj`xg1Rjg0t#^w;dL+L#VdTp2(pcyX^_Q5rhVA@=5Ypy9%Kf-#+18GLsfOj8*V@3 zYTcQWna~ZAcW~S09MBMQ*`9O*x;Z1na^zmrtF6twG;o*tjj2sq-sML$>q%>8gGrtU znbb+!3snipQ~NWl)|Omhe6TbdHkWg*0krkF%(Rso4~$j#}5Q*kiA4%#?OUS>Dzl-QLeG*+@so^X0xg|NF-!xEw; ze9m8eZxjd2%W*iI=R?#ro~&pq3d(9v%^1d*_M@v=ocM+!L5r;dOkZ2RSxZ6}LF+^r|<{w1|*Cc$f;MZY_>s|CxEH3EQ1 zw*k3p2Q60qaG#_jJ!)v1#mbR$5fzNtWFbEWn}$W=aNjMDMW5%BEbuhs>dv>n zQ$Li7u*T1!)HnsqkBiky8p*1O=fEL>Ja$JB`~dbdW*AFoBaJ(7=GknIQJVhfBbhE# z#B+lQ< z?Ah1jA!^WdZYRgm_kG$i9?tD&6e)P9ay{=wX;8nqsX)t`HcF_~cH_nk0E6>H8jgCc zM!oCEakRT!5I%fdTBb-YDNE3A;&3;I0jU<0q|uOe?NVsyn-Ah%aiZruEhs*~hY}!K zF0P#EATr(H()#lHe)3=}4Qbi&$MeF83Te=u2#Zm!mq0P(y=7S*1U!H==xE)o<1n|1$(Qr>H zb`%ZLbsj4*in3LJ@*W_i$OX0GY;IM2D-r;@)GEYm6rt-?A`$%>v`%{d3p0E52lYFw ztdB!GqP#;tDNzV3X=5lPpP~m}eq3dLP(H)L8vl;_X2|OshKQ@#RsHW zvijs*(NH-HzS)seT8H)ol3xxDCpyLkJDGtWE&S$XWoz-V>FKu&PI?=Hjx@EXo*Xk& zPY%Q6c27|ja= zOC#@g??};oIH;6bMOI%F@$r~%=%&@nqZWhZgHDx05>I!0qzc+cf-d@?ItEKxP-72E z0^#d*_NAkUp4@8Ea;ibw#d&6!whHE+7o5m=pu@gD9%0R2O4)+}qNW%Tz{>SfZ4Mje<=36H``F6#(!$R_vcYhHk?o>D`QCPmA2<4?X zk8h6z(U`G4b;z7y%Zq6l>Vj&qiwfI7?ZC}qc9XavbxNl>sYf?0VufvryeDspG(=v; zb4Rrh@%ERa-4*CLz>u>y2mf}CWDUpRQ2<3m$&RoI6TyL`9Tx3|xt zr_i5vS~EZTc1Fwa9EG^YASj zC9j|JkBaJQ?j?Ngd#7F{@3ibK7$V!5QRBDB*CFiF#pNnO-_RxWwMXR)KX7ZWVftZM-r zAQy#Gev2Sy z0VC~Sl~4`n8;qo#z?{}9xU{#GG)pO-U#A92CHeH}VKS1-7d0j++&|`IpAIl!I8H>b zNav(8+m|Wq*MMJNh)K)o*2Wte8VdRp?hE=ZUwJ-QB@fjse%15(DDLIyv8LlrCQTP+ z{9bb1me1ZGc-4=q^Kd(f-H6BRdntD}z{B-F%$~o`zwjleg z&R=>wQmTCsdG3n^B{l>=X0V$+MC`w+K+ULhE%YF%awfDyk;&jRyZXb2+iQJeQ|n79 zgdkH$8j=des(&-ss<(IoStv#=_o0_Nkk*+gAXeVp$3hU+?1*xqg4jmI`iNf3`KZ%| zSf7>CvA|(-#p_N$P*I8o;OqgHLx-xu)M!7lMntEVNg2P0jReQ2?@xm;SThM6N8k>y z{d`-bK{)}Dy`=o=vo|=iJeiEhmJ9$eNn-pk1F~FKuh!KYpi$^&!yIpB3UMH^emTQ* z+GFO{G@*72yjUM(S{F1P3Lpz`OBuzDAQE3~J3XOxaa|o%=}~03Q7(f2kvsx1yf0Jx z`68sK^ zhTND{`d1rc4LFC_i2s^Xh}yH0KiH|ozG29qU-KZymuzpfNRC8=)YTB+h$uA|g&B}FyJsJcBCJ)~pFAyjaa)nJD{1{zo#p&& zwPqP`SgPP?V!n{*WH}A+HWcznxS8tIN=Mzs<)(^x@)EMH47{mlZyYjp)*9{fCW{%i z?}8~05qQS}X#KyBvs+NUo&}h;;2S+rz!Q2t!~yMzH!}b=r14F-!K2MnGsU#XeI-*2 z*UA9;V%J+DY0xhUq@QN-JCuGnu)L2W1z~-Hi68V}!v{ZrzYg5JBa(v=Cv{a3G&Yk_ zQ)5CPIPuq}iooF>!L&5WBd4N~0#p_5E&^O*WAnsIHrb##tSyh zyQ}f}MNZcNzpagAK`4U5uxkI4C~}WCBXLsq+&Z&GbxNmrDJg?m65UK*-b?|GQ?l6n zDW~%doCcV9&_gdA)9yU@RnaxFP{rFS*GVFe^2x?f-gVwwmvaA5g|CuPbum>BE9#im zkuuwf`rZDS7884Tn23mBGEKCf7>c<0c(%X2Bd1GV#C!gLi3#D#UhTT$ISNk%Lfk>ju5Rb4PLN&z1kGGwKC}d zj?FVN%i|qmJ5wJa^a2jit#}BN=r6D53RoxDzu)`PKqcdw>w)`D*r=al)Av3wH?$Y- zHmS~}YRnyW=x)?)rGq1*^$;eF2Gak>-g}2N^=*HHfQTSPib|8vrAQGFrGzFz=t%FP z6e%hQ(n3d(4xxicQ3HZh>C#n%geKAj=>+L5^gcV__g?RN-P?|EBhlBABo7XvEJtf;#N6_)RlELTv36b^SMk^tCSDpUXs(5m zXO1^>B0lIKWE*@hVUE8UN_jba+g-osyr`1!`;8?k*0wv75-c=Gz)S^zb;OD}O$yBS zAD*dn*hKZ^Y2K|aD@x6FnkrHsW$%*I2C^~tF-IE3ZFsMpMHOE-utbb6RyJ`V0V{%1 z%Trba{pP{J%=c>P1O_fKFi_AY$$1=jT;JRL6;@QQ^5W?58oKDosP|c#t5>4+L;)tn zfl5N_^Ut3tt=h#HESq)Z8-vbHET=1hoSukm*jS!{H07ArB-L}#`~K&gf4jg2OX@v# zyJ1I%RnIQm&Ff#$mPN`m9Q&SsG;r@*5P1Q{yupi>fQ#!K-#u?*eG&z-Ns{yD6}|?( zn3j0hE*6m{>8Mo9e-HYsw|J;i57OkhwXza;u-o%sdzo=|)Q4Tb44Y`_KKqeq7n>!2 zxOX;MaEzlW9gVE!RB8VlNYV#pM#ri8{qyTv?7EW8s<^o8L1Qxos2E*hX)%2_2qbGq z)X!WRP`dG16RvwAf&BGBnrGc%Y?-;d@LOo`c>AmLRV)_Og*;|crZ(6WT@x^`c2Rzc zwQ}b{z@iQU1sl}(8{GCbd?M&e4OC3lCSQ%u%<$xetEccqf;^u+pKP7yS&-+;yLrKV z<%nW5W96-Ckb@?2}h%Ec_tI=^> z{JHvR!Cu?a`LIZ?XQJlAs%u3I@^khPPae2xb=cK44d}8uo?T?D=$u@4{`wP;p-Mza zRvbt;=OAN90ML7GKz^Pm^dGs?fVqQCXi|BMPQci_dz0Ze+&0APyEx&er*gBOtSeh-Y1S-p%n!=v_ESZLh zcdX9@c&uNglXNqE;Xx~mT~ScbMk901hapR2IyVYWJxx5L}@K# zo4YB-WQ`(|70Vq}5N7!)MsH|#AxhO(eUuLmv{$5!ZUt@Lb?uYjv>i0Sg_9+e*plwM z0-2GQ{W%{Wx5=vi-sEDBHG0GPS{{ZQ+}wy4VN+?e0v->hnpNq9EGnVzc>~pINx22u z?0ViO#}_EUFZQ*pO>JAwb-%}Ir^+1dkX|!LZ+x#_koB0HL07pR$qf>Jrf>m=;n{E? z!00&;@63)4;xJxG<~|7wfEDD6EnfY2r$8lMkwbAl;L?g}7^w@zmD#}e>Pc^17c8)5 zDgdXVHhTxf9Eq{MRbLNVoyjo6Xqh+#>g&pqC!nN#K*wqE9Od!Fm8W5<{@-e1e0?{k zYvQ}EeYolvYkOiFh;GH)L%o5{iikh|R`q9c5obbo5iq_cVs}nOm~W5iJ&ISj#r&LhmkMne+}n&h4p(J+ z0!q$;{SW)N*~9W{9iOJBUGe`&{NgoZF92bny!z`gz-#EJo#V9UZ$;kwIq$>-tj2iM zOlJlzfA_Q4^4vGUr;|w)tnxu`0Ps>+SuhI!2@l*5WZJ?^<3@+QA={*9RMG7OFy(M2rc3irZJvqc`M~W5RCMlpoNlA!fu+bZR}+eae?$qmk-PS=Oqcv)z5>2^nQn%wD!F@0qU=BzV%p})Cxa;;X*KS zhaVkc^Q+Ub?go?`zndJ8x8z1e9VqLL&t0QSo$Bj5-BHI*>lL z7N-pr7T5V+Vq)UxHkxBrRd1Q_sO`u>Ojn}rJg5Bb<^@X1i%}Dt+AK~4z_QvS zt9g`->U~!9@u*)CqcIUdE|aM+SbpKIL>;3sMkC^(WX9(&_;k8_XDbdFLtYEYKP{0{ z#27l$zI*49@2kiYu)lKdOU+&9jU5Skm7*#HrZDQ~j=Tgqt+y7w7R1F(j`&j~uRc{QuSMR+3D$I?pa;g{ z?!*rwpcO-vO3uB*2gC#BLHD%>b%{yO?LmqUgn-9)er)8wtm_Ic1ea#|ovG$Xr==lL zF>!GP{&9fQq?92owY5A#x+b7~|2|8~4ZG*RJ6@~4qKVxG$uk`Mm6Yd(zwKDA-ZkLz zk9WMjg8 z#29(f#$>C!TNMN+*nyFxyS4iCBsbElAGzFT_vH|V)@;QdQ(&So)FG zwhy#ow3K|m{0_?6(+Dq#R4Ej^y{&jF;^?Y%Jk-<5C7i*;`-$P&#hkLWw+iCnEtyca z*JTFNI@OoWgY~$e^**dB`cEwT=q6u#S0}-|)puMOaUcX;-;8(`+s$_-M@Re$Zb)W} zzWn_4C8@RbQvmZ;>~_4~DWg^6_Jrrj2koq{=ot2i`FY-J_%SdQg9781w1W4534SEk z7(~t*l^bT6J(+QvqR6z0?Ha;@j}>v7;3f1t$t9RJKyYIg-9`ais|^&h-<@^> zvTkSxDZG5vmdH=J7XFhEpo0hiR4}n;zsb7at3Nv5Ew&%ecgP>bEWXn+Ffu%x*^Xt! zr^XB93p+O4T$`GlB+%5<rU`<(ov*r0+*p^Xw?IM0j`6$57YAsa;o>I^*2Sw>n2wIZ0Af!!#0Kx4%AWuiB6x z`nr5JjuloyDAYf`U*HuF4Wsk&DXT|U@mC2VF~zV_M}Bd^nG%)&JJkySph0f@a(Wo9 zsqUsU(=|jf*WI>^b?$E}N^z7qKOwAoXNbt6ojD;+-}H#@0bnjnM$=mN+7t>2969af zdv%^K2}!8YYoiT*2!Ph7*fIrFPL|rkwnX)gR{V^XRlrRgyE@kOgPnY7rFE<`|df zE(-7>ZbyLA#iL*tlI`)$SU=y86@ks&{fN!EsveaEBQP$6Cbo@--Sm>y1vOO;O2rp# zD_7*Fg;q)~^vSL57-(`K5MkR8c4pS+rL8q>$Pamr^^vhKr*>D@zOo(9CcgyOEv{6D zjpNp-*p==#JAT;NSL`wIU2eyEGE=R7Ti~SSsu&-9n&J5_LmFk@KcB}Tyo^2?v(_Na zpyqjpDSk|3FbIQAi+p-uyLNHMA@0&O5TcRt4fivonbvBkx^xW*otJE|FN(s_Now)8 zA;l_;aZqkX7cVuTXL>*ApyPSs4!l_PYTTA$&G#mPnSYc&GUx>$hRqfxWwxwidKU?} za)~CpnY7fy1rR`7HUc0u>HTGk$0&WICI`qP9PAW(pA+6=&(!+HlZ%+7>gE|88Loww zMofGTID?&eRaxl{xWV$1#nM2Ld6G}yK^P1@1#rcz%XK;c#@8twyT;%LJD=<^OL_gC zGhH=zg>~2c0{zAUCkKQ-V1#;EQR$ZMX&cE~W0Ht>5>=D?W!mWZ88tq^x>w#?S!xNh z&!()26HWcXNh3v+C&(iGf%zQO2=Us09ENkik_ebb;TONsn?ZU$i9aw{+Q_L{qH?G^ zpU~GyBu;IKUYkia8fD6JsBfzGl6&gv{JA1!?#Ftk4VL&3CQdL*L|;!mJd6>-#==cj z+L|2{atjOkXzF=iLnfAg-90dtxHK9JyT4jIb9w9`=;yvtHO&RkrqZi8ZNQ({ilt5f z8zTk3NEMEJV^sREDS}=E!+GiUm8P|it_%Gw+YHFIDF7d6m8DVpf;-oBGEI*4<>tab zlJnIgo>9SzD`H{D{BT;qL=?=XBju%hFhz?yMbpk?&ihYzFx^Yxm-!ja3P{5bj`ml& zfZMj~I}L#1uKVn{80~`0`^@*WMu6;PEx8uLo;PWmpKMTjhG&TMOGk!GL;PfxYLYhO z$ua7J!Tl*hQ2%!6c!FzBGK$&HD!3d|YzQnBm2noqrmF3mWM&kEXjOY~jr#O_;G2)c z2hP~D6j0*Wm78JVHrV$ravpaGzY`H6x8K@$Ffs&Wjb+Py(G{?*+9xTws_dT;p7l9z z{ON)3COOe1^{6mLX0}}zmfKJN*!`FNS3(L88fw<2wVkPLZ4MMr`8;M!R8;u+NBc}y z54j4uDit!mwsak|ee<|P9iU(^_Qt24gS-59K&>~IA1^J3`>qSHq4eWnsx*~fz2jlm zWGlzxK^N@T{x7;XyAm&Gv?t!M#hh$i-iI~mzD}w^qP)&#e zr*6BTKqZxh$jmRSf5R+p0$(p7S>;m}I>ml<yn9mW*p2Y z$<*sbkT?y}vOaTX^!y5gvBa2D2Mpe;a7)NnXX z;?YDPb1WK77<7M~FrO81^Fl|^WncijwR*+CJKd%0T+yZZ1b#6u{f0F)!I`25z377q z`~arb+f4w#UGtJHzhhx?>Q^3&{5$b8&zMPJkR+^Nagi^Xxz8^rYhJ5wHV4EGhQ-`! zIl#ViWvC*O{1XM+LvMN9yo)QK5RD&V0{BKF!#Py1#HY0)cf^9~!5TKx%)LyQrn^Rk zzi%?g%X&BG@8nRd#mG$hIgM1ef*8E+lOq1F5dvYQ_P_5xmKilma6*~fFqiu zU2~6ex~s6Kvk4`CzcrqR?uKpjV@{3|$Fcfi6%McjlTg5a2d#Zt;6YX(7QUXh3I0DJ z+-dvH&wdC=rbZeAK0dzl+&gM|2qa3{XYX6I61do%2uB1^{U--9HK4tQTo9yRCNS4; zAi=~V6`LvCaCy66GWzGY97pdIKp%V!#K9fMqOD%Iv+ERFxG6UKCgz#IA8Xe@zo$aa zpF0;!Hwg9kZEjlQjsJu}?`2$E57(=D@0GgclFnA-vNnUI94 zT$~!b*WI_g9{k@g@@)rKf^sA1@-O=|Pzgr||CO8bP|**yHicLOfF3?J+~Jx7SdYV2 z1<$6V==|zPL#J;vRqJdpZ&8Lg9A|i|X*+``Wr>U|#|Jru7XF zed|L1l*83{FlbQ7i=^Oc?RX<6*(LV2S2{c4HzXMM=H5Jl=j#=b8{~1#eOD`Br-eNk_M<>qwfCo{hIJz1L-9nQIJY|3*`aDDkU8t zCj~N8S|CFe`PKSaB!F*B5;i*vXrw2xt$lm3M zc-Yc!#Yi+jNV|)ndk@0bDJoHiWw>&Kru^$ybpY$CbE14Ra1o#vhjL(_VtK%MFX}e{ zQqwbtE<*?qp!tV|Ndf3G158*!&ph3AbN!)i?+Lt`DadDbWT_e8>p)x+G-}Mrrn{Ca z{R=@MFZkHO2Vi(!=_U(Owjy_b+CKm%?sCcf1|h(2x3)b^!Ldgem5+&n1>D;H4L)|I zx`+zjoL=)=N+bECu6OM37XuH;A=bJ<$IEHi8sE;ku4dJ0h)#VKO;!~Y&0~dzs>V}N z313iXZeNEUWR!bvA9d9Y+GO}F8+m28%V;8I(rbdoJpCSxI5vzKdojyXQ@yz!jlv7a zGEyashN&iVW2gzxNL6Zu%Hu0(SvXU{nOu*hWElhJ*$2T_me% zoJtn*0+pVg#^n!{=#0Ot5RIZtK8r?H#zjCqB^%x&DBw4w|Sec+CJKkcTjRQDVOdhQIkmt3wGqJI0Qc`!(Tn zPQ=Yd(f30yNoj#FlE8M{8dCzb?-8MGnX=KaxDqOVL$C~4(Hvz=*Bh(A``llbC1Ef+$o;>ioin zkY-0de*tbd_n=$%2UvidCO*EH5*~bm>HU!F;#fn2X@jqs1uBZaz_{FEn+m?+!&X&C zgq__YJ90k&BwK&9HyaJ>5xL`CPbmxWd8=U514gO0o*gwqt)?tWD}IkogN^-8Tu@<0 z6so?`#AioHPrUos2p$<6&~17b_sAyA844P3^BK>TLQ4;IH)b%&hrk(TM?jRVE;h=7 zm74d!f}ttttdr|6@vyU`{H-Y(c<{3Hw`ez}L7#{eW=TZ*+T!u(lj- zIy?fjCElBEs~>EXL8n_pAgk^~oMe**}1@e2fs@T{~f0!f4c+x7ky8V66pmdNolkv7B+B z-hVy`Q}$>;!x;D}05dxt_=PP1avU?+6z)9V%QITLCzELmDVW$){NNgnQi)NB4Vt!g#2&97{5xVgwyk`!ds2xW*cLObFt1T(}evRg)9Dd4pT{~nsJb6bm=uJ*U_2q^vinE=x>*^lPEfO-mM zzlE|bYcAO^yP?^!wtk0qX>eEIPG7oaaTY$?$qD5oq-5C-ea?gpfex9=^x~#D4hMtG z=sdRbM}=*mc=7T2dCOox+#$;1Zw>C4r>yntp6g{`Tv>9|4`+Bc;$ai(En3%rROOD z{rQ0#(A!_vB?9VHQ8m6HXyk&UgnU?ubA zqdX)Aw!aXZ5(IQ8xn?v9{3?|`PRR(t(pivf?>wbq*(VA5_k;1UwjR%+5D0d5jWGD< zDzJT2KcRxd@dh=oFGj;KT}OLw-%kMb+(?62TaAVxxWv8%1w(@jze-7)0(t!W!9Vht z90*#5bQVN#Fw_(Jh4m&BBlL}Wd2NDCc8i8g5<>+qYw*pn69Xz_l@Jbv;*1uG;t5s+ zYvNPH1zI4t@Bd3~Hv-)d#D$&$zd|&hhLB%?lNASgd4D2~J|t|UfZu^E6*1d00F{3O zDhIi60lwk!T>h~K+A@T5c&bqW$alKGNEHx|8j-AMX?A^NeOxc)y!I8|s7yKff{^;E z3g29}j9}3Wnp4oK{R*Wl+EVsow{0}eJ|NJn#Ao%#8$_M|_sj!iTc%RybTBK2=Q5wp zwZ_hEnqm!Ot%BlMYR{3RN_weOIGSElwb6{R>CQ^nZZ&YbLXDjj(`F!J1Li`=>fDeM z0dcfX=3g5`90=ugs)b9w@7>SLn?Q0w^9U7v)?PswrV^(JLZQYTc+pt zp#6A1_$9sg_tyFMU~{5!1zyQ$N?b^}zk?>}G$0sB8HjdQjd$4~zJ{HBbeY>x7!Vw@ zNcp-5aGZJQp&$G&DvR+Wbeba*2e7s5Riug6y)l6^*8)V_X4+Qrl9Z>7U404|tl^k0 zq4*nCz~t%j=b(IUiDO*uzczim47xQptS{j$9%XZdyt5{4wO1rs%A`NGVr1{<2Z$Zh z|NZ@Lj|UCEzCX6w$sy?!2xy)jON5vQ4Hg^~d7_b)+;(VSxgqhDSqM=$dqWG@8~?!o zK`%k8QF?UKrB;;~I}!4>%M*8iStL{)+GEZ_0&bZ5-`&852vS!@ewBkns+{|>qYPBR z1}eOCLw$1;78t-_Bs z@4@z0vx-R?_uG!@B$=4A`{;1eVcwLa$r)|Q{cxnl63D2T%YPGb%8$!-pnq&SZ*<8D zT^W8S6!UW3hO=d=niuZ>52Xgl;Lyw+#Gf^22LD6!-9 z`(GWYqBUhpm)>U;$~I?qm&r5lJ~$#1_a4`T7h%s~c+^pKZ%pbT1PMT?PHoF7qrWt z>eJuyOUv|-i1fzF(ZfaeUt@hY_p~=Qb%mEuPhxqOo^>zu5V2uH%8tE19bEUTZcn+P zvH~6TdhFV=$RjL}UlftDTly`M)?7lgKtS`o8h?j`x4_F1jwEMM2Bj%Yuhuoo;4GKI z84QZa#-+-uXkMXIIl;3d>&|XUiATQ`z4)8F_8q(I#nB4o+)Hq?`jj$5QcP=8srB9M ze(YiH2paizfdqczhEUg)%UHUO&vOEAaO?E=&voYal%-w%mQeFOd~d3tP$J7&b?NGp zO^Q+M*|9c%$A=4@eN;c_ge>RNP;In=N(X_V8#5}H-`?AiWNEb=D)7bO{2*w?oZg47 zj|qh!d0|1{omZe{jIBvTedJM{ZsIGN8@trL@k!0JOfKE`_u&4+#_~J^4*mR}5jTYBGgpHQFaoJCtVKgxQ!w-~x^JDzeILawGldHXix1zwuK z8ATu^5PviHy*O02-uyf=YrUT-gk`K&k%Z@Ne!euM+HppIPQrHB$4Coy7v|fgK>DV- zS4hv*MRc*oTC3GEE>{6F(w#6}r{e0u!gr>C(V?JYhAwdFHH zjjDpG9nDW%O!ATh<3H9jZBigF!F5qaZI~98F@aI;PU#G-C}t@R&S&q#qfwqK4g3HV zqNgSs7q59GMY-qK-534#-SQ*atH#_x_Qc~y z9h7GLhfq+5NN(*L%dGFnl~QPzJ=UuWANXj&dbr6jpKRZB@XES9wJ%oSFuc1^%erMp z%3|#rrB=zd)yG@$gj}#i> zv&|Y+j0qSkmLDoZ`Y3E)oIksvF^v>r?sIg}6Kpgp-;TZVvr;ve)=S>=!fUpdD)9rt zl5V!lud2PTbwU}c)1zVXG*j4*pYUDb1ZEKF^-f=c|2zb`W9xKl6@5KioDRbt-L*P0 zhm-WKJadd&a9EYg+;fcg0uullQ>Fc`+Gd=!@jcUXPCag`ZnLO ztH7k&uRR|H5esH}@2b>Eap5dqnM&1DC{lrF$)^ zoHjq`vH|xsh4&p~`r8`|S_H%Xo;QjG=r038|2sHC&IRm34f ziwOJqG6Wln?p;lI)VEpAi=CwtVmQwUMhMcb=wQ4CM$q_ctH21$qs8ip?HOEY=hXjL zrw$#D&^4SYt)%oK`^J~xE)jI(FRAeXzw!L~9hm_5jq5Ldduj~ndpe_*!FJ0L74Mnh zBI>;nXp`_cM^HKrW;q-SZ1f7_CT;Pk0uUK`OUe0}+&1LuX!HY-<3%olzcx7_8ub() z03YM?m{ylqc}mfC$}&h_U?bI(n)>83Wo#tj6$XtHD43ee*}DTV6B8m9A0mEwhT%!h z95*!qV62}17%Ubh`q(wlb*j3&#BU5zlO$$@SyKp5TAml(H<-U8`?t((!?7b|zMqxS4vKsP|`Y;GO&6fp-5V z{+$T40ZeS3R+@KT8R&!mGCu!!c*zsH3gD>?t6GDaxcUE=@%hKcBkL3dd$;!z(dtWm z|LDJs&p%$?Ll0jnFgZbQ-K{v>vHjcl{L9a~-nbr)ck7Ms1hOQ9FxhVl|9`dH|9E=z z>E?vsaFa489CCaKcxGwn8N>yq@z@V57$|~er(*Jy|r?v^WFT@V=Xc#J{`%N(J;Jme}e##9Fgz{fbxGW1s zE7Y>qFgQ!IGl`s8YQl)~M@1vE^Ryx_>MS+9ECs*yB^ypa2`&7Ncm)I$?f)X6wE8ZE zUu`Mlfs-+dPwc9^xjo$%+hD5)LwsaI)ISWszP;nm_~UMC1>Dm5Xt0yyFMlyL5%poJ z5mRR&l&RZtBM6bY>dkrGeN^sM>1u^;{@~AnT&+YHgh^gm9>M{R!nwHlcdS5xagqKb z+eDNITayF@!TW3z0?wRM0WZEqMtrq{FaD}ZP)m;wwz={mep}WpFFhb%+qJxGu79l9 z0Z`*^cl0?DK#eb%)wW828ZH0Qn`G1hEpc3J=a{`&oF>1;ToHFx0`EoTFt7JM4r>PE z{wygzsII;ft*BlZa5Y+_(|c~sI@xlB1rhZ&ll1~lg1u07?TQA6Kkn>5jx7Bhf=-#^ z+ELI$BJ!R?E%~~3=pD$5WENVtNZ|ID{c-!vlC^H%d<8cBstszt7%Xz;Um+lo5W?PN zOHe;P4e~D%VQ3C&IGM$B)1RWiF|K|(RDILRLDuZGgeKv4z$%J>Rq#P-)(OD*pK)-SCyh=)4j3ERdE89WD8x*#c!WI?h}evycq}*6N(yY z2%;wP92m_)>;oLFL8ie{c6zN%u%S#w@&idgFy-uU)iYq(dUS!>+K@d#lZwaAY;vCT zqj_nTS7HEl83rkFF1xsp6vIWp#OnE~K}}$0xU2?#0&HI=E`@T(k-&bS7*tz3FAs~G zT{mUO)BV7v!Yg8hX1QWweR=mb9tbt~yb_GxL;#W;tsyJ?a1wR*%Rk0HQH<<-?}zEt ziN+WeOAkY|B_$2!Yhes9PhR%)m$N=QGe6V|XM6fPQL)AqkBe$I zx@oFMjt>1|(;RZZtC_I29aBDzOZRF;LO7#=-=U0gXv zGvAhk1^O!Q-5^j3XhO{uW$AJAolLDsrGqMXBQu|S1lYtxF4D7ySHX!HBewf|0jB|1 z|1wMN$3it;Zw0$7RjWA+f8ylP=7^&;)a+m~JY%}FGf^d1H#Ez@d$d8EcxIj=e0>u zp1Uk{%OV=7FwWeabL4vp6_3Sv4T?tNefBl%V$y_=)tTzAbyvco=+^S0%p>7n168oY z&k(A^pO~zVk2E)J=FcDRX7~xri-lkE((>J#6BK{;JB*A`U$OqcIbm_|nKpJtQ&2uQ zvETJ~=NQk@5P$5#faBav7(zghN9SrR{u$rPO%vN=2&GVJK|3OSL0rsN!hm~52B%dJ2YQLRq)ZSAbqypkusWMa8AlNWytC`K{Wa2u6U#KPC@RlD8^f(p~u*68RyY@1-@ za$8pn`;04av{RH)ZXlFj3DaOYZuO}2g+Oxp1Ilf$vs7xE`KP1j^Wh@HR zxL5W=g&$sFkvSf|TVe%QA>9;gSWH@JD0++PtJ}X&;P3fW4N%{Fm@oi$OJoySKH!`c+o@L3OFSDSSZdd-@U^zj1ql!9C><)~aSRo#t_b zBo@jMbO>~o>Mf23Uw`hc6Oq>s)WE^Zp+j^b35L9D5`Oi0zKQZ?RnLNze^u;q`s`U@ zf4_dBNYS=BVLd&U?WMdjs*rX2Wm5UwAakuHC@<*#xSyFS)}>6l)N2=-D&cDM z^=s>FMuuN~hFY4jI_Wf~<+ats*9#jnpx`|zQ@IA+mnUWQ^JBTN+SKkz!LP~W;;(9jWaAwe;(edfi348*GL8mT)jigDM=V)9|q<9 zqAgfXf2+`faFdWkqUCo7e zr`)m4$ZW=EA?v9)^^lC<5mzJJE%QT~7CUv>w-eRd1vxy$oumof1 zlc{HB57S}FSQ={xJlf4ls{e7AZ@89yWQN6%uqA`sy`J0Rj^TUFWBLC4U6cC5ijqzd zsB6k~aqn5jj0G)x0?O4i*?R9?tER9^^#++lgjZNT9~SzA04rmK{d3^QhU6SO2)+&m z>_P6-2hz*EUD3T!>9MeK)t~u;S6Y(Sl+!yePlQsPJ&Gza4WnzF#84ot+`iB`Rzx{#_zQ47klCtl~ zBGZ?2j<;ruO=TLg?I7-_ds`gjs(DQwf(@nH- zs-P2<)1*RIyKe81R`cYHbd7B86S&FZ;L{NRfVsdRM=$ zLFB`sU&k@C`d|T@d{vzqzB?_rDe}5gCd0J9NQT4S-FPyhN~wVNFx+I3&Mip z2uWPxVx8#Ux)8U4KFr7=9vv8epT;kdPL3{^SRbibZ<~vRo9Vl0SX!)sqsfzzy(dO> zd;Y!o;(DM+hezBwGe+22SUF9i$NFF=Y)}22ih$P!H`_qr$icubeb5Wfu{PP%?P$qw zy0h!Yq!T&+Ggl>>8hWYoCFwxf@XLlLqPaWBSlA_-JpJ{}sJtO@y_yJfKQj$sYftU% zuKGiZ4$$vKyUD2Cr|Q(Jk)2?f=U5vfG5I9Vp4Ol)8dfI--WBfx%5M5OBoX-jDshzl zvY^CAc5CggEFQ~GtgRU2tCv5XBw`U-!VSt(ez1UCMyJSM(hiq@{8ws(;|j4Xi!3gt zeE+X-+JB@)I3_^p2R_Io44xzXi;B414F7?`m? zF75Q?U-rzOydy5{bdp8-8h}Sga0iz``a3p!nn9}Vwfl-$ecM1D9`=+4WZPvw+Y#(y zr$IA>zSziPr83+QXiFu&%^z1duyiT#N=bV+$RNB-(mm%KxSB;5jh>kZ;iVzhF z4Z7cbNGA-`=bn&H&UxGg9DgpLR+YMO4qWhXbSa7fmmmAiG%p7#afmMwfcrzBL9O-| zzBsWVSd(33f^asDuNreMu2hGZnL%O|NpLa+`zf*?>rg|j20o0RCj(}2K@`^wL& z*DkBa+Ua1$nECjL5@cVt=5hLU<>LIjsHOm@L{MzdDNQL)m%va$N74u<&}c)3Jspi8 zp%(0aFxRtgJ#*%L(;G_LUlSLqoY@oN`3xGnj$&Xir*1k>358Eg2NJAzz@yJxW@9A` z0^XdEDGTDDPRCwflfCMhOp8c#fU>!)iC(8zq0uwbW=$x!rp$UQi1j>&LC&PyX22Cj z;RUdqPG5+a6d`~NmVJZKQYec0=Y#N(0v^P7m}>Yvc2KFqkBk7!3TS+C0cOSD23I|$UT}>l z2wKgiPDpY>L+L*>3_dHbT2z?= z`w0_{JDhSuwHG;!6mDz$L9yJKu%u>Oz1XKPobd_D0*y-uNpa`L6VS1%?e!>*6Oz?V zIU&w~yZGenfYiAVn&gcajz5V2J#qZ`=BzR|?m22;TT&9KCc)unp@g13lF0F}O9aPM zQ()BQ><~QMBkwSt9tz}>sxB(rkK4GNNfKcT!rjUJ=T3XqE&<$+7r+)uMc)YmH_Coy zJ^fLlEZLR_#~-v{*n91~md|l#4RN}Xt$vDf+~flZ!DKnQ+gWhWG-UI~2^M5l)@@eY z{@-%ioTHH*=*iYp1?8;PbKP0gg7={CjV{hN*oFjg?4jLFaANA*7U~(F*gG5i-s9W04r-{xQczAD3J7I;9-SRrg1MYdiN5K@OGd-bv=WqJ-X1Wh67i+i3Fxu z$u|VE`SD^P^k}0;6FZlkp)w5KB||a&~jaeJBL2 z!m6)?t*utazW~QoVRtfI5Yw7)&C@_nc@p#&Z>(&uj1|2s*3MGV2A#(r?!F}`#hm~= z!gn#JY~S^1eD*x}#$S)Xp34Dr(F#8qx?u(&{T^Ur%rH_gs1IzVYW!PtjG0=YFfO8p@R({Z1iV1+>a$DaEi=R}aBK zo5wb|;6`_`CCsSU;^mR)lJmp%WG#jpw)Zb_O`n{Q&-i~1_Qz;j0^A~c2>Xl71wnUi z4@%Y&6~)wb;Mj0@^~tY_K8v|;-;uG>o78EhRnDNgSam~4Cl8yGUgy2PRO$K{K%B>e z%3_d$Mj@Dw2V%52Bj>fLa|AYrk-Rc~N8a?J_VGDWr7A=KmSc(-o^M)J63=g<59rDX zqFG&*6iKM0d!EXOj8kTQ_eZF7RgTV*;3lR?*h}@pCLOLAL`MH^tRgr4f-W{IT?E(D zcbEv{d^*3YTrRn@Glfu1lW?^H-Qk|uKU$0RHg7X?y3QNsdLC&p>FCu2%^P*3h(4OP zVbxpo+287ujGDlHvTnOP2%0ekvISf@%0Y#6$%95BDMh_|Oa2ePry7wWB`Ct7dpBIU z$#C+9kUyr*}aDCF+cO6@<&{NaiTVU=FaOe$?G{<>J2nT z6}T-8=`lZ3Pvm>$>!dMK?K&N&DNVJ>hw<~#3yxLYeD_+)Ys=Pd5wx3r)A%q%r7PI3 zCAhu2SlXEhWWsg}(zCEwzrc8}>6X2kN_5{cQsO(V6Lo&7G}i%0gmT^7w7ZzDdxT6X7qCbg2liA*FWKi%}8W@DRp+|O6Q%W+o#>|QRd<1x!RElBHS#alPqEngwgzri&PAuRoNy{9o z6epb3=mE<3aF!l(y#_Z%VzaC#cpu!mi_}O4Y4Vo+| z+<42J{?|UPqrW4)1_Wc|aZ3fW6dDhi!%fZq(W!+!n=-BPrk*<4==$p-J>ZzaNaD@v zzjSRq@1n!fkH0abF$v{ zKfF@45QhepThKj{=y_kDm~nZilQmwCKII4^0kzxCL7>W=3}##m15FO7NaGXUxG!+J z1s!G?cYbq$VlPd9-R*F2A;|9G7UAz7aJmgQo<5+C6#@U-h;@fafl@jthhCfofrtO> zl&rSPdmO?t;D29&%xc`;fy5vE$t=lEH9InZG%h-o6|ie#jQ%2+<&qQf^2ydFtDfqP zX9HX@yI`9)Xju9Vf$@RM37*c9<#d+hrpbXAGB8W}s4@#6Wo!|Q<+u%#{cQS&W;N2G z?V1FjSkotItL;7MFU)vWqw{9}B^mZCPZydHMv?SPVf;b&4R>Y6 zZ9ASVD>>A@EQATB^5SH|lvbznA%atN`-~G4-i9QIrAIock1CnpcpA{-Tt>a~WQD?Crwn0lb%~2+MU&rxKS~0r>mR=QJ;LsoXzqS#mz=_2o%= z7if9Nc9JbQWs<^3sTWh(6LMKwMD zzwnqWGe%xynr=P(eoiG^kutg1_hXsQ+bYZA)6C5O=2?KxpgMw;As_`a@+SpTm+IL} z1a=gU;PM~7aw@*t?gRK~dzvqqiXAw**Jqf0aJ>2S%qdrleAEsF@0rGQU9N~Oz@<-E zC3oq?aAXNT<=wJRhTOlU0nhF`Uv3Wp#-vd4I-(vY2tWOyw}at_Z?=H#ZAneg`nN$E zAUf@U40rnd6;3zO*p%<$GiLA%A92!$-*C=!`z~w@x7WDRTAb&1D__^54483cFZr#m zfm%*Ww1)4AAo~mocX3jVU*K|B6=2%g`qZ-`78oIyX(!Nys@>r01K*p=77cuT%Mk>M z^|C+`VQ=OiYZfSLYDu@p$^hf0?8z^EUfrBGettMxXBLUTX-aC5hiX;6ht)1~-3p^# zD^efy3sOKRs>pe&nOxi%69QuO`U6@^qP9O+#&vwVGnGC09BOgh-vDkR38%C@w-$L2 zQd`M4?7F{2%CkNs_5fl6=iHp(%~Z=0u6}zfy-i@}{)h49M> z;B^}^-olF7lK>=%nq84BWBwk%jT!Y^;2-?>;DzR<8c#l<6o6aOe&0UQAZH3R#42E( z0{7 z;B_VDV0S%sV=Yrd6mBkww!|3H{;5?Vex}2qCyCf@MTX@8umfznNT*WN74b|hq+*Hd zg1*)o?uG&3pF_iDo8Xfdq1Vh&(UrNxLjS*WhNb#NZn>{<_0 zm~|H#d9|2}Wjr>l(bBjg!^-4Yk#vi+mWQT3ox7%TCHB!kVcVC(bJP7DuZ5qs%fcl# zdqDMz?_76ocq-Iady*?eaLKD8DSvQf)Q~@|QkQ5GlIf`QtoD_BHTmq-0*120>F`9= zJ=*TJke0?Qt5Iz#NksUJK;p-v#trgkSgu&rq@#?MQu_46&ZCvIWEHi2-+qHizx^$y zmUONf7v>-87SaqlCE1;<%t5hi-KE_vBeZj5@^YlT6Shx=`zP|de$yvO*fZGtkogP%DV5mw@$x&Y|L*8{a*af9bD=MfmI_H-23kj3 z05TZ{U^{uL0hcBQC_acaD-pvdAF|vQ`V~i@Efr}9@!1*`$We9p3-C0=(Eti=Zlno_=CO0fJ8M(mm01k@ zh=~Dk46Q)C*LXHwsqkK%@d{h+LIXzYkVz?;B_L6@{Kfy#-Ic~e{eOETOOd4{m3=E@ zS7EHh*w-1#S|MAd>|-w?OU4XFWXTpYLUv(jA52k}EM@4&gelwDvft0(-ur*>zmM+g zKDnNF;A=kH`JT^tzt8)eIUaFV!MdPm8=k>!Ro7c0X5?se4crZ7K?7+H*c^no1ul{& z1Ko8O0fXL3XNyBx&R9sDWs9t^t}E$uXuPXqeEo0n4eM@x+~8Z}7;Z#+8B#DjWX)Zc zI|s{17;m0y?cIW|tkGK9J=Uq)xz>i$;Mo9ukcTybX0tIC0cS1YRi%;eFs}?7{<$0# zZ%ne@mf!*4g#pk8cj^hpqG@IZdZ95U1jKjh5r$CLJmAG}!RJSx-1mzC|D?4zmuA2C zzTy4e@)b@TXNZ3uAa2^EY=wuhuLX3EsDbw$^HD_;?RE;|g|Nr_J`Pivon3epU)Y*H zV_)ydCB6JDvdTjj?^g?Xj{CEMq zKu%f@QZX7xJ1JVWj`JIcHUJ4)}?KZG^ds(M@!wm+8kI; zA{%EZPlpri`jyc5kYzMz)b;zhfCSR$Sy#5I=snPB$RHlAFMgBq=y?-(4WA>CxJmgr zJkGXKmhz;8+f>{2Fy!6d!L3tu$@Bjj>_38(BpZdOa?-SkR?<)!P>7 zq%&1-u9BNNb8|vPB5bIQhR?i37&qw&qNH8I5zB z>(Zb;8_Q7*m$0IJ$#?Q*-D>4Vr+erMd*-Xn(5uViID-uiq-4utZ%LQIPQ@0vTO*Qk zYW5v7hjBj#jxvrj5^&#?zeFr(T53$>=~L|Ja-lokc`1u600C2<0sY4M$Y2FGgeX^u#91TV6-Kh^Y1v;S+GqnP6Phk47bw9HS{iy!x-q4=2D?` zqK!VIntVz(lSo6>-RGsw!sI3`)r%?4bq5A&J+1N9VE-_(j&_17Z~_FL+ ze&%Eurn=uJuC^Ja!JX5k;0YJ&aaZEzK1yZULIcuG25|7hh@@>TjwKd)76=F{U5#iA z;}0PqlDFhHhc`d+f?8~~QR0rA?tymqb-DbVlwwa$gn#u-r)0u3iQ@!*d!y;FV$r~h zbEd!lU~AldzR*()>yh7DSo51CQ;Uh*l6SJtfV`^``X{;HtQe$k%do= zt>J*_ZMUF!KBZXy?Zz5Njs!dglANoluh@n*Bv_Zbs$hFdlZ#Apfb{8Y2yOq{(;tO{ z*mZqKv+jJqY+;C|CH|4VBaN_A_8~cJZ!VhS)k})>J1qX%BCepI;=z=u7a@Tb9V4J= zV43r#?_5`5$PvqQOP^ctNa?IebdUE2yd5jTF)9O)n_tX8WahZ1woYBvJ9pAaWXzPKZ&-@uEGEL zfaG2HRG7tWbh$(}7ng^Cb=%A4)?EJBEx=pVyEfJ}8BHB5a5C=5nt@|SO6w!&Eo#(0 z7qy4#=^0iGgO0?*fj(t=P5#@8>%Uc#1=W)wOxHDHA3&Jsr5*Qb=xMBnfQi(lL;MUt zc9+h+Yy+=bd>$y=tC2D5>|?JHy8eQ6@K*n;^ofp)jaC;Nt`+28jYv!1ZR~^6U7*R+ zj$L-_UWUbXJj*dxl-;Y?W|gjJO+ML}aag}yMhPaxrE8|m*auEIepDlCJ_`O$K_iTp z5)pl~&`#Ldx|ru?Plu}^ckG7qTOf3FyrPDebzp6V!{=|8v`LJS2b{hUtki3#&VIqyD;nQTZnt^mVsX}v&)`~dlHsulkirMZKvD@RoEF_{JvA!#O%_zpc8YtlT zzxwcp4L*cuCZ%MywMipu$k;Sg;V@@Et_VE|6sY$7%pR#vOVDnA z5~S?f0U#311QsW|_+9YHY@P25t#^U?Jlp-0 zRA{$r^8Mf}K!N3&`Fvrb<(YM!hWXRTRL~NE#sP{vW_#<+2yl%|q5k6@XNztnq!aPZ z{KshU=$ohEOM8zx(H<2}OnPI@I2-=m3ntgeDkx8|(MVs9;$Q;M_<&}|6pd`6V}zIj zeC8j*mwk$LUITgOQ19N_UGqzC|^P1N9ZJE+<)e^iAr+7DH z^4?f03J@hY?h7^#;#N%QgSZ+)#?hPe1TjxkYpcusZa|)O%8rix+pIGk{QyXsIi^Z6 zJbc~83W^z&l0Rp>@8~gXn#g=Tqrk9XekWtGWX1T^G4a!<_(08p(aAmfYnOGMN>Jt! zV-%s4E6g_6I%9I}#2-3UF!>_6{{BSz6yL3GL2K%$5%gpKn>_&xE7+CYwCnvT6ttiC zcAQAQmE=v;UoW_}%5r~n|_G!dxMMS~}~TdJI`i<}=_m(_Ky)ukvGd9sN2ge)~rWKQHtlBf|gZdm-1|(~XJ} z1ct3OX34|wMuPJU0yb_1y>h{EP=Z;GA5+#)!5CV)G$(q$QDNLLixDg4UGOPc0FJ-F zHu05EWUF&lnE~<%Q3)|TDW=+90CN(A@NRNTr^u~xdd#%OqS1( z1rW-YB?U+J^1e)!{mzc&*iqSELONK4T6T z1U}VRpZfurD5pu24rrmnZp4(oKRp^(cY-}-nt|ay;3}CCWM=HZfOh$p`S!Os2ynin z4slUdz@pGv@H`V1MKXy;TkKEoE4bm4nA-j6?`s1x^ne~FupA6nZc)ItyL;NN)xip>+B|u0iEpu%t!x#Yg3f=GceG75#weX@Z08$<(?lB8l!bj z3G(eQ&;=y$i3f%kJlfj}bvlHO?M+Wx8H`p)ujr^PWC7NvjVgAO1_Mf`ojI_#zw<}H zX{f()6%z)Xd)VF-ol4*nldmusjPS8)zx2 zMh97`go+%iy0JyHy0w&H?nTnO==>ND5$}3Ij3IHgoha8TR_;A;7os`w^wcmi^a(sC{DPgUM-Gg*BaLMwJhHN{x7S1^N+UF}QuB;{~?v_(WMZ z79=@J$W`Mh@*NviVQ15&52R@5Uz;7qS9?vmO@FPeauEd$qJSuW|Nm@h-U}u1AVzxv z(8dHSQ%y|#Ou|A^A+uz52vc>)+(X6HGWz7lM-cvO;(JOFIz|VOwDs4^V&P`^DvCsS zUr2L~gE2QiSto9(S=v12pB)5Un1E29bKf9&fPL5&#R~y5S7hDtK6457srT*n4V5px*E`>M zAAEWBsN}aow1%G=Uk@mb3Oir?qb0!p2KkoQy&GD%NWpPH+CHo`Hypi5o)~e+Tym$W zr0GXxQ&FQkG8@QTv?N+0&G(>lhLd1AigDqe!305Yt=u*}Ew!tOp<~kS~#6T z*fGoHewn5>++HQV<^N{9M9zRJ#wuDQYk{?;4!(5I#5zGrX8-A|J4GFOq+0Dib7U>t ziok+^G(JNfzbc-p6uIe02|OhidNUxSM8I6;HdA*BpVqB36u*~2mrQqO{jtEOW#>O)EYLy7BL#^(*OaEjza`G8{kwrGj#t&CynGYBpultW-(%h~yqM8loY zujP18$ESY&K+F5W1De_&Q?qb&%> zmbZ{NlvF_q&;9nd7x>EFkBMmz|00LbIzMY40wy+wlf>`$&d# zKzi$EqG$eX(vJF?x0xK&T@n!VYHIl&dpRTBWZ>R?&$_>M5&@eda52Ovad3#|fQp^C z=0L@0TBKPXZ(V9&1X&0%foksFKKsVYzPXfy4PA`xCjaQvSzkOCdc+WmxkP^5Lb4Agg-Xyn43nU^x5PzCmPS zVbzgYG?2O5xk+k{H`tV^9(UrN$ucPi`oUx-=KPGh-bxom*e{YKa=q84eYom2e@+F6 z%%`Se3KVudGK(s>RNITQ=|khASgECfbi@X}{10;^@6Q-5P}v-+CHFP#5h>@>GS$zj zc3McacFfPw?RTpvmzFfbVT0hTShc~t9`sYIq14L2w|`a?o43A`G2+nVYZ_ZuCK1W6 zdDecFAH#gme8xau0(taTWqQsAa>Q0shxEJ};VXHy_NIN&Rvh7|=EoqdlV?tnh(opa&>;I{hk5@tNB}#P7OEw>NV3#Sz`CTw&Q+ zHMCXrITY~kt;!Uv%Hvm#WoXRqss#EM`uwD4XIY%u?pbd`!mQ?+usMYJZXRlOU5GKE zNqk}m))9^&Xqpm@FbR&k$p2>}!h51wyshKG8`EWidzbUKZq1L0N2NP+pv#I`^Hi@A ztB&!tJf?UrX+=c1g@AOJnl9Bn)KU}o#Cdh7bhRO5K&9HHj5sit=EV;s=4NV$UEjQD zDPYeo*gJGMgWU19+_mG&akSR=!(5l(>P6?)&C})VFb&He;Q&D_wdZNZ?&-jEXkig6 zWJLEhD}zLPVaTrqve8Hp{PEj$X$7F9K3+kRILtg6{tJ9<*@t4Uucxa$S9Q+z%^Qcy zEY8Q*Xc7z24Y&G3jml2WQQXg%XTN%ucNpd)su8r1;94=LXMi++G^jHkZ^XD8^Nk~muk0L-!cQ)OEOYwdU~Lx zB?9{iPk_P0r3p6aE!c+g^HZvpN4EX+=X@g^%3mtWtk!pp^%b`n60X;f-xilqlSA&A z60+7qkM%F~JXYVq9E^Ty_$Ky%2&rCeEAH|72graADb3vqQX#(d3f2LdpmTr2@#{}l zxf|vgIfV6r1)gSI&7CHpr+MPW48W0u(Kr%NawDrhWjjSFFaIewH?8Pf zQ9523{xp5D?6bBFKx_oY4aY zi*F{moNc*VO1GQcY0x&{4ma=aMcEh)&_gnWOPo;zAZjL`8$X)D?IY_x{>ZC005w)c zZ`Fs2d}>xjTQOq6D7_ISFw?Aya`8055lG4c=-_W&@#q-C4lqK+vthzt>bKa=rd({5 zgXM_X_hYkn`E7&mYXOy3OJ7G zx$CulBOs({wXb%<@en;rnz#Z|prSq6ocrTo(#R6Ky3iJTTug_mc07vIk+rryqcLuU^t2gGx(RD^anHOHhSE&EX zy_~uIPM=T3n+K#?O?naR-UdVDQ(<|Hefz7{ew|JWMV~)@CVUrcKyb!8n$O!Hw-Nu) zKVI&pJo5SkvQ;C9KbzMLJ7D>V+0Lp4v?w#9+o>jF$;Z+Ot%0#1ZI1b=Hu$D&hY@ox zS>ebQs`8Z=s3$rKE(xp|@5#wISQSGiA~ zS7h@ha3fGFt#svrKgH4mu2-79Xc5wXUOHRNeBhuDyPb@!!=ipl^;%&Aktg%#Y1<0EN;nh`dNwtqy*2w_rk&qIx*8aC{zL9jzs zLJr|p(`-~o=~8;t#CRp%BE-&|t83iJ>YXVp;Dy~m@E8737TxbM+%Agzp)pITPvrg8 zc+TZLP3263K{ScPF4Lt6AnT6cz`wdFv#ul#K<-569wm=yxi9%41#P8e*opY?Q3ZHE z6|at>#bMyuB9?z|5KXRIPm?)t2moTS`<(m!leQ5}7ZEW4ccN6s_WR@lHSF%6#{h^H z^<&uGE%1;I3qOL5!M|yb6k-W}1YU!DSH-k1zz4@Vb#3t}?7hx=Oaeg4ip(^}Y({j! zN(d6)2YwJk{1YtuTz3k^brG?h0Gq>G{EAR{t)Sg<8&u&c?;ro&g8%~ac#0UXTsC_R zDvvLUnNVAgIUA8X=>WC!q&cwi#W3P(i1O_}gYEwwDRF908*dVA_|7A$P3}0eO$dlu z6c6Zn!ip0nBE%E_M+~>rUPvhhc-439t-`dW`h`Ia-Dl&2D%@I@hddpdV+%!f1^7Q0 zy6pRo{}PIGs?e7p%VSrI7^z4{^W?Ly)?0Fg*rtnh!Z|o z3hV54nb^~435_v>rE&zr9%{d21=a2SjeT6-)}$lRpPN@YGZQnSHDK$jDD8g%-0C5) zWs-afR4`Td-o70Y0pgW47y21@{=Rmni$|*4(*IjV;(tp>?bjaw^`G#Qk=_gY(2(J) c!PpMn8pqhl+_;<9=)jMmp4sIJ-D?m216#iq*#H0l literal 10911 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 - -package "Client Apps" { - RECTANGLE ClientApplications -} - -cloud APIGateway { - RECTANGLE ApiGateway -} - -package "Microservices" { - [Location] - [Person] - [Connection] -} - -database "Kafka Cluster" { - [Kafka Topics] -} - -package "RPC Servers" { - [RPC Server - Person] - [RPC Server - Connection] -} - -ClientApplications --down-> ApiGateway -ApiGateway --down-> Location : RESTful API -ApiGateway --down-> Person : RESTful API -ApiGateway --down-> Connection : RESTful API -Location --down-> "Kafka Topics" : Event -Person --down-> "Kafka Topics" : Event -Connection --down-> "Kafka Topics" : Event -"Kafka Topics" --down-> "RPC Server - Person" : Notification -"Kafka Topics" --down-> "RPC Server - Connection" : Notification - -@enduml diff --git a/env-dev b/env-dev index 1ebfc288e..1b3edc004 100755 --- a/env-dev +++ b/env-dev @@ -4,4 +4,7 @@ export DB_USERNAME="ct_admin" export DB_NAME="geoconnections" export DB_PASSWORD="wowimsosecure" export DB_HOST="localhost" -export DB_PORT="5432" \ No newline at end of file +export DB_PORT="5432" + +# grpc +export GRPC_SERVER_ADDRESS="localhost:50051" \ No newline at end of file diff --git a/modules/connection/app/udaconnect/services.py b/modules/connection/app/udaconnect/services.py index b29008f9f..57d6bd5fe 100644 --- a/modules/connection/app/udaconnect/services.py +++ b/modules/connection/app/udaconnect/services.py @@ -7,8 +7,13 @@ from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema from geoalchemy2.functions import ST_AsText, ST_Point from sqlalchemy.sql import text +import os +import grpc +import persons_pb2 +import persons_pb2_grpc -logging.basicConfig(level=logging.WARNING) + +logging.basicConfig(level=logging.INFO) logger = logging.getLogger("udaconnect-api") @@ -30,7 +35,13 @@ def find_contacts(person_id: int, start_date: datetime, end_date: datetime, mete ).all() # Cache all users in memory for quick lookup - person_map: Dict[str, Person] = {person.id: person for person in PersonService.retrieve_all()} + grpc_server_address = os.environ.get("GRPC_SERVER_ADDRESS") + channel = grpc.insecure_channel(grpc_server_address) + stub = persons_pb2_grpc.PersonServiceStub(channel=channel) + request = persons_pb2.RetrieveAllPersonsRequest() + list_persons: persons_pb2.ListPerson = stub.RetrieveAllPersons(request) + person_map: Dict[str, Person] = {person.id: person for person in list_persons.persons} + logging.info(person_map) # Prepare arguments for queries data = [] diff --git a/modules/connection/persons_pb2.py b/modules/connection/persons_pb2.py new file mode 100644 index 000000000..c328841be --- /dev/null +++ b/modules/connection/persons_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: persons.proto +# Protobuf Python Version: 4.25.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rpersons.proto\x12\nudaconnect\"Q\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x12\n\nfirst_name\x18\x02 \x01(\t\x12\x11\n\tlast_name\x18\x03 \x01(\t\x12\x14\n\x0c\x63ompany_name\x18\x04 \x01(\t\"1\n\nListPerson\x12#\n\x07persons\x18\x01 \x03(\x0b\x32\x12.udaconnect.Person\"\x1b\n\x19RetrieveAllPersonsRequest2d\n\rPersonService\x12S\n\x12RetrieveAllPersons\x12%.udaconnect.RetrieveAllPersonsRequest\x1a\x16.udaconnect.ListPersonb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'persons_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals['_PERSON']._serialized_start=29 + _globals['_PERSON']._serialized_end=110 + _globals['_LISTPERSON']._serialized_start=112 + _globals['_LISTPERSON']._serialized_end=161 + _globals['_RETRIEVEALLPERSONSREQUEST']._serialized_start=163 + _globals['_RETRIEVEALLPERSONSREQUEST']._serialized_end=190 + _globals['_PERSONSERVICE']._serialized_start=192 + _globals['_PERSONSERVICE']._serialized_end=292 +# @@protoc_insertion_point(module_scope) diff --git a/modules/connection/persons_pb2_grpc.py b/modules/connection/persons_pb2_grpc.py new file mode 100644 index 000000000..3f496f1ec --- /dev/null +++ b/modules/connection/persons_pb2_grpc.py @@ -0,0 +1,66 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import persons_pb2 as persons__pb2 + + +class PersonServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.RetrieveAllPersons = channel.unary_unary( + '/udaconnect.PersonService/RetrieveAllPersons', + request_serializer=persons__pb2.RetrieveAllPersonsRequest.SerializeToString, + response_deserializer=persons__pb2.ListPerson.FromString, + ) + + +class PersonServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def RetrieveAllPersons(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_PersonServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'RetrieveAllPersons': grpc.unary_unary_rpc_method_handler( + servicer.RetrieveAllPersons, + request_deserializer=persons__pb2.RetrieveAllPersonsRequest.FromString, + response_serializer=persons__pb2.ListPerson.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'udaconnect.PersonService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class PersonService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def RetrieveAllPersons(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/udaconnect.PersonService/RetrieveAllPersons', + persons__pb2.RetrieveAllPersonsRequest.SerializeToString, + persons__pb2.ListPerson.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/modules/connection/requirements.txt b/modules/connection/requirements.txt index 652e39c33..57b3dbd7e 100644 --- a/modules/connection/requirements.txt +++ b/modules/connection/requirements.txt @@ -24,3 +24,5 @@ shapely==1.7.0 SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 +grpcio==1.60.0 + diff --git a/modules/person/app/udaconnect/controllers.py b/modules/person/app/udaconnect/controllers.py index e0176ea6c..6ebeb5c08 100644 --- a/modules/person/app/udaconnect/controllers.py +++ b/modules/person/app/udaconnect/controllers.py @@ -27,12 +27,6 @@ def post(self) -> Person: new_person: Person = PersonService.create(payload) return new_person - @responds(schema=PersonSchema, many=True) - def get(self) -> List[Person]: - persons: List[Person] = PersonService.retrieve_all() - return persons - - @api.route("/persons/") @api.param("person_id", "Unique ID for a given Person", _in="query") class PersonResource(Resource): diff --git a/modules/person_rpc/app/__init__.py b/modules/person_rpc/app/__init__.py deleted file mode 100644 index 627a5c5f6..000000000 --- a/modules/person_rpc/app/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import Flask, jsonify -from flask_cors import CORS -from flask_restx import Api -from flask_sqlalchemy import SQLAlchemy - -db = SQLAlchemy() - - -def create_app(env=None): - from app.config import config_by_name - from app.routes import register_routes - - app = Flask(__name__) - app.config.from_object(config_by_name[env or "test"]) - api = Api(app, title="UdaConnect API", version="0.1.0") - - CORS(app) # Set CORS for development - - register_routes(api, app) - db.init_app(app) - - @app.route("/health") - def health(): - return jsonify("healthy") - - return app diff --git a/modules/person_rpc/app/client.py b/modules/person_rpc/app/client.py new file mode 100644 index 000000000..c931c8e5a --- /dev/null +++ b/modules/person_rpc/app/client.py @@ -0,0 +1,29 @@ +from __future__ import print_function + +import logging + +import grpc +import persons_pb2 +import persons_pb2_grpc +import os +from typing import Dict, List + +grpc_server_address = os.environ.get("GRPC_SERVER_ADDRESS") + + +def run(): + # NOTE(gRPC Python Team): .close() is possible on a channel and should be + # used in circumstances in which the with statement does not fit the needs + # of the code. + with grpc.insecure_channel(grpc_server_address) as channel: + stub = persons_pb2_grpc.PersonServiceStub(channel=channel) + logging.info("get list user") + request = persons_pb2.RetrieveAllPersonsRequest() + list_persons: persons_pb2.ListPerson = stub.RetrieveAllPersons(request) + for person in list_persons.persons: + logging.info("id: %s, name: %s, company: %s", person.id, person.first_name, person.company_name) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + run() \ No newline at end of file diff --git a/modules/person_rpc/app/config.py b/modules/person_rpc/app/config.py deleted file mode 100644 index 827b6a14a..000000000 --- a/modules/person_rpc/app/config.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -from typing import List, Type - -DB_USERNAME = os.environ["DB_USERNAME"] -DB_PASSWORD = os.environ["DB_PASSWORD"] -DB_HOST = os.environ["DB_HOST"] -DB_PORT = os.environ["DB_PORT"] -DB_NAME = os.environ["DB_NAME"] - - -class BaseConfig: - CONFIG_NAME = "base" - USE_MOCK_EQUIVALENCY = False - DEBUG = False - SQLALCHEMY_TRACK_MODIFICATIONS = False - - -class DevelopmentConfig(BaseConfig): - CONFIG_NAME = "dev" - SECRET_KEY = os.getenv( - "DEV_SECRET_KEY", "You can't see California without Marlon Widgeto's eyes" - ) - DEBUG = True - SQLALCHEMY_TRACK_MODIFICATIONS = False - TESTING = False - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" - ) - - -class TestingConfig(BaseConfig): - CONFIG_NAME = "test" - SECRET_KEY = os.getenv("TEST_SECRET_KEY", "Thanos did nothing wrong") - DEBUG = True - SQLALCHEMY_TRACK_MODIFICATIONS = False - TESTING = True - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" - ) - - -class ProductionConfig(BaseConfig): - CONFIG_NAME = "prod" - SECRET_KEY = os.getenv("PROD_SECRET_KEY", "I'm Ron Burgundy?") - DEBUG = False - SQLALCHEMY_TRACK_MODIFICATIONS = False - TESTING = False - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" - ) - - -EXPORT_CONFIGS: List[Type[BaseConfig]] = [ - DevelopmentConfig, - TestingConfig, - ProductionConfig, -] -config_by_name = {cfg.CONFIG_NAME: cfg for cfg in EXPORT_CONFIGS} diff --git a/modules/person_rpc/app/persons_pb2.py b/modules/person_rpc/app/persons_pb2.py new file mode 100644 index 000000000..c328841be --- /dev/null +++ b/modules/person_rpc/app/persons_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: persons.proto +# Protobuf Python Version: 4.25.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rpersons.proto\x12\nudaconnect\"Q\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x12\n\nfirst_name\x18\x02 \x01(\t\x12\x11\n\tlast_name\x18\x03 \x01(\t\x12\x14\n\x0c\x63ompany_name\x18\x04 \x01(\t\"1\n\nListPerson\x12#\n\x07persons\x18\x01 \x03(\x0b\x32\x12.udaconnect.Person\"\x1b\n\x19RetrieveAllPersonsRequest2d\n\rPersonService\x12S\n\x12RetrieveAllPersons\x12%.udaconnect.RetrieveAllPersonsRequest\x1a\x16.udaconnect.ListPersonb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'persons_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals['_PERSON']._serialized_start=29 + _globals['_PERSON']._serialized_end=110 + _globals['_LISTPERSON']._serialized_start=112 + _globals['_LISTPERSON']._serialized_end=161 + _globals['_RETRIEVEALLPERSONSREQUEST']._serialized_start=163 + _globals['_RETRIEVEALLPERSONSREQUEST']._serialized_end=190 + _globals['_PERSONSERVICE']._serialized_start=192 + _globals['_PERSONSERVICE']._serialized_end=292 +# @@protoc_insertion_point(module_scope) diff --git a/modules/person_rpc/app/persons_pb2_grpc.py b/modules/person_rpc/app/persons_pb2_grpc.py new file mode 100644 index 000000000..3f496f1ec --- /dev/null +++ b/modules/person_rpc/app/persons_pb2_grpc.py @@ -0,0 +1,66 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import persons_pb2 as persons__pb2 + + +class PersonServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.RetrieveAllPersons = channel.unary_unary( + '/udaconnect.PersonService/RetrieveAllPersons', + request_serializer=persons__pb2.RetrieveAllPersonsRequest.SerializeToString, + response_deserializer=persons__pb2.ListPerson.FromString, + ) + + +class PersonServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def RetrieveAllPersons(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_PersonServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'RetrieveAllPersons': grpc.unary_unary_rpc_method_handler( + servicer.RetrieveAllPersons, + request_deserializer=persons__pb2.RetrieveAllPersonsRequest.FromString, + response_serializer=persons__pb2.ListPerson.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'udaconnect.PersonService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class PersonService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def RetrieveAllPersons(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/udaconnect.PersonService/RetrieveAllPersons', + persons__pb2.RetrieveAllPersonsRequest.SerializeToString, + persons__pb2.ListPerson.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/modules/person_rpc/app/routes.py b/modules/person_rpc/app/routes.py deleted file mode 100644 index c6b1c20eb..000000000 --- a/modules/person_rpc/app/routes.py +++ /dev/null @@ -1,5 +0,0 @@ -def register_routes(api, app, root="api"): - from app.udaconnect import register_routes as attach_udaconnect - - # Add routes - attach_udaconnect(api, app) diff --git a/modules/person_rpc/app/server.py b/modules/person_rpc/app/server.py new file mode 100644 index 000000000..c68d12032 --- /dev/null +++ b/modules/person_rpc/app/server.py @@ -0,0 +1,64 @@ +from concurrent import futures +import os +import grpc +from sqlalchemy import create_engine, Column, Integer, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import logging +import persons_pb2 +import persons_pb2_grpc + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + +SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + +logging.basicConfig(level= logging.INFO) + +logging.info(SQLALCHEMY_DATABASE_URI) + +engine = create_engine(SQLALCHEMY_DATABASE_URI) +Session = sessionmaker(bind=engine) +db = Session() + +Base = declarative_base() + +class Person(Base): + __tablename__ = 'person' + + id = Column(Integer, primary_key=True) + first_name = Column(String) + last_name = Column(String) + company_name = Column(String) + +class PersonServicer(persons_pb2_grpc.PersonServiceServicer): + def RetrieveAllPersons(self, request, context): + persons = db.query(Person).all() + logging.info(persons) + + grpc_persons = persons_pb2.ListPerson(persons=[ + persons_pb2.Person(id=p.id, first_name=p.first_name, last_name=p.last_name, company_name=p.company_name) + for p in persons + ]) + + return grpc_persons + +def serve(): + logging.info("Server is starting...") + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + persons_pb2_grpc.add_PersonServiceServicer_to_server( + PersonServicer(), server + ) + server.add_insecure_port("[::]:50051") + server.start() + server.wait_for_termination() + logging.info("Server started") + + +if __name__ == "__main__": + serve() \ No newline at end of file diff --git a/modules/person_rpc/app/udaconnect/__init__.py b/modules/person_rpc/app/udaconnect/__init__.py deleted file mode 100644 index 684e4f074..000000000 --- a/modules/person_rpc/app/udaconnect/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from app.udaconnect.models import Person # noqa -from app.udaconnect.schemas import PersonSchema # noqa - - -def register_routes(api, app, root="api"): - from app.udaconnect.controllers import api as udaconnect_api - - api.add_namespace(udaconnect_api, path=f"/{root}") diff --git a/modules/person_rpc/app/udaconnect/controllers.py b/modules/person_rpc/app/udaconnect/controllers.py deleted file mode 100644 index 12651d2e9..000000000 --- a/modules/person_rpc/app/udaconnect/controllers.py +++ /dev/null @@ -1,27 +0,0 @@ -from datetime import datetime - -from app.udaconnect.models import Person -from app.udaconnect.schemas import ( - PersonSchema, -) -from app.udaconnect.services import PersonService -from flask import request -from flask_accepts import accepts, responds -from flask_restx import Namespace, Resource -from typing import Optional, List - -DATE_FORMAT = "%Y-%m-%d" - -api = Namespace("UdaConnect", description="Connections via geolocation.") # noqa - - -# TODO: This needs better exception handling - - -@api.route("/persons") -class PersonsResource(Resource): - - @responds(schema=PersonSchema, many=True) - def get(self) -> List[Person]: - persons: List[Person] = PersonService.retrieve_all() - return persons diff --git a/modules/person_rpc/app/udaconnect/models.py b/modules/person_rpc/app/udaconnect/models.py deleted file mode 100644 index 8cac7f6b5..000000000 --- a/modules/person_rpc/app/udaconnect/models.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime - -from app import db # noqa -from geoalchemy2 import Geometry -from geoalchemy2.shape import to_shape -from shapely.geometry.point import Point -from sqlalchemy import BigInteger, Column, Date, DateTime, ForeignKey, Integer, String -from sqlalchemy.dialects.postgresql import JSONB, UUID -from sqlalchemy.ext.hybrid import hybrid_property - - -class Person(db.Model): - __tablename__ = "person" - - id = Column(Integer, primary_key=True) - first_name = Column(String, nullable=False) - last_name = Column(String, nullable=False) - company_name = Column(String, nullable=False) - - - -@dataclass -class Connection: - person: Person diff --git a/modules/person_rpc/app/udaconnect/schemas.py b/modules/person_rpc/app/udaconnect/schemas.py deleted file mode 100644 index 879b2136a..000000000 --- a/modules/person_rpc/app/udaconnect/schemas.py +++ /dev/null @@ -1,15 +0,0 @@ -from app.udaconnect.models import Person -from geoalchemy2.types import Geometry as GeometryType -from marshmallow import Schema, fields -from marshmallow_sqlalchemy.convert import ModelConverter as BaseModelConverter - - -class PersonSchema(Schema): - id = fields.Integer() - first_name = fields.String() - last_name = fields.String() - company_name = fields.String() - - class Meta: - model = Person - diff --git a/modules/person_rpc/app/udaconnect/services.py b/modules/person_rpc/app/udaconnect/services.py deleted file mode 100644 index e998bc30d..000000000 --- a/modules/person_rpc/app/udaconnect/services.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -from datetime import datetime, timedelta -from typing import Dict, List - -from app import db -from app.udaconnect.models import Person -from app.udaconnect.schemas import PersonSchema -from geoalchemy2.functions import ST_AsText, ST_Point -from sqlalchemy.sql import text - -logging.basicConfig(level=logging.WARNING) -logger = logging.getLogger("udaconnect-api") - - -class PersonService: - @staticmethod - def create(person: Dict) -> Person: - new_person = Person() - new_person.first_name = person["first_name"] - new_person.last_name = person["last_name"] - new_person.company_name = person["company_name"] - - db.session.add(new_person) - db.session.commit() - - return new_person - - @staticmethod - def retrieve(person_id: int) -> Person: - person = db.session.query(Person).get(person_id) - return person - - @staticmethod - def retrieve_all() -> List[Person]: - return db.session.query(Person).all() diff --git a/modules/person_rpc/requirements.txt b/modules/person_rpc/requirements.txt index 652e39c33..41ec6c414 100644 --- a/modules/person_rpc/requirements.txt +++ b/modules/person_rpc/requirements.txt @@ -24,3 +24,4 @@ shapely==1.7.0 SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 +grpcio==1.60.0 diff --git a/modules/person_rpc/wsgi.py b/modules/person_rpc/wsgi.py index 63fc43373..fade9cb1f 100644 --- a/modules/person_rpc/wsgi.py +++ b/modules/person_rpc/wsgi.py @@ -1,6 +1,6 @@ import os -from app import create_app +from modules.person_rpc.app.server import create_app app = create_app(os.getenv("FLASK_ENV") or "test") if __name__ == "__main__": diff --git a/protobufs/REAME.md b/protobufs/REAME.md new file mode 100644 index 000000000..a38bcc5a2 --- /dev/null +++ b/protobufs/REAME.md @@ -0,0 +1,21 @@ +# The RPC Server + +## Generate Python code from the protobufs +```shell + +# person_rpc +python3 -m grpc_tools.protoc -I ./protobufs --python_out=./modules/person_rpc/app --grpc_python_out=./modules/person_rpc/app ./protobufs/*.proto + +# connection +python3 -m grpc_tools.protoc -I ./protobufs --python_out=./modules/connection --grpc_python_out=./modules/connection ./protobufs/*.proto + +``` + +## The RPC Client +```shell + +``` + +``` +flask run --host 0.0.0.0 +``` \ No newline at end of file diff --git a/protobufs/persons.proto b/protobufs/persons.proto new file mode 100644 index 000000000..705737721 --- /dev/null +++ b/protobufs/persons.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package udaconnect; + +message Person { + int32 id = 1; + string first_name = 2; + string last_name = 3; + string company_name = 4; +} + +message ListPerson { + repeated Person persons = 1; +} + +message RetrieveAllPersonsRequest {} + +service PersonService { + rpc RetrieveAllPersons(RetrieveAllPersonsRequest) returns (ListPerson); +} From d03b92bae59e1c5532c57f0b9116947c37d39c60 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Wed, 20 Dec 2023 00:42:09 +0700 Subject: [PATCH 05/12] kafka --- .../location/app/udaconnect/controllers.py | 6 +- modules/location/app/udaconnect/services.py | 129 ++++-------------- modules/location/requirements.txt | 1 + protobufs/REAME.md | 18 +++ 4 files changed, 49 insertions(+), 105 deletions(-) diff --git a/modules/location/app/udaconnect/controllers.py b/modules/location/app/udaconnect/controllers.py index a30f0688a..3fbb65f63 100644 --- a/modules/location/app/udaconnect/controllers.py +++ b/modules/location/app/udaconnect/controllers.py @@ -6,7 +6,7 @@ LocationSchema, PersonSchema, ) -from app.udaconnect.services import ConnectionService, LocationService, PersonService +from app.udaconnect.services import LocationService from flask import request from flask_accepts import accepts, responds from flask_restx import Namespace, Resource @@ -26,9 +26,9 @@ class LocationResource(Resource): @accepts(schema=LocationSchema) @responds(schema=LocationSchema) - def post(self) -> Location: + def post(self): request.get_json() - location: Location = LocationService.create(request.get_json()) + location = LocationService.create(request.get_json()) return location @responds(schema=LocationSchema) diff --git a/modules/location/app/udaconnect/services.py b/modules/location/app/udaconnect/services.py index c248c31b2..dd26e5db5 100644 --- a/modules/location/app/udaconnect/services.py +++ b/modules/location/app/udaconnect/services.py @@ -7,80 +7,14 @@ from app.udaconnect.schemas import ConnectionSchema, LocationSchema, PersonSchema from geoalchemy2.functions import ST_AsText, ST_Point from sqlalchemy.sql import text +import os +import json + +from kafka import KafkaProducer logging.basicConfig(level=logging.WARNING) logger = logging.getLogger("udaconnect-api") - -class ConnectionService: - @staticmethod - def find_contacts(person_id: int, start_date: datetime, end_date: datetime, meters=5 - ) -> List[Connection]: - """ - Finds all Person who have been within a given distance of a given Person within a date range. - - This will run rather quickly locally, but this is an expensive method and will take a bit of time to run on - large datasets. This is by design: what are some ways or techniques to help make this data integrate more - smoothly for a better user experience for API consumers? - """ - locations: List = db.session.query(Location).filter( - Location.person_id == person_id - ).filter(Location.creation_time < end_date).filter( - Location.creation_time >= start_date - ).all() - - # Cache all users in memory for quick lookup - person_map: Dict[str, Person] = {person.id: person for person in PersonService.retrieve_all()} - - # Prepare arguments for queries - data = [] - for location in locations: - data.append( - { - "person_id": person_id, - "longitude": location.longitude, - "latitude": location.latitude, - "meters": meters, - "start_date": start_date.strftime("%Y-%m-%d"), - "end_date": (end_date + timedelta(days=1)).strftime("%Y-%m-%d"), - } - ) - - query = text( - """ - SELECT person_id, id, ST_X(coordinate), ST_Y(coordinate), creation_time - FROM location - WHERE ST_DWithin(coordinate::geography,ST_SetSRID(ST_MakePoint(:latitude,:longitude),4326)::geography, :meters) - AND person_id != :person_id - AND TO_DATE(:start_date, 'YYYY-MM-DD') <= creation_time - AND TO_DATE(:end_date, 'YYYY-MM-DD') > creation_time; - """ - ) - result: List[Connection] = [] - for line in tuple(data): - for ( - exposed_person_id, - location_id, - exposed_lat, - exposed_long, - exposed_time, - ) in db.engine.execute(query, **line): - location = Location( - id=location_id, - person_id=exposed_person_id, - creation_time=exposed_time, - ) - location.set_wkt_with_coords(exposed_lat, exposed_long) - - result.append( - Connection( - person=person_map[exposed_person_id], location=location, - ) - ) - - return result - - class LocationService: @staticmethod def retrieve(location_id) -> Location: @@ -95,40 +29,31 @@ def retrieve(location_id) -> Location: return location @staticmethod - def create(location: Dict) -> Location: + def create(location: Dict): validation_results: Dict = LocationSchema().validate(location) if validation_results: logger.warning(f"Unexpected data format in payload: {validation_results}") raise Exception(f"Invalid payload: {validation_results}") + + kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') + producer_config = { + 'bootstrap_servers': kafka_bootstrap_servers, + 'client_id': 'location-producer', + 'value_serializer': lambda v: json.dumps(v).encode('utf-8'), + } + + producer = KafkaProducer(**producer_config) + creation_time_str = location["creation_time"] + location["creation_time"] = datetime.fromisoformat(creation_time_str) + + location_data = { + 'person_id': location["person_id"], + 'latitude': location["latitude"], + 'longitude': location["longitude"], + 'creation_time': location["creation_time"].isoformat() + } + + kafka_topic = 'location-topic' + producer.send(kafka_topic, value=location_data) - new_location = Location() - new_location.person_id = location["person_id"] - new_location.creation_time = location["creation_time"] - new_location.coordinate = ST_Point(location["latitude"], location["longitude"]) - db.session.add(new_location) - db.session.commit() - - return new_location - - -class PersonService: - @staticmethod - def create(person: Dict) -> Person: - new_person = Person() - new_person.first_name = person["first_name"] - new_person.last_name = person["last_name"] - new_person.company_name = person["company_name"] - - db.session.add(new_person) - db.session.commit() - - return new_person - - @staticmethod - def retrieve(person_id: int) -> Person: - person = db.session.query(Person).get(person_id) - return person - - @staticmethod - def retrieve_all() -> List[Person]: - return db.session.query(Person).all() + return location diff --git a/modules/location/requirements.txt b/modules/location/requirements.txt index 652e39c33..12de1a10a 100644 --- a/modules/location/requirements.txt +++ b/modules/location/requirements.txt @@ -24,3 +24,4 @@ shapely==1.7.0 SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 +kafka-python \ No newline at end of file diff --git a/protobufs/REAME.md b/protobufs/REAME.md index a38bcc5a2..35c1b0dd4 100644 --- a/protobufs/REAME.md +++ b/protobufs/REAME.md @@ -18,4 +18,22 @@ python3 -m grpc_tools.protoc -I ./protobufs --python_out=./modules/connection -- ``` flask run --host 0.0.0.0 +``` + +``` +curl -X POST \ + http://localhost:5000/api/locations \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 29, + "latitude": "-122.290524", + "longitude": "37.553441", + "creation_time": "2020-08-18T10:37:06", + "person_id": 1 +}' + +``` + +``` +bin/kafka-console-consumer.sh --topic location-topic --from-beginning --bootstrap-server localhost:9092 ``` \ No newline at end of file From f3838d03574c81993f8102ccd9f9b86e600eb7b2 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Wed, 20 Dec 2023 08:44:39 +0700 Subject: [PATCH 06/12] kafka person --- modules/person/app/udaconnect/services.py | 17 +++++++++++++++-- modules/person/requirements.txt | 1 + protobufs/REAME.md | 23 +++++++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/modules/person/app/udaconnect/services.py b/modules/person/app/udaconnect/services.py index e998bc30d..580ceeafa 100644 --- a/modules/person/app/udaconnect/services.py +++ b/modules/person/app/udaconnect/services.py @@ -8,6 +8,10 @@ from geoalchemy2.functions import ST_AsText, ST_Point from sqlalchemy.sql import text +import os +import json +from kafka import KafkaProducer + logging.basicConfig(level=logging.WARNING) logger = logging.getLogger("udaconnect-api") @@ -20,8 +24,17 @@ def create(person: Dict) -> Person: new_person.last_name = person["last_name"] new_person.company_name = person["company_name"] - db.session.add(new_person) - db.session.commit() + kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') + producer_config = { + 'bootstrap_servers': kafka_bootstrap_servers, + 'client_id': 'location-producer', + 'value_serializer': lambda v: json.dumps(v).encode('utf-8'), + } + + producer = KafkaProducer(**producer_config) + + kafka_topic = 'person-topic' + producer.send(kafka_topic, value=person) return new_person diff --git a/modules/person/requirements.txt b/modules/person/requirements.txt index 652e39c33..12de1a10a 100644 --- a/modules/person/requirements.txt +++ b/modules/person/requirements.txt @@ -24,3 +24,4 @@ shapely==1.7.0 SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 +kafka-python \ No newline at end of file diff --git a/protobufs/REAME.md b/protobufs/REAME.md index 35c1b0dd4..faa2e0889 100644 --- a/protobufs/REAME.md +++ b/protobufs/REAME.md @@ -20,7 +20,10 @@ python3 -m grpc_tools.protoc -I ./protobufs --python_out=./modules/connection -- flask run --host 0.0.0.0 ``` -``` +```shell + +# location + curl -X POST \ http://localhost:5000/api/locations \ -H 'Content-Type: application/json' \ @@ -32,8 +35,24 @@ curl -X POST \ "person_id": 1 }' -``` + +# person +curl -X POST \ + http://localhost:5000/api/persons \ + -H 'Content-Type: application/json' \ + -d '{ + "first_name": "John", + "last_name": "Doe", + "company_name": "ABC Corp" + }' ``` + +```shell + +bin/kafka-topics.sh --create --topic person-topic --bootstrap-server localhost:9092 + bin/kafka-console-consumer.sh --topic location-topic --from-beginning --bootstrap-server localhost:9092 + +bin/kafka-console-consumer.sh --topic person-topic --from-beginning --bootstrap-server localhost:9092 ``` \ No newline at end of file From 9de45b0f783f54cdd317afeb8175d557a8f2b8b1 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Wed, 20 Dec 2023 09:54:29 +0700 Subject: [PATCH 07/12] consumer --- env-dev | 7 +- modules/connection/app/udaconnect/services.py | 2 +- modules/location/app/udaconnect/services.py | 2 +- modules/location_consumer/Dockerfile | 12 ++ modules/location_consumer/app/app.py | 140 ++++++++++++++++++ modules/location_consumer/requirements.txt | 27 ++++ modules/person/app/udaconnect/services.py | 2 +- modules/person_consumer/Dockerfile | 12 ++ modules/person_consumer/app/app.py | 102 +++++++++++++ modules/person_consumer/requirements.txt | 27 ++++ protobufs/REAME.md | 10 +- 11 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 modules/location_consumer/Dockerfile create mode 100644 modules/location_consumer/app/app.py create mode 100644 modules/location_consumer/requirements.txt create mode 100644 modules/person_consumer/Dockerfile create mode 100644 modules/person_consumer/app/app.py create mode 100644 modules/person_consumer/requirements.txt diff --git a/env-dev b/env-dev index 1b3edc004..d0dc4a1d2 100755 --- a/env-dev +++ b/env-dev @@ -6,5 +6,8 @@ export DB_PASSWORD="wowimsosecure" export DB_HOST="localhost" export DB_PORT="5432" -# grpc -export GRPC_SERVER_ADDRESS="localhost:50051" \ No newline at end of file +# gRPC +export GRPC_SERVER_ADDRESS="localhost:50051" + +# Kafka +export GRPC_SERVER_ADDRESS="localhost:9092" diff --git a/modules/connection/app/udaconnect/services.py b/modules/connection/app/udaconnect/services.py index 57d6bd5fe..47dbbbd78 100644 --- a/modules/connection/app/udaconnect/services.py +++ b/modules/connection/app/udaconnect/services.py @@ -14,7 +14,7 @@ logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("udaconnect-api") +logger = logging.getLogger("connection-api") class ConnectionService: diff --git a/modules/location/app/udaconnect/services.py b/modules/location/app/udaconnect/services.py index dd26e5db5..8f3c5f3da 100644 --- a/modules/location/app/udaconnect/services.py +++ b/modules/location/app/udaconnect/services.py @@ -13,7 +13,7 @@ from kafka import KafkaProducer logging.basicConfig(level=logging.WARNING) -logger = logging.getLogger("udaconnect-api") +logger = logging.getLogger("location-api") class LocationService: @staticmethod diff --git a/modules/location_consumer/Dockerfile b/modules/location_consumer/Dockerfile new file mode 100644 index 000000000..1ef643ff1 --- /dev/null +++ b/modules/location_consumer/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.7-alpine + +WORKDIR . + +RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +EXPOSE 5000 + +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/location_consumer/app/app.py b/modules/location_consumer/app/app.py new file mode 100644 index 000000000..ddb726d73 --- /dev/null +++ b/modules/location_consumer/app/app.py @@ -0,0 +1,140 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List + +from geoalchemy2.functions import ST_AsText, ST_Point +from sqlalchemy import create_engine, BigInteger, Column, Date, DateTime, ForeignKey, Integer, String +from geoalchemy2 import Geometry +from geoalchemy2.shape import to_shape +from shapely.geometry.point import Point +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from marshmallow import Schema, fields +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.hybrid import hybrid_property + +import os +import json +from kafka import KafkaConsumer + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + +SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + +logging.basicConfig(level= logging.INFO) + +logging.info(SQLALCHEMY_DATABASE_URI) + +engine = create_engine(SQLALCHEMY_DATABASE_URI) +Session = sessionmaker(bind=engine) +db = Session() + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("location-consumer") + + +Base = declarative_base() + +class Location(Base): + __tablename__ = "location" + + id = Column(BigInteger, primary_key=True) + person_id = Column(Integer, nullable=False) + coordinate = Column(Geometry("POINT"), nullable=False) + creation_time = Column(DateTime, nullable=False, default=datetime.utcnow) + _wkt_shape: str = None + + @property + def wkt_shape(self) -> str: + # Persist binary form into readable text + if not self._wkt_shape: + point: Point = to_shape(self.coordinate) + # normalize WKT returned by to_wkt() from shapely and ST_AsText() from DB + self._wkt_shape = point.to_wkt().replace("POINT ", "ST_POINT") + return self._wkt_shape + + @wkt_shape.setter + def wkt_shape(self, v: str) -> None: + self._wkt_shape = v + + def set_wkt_with_coords(self, lat: str, long: str) -> str: + self._wkt_shape = f"ST_POINT({lat} {long})" + return self._wkt_shape + + @hybrid_property + def longitude(self) -> str: + coord_text = self.wkt_shape + return coord_text[coord_text.find(" ") + 1 : coord_text.find(")")] + + @hybrid_property + def latitude(self) -> str: + coord_text = self.wkt_shape + return coord_text[coord_text.find("(") + 1 : coord_text.find(" ")] + +class LocationSchema(Schema): + id = fields.Integer() + person_id = fields.Integer() + longitude = fields.String(attribute="longitude") + latitude = fields.String(attribute="latitude") + creation_time = fields.DateTime() + + class Meta: + model = Location + + +class LocationService: + @staticmethod + def create(location: Dict) -> Location: + validation_results: Dict = LocationSchema().validate(location) + if validation_results: + logger.warning(f"Unexpected data format in payload: {validation_results}") + raise Exception(f"Invalid payload: {validation_results}") + + new_location = Location() + new_location.person_id = location["person_id"] + new_location.creation_time = location["creation_time"] + new_location.coordinate = ST_Point(location["latitude"], location["longitude"]) + db.add(new_location) + db.commit() + + return new_location + + +# Kafka consumer configuration +kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') +consumer_config = { + 'bootstrap_servers': kafka_bootstrap_servers, + 'group_id': 'location-consumer-group', + 'auto_offset_reset': 'earliest', + 'enable_auto_commit': True, +} + +# Create Kafka consumer +kafka_topic = os.environ.get('KAFKA_TOPIC', 'location-topic') +consumer = KafkaConsumer(kafka_topic, **consumer_config) + +try: + for msg in consumer: + try: + # Process the received message + location = json.loads(msg.value.decode('utf-8')) + + # Create a person using PersonService + LocationService.create(location) + + logger.info(f'Location created: {location}') + except json.JSONDecodeError as e: + logger.error(f'Error decoding JSON: {e}') + +except KeyboardInterrupt: + pass +finally: + # Close the consumer + consumer.close() \ No newline at end of file diff --git a/modules/location_consumer/requirements.txt b/modules/location_consumer/requirements.txt new file mode 100644 index 000000000..12de1a10a --- /dev/null +++ b/modules/location_consumer/requirements.txt @@ -0,0 +1,27 @@ +aniso8601==7.0.0 +attrs==19.1.0 +Click==7.0 +Flask==1.1.1 +flask-accepts==0.10.0 +flask-cors==3.0.8 +Flask-RESTful==0.3.7 +flask-restplus==0.12.1 +Flask-Script==2.0.6 +Flask-SQLAlchemy==2.4.0 +GeoAlchemy2==0.8.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.0.2 +MarkupSafe==1.1.1 +marshmallow==3.7.1 +marshmallow-sqlalchemy==0.23.1 +psycopg2-binary==2.8.5 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +shapely==1.7.0 +SQLAlchemy==1.3.19 +Werkzeug==0.16.1 +flask-restx==0.2.0 +kafka-python \ No newline at end of file diff --git a/modules/person/app/udaconnect/services.py b/modules/person/app/udaconnect/services.py index 580ceeafa..841eaef73 100644 --- a/modules/person/app/udaconnect/services.py +++ b/modules/person/app/udaconnect/services.py @@ -13,7 +13,7 @@ from kafka import KafkaProducer logging.basicConfig(level=logging.WARNING) -logger = logging.getLogger("udaconnect-api") +logger = logging.getLogger("person-api") class PersonService: diff --git a/modules/person_consumer/Dockerfile b/modules/person_consumer/Dockerfile new file mode 100644 index 000000000..1ef643ff1 --- /dev/null +++ b/modules/person_consumer/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.7-alpine + +WORKDIR . + +RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +EXPOSE 5000 + +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/person_consumer/app/app.py b/modules/person_consumer/app/app.py new file mode 100644 index 000000000..afeafed5a --- /dev/null +++ b/modules/person_consumer/app/app.py @@ -0,0 +1,102 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List + +from geoalchemy2.functions import ST_AsText, ST_Point +from sqlalchemy import create_engine, Column, Integer, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from marshmallow import Schema, fields + +import os +import json +from kafka import KafkaConsumer + +DB_USERNAME = os.environ["DB_USERNAME"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_HOST = os.environ["DB_HOST"] +DB_PORT = os.environ["DB_PORT"] +DB_NAME = os.environ["DB_NAME"] + +SQLALCHEMY_DATABASE_URI = ( + f"postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + +logging.basicConfig(level= logging.INFO) + +logging.info(SQLALCHEMY_DATABASE_URI) + +engine = create_engine(SQLALCHEMY_DATABASE_URI) +Session = sessionmaker(bind=engine) +db = Session() + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("person-consumer") + + +Base = declarative_base() + +class Person(Base): + __tablename__ = 'person' + + id = Column(Integer, primary_key=True) + first_name = Column(String) + last_name = Column(String) + company_name = Column(String) + +class PersonSchema(Schema): + id = fields.Integer() + first_name = fields.String() + last_name = fields.String() + company_name = fields.String() + + class Meta: + model = Person + + +class PersonService: + @staticmethod + def create(person: Dict) -> Person: + new_person = Person() + new_person.first_name = person["first_name"] + new_person.last_name = person["last_name"] + new_person.company_name = person["company_name"] + + db.add(new_person) + db.commit() + + return new_person + + +# Kafka consumer configuration +kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') +consumer_config = { + 'bootstrap_servers': kafka_bootstrap_servers, + 'group_id': 'person-consumer-group', + 'auto_offset_reset': 'earliest', + 'enable_auto_commit': True, +} + +# Create Kafka consumer +kafka_topic = os.environ.get('KAFKA_TOPIC', 'person-topic') +consumer = KafkaConsumer(kafka_topic, **consumer_config) + +try: + for msg in consumer: + try: + # Process the received message + person_data = json.loads(msg.value.decode('utf-8')) + + # Create a person using PersonService + PersonService.create(person_data) + + logger.info(f'Person created: {person_data}') + except json.JSONDecodeError as e: + logger.error(f'Error decoding JSON: {e}') + +except KeyboardInterrupt: + pass +finally: + # Close the consumer + consumer.close() \ No newline at end of file diff --git a/modules/person_consumer/requirements.txt b/modules/person_consumer/requirements.txt new file mode 100644 index 000000000..12de1a10a --- /dev/null +++ b/modules/person_consumer/requirements.txt @@ -0,0 +1,27 @@ +aniso8601==7.0.0 +attrs==19.1.0 +Click==7.0 +Flask==1.1.1 +flask-accepts==0.10.0 +flask-cors==3.0.8 +Flask-RESTful==0.3.7 +flask-restplus==0.12.1 +Flask-Script==2.0.6 +Flask-SQLAlchemy==2.4.0 +GeoAlchemy2==0.8.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.0.2 +MarkupSafe==1.1.1 +marshmallow==3.7.1 +marshmallow-sqlalchemy==0.23.1 +psycopg2-binary==2.8.5 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +shapely==1.7.0 +SQLAlchemy==1.3.19 +Werkzeug==0.16.1 +flask-restx==0.2.0 +kafka-python \ No newline at end of file diff --git a/protobufs/REAME.md b/protobufs/REAME.md index faa2e0889..dbe8ac503 100644 --- a/protobufs/REAME.md +++ b/protobufs/REAME.md @@ -28,11 +28,11 @@ curl -X POST \ http://localhost:5000/api/locations \ -H 'Content-Type: application/json' \ -d '{ - "id": 29, + "id": 30, "latitude": "-122.290524", "longitude": "37.553441", "creation_time": "2020-08-18T10:37:06", - "person_id": 1 + "person_id": 29 }' @@ -50,9 +50,13 @@ curl -X POST \ ```shell -bin/kafka-topics.sh --create --topic person-topic --bootstrap-server localhost:9092 +bin/kafka-topics.sh --create --topic location-topic --bootstrap-server localhost:9092 bin/kafka-console-consumer.sh --topic location-topic --from-beginning --bootstrap-server localhost:9092 + +bin/kafka-topics.sh --create --topic person-topic --bootstrap-server localhost:9092 + bin/kafka-console-consumer.sh --topic person-topic --from-beginning --bootstrap-server localhost:9092 + ``` \ No newline at end of file From d92447b07b00d79a706a564996f006d9ee1c5494 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Wed, 20 Dec 2023 10:54:36 +0700 Subject: [PATCH 08/12] k8s --- deployment/kafka-configmap.yaml | 8 +++ deployment/person-rpc.yaml | 63 ++++++++++++++++++++ deployment/udaconnect-connection.yaml | 3 + deployment/udaconnect-location-consumer.yaml | 58 ++++++++++++++++++ deployment/udaconnect-location.yaml | 13 +++- deployment/udaconnect-person-consumer.yaml | 58 ++++++++++++++++++ deployment/udaconnect-person.yaml | 11 ++++ modules/location_consumer/Dockerfile | 4 +- modules/person_rpc/Dockerfile | 2 +- 9 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 deployment/kafka-configmap.yaml create mode 100644 deployment/person-rpc.yaml create mode 100644 deployment/udaconnect-location-consumer.yaml create mode 100644 deployment/udaconnect-person-consumer.yaml diff --git a/deployment/kafka-configmap.yaml b/deployment/kafka-configmap.yaml new file mode 100644 index 000000000..74192aa4a --- /dev/null +++ b/deployment/kafka-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kafka-env +data: + KAFKA_BOOTSTRAP_SERVERS: "kafka-service:9092" + KAFKA_TOPIC_PERSON: "person-topic" + KAFKA_TOPIC_LOCATION: "location-topic" \ No newline at end of file diff --git a/deployment/person-rpc.yaml b/deployment/person-rpc.yaml new file mode 100644 index 000000000..211e061cc --- /dev/null +++ b/deployment/person-rpc.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + service: person-rpc + name: person-rpc +spec: + ports: + - name: "50051" + port: 50051 + targetPort: 50051 + nodePort: 50051 + selector: + service: person-rpc + type: NodePort +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: person-rpc + name: person-rpc +spec: + replicas: 1 + selector: + matchLabels: + service: person-api + template: + metadata: + labels: + service: person-api + spec: + containers: + - image: udacity/nd064-c2-person-rpc:latest + name: person-api + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + restartPolicy: Always diff --git a/deployment/udaconnect-connection.yaml b/deployment/udaconnect-connection.yaml index 933320bd3..3c756dae8 100644 --- a/deployment/udaconnect-connection.yaml +++ b/deployment/udaconnect-connection.yaml @@ -60,4 +60,7 @@ spec: configMapKeyRef: name: db-env key: DB_PORT + # gRPC + - name: GRPC_SERVER_ADDRESS + value: "person-rpc:50051" restartPolicy: Always diff --git a/deployment/udaconnect-location-consumer.yaml b/deployment/udaconnect-location-consumer.yaml new file mode 100644 index 000000000..8fd182edc --- /dev/null +++ b/deployment/udaconnect-location-consumer.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: location-consumer + name: location-consumer +spec: + replicas: 1 + selector: + matchLabels: + service: location-consumer + template: + metadata: + labels: + service: location-consumer + spec: + containers: + - image: kydq2022/nd064-c2-location-consumer:latest + name: location-consumer + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + # Kafka + - name: KAFKA_BOOTSTRAP_SERVERS + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_BOOTSTRAP_SERVERS + - name: KAFKA_TOPIC + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_TOPIC_LOCATION + restartPolicy: Always diff --git a/deployment/udaconnect-location.yaml b/deployment/udaconnect-location.yaml index 3ae342d57..f1513ea02 100644 --- a/deployment/udaconnect-location.yaml +++ b/deployment/udaconnect-location.yaml @@ -31,7 +31,7 @@ spec: service: location-api spec: containers: - - image: udacity/nd064-c2-location-api:latest + - image: kydq2022/nd064-c2-location-api:latest name: location-api imagePullPolicy: Always env: @@ -60,4 +60,15 @@ spec: configMapKeyRef: name: db-env key: DB_PORT + # Kafka + - name: KAFKA_BOOTSTRAP_SERVERS + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_BOOTSTRAP_SERVERS + - name: KAFKA_TOPIC + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_TOPIC_LOCATION restartPolicy: Always diff --git a/deployment/udaconnect-person-consumer.yaml b/deployment/udaconnect-person-consumer.yaml new file mode 100644 index 000000000..9bcb58978 --- /dev/null +++ b/deployment/udaconnect-person-consumer.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: person-consumer + name: person-consumer +spec: + replicas: 1 + selector: + matchLabels: + service: person-consumer + template: + metadata: + labels: + service: person-consumer + spec: + containers: + - image: kydq2022/nd064-c2-person-consumer:latest + name: person-consumer + imagePullPolicy: Always + env: + - name: DB_USERNAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: DB_PASSWORD + - name: DB_NAME + valueFrom: + configMapKeyRef: + name: db-env + key: DB_NAME + - name: DB_HOST + valueFrom: + configMapKeyRef: + name: db-env + key: DB_HOST + - name: DB_PORT + valueFrom: + configMapKeyRef: + name: db-env + key: DB_PORT + # Kafka + - name: KAFKA_BOOTSTRAP_SERVERS + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_BOOTSTRAP_SERVERS + - name: KAFKA_TOPIC + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_TOPIC_PERSON + restartPolicy: Always diff --git a/deployment/udaconnect-person.yaml b/deployment/udaconnect-person.yaml index e02bfeee7..cf9a242bf 100644 --- a/deployment/udaconnect-person.yaml +++ b/deployment/udaconnect-person.yaml @@ -60,4 +60,15 @@ spec: configMapKeyRef: name: db-env key: DB_PORT + # Kafka + - name: KAFKA_BOOTSTRAP_SERVERS + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_BOOTSTRAP_SERVERS + - name: KAFKA_TOPIC + valueFrom: + configMapKeyRef: + name: kafka-env + key: KAFKA_TOPIC_PERSON restartPolicy: Always diff --git a/modules/location_consumer/Dockerfile b/modules/location_consumer/Dockerfile index 1ef643ff1..c99e98bbe 100644 --- a/modules/location_consumer/Dockerfile +++ b/modules/location_consumer/Dockerfile @@ -6,7 +6,5 @@ RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev COPY requirements.txt requirements.txt RUN pip install -r requirements.txt -EXPOSE 5000 - COPY . . -CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file +CMD ["python", "app/app.py"] \ No newline at end of file diff --git a/modules/person_rpc/Dockerfile b/modules/person_rpc/Dockerfile index 1ef643ff1..57d542c58 100644 --- a/modules/person_rpc/Dockerfile +++ b/modules/person_rpc/Dockerfile @@ -9,4 +9,4 @@ RUN pip install -r requirements.txt EXPOSE 5000 COPY . . -CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file +CMD ["python", "app/server.py"] \ No newline at end of file From f94772daaec714e33bee27002fdbbd0a31fd917b Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Wed, 20 Dec 2023 13:26:15 +0700 Subject: [PATCH 09/12] k8s --- deployment/person-rpc.yaml | 1 - deployment/udaconnect-api.yaml | 1 - deployment/udaconnect-app.yaml | 1 - deployment/udaconnect-connection.yaml | 1 - deployment/udaconnect-location.yaml | 1 - deployment/udaconnect-person.yaml | 1 - protobufs/REAME.md | 1 - 7 files changed, 7 deletions(-) diff --git a/deployment/person-rpc.yaml b/deployment/person-rpc.yaml index 211e061cc..fdcadc8b6 100644 --- a/deployment/person-rpc.yaml +++ b/deployment/person-rpc.yaml @@ -9,7 +9,6 @@ spec: - name: "50051" port: 50051 targetPort: 50051 - nodePort: 50051 selector: service: person-rpc type: NodePort diff --git a/deployment/udaconnect-api.yaml b/deployment/udaconnect-api.yaml index e62dcbd0e..fd85cdd73 100644 --- a/deployment/udaconnect-api.yaml +++ b/deployment/udaconnect-api.yaml @@ -9,7 +9,6 @@ spec: - name: "5000" port: 5000 targetPort: 5000 - nodePort: 30001 selector: service: udaconnect-api type: NodePort diff --git a/deployment/udaconnect-app.yaml b/deployment/udaconnect-app.yaml index d572e0c37..0df764848 100644 --- a/deployment/udaconnect-app.yaml +++ b/deployment/udaconnect-app.yaml @@ -9,7 +9,6 @@ spec: - name: "3000" port: 3000 targetPort: 3000 - nodePort: 30000 selector: service: udaconnect-app type: NodePort diff --git a/deployment/udaconnect-connection.yaml b/deployment/udaconnect-connection.yaml index 3c756dae8..30043ec91 100644 --- a/deployment/udaconnect-connection.yaml +++ b/deployment/udaconnect-connection.yaml @@ -9,7 +9,6 @@ spec: - name: "5000" port: 5000 targetPort: 5000 - nodePort: 30001 selector: service: connection-api type: NodePort diff --git a/deployment/udaconnect-location.yaml b/deployment/udaconnect-location.yaml index f1513ea02..66151f962 100644 --- a/deployment/udaconnect-location.yaml +++ b/deployment/udaconnect-location.yaml @@ -9,7 +9,6 @@ spec: - name: "5000" port: 5000 targetPort: 5000 - nodePort: 30001 selector: service: location-api type: NodePort diff --git a/deployment/udaconnect-person.yaml b/deployment/udaconnect-person.yaml index cf9a242bf..41cf911ae 100644 --- a/deployment/udaconnect-person.yaml +++ b/deployment/udaconnect-person.yaml @@ -9,7 +9,6 @@ spec: - name: "5000" port: 5000 targetPort: 5000 - nodePort: 30001 selector: service: person-api type: NodePort diff --git a/protobufs/REAME.md b/protobufs/REAME.md index dbe8ac503..f4e39f802 100644 --- a/protobufs/REAME.md +++ b/protobufs/REAME.md @@ -28,7 +28,6 @@ curl -X POST \ http://localhost:5000/api/locations \ -H 'Content-Type: application/json' \ -d '{ - "id": 30, "latitude": "-122.290524", "longitude": "37.553441", "creation_time": "2020-08-18T10:37:06", From 941c4ff092fe9924e525dac53a40c853aea73d27 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Thu, 21 Dec 2023 01:23:48 +0700 Subject: [PATCH 10/12] final --- deployment/kafka.yaml | 5 ++- deployment/person-rpc.yaml | 2 +- deployment/udaconnect-app.yaml | 5 +++ deployment/udaconnect-person.yaml | 2 +- docker-cmd.md | 41 +++++++++++++++++++ modules/connection/app/udaconnect/services.py | 2 +- modules/connection/requirements.txt | 2 +- modules/frontend/src/components/Connection.js | 2 +- modules/frontend/src/components/Persons.js | 2 +- modules/location/app/udaconnect/services.py | 5 ++- modules/location_consumer/app/app.py | 4 +- modules/person/app/udaconnect/services.py | 2 +- modules/person_consumer/Dockerfile | 4 +- modules/person_consumer/app/app.py | 4 +- modules/person_rpc/Dockerfile | 2 +- modules/person_rpc/app/client.py | 2 +- modules/person_rpc/requirements.txt | 1 + protobufs/REAME.md | 23 ++++++++++- 18 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 docker-cmd.md diff --git a/deployment/kafka.yaml b/deployment/kafka.yaml index af24caf1f..3e6438f06 100644 --- a/deployment/kafka.yaml +++ b/deployment/kafka.yaml @@ -16,7 +16,10 @@ spec: - name: kafka-kraft image: confluentinc/confluent-local:7.4.0 ports: - - containerPort: 9092 # Adjust the port based on your Kafka configuration + - containerPort: 9092 + env: + - name: KAFKA_ADVERTISED_LISTENERS + value: PLAINTEXT://kafka-kraft:29092,PLAINTEXT_HOST://kafka-service:9092 --- apiVersion: v1 diff --git a/deployment/person-rpc.yaml b/deployment/person-rpc.yaml index fdcadc8b6..ae4cbb3ec 100644 --- a/deployment/person-rpc.yaml +++ b/deployment/person-rpc.yaml @@ -30,7 +30,7 @@ spec: service: person-api spec: containers: - - image: udacity/nd064-c2-person-rpc:latest + - image: kydq2022/nd064-c2-person-rpc:latest name: person-api imagePullPolicy: Always env: diff --git a/deployment/udaconnect-app.yaml b/deployment/udaconnect-app.yaml index 0df764848..6ddb71c44 100644 --- a/deployment/udaconnect-app.yaml +++ b/deployment/udaconnect-app.yaml @@ -40,4 +40,9 @@ spec: limits: memory: "256Mi" cpu: "256m" + env: + - name: PERSON_API + value: "person-api:5000" + - name: CONNECTION_API + value: "person-api:5000" restartPolicy: Always diff --git a/deployment/udaconnect-person.yaml b/deployment/udaconnect-person.yaml index 41cf911ae..dc24333dc 100644 --- a/deployment/udaconnect-person.yaml +++ b/deployment/udaconnect-person.yaml @@ -30,7 +30,7 @@ spec: service: person-api spec: containers: - - image: udacity/nd064-c2-person-api:latest + - image: kydq2022/nd064-c2-person-api:latest name: person-api imagePullPolicy: Always env: diff --git a/docker-cmd.md b/docker-cmd.md new file mode 100644 index 000000000..b8a705eec --- /dev/null +++ b/docker-cmd.md @@ -0,0 +1,41 @@ +```shell + +# connection +sudo docker build -t kydq2022/nd064-c2-connection-api:latest modules/connection +sudo docker push kydq2022/nd064-c2-connection-api:latest + +# location +sudo docker build -t kydq2022/nd064-c2-location-api:latest modules/location +sudo docker push kydq2022/nd064-c2-location-api:latest + +sudo docker build -t kydq2022/nd064-c2-location-consumer:latest modules/location_consumer +sudo docker push kydq2022/nd064-c2-location-consumer:latest + + +# person +sudo docker build -t kydq2022/nd064-c2-person-api:latest modules/person +sudo docker push kydq2022/nd064-c2-person-api:latest + +sudo docker build -t kydq2022/nd064-c2-person-consumer:latest modules/person_consumer +sudo docker push kydq2022/nd064-c2-person-consumer:latest + +sudo docker build -t kydq2022/nd064-c2-person-rpc:latest modules/person_rpc +sudo docker push kydq2022/nd064-c2-person-rpc:latest + +sudo docker build -t kydq2022/nd064-c2-udaconnect-app:latest modules/frontend +sudo docker push kydq2022/nd064-c2-udaconnect-app:latest + +``` + +``` +sudo docker run -p 5000:5001 \ + -e DB_USERNAME="ct_admin" \ + -e DB_NAME="geoconnections" \ + -e DB_PASSWORD="wowimsosecure" \ + -e DB_HOST="localhost" \ + -e DB_PORT="5432" \ + -e GRPC_SERVER_ADDRESS="localhost:50051" \ + -e KAFKA_BOOTSTRAP_SERVERS="localhosxt:9092" \ + kydq2022/nd064-c2-location-api:latest + +``` \ No newline at end of file diff --git a/modules/connection/app/udaconnect/services.py b/modules/connection/app/udaconnect/services.py index 47dbbbd78..220404374 100644 --- a/modules/connection/app/udaconnect/services.py +++ b/modules/connection/app/udaconnect/services.py @@ -35,7 +35,7 @@ def find_contacts(person_id: int, start_date: datetime, end_date: datetime, mete ).all() # Cache all users in memory for quick lookup - grpc_server_address = os.environ.get("GRPC_SERVER_ADDRESS") + grpc_server_address = os.environ["GRPC_SERVER_ADDRESS"] channel = grpc.insecure_channel(grpc_server_address) stub = persons_pb2_grpc.PersonServiceStub(channel=channel) request = persons_pb2.RetrieveAllPersonsRequest() diff --git a/modules/connection/requirements.txt b/modules/connection/requirements.txt index 57b3dbd7e..d62172337 100644 --- a/modules/connection/requirements.txt +++ b/modules/connection/requirements.txt @@ -25,4 +25,4 @@ SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 grpcio==1.60.0 - +protobuf \ No newline at end of file diff --git a/modules/frontend/src/components/Connection.js b/modules/frontend/src/components/Connection.js index b2fe22c0f..85a7d0389 100644 --- a/modules/frontend/src/components/Connection.js +++ b/modules/frontend/src/components/Connection.js @@ -22,7 +22,7 @@ class Connection extends Component { if (personId) { // TODO: endpoint should be abstracted into a config variable fetch( - `${process.env.PERSON_API}/persons/${personId}/connection?start_date=2020-01-01&end_date=2020-12-30&distance=5` + `http://${process.env.CONNECTION_API}/api/persons/${personId}/connection?start_date=2020-01-01&end_date=2020-12-30&distance=5` ) .then((response) => response.json()) .then((connections) => diff --git a/modules/frontend/src/components/Persons.js b/modules/frontend/src/components/Persons.js index 24bed0cf4..755fc1ee5 100644 --- a/modules/frontend/src/components/Persons.js +++ b/modules/frontend/src/components/Persons.js @@ -5,7 +5,7 @@ class Persons extends Component { constructor(props) { super(props); // TODO: endpoint should be abstracted into a config variable - this.endpoint_url = `${process.env.PERSON_API}/persons`; + this.endpoint_url = `http://${process.env.PERSON_API}/api/persons`; this.state = { persons: [], display: null, diff --git a/modules/location/app/udaconnect/services.py b/modules/location/app/udaconnect/services.py index 8f3c5f3da..5027d0343 100644 --- a/modules/location/app/udaconnect/services.py +++ b/modules/location/app/udaconnect/services.py @@ -12,7 +12,7 @@ from kafka import KafkaProducer -logging.basicConfig(level=logging.WARNING) +logging.basicConfig(level=logging.INFO) logger = logging.getLogger("location-api") class LocationService: @@ -35,7 +35,8 @@ def create(location: Dict): logger.warning(f"Unexpected data format in payload: {validation_results}") raise Exception(f"Invalid payload: {validation_results}") - kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') + kafka_bootstrap_servers = os.environ["KAFKA_BOOTSTRAP_SERVERS"] + logging.info(kafka_bootstrap_servers) producer_config = { 'bootstrap_servers': kafka_bootstrap_servers, 'client_id': 'location-producer', diff --git a/modules/location_consumer/app/app.py b/modules/location_consumer/app/app.py index ddb726d73..36f24e668 100644 --- a/modules/location_consumer/app/app.py +++ b/modules/location_consumer/app/app.py @@ -108,7 +108,7 @@ def create(location: Dict) -> Location: # Kafka consumer configuration -kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') +kafka_bootstrap_servers = os.environ["KAFKA_BOOTSTRAP_SERVERS"] consumer_config = { 'bootstrap_servers': kafka_bootstrap_servers, 'group_id': 'location-consumer-group', @@ -117,7 +117,7 @@ def create(location: Dict) -> Location: } # Create Kafka consumer -kafka_topic = os.environ.get('KAFKA_TOPIC', 'location-topic') +kafka_topic = os.environ["KAFKA_TOPIC"] consumer = KafkaConsumer(kafka_topic, **consumer_config) try: diff --git a/modules/person/app/udaconnect/services.py b/modules/person/app/udaconnect/services.py index 841eaef73..ee015eb51 100644 --- a/modules/person/app/udaconnect/services.py +++ b/modules/person/app/udaconnect/services.py @@ -24,7 +24,7 @@ def create(person: Dict) -> Person: new_person.last_name = person["last_name"] new_person.company_name = person["company_name"] - kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') + kafka_bootstrap_servers = os.environ["KAFKA_BOOTSTRAP_SERVERS"] producer_config = { 'bootstrap_servers': kafka_bootstrap_servers, 'client_id': 'location-producer', diff --git a/modules/person_consumer/Dockerfile b/modules/person_consumer/Dockerfile index 1ef643ff1..c99e98bbe 100644 --- a/modules/person_consumer/Dockerfile +++ b/modules/person_consumer/Dockerfile @@ -6,7 +6,5 @@ RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev COPY requirements.txt requirements.txt RUN pip install -r requirements.txt -EXPOSE 5000 - COPY . . -CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file +CMD ["python", "app/app.py"] \ No newline at end of file diff --git a/modules/person_consumer/app/app.py b/modules/person_consumer/app/app.py index afeafed5a..a6175dc09 100644 --- a/modules/person_consumer/app/app.py +++ b/modules/person_consumer/app/app.py @@ -70,7 +70,7 @@ def create(person: Dict) -> Person: # Kafka consumer configuration -kafka_bootstrap_servers = os.environ.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092') +kafka_bootstrap_servers = os.environ["KAFKA_BOOTSTRAP_SERVERS"] consumer_config = { 'bootstrap_servers': kafka_bootstrap_servers, 'group_id': 'person-consumer-group', @@ -79,7 +79,7 @@ def create(person: Dict) -> Person: } # Create Kafka consumer -kafka_topic = os.environ.get('KAFKA_TOPIC', 'person-topic') +kafka_topic = os.environ["KAFKA_TOPIC"] consumer = KafkaConsumer(kafka_topic, **consumer_config) try: diff --git a/modules/person_rpc/Dockerfile b/modules/person_rpc/Dockerfile index 57d542c58..0dfc8239e 100644 --- a/modules/person_rpc/Dockerfile +++ b/modules/person_rpc/Dockerfile @@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers geos libc-dev postgresql-dev COPY requirements.txt requirements.txt RUN pip install -r requirements.txt -EXPOSE 5000 +EXPOSE 50051 COPY . . CMD ["python", "app/server.py"] \ No newline at end of file diff --git a/modules/person_rpc/app/client.py b/modules/person_rpc/app/client.py index c931c8e5a..99b8b4db2 100644 --- a/modules/person_rpc/app/client.py +++ b/modules/person_rpc/app/client.py @@ -8,7 +8,7 @@ import os from typing import Dict, List -grpc_server_address = os.environ.get("GRPC_SERVER_ADDRESS") +grpc_server_address = os.environ["GRPC_SERVER_ADDRESS"] def run(): diff --git a/modules/person_rpc/requirements.txt b/modules/person_rpc/requirements.txt index 41ec6c414..3393643b6 100644 --- a/modules/person_rpc/requirements.txt +++ b/modules/person_rpc/requirements.txt @@ -25,3 +25,4 @@ SQLAlchemy==1.3.19 Werkzeug==0.16.1 flask-restx==0.2.0 grpcio==1.60.0 +protobuf diff --git a/protobufs/REAME.md b/protobufs/REAME.md index f4e39f802..74dc4c6ad 100644 --- a/protobufs/REAME.md +++ b/protobufs/REAME.md @@ -24,6 +24,17 @@ flask run --host 0.0.0.0 # location +curl -X POST \ + http://localhost:5001/api/locations \ + -H 'Content-Type: application/json' \ + -d '{ + "latitude": "-122.290524", + "longitude": "37.553441", + "creation_time": "2020-08-18T10:37:06", + "person_id": 29 +}' + + curl -X POST \ http://localhost:5000/api/locations \ -H 'Content-Type: application/json' \ @@ -37,7 +48,17 @@ curl -X POST \ # person curl -X POST \ - http://localhost:5000/api/persons \ + http://localhost:5001/api/persons \ + -H 'Content-Type: application/json' \ + -d '{ + "first_name": "John", + "last_name": "Doe", + "company_name": "ABC Corp" + }' + + + curl -X POST \ + http://localhost:5001/api/persons \ -H 'Content-Type: application/json' \ -d '{ "first_name": "John", From ada5ea485ba923716fee717a7e1a862a6110dee5 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Thu, 21 Dec 2023 01:56:58 +0700 Subject: [PATCH 11/12] final --- deployment/person-rpc.yaml | 6 +++--- docs/system-architecture.md | 28 ---------------------------- 2 files changed, 3 insertions(+), 31 deletions(-) delete mode 100644 docs/system-architecture.md diff --git a/deployment/person-rpc.yaml b/deployment/person-rpc.yaml index ae4cbb3ec..f3e2e1a2d 100644 --- a/deployment/person-rpc.yaml +++ b/deployment/person-rpc.yaml @@ -23,15 +23,15 @@ spec: replicas: 1 selector: matchLabels: - service: person-api + service: person-rpc template: metadata: labels: - service: person-api + service: person-rpc spec: containers: - image: kydq2022/nd064-c2-person-rpc:latest - name: person-api + name: person-rpc imagePullPolicy: Always env: - name: DB_USERNAME diff --git a/docs/system-architecture.md b/docs/system-architecture.md deleted file mode 100644 index c59b9d2df..000000000 --- a/docs/system-architecture.md +++ /dev/null @@ -1,28 +0,0 @@ -``` - +-------------------+ +---------------------+ +------------------------+ - | Client | | API Gateway | | Kafka Cluster | - | Applications | | | | | - +-------------------+ +---------------------+ | | - | | | | - v v | | - +-------------------+ +---------------------+ | | - | Microservice | | Microservice | | Kafka Topics | - | - Location | | - Person | | - location_created | - | - Person | | - Connection | | - person_created | - | - Connection | +---------------------+ +------------------------+ - +-------------------+ | | | | - | | | | | - | +-----------------+| v v v - +--------->| RPC Server || +---------------------+ - | - Person || | RPC Server | - | - Connection |+------------------>| - Connection | - +-----------------+| +---------------------+ - | - v - +---------------------+ - | Microservice | - | - Connection | - | (continued) | - +---------------------+ - -``` \ No newline at end of file From 9545a39e50bc18f45dd961a1e1d09ae5d5825cf5 Mon Sep 17 00:00:00 2001 From: Ky Dinh Date: Thu, 21 Dec 2023 02:22:24 +0700 Subject: [PATCH 12/12] final --- docs/architecture_decisions.md | 38 +++++++++++++ docs/grpc.md | 29 ++++++++++ docs/openapi.yaml | 96 +++++++++++++++++++++++++++++++++ docs/pods_screenshot.png | Bin 0 -> 64937 bytes docs/postman.json | 39 ++++++++++++++ docs/services_screenshot.png | Bin 0 -> 63015 bytes 6 files changed, 202 insertions(+) create mode 100644 docs/architecture_decisions.md create mode 100644 docs/grpc.md create mode 100644 docs/openapi.yaml create mode 100644 docs/pods_screenshot.png create mode 100644 docs/postman.json create mode 100644 docs/services_screenshot.png diff --git a/docs/architecture_decisions.md b/docs/architecture_decisions.md new file mode 100644 index 000000000..6dd0a0944 --- /dev/null +++ b/docs/architecture_decisions.md @@ -0,0 +1,38 @@ +# Architecture Decisions Document + +## Date: [Date] + +## Context + +Our system involves the development of a distributed application with multiple services, each serving a specific purpose. The primary components include a frontend (fe) that serves as a REST API, a `connection` service that retrieves data from a `person-rpc` service using gRPC, and a mechanism for creating `person` and `location` entities using a Kafka topic. + +![Alt text](system-architecture.png) + +## Decision 1: Frontend as REST API + +### Considerations + +- REST APIs are widely adopted and well-suited for exposing services over HTTP. +- Simplicity and ease of integration with various clients. + +### Decision + +The frontend (`fe`) will be designed as a REST API. This decision is based on the familiarity of RESTful principles and the ease with which clients can interact with the system using standard HTTP methods. + +## Decision 2: gRPC for `connection` to `person-rpc` + +### Considerations + +- `connection` needs to retrieve data from `person-rpc`. +- gRPC provides a high-performance, language-agnostic RPC framework. + +### Decision + +`connection` will communicate with `person-rpc` using gRPC. This decision is motivated by the efficiency and ease of use offered by gRPC, enabling strongly-typed communication and efficient serialization. + +## Decision 3: Kafka Topic for Creating `person` and `location` + +### Considerations + +- Asynchronous communication for creating entities. +- Kafka provides a distributed, fault-tolerant, and scalable message broker. diff --git a/docs/grpc.md b/docs/grpc.md new file mode 100644 index 000000000..bd98b9672 --- /dev/null +++ b/docs/grpc.md @@ -0,0 +1,29 @@ +# ENV +```shell +#!/bin/bash + +export DB_USERNAME="ct_admin" +export DB_NAME="geoconnections" +export DB_PASSWORD="wowimsosecure" +export DB_HOST="localhost" +export DB_PORT="5432" + +# gRPC +export GRPC_SERVER_ADDRESS="localhost:50051" + +# Kafka +export GRPC_SERVER_ADDRESS="localhost:9092" +``` + +## Start RPC server +```shell + +python app/server.py + +``` +## Run test +```shell + +python app/client.py + +``` diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 000000000..14f3c033a --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,96 @@ +openapi: 3.1.0 +info: + title: "UdaConnect API" + version: "0.1.0" +paths: + /api/persons/{person_id}/connection: + get: + tags: + - UdaConnect + operationId: get_connection_data_resource + parameters: + - name: person_id + in: path + required: true + schema: + type: string + - name: distance + in: query + description: Proximity to a given user in meters + schema: + type: string + - name: end_date + in: query + description: Upper bound of date range + schema: + type: string + - name: start_date + in: query + description: Lower bound of date range + schema: + type: string + responses: + '200': + description: Success + + /api/persons: + post: + tags: + - UdaConnect + operationId: create_person + requestBody: + description: Person data to be created + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + '201': + description: Created + '400': + description: Bad Request + + /api/locations: + post: + tags: + - UdaConnect + operationId: create_location + requestBody: + description: Location data to be created + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Location' + responses: + '201': + description: Created + '400': + description: Bad Request + +components: + schemas: + Person: + type: object + properties: + first_name: + type: string + last_name: + type: string + company_name: + type: string + + Location: + type: object + properties: + latitude: + type: number + longitude: + type: number + + responses: + ParseError: + description: When a mask can't be parsed + MaskError: + description: When any error occurs on mask diff --git a/docs/pods_screenshot.png b/docs/pods_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..36e48e208fbe3598f38fc9747466409ce5efae52 GIT binary patch literal 64937 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

mDZ@{4W5S(qULuaJ&#wcn<$kf19t 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