gooddata-pipelines 1.52.1.dev2__py3-none-any.whl → 1.53.0__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 gooddata-pipelines might be problematic. Click here for more details.
- gooddata_pipelines/__init__.py +6 -0
- gooddata_pipelines/api/gooddata_api.py +27 -0
- gooddata_pipelines/backup_and_restore/backup_manager.py +6 -61
- gooddata_pipelines/backup_and_restore/base_manager.py +73 -0
- gooddata_pipelines/backup_and_restore/constants.py +5 -4
- gooddata_pipelines/backup_and_restore/models/storage.py +77 -19
- gooddata_pipelines/backup_and_restore/restore_manager.py +266 -0
- gooddata_pipelines/backup_and_restore/storage/base_storage.py +11 -0
- gooddata_pipelines/backup_and_restore/storage/local_storage.py +19 -8
- gooddata_pipelines/backup_and_restore/storage/s3_storage.py +25 -2
- gooddata_pipelines/provisioning/utils/utils.py +7 -4
- gooddata_pipelines/utils/decorators.py +30 -0
- gooddata_pipelines/utils/file_utils.py +63 -0
- {gooddata_pipelines-1.52.1.dev2.dist-info → gooddata_pipelines-1.53.0.dist-info}/METADATA +6 -5
- {gooddata_pipelines-1.52.1.dev2.dist-info → gooddata_pipelines-1.53.0.dist-info}/RECORD +17 -13
- {gooddata_pipelines-1.52.1.dev2.dist-info → gooddata_pipelines-1.53.0.dist-info}/WHEEL +0 -0
- {gooddata_pipelines-1.52.1.dev2.dist-info → gooddata_pipelines-1.53.0.dist-info}/licenses/LICENSE.txt +0 -0
gooddata_pipelines/__init__.py
CHANGED
|
@@ -10,6 +10,10 @@ from .backup_and_restore.models.storage import (
|
|
|
10
10
|
S3StorageConfig,
|
|
11
11
|
StorageType,
|
|
12
12
|
)
|
|
13
|
+
from .backup_and_restore.restore_manager import (
|
|
14
|
+
RestoreManager,
|
|
15
|
+
WorkspaceToRestore,
|
|
16
|
+
)
|
|
13
17
|
from .backup_and_restore.storage.local_storage import LocalStorage
|
|
14
18
|
from .backup_and_restore.storage.s3_storage import S3Storage
|
|
15
19
|
|
|
@@ -57,6 +61,8 @@ from .provisioning.generic.provision import provision
|
|
|
57
61
|
|
|
58
62
|
__all__ = [
|
|
59
63
|
"BackupManager",
|
|
64
|
+
"RestoreManager",
|
|
65
|
+
"WorkspaceToRestore",
|
|
60
66
|
"BackupRestoreConfig",
|
|
61
67
|
"StorageType",
|
|
62
68
|
"LocalStorage",
|
|
@@ -167,6 +167,17 @@ class ApiMethods:
|
|
|
167
167
|
endpoint = f"/layout/workspaces/{workspace_id}/userDataFilters"
|
|
168
168
|
return self._get(endpoint)
|
|
169
169
|
|
|
170
|
+
def put_user_data_filters(
|
|
171
|
+
self, workspace_id: str, user_data_filters: dict[str, Any]
|
|
172
|
+
) -> requests.Response:
|
|
173
|
+
"""Puts the user data filters into GoodData workspace."""
|
|
174
|
+
headers = {**self.headers, "Content-Type": "application/json"}
|
|
175
|
+
return self._put(
|
|
176
|
+
f"/layout/workspaces/{workspace_id}/userDataFilters",
|
|
177
|
+
user_data_filters,
|
|
178
|
+
headers,
|
|
179
|
+
)
|
|
180
|
+
|
|
170
181
|
def get_automations(self, workspace_id: str) -> requests.Response:
|
|
171
182
|
"""Gets the automations for a given workspace."""
|
|
172
183
|
endpoint = (
|
|
@@ -174,6 +185,22 @@ class ApiMethods:
|
|
|
174
185
|
)
|
|
175
186
|
return self._get(endpoint)
|
|
176
187
|
|
|
188
|
+
def post_automation(
|
|
189
|
+
self, workspace_id: str, automation: dict[str, Any]
|
|
190
|
+
) -> requests.Response:
|
|
191
|
+
"""Posts an automation for a given workspace."""
|
|
192
|
+
endpoint = f"/entities/workspaces/{workspace_id}/automations"
|
|
193
|
+
return self._post(endpoint, automation)
|
|
194
|
+
|
|
195
|
+
def delete_automation(
|
|
196
|
+
self, workspace_id: str, automation_id: str
|
|
197
|
+
) -> requests.Response:
|
|
198
|
+
"""Deletes an automation for a given workspace."""
|
|
199
|
+
endpoint = (
|
|
200
|
+
f"/entities/workspaces/{workspace_id}/automations/{automation_id}"
|
|
201
|
+
)
|
|
202
|
+
return self._delete(endpoint)
|
|
203
|
+
|
|
177
204
|
def get_all_metrics(self, workspace_id: str) -> requests.Response:
|
|
178
205
|
"""Get all metrics from the specified workspace.
|
|
179
206
|
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
# (C) 2025 GoodData Corporation
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import shutil
|
|
6
5
|
import tempfile
|
|
7
6
|
import time
|
|
8
7
|
import traceback
|
|
9
8
|
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
9
|
+
from typing import Any
|
|
11
10
|
|
|
12
11
|
import attrs
|
|
13
12
|
import requests
|
|
14
|
-
import yaml
|
|
15
|
-
from gooddata_sdk.utils import PROFILES_FILE_PATH, profile_content
|
|
16
13
|
|
|
17
|
-
from gooddata_pipelines.api.gooddata_api_wrapper import GoodDataApi
|
|
18
14
|
from gooddata_pipelines.backup_and_restore.backup_input_processor import (
|
|
19
15
|
BackupInputProcessor,
|
|
20
16
|
)
|
|
17
|
+
from gooddata_pipelines.backup_and_restore.base_manager import BaseManager
|
|
21
18
|
from gooddata_pipelines.backup_and_restore.constants import (
|
|
22
19
|
BackupSettings,
|
|
23
20
|
DirNames,
|
|
@@ -25,18 +22,10 @@ from gooddata_pipelines.backup_and_restore.constants import (
|
|
|
25
22
|
from gooddata_pipelines.backup_and_restore.models.input_type import InputType
|
|
26
23
|
from gooddata_pipelines.backup_and_restore.models.storage import (
|
|
27
24
|
BackupRestoreConfig,
|
|
28
|
-
StorageType,
|
|
29
25
|
)
|
|
30
26
|
from gooddata_pipelines.backup_and_restore.storage.base_storage import (
|
|
31
27
|
BackupStorage,
|
|
32
28
|
)
|
|
33
|
-
from gooddata_pipelines.backup_and_restore.storage.local_storage import (
|
|
34
|
-
LocalStorage,
|
|
35
|
-
)
|
|
36
|
-
from gooddata_pipelines.backup_and_restore.storage.s3_storage import (
|
|
37
|
-
S3Storage,
|
|
38
|
-
)
|
|
39
|
-
from gooddata_pipelines.logger import LogObserver
|
|
40
29
|
from gooddata_pipelines.utils.rate_limiter import RateLimiter
|
|
41
30
|
|
|
42
31
|
|
|
@@ -45,16 +34,12 @@ class BackupBatch:
|
|
|
45
34
|
list_of_ids: list[str]
|
|
46
35
|
|
|
47
36
|
|
|
48
|
-
class BackupManager:
|
|
37
|
+
class BackupManager(BaseManager):
|
|
49
38
|
storage: BackupStorage
|
|
50
39
|
|
|
51
40
|
def __init__(self, host: str, token: str, config: BackupRestoreConfig):
|
|
52
|
-
|
|
53
|
-
self.logger = LogObserver()
|
|
54
|
-
|
|
55
|
-
self.config = config
|
|
41
|
+
super().__init__(host, token, config)
|
|
56
42
|
|
|
57
|
-
self.storage = self._get_storage(self.config)
|
|
58
43
|
self.org_id = self._api.get_organization_id()
|
|
59
44
|
|
|
60
45
|
self.loader = BackupInputProcessor(self._api, self.config.api_page_size)
|
|
@@ -63,39 +48,6 @@ class BackupManager:
|
|
|
63
48
|
calls_per_second=self.config.api_calls_per_second,
|
|
64
49
|
)
|
|
65
50
|
|
|
66
|
-
@classmethod
|
|
67
|
-
def create(
|
|
68
|
-
cls: Type["BackupManager"],
|
|
69
|
-
config: BackupRestoreConfig,
|
|
70
|
-
host: str,
|
|
71
|
-
token: str,
|
|
72
|
-
) -> "BackupManager":
|
|
73
|
-
"""Creates a backup worker instance using the provided host and token."""
|
|
74
|
-
return cls(host=host, token=token, config=config)
|
|
75
|
-
|
|
76
|
-
@classmethod
|
|
77
|
-
def create_from_profile(
|
|
78
|
-
cls: Type["BackupManager"],
|
|
79
|
-
config: BackupRestoreConfig,
|
|
80
|
-
profile: str = "default",
|
|
81
|
-
profiles_path: Path = PROFILES_FILE_PATH,
|
|
82
|
-
) -> "BackupManager":
|
|
83
|
-
"""Creates a backup worker instance using a GoodData profile file."""
|
|
84
|
-
content = profile_content(profile, profiles_path)
|
|
85
|
-
return cls(**content, config=config)
|
|
86
|
-
|
|
87
|
-
@staticmethod
|
|
88
|
-
def _get_storage(conf: BackupRestoreConfig) -> BackupStorage:
|
|
89
|
-
"""Returns the storage class based on the storage type."""
|
|
90
|
-
if conf.storage_type == StorageType.S3:
|
|
91
|
-
return S3Storage(conf)
|
|
92
|
-
elif conf.storage_type == StorageType.LOCAL:
|
|
93
|
-
return LocalStorage(conf)
|
|
94
|
-
else:
|
|
95
|
-
raise RuntimeError(
|
|
96
|
-
f'Unsupported storage type "{conf.storage_type.value}".'
|
|
97
|
-
)
|
|
98
|
-
|
|
99
51
|
def get_user_data_filters(self, ws_id: str) -> dict:
|
|
100
52
|
"""Returns the user data filters for the specified workspace."""
|
|
101
53
|
with self._api_rate_limiter:
|
|
@@ -133,19 +85,13 @@ class BackupManager:
|
|
|
133
85
|
"user_data_filters",
|
|
134
86
|
filter["id"] + ".yaml",
|
|
135
87
|
)
|
|
136
|
-
self.
|
|
88
|
+
self.yaml_utils.dump(udf_file_path, filter)
|
|
137
89
|
|
|
138
90
|
@staticmethod
|
|
139
91
|
def _move_folder(source: Path, destination: Path) -> None:
|
|
140
92
|
"""Moves the source folder to the destination."""
|
|
141
93
|
shutil.move(source, destination)
|
|
142
94
|
|
|
143
|
-
@staticmethod
|
|
144
|
-
def _write_to_yaml(path: str, source: Any) -> None:
|
|
145
|
-
"""Writes the source to a YAML file."""
|
|
146
|
-
with open(path, "w") as outfile:
|
|
147
|
-
yaml.dump(source, outfile)
|
|
148
|
-
|
|
149
95
|
def _get_automations_from_api(self, workspace_id: str) -> Any:
|
|
150
96
|
"""Returns automations for the workspace as JSON."""
|
|
151
97
|
with self._api_rate_limiter:
|
|
@@ -182,8 +128,7 @@ class BackupManager:
|
|
|
182
128
|
|
|
183
129
|
# Store the automations in a JSON file
|
|
184
130
|
if len(automations["data"]) > 0:
|
|
185
|
-
|
|
186
|
-
json.dump(automations, f)
|
|
131
|
+
self.json_utils.dump(automations_file_path, automations)
|
|
187
132
|
|
|
188
133
|
def store_declarative_filter_views(
|
|
189
134
|
self, export_path: Path, workspace_id: str
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# (C) 2025 GoodData Corporation
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Type, TypeVar
|
|
6
|
+
|
|
7
|
+
from gooddata_sdk.utils import PROFILES_FILE_PATH, profile_content
|
|
8
|
+
|
|
9
|
+
from gooddata_pipelines.api.gooddata_api_wrapper import GoodDataApi
|
|
10
|
+
from gooddata_pipelines.backup_and_restore.models.storage import (
|
|
11
|
+
BackupRestoreConfig,
|
|
12
|
+
StorageType,
|
|
13
|
+
)
|
|
14
|
+
from gooddata_pipelines.backup_and_restore.storage.base_storage import (
|
|
15
|
+
BackupStorage,
|
|
16
|
+
)
|
|
17
|
+
from gooddata_pipelines.backup_and_restore.storage.local_storage import (
|
|
18
|
+
LocalStorage,
|
|
19
|
+
)
|
|
20
|
+
from gooddata_pipelines.backup_and_restore.storage.s3_storage import S3Storage
|
|
21
|
+
from gooddata_pipelines.logger import LogObserver
|
|
22
|
+
from gooddata_pipelines.utils.file_utils import JsonUtils, YamlUtils
|
|
23
|
+
|
|
24
|
+
ManagerT = TypeVar("ManagerT", bound="BaseManager")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseManager(abc.ABC):
|
|
28
|
+
"""Base class to provide constructors for backup and restore managers."""
|
|
29
|
+
|
|
30
|
+
storage: BackupStorage
|
|
31
|
+
|
|
32
|
+
def __init__(self, host: str, token: str, config: BackupRestoreConfig):
|
|
33
|
+
self.config = config
|
|
34
|
+
|
|
35
|
+
self._api: GoodDataApi = GoodDataApi(host, token)
|
|
36
|
+
self.logger: LogObserver = LogObserver()
|
|
37
|
+
|
|
38
|
+
self.storage = self._get_storage(self.config)
|
|
39
|
+
|
|
40
|
+
self.yaml_utils = YamlUtils()
|
|
41
|
+
self.json_utils = JsonUtils()
|
|
42
|
+
|
|
43
|
+
def _get_storage(self, conf: BackupRestoreConfig) -> BackupStorage:
|
|
44
|
+
"""Returns the storage class based on the storage type."""
|
|
45
|
+
if conf.storage_type == StorageType.S3:
|
|
46
|
+
return S3Storage(conf)
|
|
47
|
+
elif conf.storage_type == StorageType.LOCAL:
|
|
48
|
+
return LocalStorage(conf)
|
|
49
|
+
else:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f'Unsupported storage type "{conf.storage_type.value}".'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def create(
|
|
56
|
+
cls: Type[ManagerT],
|
|
57
|
+
config: BackupRestoreConfig,
|
|
58
|
+
host: str,
|
|
59
|
+
token: str,
|
|
60
|
+
) -> ManagerT:
|
|
61
|
+
"""Creates a backup worker instance using the provided host and token."""
|
|
62
|
+
return cls(host=host, token=token, config=config)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def create_from_profile(
|
|
66
|
+
cls: Type[ManagerT],
|
|
67
|
+
config: BackupRestoreConfig,
|
|
68
|
+
profile: str = "default",
|
|
69
|
+
profiles_path: Path = PROFILES_FILE_PATH,
|
|
70
|
+
) -> ManagerT:
|
|
71
|
+
"""Creates a backup worker instance using a GoodData profile file."""
|
|
72
|
+
content = profile_content(profile, profiles_path)
|
|
73
|
+
return cls(host=content["host"], token=content["token"], config=config)
|
|
@@ -23,13 +23,14 @@ class DirNames:
|
|
|
23
23
|
|
|
24
24
|
@attrs.frozen
|
|
25
25
|
class ApiDefaults:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
PAGE_SIZE = 100
|
|
27
|
+
BATCH_SIZE = 100
|
|
28
|
+
CALLS_PER_SECOND = 1.0
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
@attrs.frozen
|
|
32
|
-
class BackupSettings
|
|
32
|
+
class BackupSettings:
|
|
33
|
+
API = ApiDefaults()
|
|
33
34
|
MAX_RETRIES = 3
|
|
34
35
|
RETRY_DELAY = 5 # seconds
|
|
35
36
|
TIMESTAMP_SDK_FOLDER = (
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# (C) 2025 GoodData Corporation
|
|
2
2
|
|
|
3
3
|
from enum import Enum
|
|
4
|
-
from typing import Annotated
|
|
4
|
+
from typing import Annotated
|
|
5
5
|
|
|
6
6
|
import yaml
|
|
7
|
-
from pydantic import BaseModel, Field
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
8
|
|
|
9
9
|
from gooddata_pipelines.backup_and_restore.constants import BackupSettings
|
|
10
10
|
|
|
@@ -17,18 +17,32 @@ class StorageType(Enum):
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class S3StorageConfig(BaseModel):
|
|
20
|
-
"""Configuration for S3 storage.
|
|
20
|
+
"""Configuration for S3 storage.
|
|
21
|
+
|
|
22
|
+
Can be created using the following constructor methods:
|
|
23
|
+
- `from_iam_role`
|
|
24
|
+
- `from_aws_credentials`
|
|
25
|
+
- `from_aws_profile`
|
|
26
|
+
"""
|
|
21
27
|
|
|
22
28
|
backup_path: str
|
|
23
29
|
bucket: str
|
|
24
|
-
profile:
|
|
25
|
-
aws_access_key_id:
|
|
26
|
-
aws_secret_access_key:
|
|
27
|
-
aws_default_region:
|
|
30
|
+
profile: str | None = None
|
|
31
|
+
aws_access_key_id: str | None = None
|
|
32
|
+
aws_secret_access_key: str | None = None
|
|
33
|
+
aws_default_region: str = "us-east-1"
|
|
28
34
|
|
|
29
35
|
@classmethod
|
|
30
36
|
def from_iam_role(cls, backup_path: str, bucket: str) -> "S3StorageConfig":
|
|
31
|
-
"""Use default IAM role or environment credentials.
|
|
37
|
+
"""Use default IAM role or environment credentials.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
backup_path: The path to the backup directory.
|
|
41
|
+
bucket: The name of the S3 bucket.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
S3StorageConfig: The S3 storage configuration.
|
|
45
|
+
"""
|
|
32
46
|
return cls(backup_path=backup_path, bucket=bucket)
|
|
33
47
|
|
|
34
48
|
@classmethod
|
|
@@ -40,7 +54,18 @@ class S3StorageConfig(BaseModel):
|
|
|
40
54
|
aws_secret_access_key: str,
|
|
41
55
|
aws_default_region: str,
|
|
42
56
|
) -> "S3StorageConfig":
|
|
43
|
-
"""Use explicit AWS access keys and region.
|
|
57
|
+
"""Use explicit AWS access keys and region.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
backup_path: The path to the backup directory.
|
|
61
|
+
bucket: The name of the S3 bucket.
|
|
62
|
+
aws_access_key_id: The AWS access key ID.
|
|
63
|
+
aws_secret_access_key: The AWS secret access key.
|
|
64
|
+
aws_default_region: The AWS default region.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
S3StorageConfig: The S3 storage configuration.
|
|
68
|
+
"""
|
|
44
69
|
return cls(
|
|
45
70
|
backup_path=backup_path,
|
|
46
71
|
bucket=bucket,
|
|
@@ -53,46 +78,79 @@ class S3StorageConfig(BaseModel):
|
|
|
53
78
|
def from_aws_profile(
|
|
54
79
|
cls, backup_path: str, bucket: str, profile: str
|
|
55
80
|
) -> "S3StorageConfig":
|
|
56
|
-
"""Use a named AWS CLI profile.
|
|
81
|
+
"""Use a named AWS CLI profile.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
backup_path: The path to the backup directory.
|
|
85
|
+
bucket: The name of the S3 bucket.
|
|
86
|
+
profile: The name of the AWS profile.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
S3StorageConfig: The S3 storage configuration.
|
|
90
|
+
"""
|
|
57
91
|
return cls(backup_path=backup_path, bucket=bucket, profile=profile)
|
|
58
92
|
|
|
59
93
|
|
|
60
94
|
class LocalStorageConfig(BaseModel):
|
|
61
95
|
"""Placeholder for local storage config."""
|
|
62
96
|
|
|
63
|
-
|
|
64
|
-
StorageConfig: TypeAlias = S3StorageConfig | LocalStorageConfig
|
|
97
|
+
backup_path: str = Field(default="local_backups")
|
|
65
98
|
|
|
66
99
|
|
|
67
100
|
class BackupRestoreConfig(BaseModel):
|
|
68
|
-
"""Configuration for backup and restore.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
"""Configuration for backup and restore.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
storage_type: The type of storage to use. Defaults to `StorageType.LOCAL`.
|
|
105
|
+
storage: Storage configuration. Either `S3StorageConfig` or `LocalStorageConfig`. Defaults to `LocalStorageConfig()`.
|
|
106
|
+
api_page_size: The page size for fetching workspace relationships. Defaults to `BackupSettings.API.PAGE_SIZE`.
|
|
107
|
+
batch_size: The batch size for fetching workspace relationships. Defaults to `BackupSettings.API.BATCH_SIZE`.
|
|
108
|
+
api_calls_per_second: The maximum API calls per second (rate limiting). Defaults to `BackupSettings.API.CALLS_PER_SECOND`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
storage_type: StorageType = Field(default=StorageType.LOCAL)
|
|
112
|
+
storage: S3StorageConfig | LocalStorageConfig = Field(
|
|
113
|
+
default_factory=LocalStorageConfig
|
|
114
|
+
)
|
|
72
115
|
api_page_size: Annotated[
|
|
73
116
|
int,
|
|
74
117
|
Field(
|
|
75
118
|
gt=0,
|
|
76
119
|
description="Page size must be greater than 0",
|
|
77
120
|
),
|
|
78
|
-
] = Field(default=BackupSettings.
|
|
121
|
+
] = Field(default=BackupSettings.API.PAGE_SIZE)
|
|
79
122
|
batch_size: Annotated[
|
|
80
123
|
int,
|
|
81
124
|
Field(
|
|
82
125
|
gt=0,
|
|
83
126
|
description="Batch size must be greater than 0",
|
|
84
127
|
),
|
|
85
|
-
] = Field(default=BackupSettings.
|
|
128
|
+
] = Field(default=BackupSettings.API.BATCH_SIZE)
|
|
86
129
|
api_calls_per_second: Annotated[
|
|
87
130
|
float,
|
|
88
131
|
Field(
|
|
89
132
|
gt=0,
|
|
90
133
|
description="Maximum API calls per second (rate limiting)",
|
|
91
134
|
),
|
|
92
|
-
] = Field(default=BackupSettings.
|
|
135
|
+
] = Field(default=BackupSettings.API.CALLS_PER_SECOND)
|
|
93
136
|
|
|
94
137
|
@classmethod
|
|
95
138
|
def from_yaml(cls, conf_path: str) -> "BackupRestoreConfig":
|
|
96
139
|
with open(conf_path, "r") as stream:
|
|
97
140
|
conf: dict = yaml.safe_load(stream)
|
|
98
141
|
return cls(**conf)
|
|
142
|
+
|
|
143
|
+
@model_validator(mode="after")
|
|
144
|
+
def validate_storage(self) -> "BackupRestoreConfig":
|
|
145
|
+
"""Check that the storage gets correct configuration when using S3 storage"""
|
|
146
|
+
if self.storage_type == StorageType.S3:
|
|
147
|
+
if not isinstance(self.storage, S3StorageConfig):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"S3 storage must be configured with S3StorageConfig object"
|
|
150
|
+
)
|
|
151
|
+
elif self.storage_type == StorageType.LOCAL:
|
|
152
|
+
if not isinstance(self.storage, LocalStorageConfig):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Local storage must be configured with LocalStorageConfig object"
|
|
155
|
+
)
|
|
156
|
+
return self
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# (C) 2025 GoodData Corporation
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import attrs
|
|
10
|
+
from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.analytics_model import (
|
|
11
|
+
CatalogDeclarativeAnalytics,
|
|
12
|
+
)
|
|
13
|
+
from gooddata_sdk.catalog.workspace.declarative_model.workspace.automation import (
|
|
14
|
+
CatalogDeclarativeAutomation,
|
|
15
|
+
)
|
|
16
|
+
from gooddata_sdk.catalog.workspace.declarative_model.workspace.logical_model.ldm import (
|
|
17
|
+
CatalogDeclarativeModel,
|
|
18
|
+
)
|
|
19
|
+
from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import (
|
|
20
|
+
CatalogDeclarativeFilterView,
|
|
21
|
+
)
|
|
22
|
+
from pydantic import BaseModel, ConfigDict
|
|
23
|
+
|
|
24
|
+
from gooddata_pipelines.backup_and_restore.base_manager import BaseManager
|
|
25
|
+
from gooddata_pipelines.backup_and_restore.constants import DirNames
|
|
26
|
+
from gooddata_pipelines.utils.decorators import log_and_reraise_exception
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@attrs.define
|
|
30
|
+
class WorkspaceModel:
|
|
31
|
+
logical_data_model: CatalogDeclarativeModel
|
|
32
|
+
analytics_model: CatalogDeclarativeAnalytics
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WorkspaceToRestore(BaseModel):
|
|
36
|
+
"""Workspace to restore.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
id: The ID of the workspace to restore.
|
|
40
|
+
path: The path to the folder containing the `gooddata_layouts.zip` file
|
|
41
|
+
to restore. Should be a continuation of the `backup_path` specified
|
|
42
|
+
in the storage configuration. Typically, it would look something like
|
|
43
|
+
`organization_id/workspace_id/backup_timestamp`
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
path: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RestoreManager(BaseManager):
|
|
53
|
+
"""Restores previsouly created backups of workspace metadata."""
|
|
54
|
+
|
|
55
|
+
@log_and_reraise_exception("Failed to extract backup from zip archive.")
|
|
56
|
+
def _extract_zip_archive(
|
|
57
|
+
self, file_to_extract: Path, destination: Path
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Extracts the backup from zip archive."""
|
|
60
|
+
with zipfile.ZipFile(file_to_extract, "r") as zip_ref:
|
|
61
|
+
zip_ref.extractall(destination)
|
|
62
|
+
|
|
63
|
+
def _check_workspace_is_valid(self, workspace_root_dir_path: Path) -> None:
|
|
64
|
+
"""Checks if the workspace layout is valid."""
|
|
65
|
+
if (
|
|
66
|
+
not workspace_root_dir_path.exists()
|
|
67
|
+
or not workspace_root_dir_path.is_dir()
|
|
68
|
+
):
|
|
69
|
+
self.logger.error(
|
|
70
|
+
"Invalid source path found upon backup fetch. "
|
|
71
|
+
f"Got {workspace_root_dir_path}. "
|
|
72
|
+
"Check if target zip contains gooddata_layouts directory."
|
|
73
|
+
)
|
|
74
|
+
raise RuntimeError("Invalid source path upon load.")
|
|
75
|
+
|
|
76
|
+
children = list(workspace_root_dir_path.iterdir())
|
|
77
|
+
am_path = workspace_root_dir_path / DirNames.AM
|
|
78
|
+
ldm_path = workspace_root_dir_path / DirNames.LDM
|
|
79
|
+
udf_path = workspace_root_dir_path / DirNames.UDF
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
am_path not in children
|
|
83
|
+
or ldm_path not in children
|
|
84
|
+
or udf_path not in children
|
|
85
|
+
):
|
|
86
|
+
self.logger.error(
|
|
87
|
+
f"{DirNames.AM} or {DirNames.LDM} directory missing in the "
|
|
88
|
+
+ "workspace hierarchy. "
|
|
89
|
+
)
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
f"{DirNames.AM} or {DirNames.LDM} directory missing."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@log_and_reraise_exception("Failed to load workspace declaration.")
|
|
95
|
+
def _load_workspace_layout(
|
|
96
|
+
self, workspace_root_dir_path: Path
|
|
97
|
+
) -> WorkspaceModel:
|
|
98
|
+
"""Loads the workspace layout from the backup."""
|
|
99
|
+
sdk_catalog = self._api._sdk.catalog_workspace_content
|
|
100
|
+
|
|
101
|
+
ldm = sdk_catalog.load_ldm_from_disk(workspace_root_dir_path)
|
|
102
|
+
am = sdk_catalog.load_analytics_model_from_disk(workspace_root_dir_path)
|
|
103
|
+
|
|
104
|
+
return WorkspaceModel(logical_data_model=ldm, analytics_model=am)
|
|
105
|
+
|
|
106
|
+
@log_and_reraise_exception("Failed to load user data filters from folder.")
|
|
107
|
+
def _load_user_data_filters(self, workspace_root_dir_path: Path) -> dict:
|
|
108
|
+
user_data_filters: dict = {"userDataFilters": []}
|
|
109
|
+
user_data_filters_folder = os.path.join(
|
|
110
|
+
workspace_root_dir_path, DirNames.UDF
|
|
111
|
+
)
|
|
112
|
+
for filename in os.listdir(user_data_filters_folder):
|
|
113
|
+
file_path = os.path.join(user_data_filters_folder, filename)
|
|
114
|
+
user_data_filter = self.yaml_utils.safe_load(Path(file_path))
|
|
115
|
+
user_data_filters["userDataFilters"].append(user_data_filter)
|
|
116
|
+
|
|
117
|
+
return user_data_filters
|
|
118
|
+
|
|
119
|
+
@log_and_reraise_exception("Failed to put workspace layout into GoodData.")
|
|
120
|
+
def _put_workspace_layout(
|
|
121
|
+
self, workspace_id: str, workspace_model: WorkspaceModel
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Puts the workspace layout into GoodData."""
|
|
124
|
+
self._api._sdk.catalog_workspace_content.put_declarative_ldm(
|
|
125
|
+
workspace_id, workspace_model.logical_data_model
|
|
126
|
+
)
|
|
127
|
+
self._api._sdk.catalog_workspace_content.put_declarative_analytics_model(
|
|
128
|
+
workspace_id, workspace_model.analytics_model
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@log_and_reraise_exception("Failed to put user data filters.")
|
|
132
|
+
def _put_user_data_filters(
|
|
133
|
+
self, workspace_id: str, user_data_filters: dict
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Puts the user data filters into GoodData workspace."""
|
|
136
|
+
response = self._api.put_user_data_filters(
|
|
137
|
+
workspace_id, user_data_filters
|
|
138
|
+
)
|
|
139
|
+
self._api.raise_if_response_not_ok(response)
|
|
140
|
+
|
|
141
|
+
def _load_and_put_filter_views(
|
|
142
|
+
self, workspace_id: str, workspace_root_dir_path: Path
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Loads and puts filter views into GoodData workspace."""
|
|
145
|
+
filter_views: list[CatalogDeclarativeFilterView] = []
|
|
146
|
+
if not (workspace_root_dir_path / "filter_views").exists():
|
|
147
|
+
# Skip if the filter_views directory does not exist
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
for file in Path(workspace_root_dir_path / "filter_views").iterdir():
|
|
151
|
+
filter_view_content: dict[str, Any] = dict(
|
|
152
|
+
self.yaml_utils.safe_load(file)
|
|
153
|
+
)
|
|
154
|
+
filter_view: CatalogDeclarativeFilterView = (
|
|
155
|
+
CatalogDeclarativeFilterView.from_dict(filter_view_content)
|
|
156
|
+
)
|
|
157
|
+
filter_views.append(filter_view)
|
|
158
|
+
|
|
159
|
+
if filter_views:
|
|
160
|
+
self._api._sdk.catalog_workspace.put_declarative_filter_views(
|
|
161
|
+
workspace_id, filter_views
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _load_and_post_automations(
|
|
165
|
+
self, workspace_id: str, workspace_root_dir_path: Path
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Loads automations from specified json file and creates them in the workspace."""
|
|
168
|
+
# Load automations from JSON
|
|
169
|
+
path_to_json: Path = Path(
|
|
170
|
+
workspace_root_dir_path, "automations", "automations.json"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Both the folder and the file must exist, otherwise skip
|
|
174
|
+
if not (workspace_root_dir_path.exists() and path_to_json.exists()):
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# Delete all automations from the workspace and restore the automations from the backup.
|
|
178
|
+
self._delete_all_automations(workspace_id)
|
|
179
|
+
|
|
180
|
+
data: dict = self.json_utils.load(path_to_json)
|
|
181
|
+
automations: list[dict] = data["data"]
|
|
182
|
+
|
|
183
|
+
for automation in automations:
|
|
184
|
+
self._post_automation(workspace_id, automation)
|
|
185
|
+
|
|
186
|
+
def _delete_all_automations(self, workspace_id: str) -> None:
|
|
187
|
+
"""Deletes all automations in the workspace."""
|
|
188
|
+
automations: list[CatalogDeclarativeAutomation] = (
|
|
189
|
+
self._api._sdk.catalog_workspace.get_declarative_automations(
|
|
190
|
+
workspace_id
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
for automation in automations:
|
|
194
|
+
self._api.delete_automation(workspace_id, automation.id)
|
|
195
|
+
|
|
196
|
+
def _post_automation(self, workspace_id: str, automation: dict) -> None:
|
|
197
|
+
"""Posts a scheduled export to the workspace."""
|
|
198
|
+
attributes: dict = automation["attributes"]
|
|
199
|
+
relationships: dict = automation["relationships"]
|
|
200
|
+
id: str = automation["id"]
|
|
201
|
+
|
|
202
|
+
if attributes.get("schedule"):
|
|
203
|
+
if attributes["schedule"].get("cronDescription"):
|
|
204
|
+
# The cron description attribute is causing a 500 ("No mapping found...")
|
|
205
|
+
# error. Known and reported issue.
|
|
206
|
+
del attributes["schedule"]["cronDescription"]
|
|
207
|
+
|
|
208
|
+
data = {
|
|
209
|
+
"data": {
|
|
210
|
+
"attributes": attributes,
|
|
211
|
+
"id": id,
|
|
212
|
+
"type": "automation",
|
|
213
|
+
"relationships": relationships,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
response = self._api.post_automation(workspace_id, data)
|
|
218
|
+
|
|
219
|
+
if not response.ok:
|
|
220
|
+
self.logger.error(
|
|
221
|
+
f"Failed to post automation ({response.status_code}): {response.text}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def _restore_backup(
|
|
225
|
+
self, workspace_to_restore: WorkspaceToRestore, tempdir_path: Path
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Restores the backup of a workspace."""
|
|
228
|
+
|
|
229
|
+
zip_target = tempdir_path / f"{DirNames.LAYOUTS}.zip"
|
|
230
|
+
src_path = tempdir_path / DirNames.LAYOUTS
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
self.storage.get_ws_declaration(
|
|
234
|
+
workspace_to_restore.path, str(zip_target)
|
|
235
|
+
)
|
|
236
|
+
self._extract_zip_archive(zip_target, tempdir_path)
|
|
237
|
+
self._check_workspace_is_valid(src_path)
|
|
238
|
+
workspace_model: WorkspaceModel = self._load_workspace_layout(
|
|
239
|
+
src_path
|
|
240
|
+
)
|
|
241
|
+
user_data_filters = self._load_user_data_filters(src_path)
|
|
242
|
+
self._put_workspace_layout(workspace_to_restore.id, workspace_model)
|
|
243
|
+
self._put_user_data_filters(
|
|
244
|
+
workspace_to_restore.id, user_data_filters
|
|
245
|
+
)
|
|
246
|
+
self._load_and_put_filter_views(workspace_to_restore.id, src_path)
|
|
247
|
+
self._load_and_post_automations(workspace_to_restore.id, src_path)
|
|
248
|
+
self.logger.info(
|
|
249
|
+
f"Finished backup restore of {workspace_to_restore.id} from {workspace_to_restore.path}."
|
|
250
|
+
)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
self.logger.error(
|
|
253
|
+
f"Failed to restore backup of {workspace_to_restore.id} from {workspace_to_restore.path}. "
|
|
254
|
+
f"Error caused by {e.__class__.__name__}: {e}."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def restore(self, workspaces_to_restore: list[WorkspaceToRestore]) -> None:
|
|
258
|
+
"""Restores the backups of workspaces.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
workspaces_to_restore: List of workspaces to restore.
|
|
262
|
+
"""
|
|
263
|
+
for workspace_to_restore in workspaces_to_restore:
|
|
264
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
265
|
+
tempdir_path = Path(tempdir)
|
|
266
|
+
self._restore_backup(workspace_to_restore, tempdir_path)
|
|
@@ -12,7 +12,18 @@ class BackupStorage(abc.ABC):
|
|
|
12
12
|
def __init__(self, conf: BackupRestoreConfig):
|
|
13
13
|
self.logger = LogObserver()
|
|
14
14
|
|
|
15
|
+
suffix = "/" if not conf.storage.backup_path.endswith("/") else ""
|
|
16
|
+
self._backup_path = conf.storage.backup_path + suffix
|
|
17
|
+
|
|
15
18
|
@abc.abstractmethod
|
|
16
19
|
def export(self, folder: str, org_id: str) -> None:
|
|
17
20
|
"""Exports the content of the folder to the storage."""
|
|
18
21
|
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def get_ws_declaration(
|
|
25
|
+
self, target_path: str, local_target_path: str
|
|
26
|
+
) -> None:
|
|
27
|
+
raise NotImplementedError(
|
|
28
|
+
"This method should be implemented by the subclass."
|
|
29
|
+
)
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from gooddata_pipelines.backup_and_restore.models.storage import (
|
|
7
7
|
BackupRestoreConfig,
|
|
8
|
+
LocalStorageConfig,
|
|
8
9
|
)
|
|
9
10
|
from gooddata_pipelines.backup_and_restore.storage.base_storage import (
|
|
10
11
|
BackupStorage,
|
|
@@ -14,24 +15,34 @@ from gooddata_pipelines.backup_and_restore.storage.base_storage import (
|
|
|
14
15
|
class LocalStorage(BackupStorage):
|
|
15
16
|
def __init__(self, conf: BackupRestoreConfig):
|
|
16
17
|
super().__init__(conf)
|
|
18
|
+
if not isinstance(conf.storage, LocalStorageConfig):
|
|
19
|
+
raise ValueError("Local storage config is required")
|
|
20
|
+
self._config: LocalStorageConfig = conf.storage
|
|
17
21
|
|
|
18
|
-
def _export(
|
|
19
|
-
self, folder: str, org_id: str, export_folder: str = "local_backups"
|
|
20
|
-
) -> None:
|
|
22
|
+
def _export(self, folder: str, org_id: str, export_folder: str) -> None:
|
|
21
23
|
"""Copies the content of the folder to local storage as backup."""
|
|
24
|
+
|
|
22
25
|
self.logger.info(f"Saving {org_id} to local storage")
|
|
23
26
|
shutil.copytree(
|
|
24
27
|
Path(folder), Path(Path.cwd(), export_folder), dirs_exist_ok=True
|
|
25
28
|
)
|
|
26
29
|
|
|
27
|
-
def export(
|
|
28
|
-
self, folder: str, org_id: str, export_folder: str = "local_backups"
|
|
29
|
-
) -> None:
|
|
30
|
+
def export(self, folder: str, org_id: str) -> None:
|
|
30
31
|
"""Copies the content of the folder to local storage as backup."""
|
|
31
32
|
try:
|
|
32
|
-
self._export(folder, org_id,
|
|
33
|
+
self._export(folder, org_id, self._config.backup_path)
|
|
33
34
|
except Exception as e:
|
|
34
35
|
self.logger.error(
|
|
35
|
-
f"Error exporting {folder} to {
|
|
36
|
+
f"Error exporting {folder} to {self._config.backup_path}: {e}"
|
|
36
37
|
)
|
|
37
38
|
raise
|
|
39
|
+
|
|
40
|
+
def get_ws_declaration(
|
|
41
|
+
self, target_path: str, local_target_path: str
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Retrieves workspace declaration from local storage and copies to the local target path.
|
|
44
|
+
|
|
45
|
+
The local target should be a temporary directory.
|
|
46
|
+
"""
|
|
47
|
+
file_to_copy = self._backup_path + target_path + "/gooddata_layouts.zip"
|
|
48
|
+
shutil.copy(file_to_copy, local_target_path)
|
|
@@ -25,8 +25,6 @@ class S3Storage(BackupStorage):
|
|
|
25
25
|
self._client = self._session.client("s3")
|
|
26
26
|
self._resource = self._session.resource("s3")
|
|
27
27
|
self._bucket = self._resource.Bucket(self._config.bucket) # type: ignore [missing library stubs]
|
|
28
|
-
suffix = "/" if not self._config.backup_path.endswith("/") else ""
|
|
29
|
-
self._backup_path = self._config.backup_path + suffix
|
|
30
28
|
|
|
31
29
|
self._verify_connection()
|
|
32
30
|
|
|
@@ -95,3 +93,28 @@ class S3Storage(BackupStorage):
|
|
|
95
93
|
self._client.put_object(
|
|
96
94
|
Bucket=self._config.bucket, Key=export_path, Body=data
|
|
97
95
|
)
|
|
96
|
+
|
|
97
|
+
def get_ws_declaration(
|
|
98
|
+
self, target_path: str, local_target_path: str
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Retrieves workspace declaration from S3 bucket."""
|
|
101
|
+
target_s3_prefix = f"{self._backup_path}{target_path}"
|
|
102
|
+
|
|
103
|
+
objs_found = list(self._bucket.objects.filter(Prefix=target_s3_prefix))
|
|
104
|
+
|
|
105
|
+
# Remove the included directory (which equals prefix) on hit
|
|
106
|
+
objs_found = objs_found[1:] if len(objs_found) > 0 else objs_found
|
|
107
|
+
|
|
108
|
+
if not objs_found:
|
|
109
|
+
message = f"No target backup found for {target_s3_prefix}."
|
|
110
|
+
self.logger.error(message)
|
|
111
|
+
raise Exception(message)
|
|
112
|
+
|
|
113
|
+
if len(objs_found) > 1:
|
|
114
|
+
self.logger.warning(
|
|
115
|
+
f"Multiple backups found at {target_s3_prefix}."
|
|
116
|
+
" Continuing with the first one, ignoring the rest..."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
s3_obj = objs_found[0]
|
|
120
|
+
self._bucket.download_file(s3_obj.key, local_target_path)
|
|
@@ -26,10 +26,11 @@ class AttributesMixin:
|
|
|
26
26
|
Returns:
|
|
27
27
|
dict: Returns a dictionary of the objects' attributes.
|
|
28
28
|
"""
|
|
29
|
-
# TODO: This might not work great with nested objects, values which are lists of objects etc.
|
|
30
|
-
# If we care about parsing the logs back from the string, we should consider some other approach
|
|
31
29
|
attributes: dict[str, str] = {}
|
|
32
|
-
for context_object in objects:
|
|
30
|
+
for index, context_object in enumerate(objects):
|
|
31
|
+
if isinstance(context_object, str):
|
|
32
|
+
attributes[f"string_context_{index}"] = context_object
|
|
33
|
+
|
|
33
34
|
if isinstance(context_object, Response):
|
|
34
35
|
# for request.Response objects, keys need to be renamed to match the log schema
|
|
35
36
|
attributes.update(
|
|
@@ -48,10 +49,12 @@ class AttributesMixin:
|
|
|
48
49
|
cast(attrs.AttrsInstance, context_object)
|
|
49
50
|
).items():
|
|
50
51
|
self._add_to_dict(attributes, key, value)
|
|
51
|
-
|
|
52
|
+
elif hasattr(context_object, "__dict__"):
|
|
52
53
|
# Generic handling for other objects
|
|
53
54
|
for key, value in context_object.__dict__.items():
|
|
54
55
|
self._add_to_dict(attributes, key, value)
|
|
56
|
+
else:
|
|
57
|
+
attributes[f"object_{index}"] = str(context_object)
|
|
55
58
|
|
|
56
59
|
if overrides:
|
|
57
60
|
attributes.update(overrides)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# (C) 2025 GoodData Corporation
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from gooddata_pipelines.logger.logger import LogObserver
|
|
6
|
+
|
|
7
|
+
logger: LogObserver = LogObserver()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_and_reraise_exception(message: str) -> Callable:
|
|
11
|
+
"""
|
|
12
|
+
Decorator to log an exception and re-raise it.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
message (str): The message to log.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def decorator(fn: Callable) -> Callable:
|
|
19
|
+
def wrapper(*method_args: Any, **method_kwargs: Any) -> Callable:
|
|
20
|
+
try:
|
|
21
|
+
return fn(*method_args, **method_kwargs)
|
|
22
|
+
except Exception:
|
|
23
|
+
logger.error(
|
|
24
|
+
f"{message}, {fn.__name__}, Args: {method_args}, Kwargs: {method_kwargs}"
|
|
25
|
+
)
|
|
26
|
+
raise
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# (C) 2025 GoodData Corporation
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import attrs
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PathUtils:
|
|
12
|
+
"""Handles common path operations."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def validate_path(path: str | Path) -> Path:
|
|
16
|
+
"""Validates a path."""
|
|
17
|
+
if not isinstance(path, Path):
|
|
18
|
+
path = Path(path)
|
|
19
|
+
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
def check_path_exists(self, path: Path) -> None:
|
|
23
|
+
"""Checks if a path exists."""
|
|
24
|
+
if not self.validate_path(path).exists():
|
|
25
|
+
raise FileNotFoundError(f"File {path} does not exist.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@attrs.define
|
|
29
|
+
class JsonUtils:
|
|
30
|
+
"""Handles common JSON interactions."""
|
|
31
|
+
|
|
32
|
+
path_utils: PathUtils = attrs.field(factory=PathUtils)
|
|
33
|
+
|
|
34
|
+
def load(self, path: Path) -> Any:
|
|
35
|
+
"""Loads a JSON file."""
|
|
36
|
+
self.path_utils.check_path_exists(path)
|
|
37
|
+
|
|
38
|
+
with open(path, "r") as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
|
|
41
|
+
def dump(self, path: Path, data: Any) -> None:
|
|
42
|
+
"""Writes the source to a JSON file."""
|
|
43
|
+
with open(path, "w") as output_file:
|
|
44
|
+
json.dump(data, output_file)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@attrs.define
|
|
48
|
+
class YamlUtils:
|
|
49
|
+
"""Handles common YMAL interactions."""
|
|
50
|
+
|
|
51
|
+
path_utils: PathUtils = attrs.field(factory=PathUtils)
|
|
52
|
+
|
|
53
|
+
def safe_load(self, path: Path) -> Any:
|
|
54
|
+
"""Safe loads a YAML file."""
|
|
55
|
+
self.path_utils.check_path_exists(path)
|
|
56
|
+
|
|
57
|
+
with open(path, "r") as f:
|
|
58
|
+
return yaml.safe_load(f)
|
|
59
|
+
|
|
60
|
+
def dump(self, path: str, data: Any) -> None:
|
|
61
|
+
"""Writes the source to a YAML file."""
|
|
62
|
+
with open(path, "w") as output_file:
|
|
63
|
+
yaml.dump(data, output_file)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gooddata-pipelines
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.53.0
|
|
4
4
|
Summary: GoodData Cloud lifecycle automation pipelines
|
|
5
5
|
Author-email: GoodData <support@gooddata.com>
|
|
6
6
|
License: MIT
|
|
@@ -8,7 +8,7 @@ License-File: LICENSE.txt
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Requires-Dist: boto3-stubs<2.0.0,>=1.39.3
|
|
10
10
|
Requires-Dist: boto3<2.0.0,>=1.39.3
|
|
11
|
-
Requires-Dist: gooddata-sdk~=1.
|
|
11
|
+
Requires-Dist: gooddata-sdk~=1.53.0
|
|
12
12
|
Requires-Dist: pydantic<3.0.0,>=2.11.3
|
|
13
13
|
Requires-Dist: requests<3.0.0,>=2.32.3
|
|
14
14
|
Requires-Dist: types-pyyaml<7.0.0,>=6.0.12.20250326
|
|
@@ -27,9 +27,10 @@ You can use the package to manage following resources in GDC:
|
|
|
27
27
|
- User/Group permissions
|
|
28
28
|
- User Data Filters
|
|
29
29
|
- Child workspaces (incl. Workspace Data Filter settings)
|
|
30
|
-
1.
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
1. Backup and restore of workspaces
|
|
31
|
+
- Create and backup snapshots of workspace metadata.
|
|
32
|
+
1. LDM Extension
|
|
33
|
+
- extend the Logical Data Model of a child workspace with custom datasets and fields
|
|
33
34
|
|
|
34
35
|
In case you are not interested in incorporating a library in your own program but would like to use a ready-made script, consider having a look at [GoodData Productivity Tools](https://github.com/gooddata/gooddata-productivity-tools).
|
|
35
36
|
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
gooddata_pipelines/__init__.py,sha256=
|
|
1
|
+
gooddata_pipelines/__init__.py,sha256=2vDjg0bX5qOdAFbr3H9Q2IuoTk2_kH-bfRK_miVtcoY,2746
|
|
2
2
|
gooddata_pipelines/_version.py,sha256=Zi8Ht5ofjFeSYGG5USixQtJNB1po6okh0Rez8VyAsFM,200
|
|
3
3
|
gooddata_pipelines/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
gooddata_pipelines/api/__init__.py,sha256=0WaBI2XMdkkZgnUsQ9kqipNzh2l2zamZvUt_qjp8xCk,106
|
|
5
5
|
gooddata_pipelines/api/exceptions.py,sha256=rddQXfv8Ktckz7RONKBnKfm53M7dzPCh50Dl1k-8hqs,1545
|
|
6
|
-
gooddata_pipelines/api/gooddata_api.py,sha256
|
|
6
|
+
gooddata_pipelines/api/gooddata_api.py,sha256=-A_yqk8JbfNW16B4egXvJcWSSzYSS8fwVk1Bc2AsEEU,11956
|
|
7
7
|
gooddata_pipelines/api/gooddata_api_wrapper.py,sha256=t7dFrXJ6X4yXS9XDthOmvd2CyzdnDDNPeIngTEW72YU,1152
|
|
8
8
|
gooddata_pipelines/api/gooddata_sdk.py,sha256=wd5O4e9BQLWUawt6odrs5a51nqFGthBkvqh9WOiW36Q,13734
|
|
9
9
|
gooddata_pipelines/api/utils.py,sha256=3QY_aYH17I9THoCINE3l-n5oj52k-gNeT1wv6Z_VxN8,1433
|
|
10
10
|
gooddata_pipelines/backup_and_restore/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
|
|
11
11
|
gooddata_pipelines/backup_and_restore/backup_input_processor.py,sha256=iUB-bvYMsOc6FGiHsIzHPWkz-TGM8VontudlVWzmBDI,7921
|
|
12
|
-
gooddata_pipelines/backup_and_restore/backup_manager.py,sha256=
|
|
13
|
-
gooddata_pipelines/backup_and_restore/
|
|
12
|
+
gooddata_pipelines/backup_and_restore/backup_manager.py,sha256=yhUqw_DnJ8PYBUPPH_10tgjaoGToWCbty6cLlG2PS-A,13351
|
|
13
|
+
gooddata_pipelines/backup_and_restore/base_manager.py,sha256=dceZQ-ACSuLtrt-GO9wS9rnRv4uIEVrBCikD87LQlJs,2448
|
|
14
|
+
gooddata_pipelines/backup_and_restore/constants.py,sha256=tEFpz-GavUvyZADMb_zGsoxbRs1wQ4CDuQkeXRGmpyA,816
|
|
14
15
|
gooddata_pipelines/backup_and_restore/csv_reader.py,sha256=0Kw7mJT7REj3Gjqfsc6YT9MbhcqfCGNB_SKBwzTI1rk,1268
|
|
16
|
+
gooddata_pipelines/backup_and_restore/restore_manager.py,sha256=itvhu7MjxwdX0fJxA4F-g8BjxIpe8cxG2CVsYmq37z0,10491
|
|
15
17
|
gooddata_pipelines/backup_and_restore/models/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
|
|
16
18
|
gooddata_pipelines/backup_and_restore/models/input_type.py,sha256=CBKJigKdmZ-NJD9MSfNhq89bo86W0AqCMMoyonbd1QA,239
|
|
17
|
-
gooddata_pipelines/backup_and_restore/models/storage.py,sha256=
|
|
19
|
+
gooddata_pipelines/backup_and_restore/models/storage.py,sha256=kESVWGM_qq6HC_BCbrxsN6IyAFE4G4nPC96ggQy05ZI,5107
|
|
18
20
|
gooddata_pipelines/backup_and_restore/models/workspace_response.py,sha256=eQbYLgRQc17IRG0yPTAJVrD-Xs05SzuwtzoNrPT2DoY,833
|
|
19
21
|
gooddata_pipelines/backup_and_restore/storage/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
|
|
20
|
-
gooddata_pipelines/backup_and_restore/storage/base_storage.py,sha256=
|
|
21
|
-
gooddata_pipelines/backup_and_restore/storage/local_storage.py,sha256=
|
|
22
|
-
gooddata_pipelines/backup_and_restore/storage/s3_storage.py,sha256=
|
|
22
|
+
gooddata_pipelines/backup_and_restore/storage/base_storage.py,sha256=lzkfjsp_WZQS1U-178iSlzDplZ-ejsoodLQscK1Vd9s,858
|
|
23
|
+
gooddata_pipelines/backup_and_restore/storage/local_storage.py,sha256=LYM8uZCQKwe_SiItVSxKr7DqaoW-1ePhHjB3usQgFck,1703
|
|
24
|
+
gooddata_pipelines/backup_and_restore/storage/s3_storage.py,sha256=mcd91cHB8NMgo2t_F_DxMXqsCiBhSXCbULxWgaVAXr0,4535
|
|
23
25
|
gooddata_pipelines/ldm_extension/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
|
|
24
26
|
gooddata_pipelines/ldm_extension/input_processor.py,sha256=lNIx6YfU4OJpSLyAitCoPwwf6eFIT6OyivRnqYX5O-o,11678
|
|
25
27
|
gooddata_pipelines/ldm_extension/input_validator.py,sha256=sAl-tixrS69G_lP19U9CjKHiWZinXOcjeAqwiydVctQ,7459
|
|
@@ -58,10 +60,12 @@ gooddata_pipelines/provisioning/generic/provision.py,sha256=TgBFbOroG9nHVhHQkqAm
|
|
|
58
60
|
gooddata_pipelines/provisioning/utils/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
|
|
59
61
|
gooddata_pipelines/provisioning/utils/context_objects.py,sha256=HJoeumH_gXwM6X-GO3HkC4w-6RYozz6-aqQOhDnu7no,879
|
|
60
62
|
gooddata_pipelines/provisioning/utils/exceptions.py,sha256=1WnAOlPhqOf0xRcvn70lxAlLb8Oo6m6WCYS4hj9uzDU,3630
|
|
61
|
-
gooddata_pipelines/provisioning/utils/utils.py,sha256=
|
|
63
|
+
gooddata_pipelines/provisioning/utils/utils.py,sha256=OSskOvxx4Eu5_khLxuby6t5YGR3qJa-h5G6m7pVOUgQ,2818
|
|
62
64
|
gooddata_pipelines/utils/__init__.py,sha256=s9TtSjKqo1gSGWOVoGrXaGi1TsbRowjRDYKtjmKy7BY,155
|
|
65
|
+
gooddata_pipelines/utils/decorators.py,sha256=k2v0Kk-fnTyVKXBBCkWI6f5cbv_vE9QXpCNL8Us11IY,775
|
|
66
|
+
gooddata_pipelines/utils/file_utils.py,sha256=mr2rVicXjEaEfq8cV7oeIkXaYY1PdtuL7QBvhV4C-l4,1619
|
|
63
67
|
gooddata_pipelines/utils/rate_limiter.py,sha256=owbcEZhUxlTnE7rRHiWQ8XBC-vML2fVPbt41EeGEM7o,2002
|
|
64
|
-
gooddata_pipelines-1.
|
|
65
|
-
gooddata_pipelines-1.
|
|
66
|
-
gooddata_pipelines-1.
|
|
67
|
-
gooddata_pipelines-1.
|
|
68
|
+
gooddata_pipelines-1.53.0.dist-info/METADATA,sha256=KzKB0b_avhrKeCAC-dB00gZdkYbrn5Zp0qP3ZY5a4ak,3699
|
|
69
|
+
gooddata_pipelines-1.53.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
70
|
+
gooddata_pipelines-1.53.0.dist-info/licenses/LICENSE.txt,sha256=PNC7WXGIo6OKkNoPLRxlVrw6jaLcjSTUsSxy9Xcu9Jo,560365
|
|
71
|
+
gooddata_pipelines-1.53.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|