fractal-server 2.8.1__py3-none-any.whl → 2.9.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.
- fractal_server/__init__.py +1 -1
- fractal_server/app/db/__init__.py +2 -35
- fractal_server/app/models/v2/__init__.py +3 -3
- fractal_server/app/models/v2/task.py +0 -72
- fractal_server/app/models/v2/task_group.py +113 -0
- fractal_server/app/routes/admin/v1.py +13 -30
- fractal_server/app/routes/admin/v2/__init__.py +4 -0
- fractal_server/app/routes/admin/v2/job.py +13 -24
- fractal_server/app/routes/admin/v2/task.py +13 -0
- fractal_server/app/routes/admin/v2/task_group.py +75 -14
- fractal_server/app/routes/admin/v2/task_group_lifecycle.py +267 -0
- fractal_server/app/routes/api/v1/project.py +7 -19
- fractal_server/app/routes/api/v2/__init__.py +11 -2
- fractal_server/app/routes/api/v2/{_aux_functions_task_collection.py → _aux_functions_task_lifecycle.py} +83 -0
- fractal_server/app/routes/api/v2/_aux_functions_tasks.py +27 -17
- fractal_server/app/routes/api/v2/submit.py +19 -24
- fractal_server/app/routes/api/v2/task_collection.py +33 -65
- fractal_server/app/routes/api/v2/task_collection_custom.py +3 -3
- fractal_server/app/routes/api/v2/task_group.py +86 -14
- fractal_server/app/routes/api/v2/task_group_lifecycle.py +272 -0
- fractal_server/app/routes/api/v2/workflow.py +1 -1
- fractal_server/app/routes/api/v2/workflow_import.py +2 -2
- fractal_server/app/routes/auth/current_user.py +60 -17
- fractal_server/app/routes/auth/group.py +67 -39
- fractal_server/app/routes/auth/users.py +97 -99
- fractal_server/app/routes/aux/__init__.py +20 -0
- fractal_server/app/runner/executors/slurm/_slurm_config.py +0 -17
- fractal_server/app/runner/executors/slurm/ssh/executor.py +49 -204
- fractal_server/app/runner/executors/slurm/sudo/executor.py +26 -109
- fractal_server/app/runner/executors/slurm/utils_executors.py +58 -0
- fractal_server/app/runner/v2/_local_experimental/executor.py +2 -1
- fractal_server/app/schemas/_validators.py +0 -15
- fractal_server/app/schemas/user.py +16 -10
- fractal_server/app/schemas/user_group.py +0 -11
- fractal_server/app/schemas/v1/applyworkflow.py +0 -8
- fractal_server/app/schemas/v1/dataset.py +0 -5
- fractal_server/app/schemas/v1/project.py +0 -5
- fractal_server/app/schemas/v1/state.py +0 -5
- fractal_server/app/schemas/v1/workflow.py +0 -5
- fractal_server/app/schemas/v2/__init__.py +4 -2
- fractal_server/app/schemas/v2/dataset.py +0 -6
- fractal_server/app/schemas/v2/job.py +0 -8
- fractal_server/app/schemas/v2/project.py +0 -5
- fractal_server/app/schemas/v2/task_collection.py +0 -21
- fractal_server/app/schemas/v2/task_group.py +59 -8
- fractal_server/app/schemas/v2/workflow.py +0 -5
- fractal_server/app/security/__init__.py +17 -0
- fractal_server/config.py +61 -59
- fractal_server/migrations/versions/d256a7379ab8_taskgroup_activity_and_venv_info_to_.py +117 -0
- fractal_server/ssh/_fabric.py +156 -83
- fractal_server/tasks/utils.py +2 -12
- fractal_server/tasks/v2/local/__init__.py +3 -0
- fractal_server/tasks/v2/local/_utils.py +70 -0
- fractal_server/tasks/v2/local/collect.py +291 -0
- fractal_server/tasks/v2/local/deactivate.py +218 -0
- fractal_server/tasks/v2/local/reactivate.py +159 -0
- fractal_server/tasks/v2/ssh/__init__.py +3 -0
- fractal_server/tasks/v2/ssh/_utils.py +87 -0
- fractal_server/tasks/v2/ssh/collect.py +311 -0
- fractal_server/tasks/v2/ssh/deactivate.py +253 -0
- fractal_server/tasks/v2/ssh/reactivate.py +202 -0
- fractal_server/tasks/v2/templates/{_2_preliminary_pip_operations.sh → 1_create_venv.sh} +6 -7
- fractal_server/tasks/v2/templates/{_3_pip_install.sh → 2_pip_install.sh} +8 -1
- fractal_server/tasks/v2/templates/{_4_pip_freeze.sh → 3_pip_freeze.sh} +0 -7
- fractal_server/tasks/v2/templates/{_5_pip_show.sh → 4_pip_show.sh} +5 -6
- fractal_server/tasks/v2/templates/5_get_venv_size_and_file_number.sh +10 -0
- fractal_server/tasks/v2/templates/6_pip_install_from_freeze.sh +35 -0
- fractal_server/tasks/v2/utils_background.py +42 -127
- fractal_server/tasks/v2/utils_templates.py +32 -2
- fractal_server/utils.py +4 -2
- fractal_server/zip_tools.py +21 -4
- {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/METADATA +3 -5
- {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/RECORD +77 -64
- fractal_server/app/models/v2/collection_state.py +0 -22
- fractal_server/tasks/v2/collection_local.py +0 -357
- fractal_server/tasks/v2/collection_ssh.py +0 -352
- fractal_server/tasks/v2/templates/_1_create_venv.sh +0 -42
- /fractal_server/tasks/v2/{database_operations.py → utils_database.py} +0 -0
- {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/LICENSE +0 -0
- {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/WHEEL +0 -0
- {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -1,352 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
from pathlib import Path
|
3
|
-
from tempfile import TemporaryDirectory
|
4
|
-
|
5
|
-
from sqlalchemy.orm.attributes import flag_modified
|
6
|
-
|
7
|
-
from .database_operations import create_db_tasks_and_update_task_group
|
8
|
-
from .utils_background import _handle_failure
|
9
|
-
from .utils_background import _prepare_tasks_metadata
|
10
|
-
from .utils_background import _set_collection_state_data_status
|
11
|
-
from fractal_server.app.db import get_sync_db
|
12
|
-
from fractal_server.app.models.v2 import CollectionStateV2
|
13
|
-
from fractal_server.app.models.v2 import TaskGroupV2
|
14
|
-
from fractal_server.app.schemas.v2 import CollectionStatusV2
|
15
|
-
from fractal_server.app.schemas.v2.manifest import ManifestV2
|
16
|
-
from fractal_server.config import get_settings
|
17
|
-
from fractal_server.logger import get_logger
|
18
|
-
from fractal_server.logger import set_logger
|
19
|
-
from fractal_server.ssh._fabric import FractalSSH
|
20
|
-
from fractal_server.syringe import Inject
|
21
|
-
from fractal_server.tasks.v2.utils_background import _refresh_logs
|
22
|
-
from fractal_server.tasks.v2.utils_package_names import compare_package_names
|
23
|
-
from fractal_server.tasks.v2.utils_python_interpreter import (
|
24
|
-
get_python_interpreter_v2,
|
25
|
-
)
|
26
|
-
from fractal_server.tasks.v2.utils_templates import customize_template
|
27
|
-
from fractal_server.tasks.v2.utils_templates import parse_script_5_stdout
|
28
|
-
|
29
|
-
|
30
|
-
def _customize_and_run_template(
|
31
|
-
*,
|
32
|
-
template_name: str,
|
33
|
-
replacements: list[tuple[str, str]],
|
34
|
-
script_dir: str,
|
35
|
-
logger_name: str,
|
36
|
-
fractal_ssh: FractalSSH,
|
37
|
-
tasks_base_dir: str,
|
38
|
-
) -> str:
|
39
|
-
"""
|
40
|
-
Customize one of the template bash scripts, transfer it to the remote host
|
41
|
-
via SFTP and then run it via SSH.
|
42
|
-
|
43
|
-
Args:
|
44
|
-
script_filename:
|
45
|
-
replacements:
|
46
|
-
tmpdir:
|
47
|
-
logger_name:
|
48
|
-
fractal_ssh:
|
49
|
-
"""
|
50
|
-
logger = get_logger(logger_name)
|
51
|
-
logger.debug(f"_customize_and_run_template {template_name} - START")
|
52
|
-
|
53
|
-
script_path_local = Path(script_dir) / template_name
|
54
|
-
|
55
|
-
customize_template(
|
56
|
-
template_name=template_name,
|
57
|
-
replacements=replacements,
|
58
|
-
script_path=script_path_local,
|
59
|
-
)
|
60
|
-
|
61
|
-
# Transfer script to remote host
|
62
|
-
script_path_remote = os.path.join(
|
63
|
-
tasks_base_dir,
|
64
|
-
f"script_{abs(hash(script_dir))}{template_name}",
|
65
|
-
)
|
66
|
-
logger.debug(f"Now transfer {script_path_local=} over SSH.")
|
67
|
-
fractal_ssh.send_file(
|
68
|
-
local=script_path_local,
|
69
|
-
remote=script_path_remote,
|
70
|
-
)
|
71
|
-
|
72
|
-
# Execute script remotely
|
73
|
-
cmd = f"bash {script_path_remote}"
|
74
|
-
logger.debug(f"Now run '{cmd}' over SSH.")
|
75
|
-
stdout = fractal_ssh.run_command(cmd=cmd)
|
76
|
-
logger.debug(f"Standard output of '{cmd}':\n{stdout}")
|
77
|
-
|
78
|
-
logger.debug(f"_customize_and_run_template {template_name} - END")
|
79
|
-
return stdout
|
80
|
-
|
81
|
-
|
82
|
-
def collect_package_ssh(
|
83
|
-
*,
|
84
|
-
state_id: int,
|
85
|
-
task_group: TaskGroupV2,
|
86
|
-
fractal_ssh: FractalSSH,
|
87
|
-
tasks_base_dir: str,
|
88
|
-
) -> None:
|
89
|
-
"""
|
90
|
-
Collect a task package over SSH
|
91
|
-
|
92
|
-
This function is run as a background task, therefore exceptions must be
|
93
|
-
handled.
|
94
|
-
|
95
|
-
NOTE: by making this function sync, it will run within a thread - due to
|
96
|
-
starlette/fastapi handling of background tasks (see
|
97
|
-
https://github.com/encode/starlette/blob/master/starlette/background.py).
|
98
|
-
|
99
|
-
|
100
|
-
Arguments:
|
101
|
-
state_id:
|
102
|
-
task_group:
|
103
|
-
fractal_ssh:
|
104
|
-
tasks_base_dir:
|
105
|
-
"""
|
106
|
-
|
107
|
-
# Work within a temporary folder, where also logs will be placed
|
108
|
-
with TemporaryDirectory() as tmpdir:
|
109
|
-
LOGGER_NAME = "task_collection_ssh"
|
110
|
-
log_file_path = Path(tmpdir) / "log"
|
111
|
-
logger = set_logger(
|
112
|
-
logger_name=LOGGER_NAME,
|
113
|
-
log_file_path=log_file_path,
|
114
|
-
)
|
115
|
-
logger.debug("START")
|
116
|
-
for key, value in task_group.model_dump().items():
|
117
|
-
logger.debug(f"task_group.{key}: {value}")
|
118
|
-
|
119
|
-
# `remove_venv_folder_upon_failure` is set to True only if
|
120
|
-
# script 1 goes through, which means that the remote folder
|
121
|
-
# `package_env_dir` did not already exist. If this remote
|
122
|
-
# folder already existed, then script 1 fails and the boolean
|
123
|
-
# flag `remove_venv_folder_upon_failure` remains false.
|
124
|
-
remove_venv_folder_upon_failure = False
|
125
|
-
|
126
|
-
# Open a DB session soon, since it is needed for updating `state`
|
127
|
-
with next(get_sync_db()) as db:
|
128
|
-
try:
|
129
|
-
# Prepare replacements for task-collection scripts
|
130
|
-
python_bin = get_python_interpreter_v2(
|
131
|
-
python_version=task_group.python_version
|
132
|
-
)
|
133
|
-
install_string = task_group.pip_install_string
|
134
|
-
settings = Inject(get_settings)
|
135
|
-
replacements = [
|
136
|
-
("__PACKAGE_NAME__", task_group.pkg_name),
|
137
|
-
("__TASK_GROUP_DIR__", task_group.path),
|
138
|
-
("__PACKAGE_ENV_DIR__", task_group.venv_path),
|
139
|
-
("__PYTHON__", python_bin),
|
140
|
-
("__INSTALL_STRING__", install_string),
|
141
|
-
(
|
142
|
-
"__FRACTAL_MAX_PIP_VERSION__",
|
143
|
-
settings.FRACTAL_MAX_PIP_VERSION,
|
144
|
-
),
|
145
|
-
(
|
146
|
-
"__PINNED_PACKAGE_LIST__",
|
147
|
-
task_group.pinned_package_versions_string,
|
148
|
-
),
|
149
|
-
]
|
150
|
-
|
151
|
-
common_args = dict(
|
152
|
-
replacements=replacements,
|
153
|
-
script_dir=tmpdir,
|
154
|
-
logger_name=LOGGER_NAME,
|
155
|
-
fractal_ssh=fractal_ssh,
|
156
|
-
tasks_base_dir=tasks_base_dir,
|
157
|
-
)
|
158
|
-
|
159
|
-
fractal_ssh.check_connection()
|
160
|
-
|
161
|
-
logger.debug("installing - START")
|
162
|
-
_set_collection_state_data_status(
|
163
|
-
state_id=state_id,
|
164
|
-
new_status=CollectionStatusV2.INSTALLING,
|
165
|
-
logger_name=LOGGER_NAME,
|
166
|
-
db=db,
|
167
|
-
)
|
168
|
-
_refresh_logs(
|
169
|
-
state_id=state_id,
|
170
|
-
log_file_path=log_file_path,
|
171
|
-
db=db,
|
172
|
-
)
|
173
|
-
db.close()
|
174
|
-
# Create remote folder (note that because of `parents=True` we
|
175
|
-
# are in the `no error if existing, make parent directories as
|
176
|
-
# needed` scenario)
|
177
|
-
fractal_ssh.mkdir(folder=tasks_base_dir, parents=True)
|
178
|
-
|
179
|
-
stdout = _customize_and_run_template(
|
180
|
-
template_name="_1_create_venv.sh",
|
181
|
-
**common_args,
|
182
|
-
)
|
183
|
-
remove_venv_folder_upon_failure = True
|
184
|
-
_refresh_logs(
|
185
|
-
state_id=state_id,
|
186
|
-
log_file_path=log_file_path,
|
187
|
-
db=db,
|
188
|
-
)
|
189
|
-
|
190
|
-
stdout = _customize_and_run_template(
|
191
|
-
template_name="_2_preliminary_pip_operations.sh",
|
192
|
-
**common_args,
|
193
|
-
)
|
194
|
-
_refresh_logs(
|
195
|
-
state_id=state_id,
|
196
|
-
log_file_path=log_file_path,
|
197
|
-
db=db,
|
198
|
-
)
|
199
|
-
stdout = _customize_and_run_template(
|
200
|
-
template_name="_3_pip_install.sh",
|
201
|
-
**common_args,
|
202
|
-
)
|
203
|
-
_refresh_logs(
|
204
|
-
state_id=state_id,
|
205
|
-
log_file_path=log_file_path,
|
206
|
-
db=db,
|
207
|
-
)
|
208
|
-
stdout_pip_freeze = _customize_and_run_template(
|
209
|
-
template_name="_4_pip_freeze.sh",
|
210
|
-
**common_args,
|
211
|
-
)
|
212
|
-
logger.debug("installing - END")
|
213
|
-
_refresh_logs(
|
214
|
-
state_id=state_id,
|
215
|
-
log_file_path=log_file_path,
|
216
|
-
db=db,
|
217
|
-
)
|
218
|
-
logger.debug("collecting - START")
|
219
|
-
_set_collection_state_data_status(
|
220
|
-
state_id=state_id,
|
221
|
-
new_status=CollectionStatusV2.COLLECTING,
|
222
|
-
logger_name=LOGGER_NAME,
|
223
|
-
db=db,
|
224
|
-
)
|
225
|
-
_refresh_logs(
|
226
|
-
state_id=state_id,
|
227
|
-
log_file_path=log_file_path,
|
228
|
-
db=db,
|
229
|
-
)
|
230
|
-
|
231
|
-
stdout = _customize_and_run_template(
|
232
|
-
template_name="_5_pip_show.sh",
|
233
|
-
**common_args,
|
234
|
-
)
|
235
|
-
pkg_attrs = parse_script_5_stdout(stdout)
|
236
|
-
for key, value in pkg_attrs.items():
|
237
|
-
logger.debug(
|
238
|
-
f"collecting - parsed from pip-show: {key}={value}"
|
239
|
-
)
|
240
|
-
# Check package_name match between pip show and task-group
|
241
|
-
package_name_pip_show = pkg_attrs.get("package_name")
|
242
|
-
package_name_task_group = task_group.pkg_name
|
243
|
-
compare_package_names(
|
244
|
-
pkg_name_pip_show=package_name_pip_show,
|
245
|
-
pkg_name_task_group=package_name_task_group,
|
246
|
-
logger_name=LOGGER_NAME,
|
247
|
-
)
|
248
|
-
|
249
|
-
_refresh_logs(
|
250
|
-
state_id=state_id,
|
251
|
-
log_file_path=log_file_path,
|
252
|
-
db=db,
|
253
|
-
)
|
254
|
-
|
255
|
-
# Extract/drop parsed attributes
|
256
|
-
package_name = package_name_task_group
|
257
|
-
python_bin = pkg_attrs.pop("python_bin")
|
258
|
-
package_root_parent_remote = pkg_attrs.pop(
|
259
|
-
"package_root_parent"
|
260
|
-
)
|
261
|
-
manifest_path_remote = pkg_attrs.pop("manifest_path")
|
262
|
-
|
263
|
-
# FIXME SSH: Use more robust logic to determine `package_root`.
|
264
|
-
# Examples: use `importlib.util.find_spec`, or parse the output
|
265
|
-
# of `pip show --files {package_name}`.
|
266
|
-
package_name_underscore = package_name.replace("-", "_")
|
267
|
-
package_root_remote = (
|
268
|
-
Path(package_root_parent_remote) / package_name_underscore
|
269
|
-
).as_posix()
|
270
|
-
|
271
|
-
# Read and validate remote manifest file
|
272
|
-
pkg_manifest_dict = fractal_ssh.read_remote_json_file(
|
273
|
-
manifest_path_remote
|
274
|
-
)
|
275
|
-
logger.info(f"collecting - loaded {manifest_path_remote=}")
|
276
|
-
pkg_manifest = ManifestV2(**pkg_manifest_dict)
|
277
|
-
logger.info("collecting - manifest is a valid ManifestV2")
|
278
|
-
|
279
|
-
logger.info("collecting - _prepare_tasks_metadata - start")
|
280
|
-
task_list = _prepare_tasks_metadata(
|
281
|
-
package_manifest=pkg_manifest,
|
282
|
-
package_version=task_group.version,
|
283
|
-
package_root=Path(package_root_remote),
|
284
|
-
python_bin=Path(python_bin),
|
285
|
-
)
|
286
|
-
logger.info("collecting - _prepare_tasks_metadata - end")
|
287
|
-
|
288
|
-
logger.info(
|
289
|
-
"collecting - create_db_tasks_and_update_task_group - "
|
290
|
-
"start"
|
291
|
-
)
|
292
|
-
create_db_tasks_and_update_task_group(
|
293
|
-
task_list=task_list,
|
294
|
-
task_group_id=task_group.id,
|
295
|
-
db=db,
|
296
|
-
)
|
297
|
-
logger.info(
|
298
|
-
"collecting - create_db_tasks_and_update_task_group - end"
|
299
|
-
)
|
300
|
-
|
301
|
-
logger.debug("collecting - END")
|
302
|
-
_refresh_logs(
|
303
|
-
state_id=state_id,
|
304
|
-
log_file_path=log_file_path,
|
305
|
-
db=db,
|
306
|
-
)
|
307
|
-
|
308
|
-
# Finalize (write metadata to DB)
|
309
|
-
logger.debug("finalising - START")
|
310
|
-
|
311
|
-
collection_state = db.get(CollectionStateV2, state_id)
|
312
|
-
collection_state.data["log"] = log_file_path.open("r").read()
|
313
|
-
collection_state.data["freeze"] = stdout_pip_freeze
|
314
|
-
collection_state.data["status"] = CollectionStatusV2.OK
|
315
|
-
flag_modified(collection_state, "data")
|
316
|
-
db.commit()
|
317
|
-
logger.debug("finalising - END")
|
318
|
-
logger.debug("END")
|
319
|
-
|
320
|
-
except Exception as collection_e:
|
321
|
-
# Delete corrupted package dir
|
322
|
-
if remove_venv_folder_upon_failure:
|
323
|
-
try:
|
324
|
-
logger.info(
|
325
|
-
f"Now delete remote folder {task_group.path}"
|
326
|
-
)
|
327
|
-
fractal_ssh.remove_folder(
|
328
|
-
folder=task_group.path,
|
329
|
-
safe_root=tasks_base_dir,
|
330
|
-
)
|
331
|
-
logger.info(
|
332
|
-
f"Deleted remoted folder {task_group.path}"
|
333
|
-
)
|
334
|
-
except Exception as e_rm:
|
335
|
-
logger.error(
|
336
|
-
"Removing folder failed. "
|
337
|
-
f"Original error:\n{str(e_rm)}"
|
338
|
-
)
|
339
|
-
else:
|
340
|
-
logger.info(
|
341
|
-
"Not trying to remove remote folder "
|
342
|
-
f"{task_group.path}."
|
343
|
-
)
|
344
|
-
_handle_failure(
|
345
|
-
state_id=state_id,
|
346
|
-
log_file_path=log_file_path,
|
347
|
-
logger_name=LOGGER_NAME,
|
348
|
-
exception=collection_e,
|
349
|
-
db=db,
|
350
|
-
task_group_id=task_group.id,
|
351
|
-
)
|
352
|
-
return
|
@@ -1,42 +0,0 @@
|
|
1
|
-
set -e
|
2
|
-
|
3
|
-
write_log(){
|
4
|
-
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
5
|
-
echo "[collect-task, $TIMESTAMP] $1"
|
6
|
-
}
|
7
|
-
|
8
|
-
|
9
|
-
# Variables to be filled within fractal-server
|
10
|
-
TASK_GROUP_DIR=__TASK_GROUP_DIR__
|
11
|
-
PACKAGE_ENV_DIR=__PACKAGE_ENV_DIR__
|
12
|
-
PYTHON=__PYTHON__
|
13
|
-
|
14
|
-
TIME_START=$(date +%s)
|
15
|
-
|
16
|
-
# Check that task-group and venv folders do not exist
|
17
|
-
for DIR_TO_BE_CHECKED in "$TASK_GROUP_DIR" "$PACKAGE_ENV_DIR";
|
18
|
-
do
|
19
|
-
if [ -d "$DIR_TO_BE_CHECKED" ]; then
|
20
|
-
write_log "ERROR: Folder $DIR_TO_BE_CHECKED already exists. Exit."
|
21
|
-
exit 1
|
22
|
-
fi
|
23
|
-
done
|
24
|
-
|
25
|
-
write_log "START mkdir -p $PACKAGE_ENV_DIR"
|
26
|
-
mkdir -p $PACKAGE_ENV_DIR
|
27
|
-
write_log "END mkdir -p $PACKAGE_ENV_DIR"
|
28
|
-
echo
|
29
|
-
|
30
|
-
|
31
|
-
# Create venv
|
32
|
-
write_log "START create venv in ${PACKAGE_ENV_DIR}"
|
33
|
-
"$PYTHON" -m venv "$PACKAGE_ENV_DIR" --copies
|
34
|
-
write_log "END create venv in ${PACKAGE_ENV_DIR}"
|
35
|
-
echo
|
36
|
-
|
37
|
-
# End
|
38
|
-
TIME_END=$(date +%s)
|
39
|
-
write_log "All good up to here."
|
40
|
-
write_log "Elapsed: $((TIME_END - TIME_START)) seconds"
|
41
|
-
write_log "Exit."
|
42
|
-
echo
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|