orchestrator-lso 2.1.0__py3-none-any.whl → 2.2.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.
- lso/__init__.py +1 -1
- lso/execute.py +31 -3
- lso/routes/execute.py +14 -12
- lso/schema.py +50 -0
- lso/tasks.py +29 -50
- lso/utils.py +13 -0
- {orchestrator_lso-2.1.0.dist-info → orchestrator_lso-2.2.0.dist-info}/METADATA +7 -7
- orchestrator_lso-2.2.0.dist-info/RECORD +18 -0
- {orchestrator_lso-2.1.0.dist-info → orchestrator_lso-2.2.0.dist-info}/WHEEL +1 -1
- orchestrator_lso-2.1.0.dist-info/RECORD +0 -17
- {orchestrator_lso-2.1.0.dist-info → orchestrator_lso-2.2.0.dist-info}/licenses/LICENSE +0 -0
lso/__init__.py
CHANGED
lso/execute.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Module for handling the execution of arbitrary executables."""
|
|
2
2
|
|
|
3
|
+
import subprocess # noqa: S404
|
|
3
4
|
import uuid
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from pydantic import HttpUrl
|
|
7
8
|
|
|
8
9
|
from lso.config import ExecutorType, settings
|
|
10
|
+
from lso.schema import ExecutionResult
|
|
9
11
|
from lso.tasks import run_executable_proc_task
|
|
10
12
|
from lso.utils import get_thread_pool
|
|
11
13
|
|
|
@@ -15,17 +17,43 @@ def get_executable_path(executable_name: Path) -> Path:
|
|
|
15
17
|
return Path(settings.EXECUTABLES_ROOT_DIR) / executable_name
|
|
16
18
|
|
|
17
19
|
|
|
18
|
-
def
|
|
20
|
+
def run_executable_async(executable_path: Path, args: list[str], callback: HttpUrl | None) -> uuid.UUID:
|
|
19
21
|
"""Dispatch the task for executing an arbitrary executable remotely.
|
|
20
22
|
|
|
21
23
|
Uses a ThreadPoolExecutor (for local execution) or a Celery worker (for distributed tasks).
|
|
22
24
|
"""
|
|
23
25
|
job_id = uuid.uuid4()
|
|
26
|
+
callback_url = str(callback) if callback else None
|
|
24
27
|
if settings.EXECUTOR == ExecutorType.THREADPOOL:
|
|
25
28
|
executor = get_thread_pool()
|
|
26
|
-
future = executor.submit(run_executable_proc_task, str(job_id), str(executable_path), args,
|
|
29
|
+
future = executor.submit(run_executable_proc_task, str(job_id), str(executable_path), args, callback_url)
|
|
27
30
|
if settings.TESTING:
|
|
28
31
|
future.result()
|
|
29
32
|
elif settings.EXECUTOR == ExecutorType.WORKER:
|
|
30
|
-
run_executable_proc_task.delay(str(job_id), str(executable_path), args,
|
|
33
|
+
run_executable_proc_task.delay(str(job_id), str(executable_path), args, callback_url)
|
|
31
34
|
return job_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_executable_sync(executable_path: str, args: list[str]) -> ExecutionResult:
|
|
38
|
+
"""Run the given executable synchronously and return the result."""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run( # noqa: S603
|
|
41
|
+
[executable_path, *args],
|
|
42
|
+
text=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
timeout=settings.EXECUTABLE_TIMEOUT_SEC,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|
|
47
|
+
output = result.stdout + result.stderr
|
|
48
|
+
return_code = result.returncode
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
output = "Execution timed out."
|
|
51
|
+
return_code = -1
|
|
52
|
+
except Exception as e: # noqa: BLE001
|
|
53
|
+
output = str(e)
|
|
54
|
+
return_code = -1
|
|
55
|
+
|
|
56
|
+
return ExecutionResult( # type: ignore[call-arg]
|
|
57
|
+
output=output,
|
|
58
|
+
return_code=return_code,
|
|
59
|
+
)
|
lso/routes/execute.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""FastAPI route for running arbitrary executables."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import uuid
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Annotated
|
|
@@ -7,7 +8,8 @@ from typing import Annotated
|
|
|
7
8
|
from fastapi import APIRouter, HTTPException, status
|
|
8
9
|
from pydantic import AfterValidator, BaseModel, HttpUrl
|
|
9
10
|
|
|
10
|
-
from lso.execute import get_executable_path,
|
|
11
|
+
from lso.execute import get_executable_path, run_executable_async, run_executable_sync
|
|
12
|
+
from lso.schema import ExecutableRunResponse
|
|
11
13
|
|
|
12
14
|
router = APIRouter()
|
|
13
15
|
|
|
@@ -35,17 +37,17 @@ class ExecutableRunParams(BaseModel):
|
|
|
35
37
|
|
|
36
38
|
executable_name: ExecutableName
|
|
37
39
|
args: list[str] = []
|
|
38
|
-
callback: HttpUrl
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class ExecutableRunResponse(BaseModel):
|
|
42
|
-
"""Response for running an arbitrary executable."""
|
|
43
|
-
|
|
44
|
-
job_id: uuid.UUID
|
|
40
|
+
callback: HttpUrl | None = None
|
|
41
|
+
is_async: bool = True
|
|
45
42
|
|
|
46
43
|
|
|
47
44
|
@router.post("/", response_model=ExecutableRunResponse, status_code=status.HTTP_201_CREATED)
|
|
48
|
-
def run_executable_endpoint(params: ExecutableRunParams) -> ExecutableRunResponse:
|
|
49
|
-
"""Dispatch
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
async def run_executable_endpoint(params: ExecutableRunParams) -> ExecutableRunResponse:
|
|
46
|
+
"""Dispatch a task to run an arbitrary executable."""
|
|
47
|
+
if params.is_async:
|
|
48
|
+
job_id = run_executable_async(params.executable_name, params.args, params.callback)
|
|
49
|
+
return ExecutableRunResponse(job_id=job_id)
|
|
50
|
+
|
|
51
|
+
job_id = uuid.uuid4()
|
|
52
|
+
result = await asyncio.to_thread(run_executable_sync, str(params.executable_name), params.args)
|
|
53
|
+
return ExecutableRunResponse(job_id=job_id, result=result)
|
lso/schema.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2024-2025 GÉANT Vereniging.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""Module for defining the schema for running arbitrary executables."""
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, model_validator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JobStatus(StrEnum):
|
|
23
|
+
"""Enumeration of possible job statuses."""
|
|
24
|
+
|
|
25
|
+
SUCCESSFUL = "successful"
|
|
26
|
+
FAILED = "failed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExecutionResult(BaseModel):
|
|
30
|
+
"""Model for capturing the result of an executable run."""
|
|
31
|
+
|
|
32
|
+
output: str
|
|
33
|
+
return_code: int
|
|
34
|
+
status: JobStatus
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="before")
|
|
37
|
+
def populate_status(cls, values: dict) -> dict:
|
|
38
|
+
"""Set the status based on the return code."""
|
|
39
|
+
rc = values.get("return_code")
|
|
40
|
+
if rc is not None:
|
|
41
|
+
values["status"] = JobStatus.SUCCESSFUL if rc == 0 else JobStatus.FAILED
|
|
42
|
+
|
|
43
|
+
return values
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExecutableRunResponse(BaseModel):
|
|
47
|
+
"""Response for running an arbitrary executable."""
|
|
48
|
+
|
|
49
|
+
job_id: uuid.UUID
|
|
50
|
+
result: ExecutionResult | None = None
|
lso/tasks.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright
|
|
1
|
+
# Copyright 2024-2025 GÉANT Vereniging.
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -18,15 +18,15 @@ the results to a specified callback URL.
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import logging
|
|
21
|
-
import subprocess # noqa: S404
|
|
22
|
-
from enum import StrEnum
|
|
23
21
|
from typing import Any
|
|
22
|
+
from uuid import UUID
|
|
24
23
|
|
|
25
24
|
import ansible_runner
|
|
26
25
|
import requests
|
|
27
26
|
from starlette import status
|
|
28
27
|
|
|
29
28
|
from lso.config import settings
|
|
29
|
+
from lso.schema import ExecutableRunResponse
|
|
30
30
|
from lso.worker import RUN_EXECUTABLE, RUN_PLAYBOOK, celery
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
@@ -36,13 +36,6 @@ class CallbackFailedError(Exception):
|
|
|
36
36
|
"""Exception raised when a callback url can't be reached."""
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
class JobStatus(StrEnum):
|
|
40
|
-
"""Enumeration of possible job statuses."""
|
|
41
|
-
|
|
42
|
-
SUCCESSFUL = "successful"
|
|
43
|
-
FAILED = "failed"
|
|
44
|
-
|
|
45
|
-
|
|
46
39
|
@celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
|
|
47
40
|
def run_playbook_proc_task(
|
|
48
41
|
job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
|
|
@@ -74,48 +67,34 @@ def run_playbook_proc_task(
|
|
|
74
67
|
|
|
75
68
|
|
|
76
69
|
@celery.task(name=RUN_EXECUTABLE) # type: ignore[misc]
|
|
77
|
-
def run_executable_proc_task(job_id: str, executable_path: str, args: list[str], callback: str) -> None:
|
|
70
|
+
def run_executable_proc_task(job_id: str, executable_path: str, args: list[str], callback: str | None) -> None:
|
|
78
71
|
"""Celery task to run an arbitrary executable and notify via callback.
|
|
79
72
|
|
|
80
|
-
Executes the executable with the provided arguments and posts back the
|
|
73
|
+
Executes the executable with the provided arguments and posts back the result if a callback URL is provided.
|
|
81
74
|
"""
|
|
75
|
+
from lso.execute import run_executable_sync # noqa: PLC0415
|
|
76
|
+
|
|
82
77
|
msg = f"Executing executable: {executable_path} with args: {args}, callback: {callback}"
|
|
83
78
|
logger.info(msg)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
def _raise_callback_error(message: str, error: Exception | None = None) -> None:
|
|
109
|
-
if error:
|
|
110
|
-
raise CallbackFailedError(message) from error
|
|
111
|
-
raise CallbackFailedError(message)
|
|
112
|
-
|
|
113
|
-
try:
|
|
114
|
-
response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
|
|
115
|
-
if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
|
|
116
|
-
msg = f"Callback failed: {response.text}, url: {callback}"
|
|
117
|
-
_raise_callback_error(msg)
|
|
118
|
-
except Exception as e:
|
|
119
|
-
error_msg = f"Callback error: {e}"
|
|
120
|
-
logger.exception(error_msg)
|
|
121
|
-
_raise_callback_error(error_msg, e)
|
|
79
|
+
result = run_executable_sync(executable_path, args)
|
|
80
|
+
|
|
81
|
+
if callback:
|
|
82
|
+
payload = ExecutableRunResponse(
|
|
83
|
+
job_id=UUID(job_id),
|
|
84
|
+
result=result,
|
|
85
|
+
).model_dump(mode="json")
|
|
86
|
+
|
|
87
|
+
def _raise_callback_error(message: str, error: Exception | None = None) -> None:
|
|
88
|
+
if error:
|
|
89
|
+
raise CallbackFailedError(message) from error
|
|
90
|
+
raise CallbackFailedError(message)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
|
|
94
|
+
if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
|
|
95
|
+
msg = f"Callback failed: {response.text}, url: {callback}"
|
|
96
|
+
_raise_callback_error(msg)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
error_msg = f"Callback error: {e}"
|
|
99
|
+
logger.exception(error_msg)
|
|
100
|
+
_raise_callback_error(error_msg, e)
|
lso/utils.py
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# Copyright 2024-2025 GÉANT Vereniging.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
1
14
|
"""Utility functions for the LSO package."""
|
|
2
15
|
|
|
3
16
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: orchestrator-lso
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: LSO, an API for remotely running Ansible playbooks.
|
|
5
5
|
Author-email: GÉANT Orchestration and Automation Team <goat@geant.org>
|
|
6
6
|
Requires-Python: >=3.11,<3.13
|
|
@@ -26,14 +26,14 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
26
26
|
Classifier: Programming Language :: Python :: 3.11
|
|
27
27
|
Classifier: Programming Language :: Python :: 3.12
|
|
28
28
|
License-File: LICENSE
|
|
29
|
-
Requires-Dist: ansible-runner==2.4.
|
|
29
|
+
Requires-Dist: ansible-runner==2.4.1
|
|
30
30
|
Requires-Dist: ansible==10.7.0
|
|
31
|
-
Requires-Dist: fastapi==0.115.
|
|
31
|
+
Requires-Dist: fastapi==0.115.12
|
|
32
32
|
Requires-Dist: httpx==0.28.1
|
|
33
|
-
Requires-Dist: uvicorn[standard]==0.34.
|
|
34
|
-
Requires-Dist: requests==2.32.
|
|
35
|
-
Requires-Dist: pydantic-settings==2.
|
|
36
|
-
Requires-Dist: celery==5.
|
|
33
|
+
Requires-Dist: uvicorn[standard]==0.34.3
|
|
34
|
+
Requires-Dist: requests==2.32.4
|
|
35
|
+
Requires-Dist: pydantic-settings==2.9.1
|
|
36
|
+
Requires-Dist: celery==5.5.3
|
|
37
37
|
Requires-Dist: redis==5.2.1
|
|
38
38
|
Requires-Dist: types-setuptools ; extra == "dev"
|
|
39
39
|
Requires-Dist: types-requests ; extra == "dev"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
lso/__init__.py,sha256=jBH_2-SwYCAcTSYY-AZXATzRzEMrZh2tQVHLM12XrQ0,1589
|
|
2
|
+
lso/app.py,sha256=WDtlmjELIeFA437j-WPfqBQf6QT_l35LEABbEosHlqY,775
|
|
3
|
+
lso/config.py,sha256=fceYs85UGN76GRyGs4x6TiKhktv-ykjc6-WocPRmFGY,1639
|
|
4
|
+
lso/environment.py,sha256=iZ3DmsSKAC5a7VNL-HfJOJZ0sQwUMf7ZzNGC34B2CG0,1771
|
|
5
|
+
lso/execute.py,sha256=G1AacmmN46mAzPWpnoI31RhXoWN6WEZUFr_P8Z_9Ngw,2139
|
|
6
|
+
lso/playbook.py,sha256=NHCeVttY5u1xGAdty954SZEYLcZEGBbBfjPuvBCgUwI,2409
|
|
7
|
+
lso/schema.py,sha256=-QCBaUcfMH1MaEEDaGzZoGJn-NcQLbH4dfrLxFYoXXs,1513
|
|
8
|
+
lso/tasks.py,sha256=tM26bAiEviaKAVwiHkKr6od3N00covBAAXTFVxIvdY8,4030
|
|
9
|
+
lso/utils.py,sha256=eVKyYRtdu_yPkbUQqDJlCKlCPAgc0dFxG1E1kY2Qsao,1043
|
|
10
|
+
lso/worker.py,sha256=61TXUefv8mGMq9LsD419r0O9Qxpa-WAtgSfu7SPEg44,1727
|
|
11
|
+
lso/routes/__init__.py,sha256=1kRrth9zkFgmj6LChujieYJq5cjIETeTGXa1G70pduk,639
|
|
12
|
+
lso/routes/default.py,sha256=a7STN1BJyFVizXUzmqKuADO0fpE1SHun-PzaZ-jx1wU,1438
|
|
13
|
+
lso/routes/execute.py,sha256=zwObIRB2aSlCikpmHQ6TJFcZFCjEPFC4R7oIu1UA7R8,2122
|
|
14
|
+
lso/routes/playbook.py,sha256=YNPZGuJXh5JkDWJ-sVRVE-cTg9eEm6BPjNThFLDqxeM,4904
|
|
15
|
+
orchestrator_lso-2.2.0.dist-info/licenses/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
|
|
16
|
+
orchestrator_lso-2.2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
17
|
+
orchestrator_lso-2.2.0.dist-info/METADATA,sha256=WU3uePo1qeayracQz2hlaj90m9ZXhPcjoinRCy5F15o,6352
|
|
18
|
+
orchestrator_lso-2.2.0.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
lso/__init__.py,sha256=rvLpPFoeQhGsSfP4vjcdFBmHwJJhPVOzjwaMt-iNUfQ,1589
|
|
2
|
-
lso/app.py,sha256=WDtlmjELIeFA437j-WPfqBQf6QT_l35LEABbEosHlqY,775
|
|
3
|
-
lso/config.py,sha256=fceYs85UGN76GRyGs4x6TiKhktv-ykjc6-WocPRmFGY,1639
|
|
4
|
-
lso/environment.py,sha256=iZ3DmsSKAC5a7VNL-HfJOJZ0sQwUMf7ZzNGC34B2CG0,1771
|
|
5
|
-
lso/execute.py,sha256=BZeR0NYLZaLE28K7IcQ6JA7opdJOZrVK7rsSZyArsBo,1208
|
|
6
|
-
lso/playbook.py,sha256=NHCeVttY5u1xGAdty954SZEYLcZEGBbBfjPuvBCgUwI,2409
|
|
7
|
-
lso/tasks.py,sha256=ToHEDEqGipMUYk7sY8qZzrYV5dWwM8nekl2OGiEVVko,4509
|
|
8
|
-
lso/utils.py,sha256=8B3_p5nliD0KQcU2dLRdbEa7LvDqtNH0ZViMJ4yB4gE,458
|
|
9
|
-
lso/worker.py,sha256=61TXUefv8mGMq9LsD419r0O9Qxpa-WAtgSfu7SPEg44,1727
|
|
10
|
-
lso/routes/__init__.py,sha256=1kRrth9zkFgmj6LChujieYJq5cjIETeTGXa1G70pduk,639
|
|
11
|
-
lso/routes/default.py,sha256=a7STN1BJyFVizXUzmqKuADO0fpE1SHun-PzaZ-jx1wU,1438
|
|
12
|
-
lso/routes/execute.py,sha256=8WACuI8QN8piI4pkHNtxARB8l5QcBKWSZ3qE_LWqBfU,1896
|
|
13
|
-
lso/routes/playbook.py,sha256=YNPZGuJXh5JkDWJ-sVRVE-cTg9eEm6BPjNThFLDqxeM,4904
|
|
14
|
-
orchestrator_lso-2.1.0.dist-info/licenses/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
|
|
15
|
-
orchestrator_lso-2.1.0.dist-info/WHEEL,sha256=_2ozNFCLWc93bK4WKHCO-eDUENDlo-dgc9cU3qokYO4,82
|
|
16
|
-
orchestrator_lso-2.1.0.dist-info/METADATA,sha256=TlUqzxd8VwnU0I7RgS4xFxi2XfqAM2yabT3a0Zo2Hcs,6351
|
|
17
|
-
orchestrator_lso-2.1.0.dist-info/RECORD,,
|
|
File without changes
|