mage-ai 0.8.97__py3-none-any.whl → 0.8.99__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mage-ai might be problematic. Click here for more details.
- mage_ai/api/policies/AutocompleteItemPolicy.py +2 -1
- mage_ai/api/policies/BasePolicy.py +2 -2
- mage_ai/api/policies/BlockTemplatePolicy.py +2 -1
- mage_ai/api/policies/ClusterPolicy.py +2 -1
- mage_ai/api/policies/DataProviderPolicy.py +2 -1
- mage_ai/api/policies/EventRulePolicy.py +2 -1
- mage_ai/api/policies/ExtensionOptionPolicy.py +2 -1
- mage_ai/api/policies/FileVersionPolicy.py +2 -1
- mage_ai/api/policies/GitBranchPolicy.py +9 -0
- mage_ai/api/policies/KernelPolicy.py +2 -1
- mage_ai/api/policies/LogPolicy.py +2 -2
- mage_ai/api/policies/OauthPolicy.py +15 -0
- mage_ai/api/policies/OutputPolicy.py +2 -2
- mage_ai/api/policies/PipelinePolicy.py +2 -2
- mage_ai/api/policies/PipelineRunPolicy.py +2 -2
- mage_ai/api/policies/PipelineSchedulePolicy.py +2 -2
- mage_ai/api/policies/PullRequestPolicy.py +64 -0
- mage_ai/api/policies/SessionPolicy.py +4 -1
- mage_ai/api/policies/VariablePolicy.py +2 -2
- mage_ai/api/policies/WidgetPolicy.py +2 -2
- mage_ai/api/policies/WorkspacePolicy.py +3 -3
- mage_ai/api/presenters/PipelinePresenter.py +1 -0
- mage_ai/api/presenters/PullRequestPresenter.py +16 -0
- mage_ai/api/presenters/StatusPresenter.py +2 -0
- mage_ai/api/presenters/SyncPresenter.py +1 -0
- mage_ai/api/presenters/WorkspacePresenter.py +2 -0
- mage_ai/api/resources/GitBranchResource.py +81 -26
- mage_ai/api/resources/OauthResource.py +31 -4
- mage_ai/api/resources/PipelineResource.py +8 -1
- mage_ai/api/resources/PullRequestResource.py +87 -0
- mage_ai/api/resources/RoleResource.py +6 -3
- mage_ai/api/resources/SecretResource.py +2 -5
- mage_ai/api/resources/SessionResource.py +18 -0
- mage_ai/api/resources/StatusResource.py +7 -3
- mage_ai/api/resources/UserResource.py +11 -16
- mage_ai/api/resources/WorkspaceResource.py +83 -53
- mage_ai/authentication/oauth/active_directory.py +17 -0
- mage_ai/authentication/oauth/constants.py +9 -0
- mage_ai/authentication/oauth/utils.py +2 -1
- mage_ai/authentication/oauth2.py +9 -3
- mage_ai/cli/main.py +94 -51
- mage_ai/cluster_manager/kubernetes/workload_manager.py +141 -45
- mage_ai/data_preparation/git/__init__.py +86 -16
- mage_ai/data_preparation/git/api.py +175 -0
- mage_ai/data_preparation/models/block/dbt/utils/__init__.py +49 -14
- mage_ai/data_preparation/models/block/sql/__init__.py +3 -2
- mage_ai/data_preparation/models/pipeline.py +4 -1
- mage_ai/data_preparation/models/pipelines/integration_pipeline.py +7 -3
- mage_ai/data_preparation/preferences.py +4 -2
- mage_ai/data_preparation/repo_manager.py +41 -10
- mage_ai/data_preparation/shared/secrets.py +5 -6
- mage_ai/data_preparation/sync/__init__.py +2 -1
- mage_ai/data_preparation/sync/git_sync.py +2 -5
- mage_ai/data_preparation/templates/utils.py +2 -0
- mage_ai/orchestration/db/models/oauth.py +22 -4
- mage_ai/orchestration/pipeline_scheduler.py +19 -8
- mage_ai/orchestration/queue/process_queue.py +15 -12
- mage_ai/server/api/clusters.py +21 -11
- mage_ai/server/constants.py +1 -1
- mage_ai/server/frontend_dist/404.html +2 -2
- mage_ai/server/frontend_dist/404.html.html +2 -2
- mage_ai/server/frontend_dist/_next/static/WRxCTOtmZhTqQws_7OJZD/_buildManifest.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/{1286-993725c925c56a98.js → 1286-b90bd4b7f8abfc3a.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/{1424-f475cae42f8a7fca.js → 1424-90c0f66ba2f86b88.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/3883-c95563b9f60ae526.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/6694-c8f2a68074420906.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/{9350-1ff50f1d7b9ee754.js → 9350-5191c83a8d0cf454.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/{_app-3527178abd99bc87.js → _app-171846e16d26855a.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/files-e4e778f8f5e1bf2e.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/settings-c788c1b127999825.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/users/[user]-b4650224a19e8fe6.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/users/new-931eb719e3fae29c.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/users-d3724bde0b186dd9.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage-af11f9cf94024ac0.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/backfills/{[...slug]-3ec5eb9562e4bff4.js → [...slug]-34326db259f922d1.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/edit-503ecb7a72257b79.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/runs/{[run]-7667080098731e30.js → [run]-2994b8ab7862c07b.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/runs-7b31b851e2544b42.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/triggers/{[...slug]-e18058e13882b20d.js → [...slug]-4445619d4eabe065.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/{triggers-6854c10d5589d394.js → triggers-b7db0b682fadb840.js} +1 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/account/profile-ee0931af3abb55b3.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/preferences-f8a59d718751be9a.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/sync-data-90f8830890036eb2.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/users-9f82673fc438ea83.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/sign-in-a1871b8a537d823c.js +1 -0
- mage_ai/server/frontend_dist/_next/static/chunks/pages/version-control-48859b4e9c846212.js +1 -0
- mage_ai/server/frontend_dist/files.html +2 -2
- mage_ai/server/frontend_dist/index.html +2 -2
- mage_ai/server/frontend_dist/manage/settings.html +24 -0
- mage_ai/server/frontend_dist/manage/users/[user].html +2 -2
- mage_ai/server/frontend_dist/manage/users/new.html +24 -0
- mage_ai/server/frontend_dist/manage/users.html +2 -2
- mage_ai/server/frontend_dist/manage.html +2 -2
- mage_ai/server/frontend_dist/pipeline-runs.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/backfills/[...slug].html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/backfills.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/edit.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/logs.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/monitors/block-runs.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/monitors/block-runtime.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/monitors.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/runs/[run].html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/runs.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/settings.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/syncs.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/triggers/[...slug].html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline]/triggers.html +2 -2
- mage_ai/server/frontend_dist/pipelines/[pipeline].html +2 -2
- mage_ai/server/frontend_dist/pipelines.html +2 -2
- mage_ai/server/frontend_dist/settings/account/profile.html +2 -2
- mage_ai/server/frontend_dist/settings/workspace/preferences.html +2 -2
- mage_ai/server/frontend_dist/settings/workspace/sync-data.html +2 -2
- mage_ai/server/frontend_dist/settings/workspace/users.html +2 -2
- mage_ai/server/frontend_dist/settings.html +2 -2
- mage_ai/server/frontend_dist/sign-in.html +2 -2
- mage_ai/server/frontend_dist/terminal.html +2 -2
- mage_ai/server/frontend_dist/test.html +2 -2
- mage_ai/server/frontend_dist/triggers.html +2 -2
- mage_ai/server/frontend_dist/version-control.html +2 -2
- mage_ai/server/scheduler_manager.py +7 -2
- mage_ai/server/server.py +37 -3
- mage_ai/server/terminal_server.py +2 -2
- mage_ai/server/websocket_server.py +6 -2
- mage_ai/services/newrelic/__init__.py +21 -0
- mage_ai/settings/__init__.py +32 -0
- mage_ai/shared/hash.py +2 -0
- mage_ai/tests/api/test_utils.py +29 -2
- mage_ai/tests/data_preparation/models/test_pipeline.py +5 -0
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/METADATA +8 -3
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/RECORD +136 -127
- mage_ai/data_preparation/templates/main/projects/__init__.py +0 -0
- mage_ai/server/frontend_dist/_next/static/YLZRSrQ0aqtl-GGePfsMB/_buildManifest.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/3077-d58f18ed770e5137.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/3714-b676173cd4d8d86c.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/files-82b5409dac9564f4.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/users/[user]-bb6aaa23e92a5add.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage/users-c91ee702a4cd7a6f.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/manage-7961010cb0fb9abd.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/edit-7b8ce89f0d717465.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/pipelines/[pipeline]/runs-5bd17a8f3f3d57ef.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/account/profile-7d75e42d5f4936bb.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/preferences-8220c1200472bf70.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/sync-data-b602fa9b6ffabd12.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/settings/workspace/users-3f9d5800f268a263.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/sign-in-2925c2c1b0c5559a.js +0 -1
- mage_ai/server/frontend_dist/_next/static/chunks/pages/version-control-5ffc663cfb0ec81e.js +0 -1
- /mage_ai/server/frontend_dist/_next/static/{YLZRSrQ0aqtl-GGePfsMB → WRxCTOtmZhTqQws_7OJZD}/_middlewareManifest.js +0 -0
- /mage_ai/server/frontend_dist/_next/static/{YLZRSrQ0aqtl-GGePfsMB → WRxCTOtmZhTqQws_7OJZD}/_ssgManifest.js +0 -0
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/LICENSE +0 -0
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/WHEEL +0 -0
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/entry_points.txt +0 -0
- {mage_ai-0.8.97.dist-info → mage_ai-0.8.99.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import requests
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from git.remote import RemoteProgress
|
|
6
|
+
from git.repo.base import Repo
|
|
7
|
+
from mage_ai.authentication.oauth.constants import OAUTH_PROVIDER_GITHUB
|
|
8
|
+
from mage_ai.orchestration.db.models.oauth import Oauth2AccessToken, Oauth2Application, User
|
|
9
|
+
from typing import Dict
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
API_ENDPOINT = 'https://api.github.com'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_access_token_for_user(user: User) -> Oauth2AccessToken:
|
|
17
|
+
oauth_client = Oauth2Application.query.filter(
|
|
18
|
+
Oauth2Application.client_id == OAUTH_PROVIDER_GITHUB,
|
|
19
|
+
).first()
|
|
20
|
+
|
|
21
|
+
if oauth_client:
|
|
22
|
+
access_token = Oauth2AccessToken.query.filter(
|
|
23
|
+
Oauth2AccessToken.expires > datetime.utcnow(),
|
|
24
|
+
Oauth2AccessToken.oauth2_application_id == oauth_client.id,
|
|
25
|
+
).first()
|
|
26
|
+
|
|
27
|
+
return access_token
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch(remote_name: str, remote_url: str, token: str) -> RemoteProgress:
|
|
31
|
+
from mage_ai.data_preparation.git import Git
|
|
32
|
+
|
|
33
|
+
custom_progress = RemoteProgress()
|
|
34
|
+
username = get_username(token)
|
|
35
|
+
|
|
36
|
+
url = build_authenticated_remote_url(remote_url, username, token)
|
|
37
|
+
git_manager = Git.get_manager()
|
|
38
|
+
|
|
39
|
+
remote = git_manager.repo.remotes[remote_name]
|
|
40
|
+
url_original = list(remote.urls)[0]
|
|
41
|
+
remote.set_url(url)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
remote.fetch(progress=custom_progress)
|
|
45
|
+
except Exception as err:
|
|
46
|
+
raise err
|
|
47
|
+
finally:
|
|
48
|
+
try:
|
|
49
|
+
remote.set_url(url_original)
|
|
50
|
+
except Exception as err:
|
|
51
|
+
print('WARNING (mage_ai.data_preparation.git.api):')
|
|
52
|
+
print(err)
|
|
53
|
+
|
|
54
|
+
return custom_progress
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pull(remote_name: str, remote_url: str, branch_name: str, token: str) -> RemoteProgress:
|
|
58
|
+
from mage_ai.data_preparation.git import Git
|
|
59
|
+
|
|
60
|
+
custom_progress = RemoteProgress()
|
|
61
|
+
username = get_username(token)
|
|
62
|
+
|
|
63
|
+
url = build_authenticated_remote_url(remote_url, username, token)
|
|
64
|
+
git_manager = Git.get_manager()
|
|
65
|
+
|
|
66
|
+
remote = git_manager.repo.remotes[remote_name]
|
|
67
|
+
url_original = list(remote.urls)[0]
|
|
68
|
+
remote.set_url(url)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
remote.pull(branch_name, custom_progress)
|
|
72
|
+
except Exception as err:
|
|
73
|
+
raise err
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
remote.set_url(url_original)
|
|
77
|
+
except Exception as err:
|
|
78
|
+
print('WARNING (mage_ai.data_preparation.git.api):')
|
|
79
|
+
print(err)
|
|
80
|
+
|
|
81
|
+
return custom_progress
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def push(remote_name: str, remote_url: str, branch_name: str, token: str) -> RemoteProgress:
|
|
85
|
+
from mage_ai.data_preparation.git import Git
|
|
86
|
+
|
|
87
|
+
custom_progress = RemoteProgress()
|
|
88
|
+
username = get_username(token)
|
|
89
|
+
|
|
90
|
+
url = build_authenticated_remote_url(remote_url, username, token)
|
|
91
|
+
git_manager = Git.get_manager()
|
|
92
|
+
|
|
93
|
+
remote = git_manager.repo.remotes[remote_name]
|
|
94
|
+
url_original = list(remote.urls)[0]
|
|
95
|
+
remote.set_url(url)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
remote.push(branch_name, custom_progress)
|
|
99
|
+
except Exception as err:
|
|
100
|
+
raise err
|
|
101
|
+
finally:
|
|
102
|
+
try:
|
|
103
|
+
remote.set_url(url_original)
|
|
104
|
+
except Exception as err:
|
|
105
|
+
print('WARNING (mage_ai.data_preparation.git.api):')
|
|
106
|
+
print(err)
|
|
107
|
+
|
|
108
|
+
return custom_progress
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_authenticated_remote_url(remote_url: str, username: str, token: str) -> str:
|
|
112
|
+
# https://[username]:[token]@github.com/[remote_url]
|
|
113
|
+
url = urlsplit(remote_url)
|
|
114
|
+
url = url._replace(netloc=f'{username}:{token}@{url.netloc}')
|
|
115
|
+
return urlunsplit(url)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_username(token: str) -> str:
|
|
119
|
+
return get_user(token)['login']
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_user(token: str) -> Dict:
|
|
123
|
+
"""
|
|
124
|
+
https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user
|
|
125
|
+
"""
|
|
126
|
+
resp = requests.get(f'{API_ENDPOINT}/user', headers={
|
|
127
|
+
'Accept': 'application/vnd.github+json',
|
|
128
|
+
'Authorization': f'Bearer {token}',
|
|
129
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return resp.json()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_connection(repo: Repo, remote_url: str) -> None:
|
|
136
|
+
asyncio.run(validate_authentication_for_remote_url(repo, remote_url))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def validate_authentication_for_remote_url(repo: Repo, remote_url: str) -> None:
|
|
140
|
+
proc = repo.git.ls_remote(remote_url, as_process=True)
|
|
141
|
+
|
|
142
|
+
asyncio.run(__poll_process_with_timeout(
|
|
143
|
+
proc,
|
|
144
|
+
error_message='Error connecting to remote, make sure your access is valid.',
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def __poll_process_with_timeout(
|
|
149
|
+
proc: subprocess.Popen,
|
|
150
|
+
error_message: str = None,
|
|
151
|
+
timeout: int = 10,
|
|
152
|
+
):
|
|
153
|
+
ct = 0
|
|
154
|
+
while ct < timeout * 2:
|
|
155
|
+
return_code = proc.poll()
|
|
156
|
+
if return_code is not None:
|
|
157
|
+
proc.kill()
|
|
158
|
+
break
|
|
159
|
+
ct += 1
|
|
160
|
+
await asyncio.sleep(0.5)
|
|
161
|
+
|
|
162
|
+
if error_message is None:
|
|
163
|
+
error_message = 'Error running Git process'
|
|
164
|
+
|
|
165
|
+
if return_code is not None and return_code != 0:
|
|
166
|
+
_, err = proc.communicate()
|
|
167
|
+
message = (
|
|
168
|
+
err.decode('UTF-8') if err
|
|
169
|
+
else error_message
|
|
170
|
+
)
|
|
171
|
+
raise ChildProcessError(message)
|
|
172
|
+
|
|
173
|
+
if return_code is None:
|
|
174
|
+
proc.kill()
|
|
175
|
+
raise TimeoutError(error_message)
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
1
7
|
from contextlib import redirect_stdout
|
|
2
8
|
from datetime import datetime
|
|
3
|
-
from jinja2 import Template
|
|
4
9
|
from logging import Logger
|
|
10
|
+
from typing import Callable, Dict, List, Tuple
|
|
11
|
+
|
|
12
|
+
import aiofiles
|
|
13
|
+
import simplejson
|
|
14
|
+
import yaml
|
|
15
|
+
from jinja2 import Template
|
|
16
|
+
from pandas import DataFrame
|
|
17
|
+
|
|
5
18
|
from mage_ai.data_preparation.models.block import Block
|
|
19
|
+
from mage_ai.data_preparation.models.block.sql import bigquery
|
|
6
20
|
from mage_ai.data_preparation.models.block.sql import (
|
|
7
|
-
bigquery,
|
|
8
21
|
execute_sql_code as execute_sql_code_orig,
|
|
22
|
+
)
|
|
23
|
+
from mage_ai.data_preparation.models.block.sql import (
|
|
9
24
|
mssql,
|
|
10
25
|
mysql,
|
|
11
26
|
postgres,
|
|
@@ -13,6 +28,7 @@ from mage_ai.data_preparation.models.block.sql import (
|
|
|
13
28
|
snowflake,
|
|
14
29
|
spark,
|
|
15
30
|
trino,
|
|
31
|
+
clickhouse,
|
|
16
32
|
)
|
|
17
33
|
from mage_ai.data_preparation.models.constants import BlockLanguage, BlockType
|
|
18
34
|
from mage_ai.data_preparation.repo_manager import get_repo_path
|
|
@@ -27,18 +43,6 @@ from mage_ai.shared.hash import merge_dict
|
|
|
27
43
|
from mage_ai.shared.parsers import encode_complex
|
|
28
44
|
from mage_ai.shared.strings import remove_extension_from_filename
|
|
29
45
|
from mage_ai.shared.utils import clean_name, files_in_path
|
|
30
|
-
from pandas import DataFrame
|
|
31
|
-
from typing import Callable, Dict, List, Tuple
|
|
32
|
-
import aiofiles
|
|
33
|
-
import os
|
|
34
|
-
import re
|
|
35
|
-
import shutil
|
|
36
|
-
import simplejson
|
|
37
|
-
import subprocess
|
|
38
|
-
import sys
|
|
39
|
-
import uuid
|
|
40
|
-
import yaml
|
|
41
|
-
|
|
42
46
|
|
|
43
47
|
PROFILES_FILE_NAME = 'profiles.yml'
|
|
44
48
|
|
|
@@ -594,6 +598,23 @@ def config_file_loader_and_configuration(block, profile_target: str) -> Dict:
|
|
|
594
598
|
data_provider_schema=schema,
|
|
595
599
|
export_write_policy=ExportWritePolicy.REPLACE,
|
|
596
600
|
)
|
|
601
|
+
elif DataSource.CLICKHOUSE == profile_type:
|
|
602
|
+
database = profile.get('schema')
|
|
603
|
+
interface = profile.get('driver')
|
|
604
|
+
|
|
605
|
+
config_file_loader = ConfigFileLoader(config=dict(
|
|
606
|
+
CLICKHOUSE_DATABASE=database,
|
|
607
|
+
CLICKHOUSE_HOST=profile.get('host'),
|
|
608
|
+
CLICKHOUSE_INTERFACE=interface,
|
|
609
|
+
CLICKHOUSE_PASSWORD=profile.get('password'),
|
|
610
|
+
CLICKHOUSE_PORT=profile.get('port'),
|
|
611
|
+
CLICKHOUSE_USERNAME=profile.get('user'),
|
|
612
|
+
))
|
|
613
|
+
configuration = dict(
|
|
614
|
+
data_provider=profile_type,
|
|
615
|
+
data_provider_database=database,
|
|
616
|
+
export_write_policy=ExportWritePolicy.REPLACE,
|
|
617
|
+
)
|
|
597
618
|
|
|
598
619
|
if not config_file_loader or not configuration:
|
|
599
620
|
attr = parse_attributes(block)
|
|
@@ -730,6 +751,15 @@ def create_upstream_tables(
|
|
|
730
751
|
block,
|
|
731
752
|
**kwargs_shared,
|
|
732
753
|
)
|
|
754
|
+
elif DataSource.CLICKHOUSE == data_provider:
|
|
755
|
+
from mage_ai.io.clickhouse import ClickHouse
|
|
756
|
+
|
|
757
|
+
loader = ClickHouse.with_config(config_file_loader)
|
|
758
|
+
clickhouse.create_upstream_block_tables(
|
|
759
|
+
loader,
|
|
760
|
+
block,
|
|
761
|
+
**kwargs_shared,
|
|
762
|
+
)
|
|
733
763
|
|
|
734
764
|
block.upstream_blocks = upstream_blocks_init
|
|
735
765
|
|
|
@@ -927,6 +957,11 @@ def execute_query(
|
|
|
927
957
|
|
|
928
958
|
with Trino.with_config(config_file_loader) as loader:
|
|
929
959
|
return loader.load(query_string, **shared_kwargs)
|
|
960
|
+
elif DataSource.CLICKHOUSE == data_provider:
|
|
961
|
+
from mage_ai.io.clickhouse import ClickHouse
|
|
962
|
+
|
|
963
|
+
loader = ClickHouse.with_config(config_file_loader)
|
|
964
|
+
return loader.load(query_string, **shared_kwargs)
|
|
930
965
|
|
|
931
966
|
|
|
932
967
|
def query_from_compiled_sql(block, profile_target: str, limit: int = None) -> DataFrame:
|
|
@@ -137,8 +137,9 @@ def execute_sql_code(
|
|
|
137
137
|
NotFound: 404 Not found: Table database:schema.table_name
|
|
138
138
|
was not found in location XX
|
|
139
139
|
"""
|
|
140
|
+
total_retries = 5
|
|
140
141
|
tries = 0
|
|
141
|
-
while tries <
|
|
142
|
+
while tries < total_retries:
|
|
142
143
|
sleep(tries)
|
|
143
144
|
tries += 1
|
|
144
145
|
try:
|
|
@@ -149,7 +150,7 @@ def execute_sql_code(
|
|
|
149
150
|
)
|
|
150
151
|
return [result]
|
|
151
152
|
except Exception as err:
|
|
152
|
-
if '404' not in str(err):
|
|
153
|
+
if '404' not in str(err) or tries == total_retries:
|
|
153
154
|
raise err
|
|
154
155
|
elif DataSource.CLICKHOUSE.value == data_provider:
|
|
155
156
|
from mage_ai.io.clickhouse import ClickHouse
|
|
@@ -53,6 +53,7 @@ class Pipeline:
|
|
|
53
53
|
self.executor_type = None
|
|
54
54
|
self.executor_config = dict()
|
|
55
55
|
self.name = None
|
|
56
|
+
self.notification_config = dict()
|
|
56
57
|
self.repo_path = repo_path or get_repo_path()
|
|
57
58
|
self.schedules = []
|
|
58
59
|
self.uuid = uuid
|
|
@@ -425,7 +426,8 @@ class Pipeline:
|
|
|
425
426
|
self.callback_configs = config.get('callbacks') or []
|
|
426
427
|
self.conditional_configs = config.get('conditionals') or []
|
|
427
428
|
self.executor_type = config.get('executor_type')
|
|
428
|
-
self.executor_config = config.get('
|
|
429
|
+
self.executor_config = config.get('executor_config') or dict()
|
|
430
|
+
self.notification_config = config.get('notification_config') or dict()
|
|
429
431
|
self.spark_config = config.get('spark_config') or dict()
|
|
430
432
|
self.widget_configs = config.get('widgets') or []
|
|
431
433
|
|
|
@@ -543,6 +545,7 @@ class Pipeline:
|
|
|
543
545
|
executor_count=self.executor_count,
|
|
544
546
|
executor_type=self.executor_type,
|
|
545
547
|
name=self.name,
|
|
548
|
+
notification_config=self.notification_config,
|
|
546
549
|
type=self.type.value if type(self.type) is not str else self.type,
|
|
547
550
|
updated_at=self.updated_at,
|
|
548
551
|
uuid=self.uuid,
|
|
@@ -212,6 +212,8 @@ class IntegrationPipeline(Pipeline):
|
|
|
212
212
|
error = dig(json_object, 'tags.error')
|
|
213
213
|
except Exception:
|
|
214
214
|
error = line
|
|
215
|
+
elif not error and line.startswith('CRITICAL'):
|
|
216
|
+
error = line
|
|
215
217
|
raise Exception(error)
|
|
216
218
|
|
|
217
219
|
def preview_data(self, block_type: BlockType, streams: List[str] = None) -> List[str]:
|
|
@@ -286,9 +288,11 @@ class IntegrationPipeline(Pipeline):
|
|
|
286
288
|
except Exception:
|
|
287
289
|
error = line
|
|
288
290
|
if not error:
|
|
289
|
-
raise Exception('The sample data was not able to be loaded. Please check
|
|
290
|
-
|
|
291
|
-
|
|
291
|
+
raise Exception('The sample data was not able to be loaded. Please check if the ' +
|
|
292
|
+
'stream still exists. If it does not, click the "View and select ' +
|
|
293
|
+
'streams" button and confirm the valid streams. If it does, ' +
|
|
294
|
+
'loading sample data for this source may not currently ' +
|
|
295
|
+
'be supported.')
|
|
292
296
|
raise Exception(error)
|
|
293
297
|
|
|
294
298
|
def count_records(self) -> List[Dict]:
|
|
@@ -16,7 +16,8 @@ GIT_USERNAME_VAR = 'GIT_USERNAME'
|
|
|
16
16
|
GIT_EMAIL_VAR = 'GIT_EMAIL'
|
|
17
17
|
GIT_AUTH_TYPE_VAR = 'GIT_AUTH_TYPE'
|
|
18
18
|
GIT_BRANCH_VAR = 'GIT_BRANCH'
|
|
19
|
-
|
|
19
|
+
GIT_SYNC_ON_PIPELINE_RUN_VAR = 'GIT_SYNC_ON_PIPELINE_RUN'
|
|
20
|
+
GIT_SYNC_ON_START_VAR = 'GIT_SYNC_ON_PIPELINE_RUN'
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class Preferences:
|
|
@@ -52,7 +53,8 @@ class Preferences:
|
|
|
52
53
|
username=os.getenv(GIT_USERNAME_VAR),
|
|
53
54
|
email=os.getenv(GIT_EMAIL_VAR),
|
|
54
55
|
branch=os.getenv(GIT_BRANCH_VAR),
|
|
55
|
-
sync_on_pipeline_run=bool(int(os.getenv(
|
|
56
|
+
sync_on_pipeline_run=bool(int(os.getenv(GIT_SYNC_ON_PIPELINE_RUN_VAR) or 0)),
|
|
57
|
+
sync_on_start=bool(int(os.getenv(GIT_SYNC_ON_START_VAR) or 0)),
|
|
56
58
|
)
|
|
57
59
|
else:
|
|
58
60
|
project_sync_config = project_preferences.get('sync_config', dict())
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import shutil
|
|
3
2
|
import sys
|
|
4
3
|
import traceback
|
|
4
|
+
import uuid
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import Dict
|
|
7
7
|
|
|
@@ -16,7 +16,6 @@ from mage_ai.data_preparation.shared.constants import (
|
|
|
16
16
|
from mage_ai.data_preparation.templates.utils import copy_template_directory
|
|
17
17
|
from mage_ai.shared.environments import is_test
|
|
18
18
|
|
|
19
|
-
|
|
20
19
|
if is_test():
|
|
21
20
|
DEFAULT_MAGE_DATA_DIR = './'
|
|
22
21
|
else:
|
|
@@ -155,28 +154,34 @@ class RepoConfig:
|
|
|
155
154
|
yml.dump(data, f)
|
|
156
155
|
|
|
157
156
|
|
|
158
|
-
def init_repo(
|
|
157
|
+
def init_repo(
|
|
158
|
+
repo_path: str,
|
|
159
|
+
project_type: str = ProjectType.STANDALONE,
|
|
160
|
+
cluster_type: str = None,
|
|
161
|
+
project_uuid: str = None,
|
|
162
|
+
) -> None:
|
|
159
163
|
"""
|
|
160
164
|
Initialize a repository under the current path.
|
|
161
165
|
"""
|
|
162
166
|
if os.path.exists(repo_path):
|
|
163
167
|
raise FileExistsError(f'Repository {repo_path} already exists')
|
|
164
168
|
|
|
169
|
+
new_config = dict()
|
|
165
170
|
if project_type == ProjectType.MAIN:
|
|
166
171
|
copy_template_directory('main', repo_path)
|
|
172
|
+
new_config.update(
|
|
173
|
+
cluster_type=cluster_type,
|
|
174
|
+
)
|
|
167
175
|
elif project_type == ProjectType.SUB:
|
|
168
176
|
os.makedirs(
|
|
169
177
|
os.getenv(MAGE_DATA_DIR_ENV_VAR) or DEFAULT_MAGE_DATA_DIR,
|
|
170
178
|
exist_ok=True,
|
|
171
179
|
)
|
|
172
180
|
copy_template_directory('repo', repo_path)
|
|
173
|
-
|
|
174
|
-
current_metadata = get_repo_config().metadata_path
|
|
175
|
-
new_metadata = new_repo_config.metadata_path
|
|
176
|
-
if os.path.exists(current_metadata):
|
|
177
|
-
shutil.copyfile(current_metadata, new_metadata)
|
|
178
|
-
new_repo_config.save(
|
|
181
|
+
new_config.update(
|
|
179
182
|
project_type=ProjectType.SUB.value,
|
|
183
|
+
cluster_type=cluster_type,
|
|
184
|
+
project_uuid=project_uuid,
|
|
180
185
|
)
|
|
181
186
|
else:
|
|
182
187
|
os.makedirs(
|
|
@@ -185,6 +190,11 @@ def init_repo(repo_path: str, project_type: str = ProjectType.STANDALONE) -> Non
|
|
|
185
190
|
)
|
|
186
191
|
copy_template_directory('repo', repo_path)
|
|
187
192
|
|
|
193
|
+
if not project_uuid:
|
|
194
|
+
project_uuid = uuid.uuid4().hex
|
|
195
|
+
new_config.update(project_uuid=project_uuid)
|
|
196
|
+
get_repo_config(repo_path).save(**new_config)
|
|
197
|
+
|
|
188
198
|
|
|
189
199
|
def get_data_dir() -> str:
|
|
190
200
|
return os.getenv(MAGE_DATA_DIR_ENV_VAR) or DEFAULT_MAGE_DATA_DIR
|
|
@@ -203,7 +213,11 @@ def get_repo_config(repo_path=None) -> RepoConfig:
|
|
|
203
213
|
|
|
204
214
|
|
|
205
215
|
def get_project_type(repo_path=None) -> ProjectType:
|
|
206
|
-
|
|
216
|
+
try:
|
|
217
|
+
return get_repo_config(repo_path=repo_path).project_type
|
|
218
|
+
except Exception:
|
|
219
|
+
# default to standalone project type
|
|
220
|
+
return ProjectType.STANDALONE
|
|
207
221
|
|
|
208
222
|
|
|
209
223
|
def set_repo_path(repo_path: str) -> None:
|
|
@@ -213,3 +227,20 @@ def set_repo_path(repo_path: str) -> None:
|
|
|
213
227
|
|
|
214
228
|
def get_variables_dir(repo_path: str = None) -> str:
|
|
215
229
|
return get_repo_config(repo_path=repo_path).variables_dir
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
config = get_repo_config()
|
|
233
|
+
project_uuid = config.project_uuid
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def update_project_uuid():
|
|
237
|
+
global project_uuid
|
|
238
|
+
project_uuid = get_repo_config().project_uuid
|
|
239
|
+
if not project_uuid:
|
|
240
|
+
puuid = uuid.uuid4().hex
|
|
241
|
+
config.save(project_uuid=puuid)
|
|
242
|
+
project_uuid = puuid
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_project_uuid() -> str:
|
|
246
|
+
return project_uuid
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from cryptography.fernet import Fernet, InvalidToken
|
|
2
|
-
from mage_ai.data_preparation.repo_manager import (
|
|
3
|
-
get_data_dir,
|
|
4
|
-
get_repo_path
|
|
5
|
-
)
|
|
6
|
-
from typing import List
|
|
7
1
|
import os
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
5
|
+
|
|
6
|
+
from mage_ai.data_preparation.repo_manager import get_data_dir, get_repo_path
|
|
8
7
|
|
|
9
8
|
DEFAULT_MAGE_SECRETS_DIR = 'secrets'
|
|
10
9
|
|
|
@@ -17,10 +17,11 @@ class AuthType(str, Enum):
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|
|
19
19
|
class GitConfig(BaseConfig):
|
|
20
|
-
remote_repo_link: str
|
|
20
|
+
remote_repo_link: str = None
|
|
21
21
|
repo_path: str = os.getcwd()
|
|
22
22
|
branch: str = 'main'
|
|
23
23
|
sync_on_pipeline_run: bool = False
|
|
24
|
+
sync_on_start: bool = False
|
|
24
25
|
auth_type: AuthType = AuthType.SSH
|
|
25
26
|
# User settings moved to UserGitConfig, these will be used for Git syncs
|
|
26
27
|
username: str = ''
|
|
@@ -11,12 +11,9 @@ class GitSync(BaseSync):
|
|
|
11
11
|
self.git_manager = Git(sync_config)
|
|
12
12
|
|
|
13
13
|
def sync_data(self):
|
|
14
|
-
|
|
15
|
-
f'Syncing data with remote repo {self.remote_repo_link}',
|
|
16
|
-
verbose=True,
|
|
17
|
-
):
|
|
18
|
-
self.git_manager.reset(self.branch)
|
|
14
|
+
self.git_manager.reset(self.branch)
|
|
19
15
|
|
|
16
|
+
# Reset git sync by cloning the remote repo
|
|
20
17
|
def reset(self):
|
|
21
18
|
with VerboseFunctionExec(
|
|
22
19
|
f'Attempting to clone from remote repo {self.remote_repo_link}',
|
|
@@ -51,6 +51,8 @@ def write_template(template_source: str, dest_path: str) -> None:
|
|
|
51
51
|
template_source (str): Template source code to write to file
|
|
52
52
|
dest_path (str): Destination file to write template source code to.
|
|
53
53
|
"""
|
|
54
|
+
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
55
|
+
|
|
54
56
|
with open(dest_path, 'w') as foutput:
|
|
55
57
|
foutput.write(template_source)
|
|
56
58
|
|
|
@@ -15,7 +15,7 @@ from sqlalchemy import (
|
|
|
15
15
|
)
|
|
16
16
|
from sqlalchemy.orm import relationship, validates
|
|
17
17
|
|
|
18
|
-
from mage_ai.data_preparation.repo_manager import get_repo_path
|
|
18
|
+
from mage_ai.data_preparation.repo_manager import get_project_uuid, get_repo_path
|
|
19
19
|
from mage_ai.orchestration.db import db_connection, safe_db_query
|
|
20
20
|
from mage_ai.orchestration.db.errors import ValidationError
|
|
21
21
|
from mage_ai.orchestration.db.models.base import BaseModel
|
|
@@ -70,7 +70,7 @@ class User(BaseModel):
|
|
|
70
70
|
|
|
71
71
|
@property
|
|
72
72
|
def project_access(self) -> int:
|
|
73
|
-
return self.get_access(Permission.Entity.PROJECT,
|
|
73
|
+
return self.get_access(Permission.Entity.PROJECT, get_project_uuid())
|
|
74
74
|
|
|
75
75
|
def get_access(
|
|
76
76
|
self,
|
|
@@ -195,11 +195,13 @@ class Role(BaseModel):
|
|
|
195
195
|
|
|
196
196
|
def get_access(
|
|
197
197
|
self,
|
|
198
|
-
entity: Union['Permission.Entity', None]
|
|
198
|
+
entity: Union['Permission.Entity', None],
|
|
199
199
|
entity_id: Union[str, None] = None,
|
|
200
200
|
) -> int:
|
|
201
201
|
permissions = []
|
|
202
202
|
if entity is None:
|
|
203
|
+
return 0
|
|
204
|
+
elif entity == Permission.Entity.ANY:
|
|
203
205
|
permissions.extend(self.permissions)
|
|
204
206
|
else:
|
|
205
207
|
entity_permissions = list(filter(
|
|
@@ -225,7 +227,7 @@ class Role(BaseModel):
|
|
|
225
227
|
we will go up the entity chain to see if there are permissions for parent entities.
|
|
226
228
|
'''
|
|
227
229
|
if entity == Permission.Entity.PIPELINE:
|
|
228
|
-
return self.get_access(Permission.Entity.PROJECT,
|
|
230
|
+
return self.get_access(Permission.Entity.PROJECT, get_project_uuid())
|
|
229
231
|
elif entity == Permission.Entity.PROJECT:
|
|
230
232
|
return self.get_access(Permission.Entity.GLOBAL)
|
|
231
233
|
else:
|
|
@@ -239,6 +241,9 @@ class UserRole(BaseModel):
|
|
|
239
241
|
|
|
240
242
|
class Permission(BaseModel):
|
|
241
243
|
class Entity(str, enum.Enum):
|
|
244
|
+
# Permissions saved to the DB should not have the "ANY" entity. It should only be used
|
|
245
|
+
# when evaluating permissions.
|
|
246
|
+
ANY = 'any'
|
|
242
247
|
GLOBAL = 'global'
|
|
243
248
|
PROJECT = 'project'
|
|
244
249
|
PIPELINE = 'pipeline'
|
|
@@ -260,6 +265,19 @@ class Permission(BaseModel):
|
|
|
260
265
|
|
|
261
266
|
role = relationship(Role, back_populates='permissions')
|
|
262
267
|
|
|
268
|
+
@validates('entity')
|
|
269
|
+
def validate_entity(self, key, value):
|
|
270
|
+
if value == Permission.Entity.ANY:
|
|
271
|
+
raise ValidationError(
|
|
272
|
+
'Permission entity cannot be ANY. Please select a specific entity.',
|
|
273
|
+
metadata=dict(
|
|
274
|
+
key=key,
|
|
275
|
+
value=value,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return value
|
|
280
|
+
|
|
263
281
|
@classmethod
|
|
264
282
|
@safe_db_query
|
|
265
283
|
def create_default_permissions(
|
|
@@ -76,7 +76,12 @@ class PipelineScheduler:
|
|
|
76
76
|
)
|
|
77
77
|
self.logger = DictLogger(self.logger_manager.logger)
|
|
78
78
|
self.notification_sender = NotificationSender(
|
|
79
|
-
NotificationConfig.load(
|
|
79
|
+
NotificationConfig.load(
|
|
80
|
+
config=merge_dict(
|
|
81
|
+
self.pipeline.repo_config.notification_config,
|
|
82
|
+
self.pipeline.notification_config,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
80
85
|
)
|
|
81
86
|
|
|
82
87
|
self.allow_blocks_to_fail = (
|
|
@@ -85,12 +90,13 @@ class PipelineScheduler:
|
|
|
85
90
|
)
|
|
86
91
|
|
|
87
92
|
def start(self, should_schedule: bool = True) -> None:
|
|
88
|
-
|
|
93
|
+
preferences = get_preferences()
|
|
94
|
+
if preferences.sync_config:
|
|
89
95
|
tags = dict(
|
|
90
96
|
pipeline_run_id=self.pipeline_run.id,
|
|
91
97
|
pipeline_uuid=self.pipeline.uuid,
|
|
92
98
|
)
|
|
93
|
-
sync_config = GitConfig.load(config=
|
|
99
|
+
sync_config = GitConfig.load(config=preferences.sync_config)
|
|
94
100
|
if sync_config.sync_on_pipeline_run:
|
|
95
101
|
sync = GitSync(sync_config)
|
|
96
102
|
try:
|
|
@@ -1060,10 +1066,6 @@ def check_sla():
|
|
|
1060
1066
|
pipeline_runs = PipelineRun.in_progress_runs(pipeline_schedules)
|
|
1061
1067
|
|
|
1062
1068
|
if pipeline_runs:
|
|
1063
|
-
notification_sender = NotificationSender(
|
|
1064
|
-
NotificationConfig.load(config=get_repo_config(get_repo_path()).notification_config),
|
|
1065
|
-
)
|
|
1066
|
-
|
|
1067
1069
|
current_time = datetime.now(tz=pytz.UTC)
|
|
1068
1070
|
# TODO: combine all SLA alerts in one notification
|
|
1069
1071
|
for pipeline_run in pipeline_runs:
|
|
@@ -1076,8 +1078,17 @@ def check_sla():
|
|
|
1076
1078
|
else pipeline_run.created_at
|
|
1077
1079
|
if compare(start_date, current_time - timedelta(seconds=sla)) == 1:
|
|
1078
1080
|
# passed SLA for pipeline_run
|
|
1081
|
+
pipeline = Pipeline.get(pipeline_run.pipeline_schedule.pipeline_uuid)
|
|
1082
|
+
notification_sender = NotificationSender(
|
|
1083
|
+
NotificationConfig.load(
|
|
1084
|
+
config=merge_dict(
|
|
1085
|
+
pipeline.repo_config.notification_config,
|
|
1086
|
+
pipeline.notification_config,
|
|
1087
|
+
),
|
|
1088
|
+
),
|
|
1089
|
+
)
|
|
1079
1090
|
notification_sender.send_pipeline_run_sla_passed_message(
|
|
1080
|
-
|
|
1091
|
+
pipeline,
|
|
1081
1092
|
pipeline_run,
|
|
1082
1093
|
)
|
|
1083
1094
|
|