Skip to content

Commit 43f743f

Browse files
authored
Merge pull request #412 from nextcloud/feature/PSR-4/apps-info-and-config
add hability to read appInfos and settings exposed to the PSR-4 autoloader
2 parents 11bffe0 + f078c1b commit 43f743f

File tree

8 files changed

+766
-43
lines changed

8 files changed

+766
-43
lines changed

plugins/module_utils/app.py

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,25 @@
2323
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2424
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
2525

26+
from __future__ import annotations
2627
import json
2728
from typing import Union
2829
from ansible_collections.nextcloud.admin.plugins.module_utils.exceptions import (
2930
OccExceptions,
3031
AppExceptions,
32+
PhpInlineExceptions,
33+
PhpResultJsonException,
34+
AppPSR4InfosNotReadable,
35+
AppPSR4InfosUnavailable,
3136
)
32-
from ansible_collections.nextcloud.admin.plugins.module_utils.nc_tools import run_occ # type: ignore
37+
from ansible_collections.nextcloud.admin.plugins.module_utils.nc_tools import run_occ, run_php_inline # type: ignore
3338

3439

3540
class app:
36-
_update_version_available = "unchecked"
41+
_update_version_available = ""
3742
_path = None
43+
_autoloaded_infos = None
44+
_current_settings = None
3845

3946
def __init__(self, module, app_name: str):
4047
self.module = module
@@ -62,14 +69,14 @@ def __init__(self, module, app_name: str):
6269

6370
@property
6471
def update_version_available(self) -> Union[str, None]:
65-
if self._update_version_available == "unchecked":
72+
if self._update_version_available == "":
6673
_check_app_update = run_occ(
6774
self.module, ["app:update", "--showonly", self.app_name]
6875
)[1]
69-
if _check_app_update != "":
70-
result = _check_app_update.split()[-1]
71-
else:
76+
if _check_app_update == "" or "up-to-date" in _check_app_update:
7277
result = None
78+
else:
79+
result = _check_app_update.split()[-1]
7380
self._update_version_available = result
7481
return self._update_version_available
7582

@@ -84,18 +91,105 @@ def path(self) -> str:
8491
self._path = result
8592
return self._path
8693

87-
def infos(self):
88-
result = dict(
89-
name=self.app_name,
94+
def get_facts(self) -> dict[str, any]:
95+
facts = dict(
9096
state=self.state,
9197
is_shipped=self.shipped,
9298
)
9399
if self.state != "absent":
94-
result.update(update_available=self.update_available)
95-
result.update(version=self.version)
96-
result.update(version_available=self.update_version_available)
97-
result.update(app_path=self.path)
98-
return result
100+
facts.update(update_available=self.update_available)
101+
facts.update(version=self.version)
102+
facts.update(version_available=self.update_version_available)
103+
facts.update(app_path=self.path)
104+
return facts
105+
106+
@property
107+
def autoloaded_infos(self) -> dict:
108+
if self._autoloaded_infos is None:
109+
self._autoloaded_infos = self._get_autoloaded_infos()
110+
return self._autoloaded_infos
111+
112+
@property
113+
def infos(self) -> dict:
114+
return self.autoloaded_infos.get("appInfo")
115+
116+
@property
117+
def default_settings(self) -> dict:
118+
return self.autoloaded_infos["settings"]
119+
120+
def _get_autoloaded_infos(self) -> dict:
121+
"""
122+
Run inline php script that use the server autoloading system to inspect the app.
123+
return a dict that contains keys: appInfo, settings.
124+
setting can contain admin and personal default settings if any is available.
125+
"""
126+
php_script = f"""
127+
$appId = '{self.app_name}';
128+
// Get App PSR-4 infos
129+
$appManager = \\OC::$server->getAppManager();
130+
$appInfo = $appManager->getAppInfo($appId);
131+
$result = array(
132+
'appInfo' => $appInfo,
133+
'settings' => array()
134+
);
135+
foreach (['admin', 'personal'] as $section) {{
136+
if (!empty($appInfo['settings'][$section])) {{
137+
$className = $appInfo['settings'][$section][0];
138+
if (class_exists($className)) {{
139+
$settingsInstance = \\OC::$server->get($className);
140+
$form = $settingsInstance->getForm();
141+
142+
if (method_exists($form, 'getParams')) {{
143+
$result['settings'][$section] = $form->getParams();
144+
}} else {{
145+
$result['settings'][$section] = 'Unavailable';
146+
}}
147+
}} else {{
148+
$result['settings'][$section] = 'Settings not currently loaded';
149+
}}
150+
}}
151+
}}
152+
"""
153+
try:
154+
result = run_php_inline(self.module, php_script)
155+
# force the 'settings' key to be dict if it is empty
156+
if isinstance(result["settings"], list) and not result["settings"]:
157+
result["settings"] = {}
158+
return result
159+
except PhpResultJsonException as e:
160+
raise AppPSR4InfosNotReadable(app_name=self.app_name, **e.__dict__)
161+
except PhpInlineExceptions as e:
162+
raise AppPSR4InfosUnavailable(app_name=self.app_name, **e.__dict__)
163+
164+
@property
165+
def current_settings(self) -> dict[str, any]:
166+
if self._current_settings is None:
167+
self._current_settings = self._get_current_settings()
168+
return self._current_settings
169+
170+
def _get_current_settings(self) -> dict[str, any]:
171+
"""
172+
Returns the current configured settings for the app, using `occ config:list <app>`.
173+
"""
174+
non_informative = ["installed_version", "enabled", "types"]
175+
try:
176+
raw_config = run_occ(self.module, ["config:list", self.app_name])[1]
177+
occ_config = json.loads(raw_config).get("apps", {}).get(self.app_name, {})
178+
if isinstance(occ_config, list) and not occ_config:
179+
return {}
180+
else:
181+
return {k: v for k, v in occ_config.items() if k not in non_informative}
182+
except OccExceptions as e:
183+
self.module.warn(
184+
f"Failed to get current config for {self.app_name}: {e.stderr}"
185+
)
186+
return {}
187+
except Exception as e:
188+
raise AppExceptions(
189+
msg=f"Unexpected error in reading configured values. {str(e)}",
190+
app_name=self.app_name,
191+
**e.__dict__,
192+
)
99193

100194
def install(self, enable: bool = True):
101195
occ_args = ["app:install", self.app_name]

plugins/module_utils/exceptions.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ class OccAuthenticationException(OccExceptions):
111111
pass
112112

113113

114+
class PhpInlineExceptions(NextcloudException):
115+
"""Base exception for php run in the nextcloud server."""
116+
117+
pass
118+
119+
120+
class PhpScriptException(PhpInlineExceptions):
121+
"""Raised when a php script return an error."""
122+
123+
pass
124+
125+
126+
class PhpResultJsonException(PhpInlineExceptions):
127+
"""Exception raised for php script results failing python's json deserialization."""
128+
129+
pass
130+
131+
114132
class AppExceptions(NextcloudException):
115133
"""
116134
Base exception for app-related errors in Nextcloud.
@@ -134,15 +152,15 @@ def __init__(self, **kwargs):
134152
super().__init__(**kwargs)
135153

136154

137-
class AppFormNotAvailable(AppExceptions):
138-
"""Raised when an app does not expose an admin form via its Settings API."""
155+
class AppPSR4InfosUnavailable(AppExceptions):
156+
"""Raised when an app does not expose proper PSR4 Infos"""
139157

140158
def __init__(self, **kwargs):
141-
super().__init__(dft_msg="Admin form not available", **kwargs)
159+
super().__init__(dft_msg="PSR-4 infos not available", **kwargs)
142160

143161

144-
class AppFormInvalidJson(AppExceptions):
162+
class AppPSR4InfosNotReadable(AppExceptions):
145163
"""Raised when an app's getForm() method returns invalid JSON."""
146164

147165
def __init__(self, **kwargs):
148-
super().__init__(dft_msg="Invalid JSON returned by getForm()", **kwargs)
166+
super().__init__(dft_msg="PSR-4 infos are invalid JSON", **kwargs)

plugins/module_utils/nc_tools.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
import os
2727
from multiprocessing import Process, Pipe
28+
import json
29+
from textwrap import dedent
2830
from ansible_collections.nextcloud.admin.plugins.module_utils.exceptions import (
2931
OccExceptions,
3032
OccAuthenticationException,
@@ -33,6 +35,9 @@
3335
OccNotEnoughArguments,
3436
OccOptionRequiresValue,
3537
OccOptionNotDefined,
38+
PhpInlineExceptions,
39+
PhpScriptException,
40+
PhpResultJsonException,
3641
)
3742
from shlex import shlex
3843
import copy
@@ -164,3 +169,52 @@ def run_occ(
164169
raise OccExceptions(full_command, **result)
165170

166171
return result["rc"], result["stdout"], result["stderr"], maintenanceMode
172+
173+
174+
def run_php_inline(module, php_code: str) -> dict:
175+
"""
176+
Interface with Nextcloud server through ad-hoc php scripts.
177+
The script must define the var $result that will be exported into a python dict
178+
"""
179+
if isinstance(php_code, list):
180+
php_code = "\n".join(php_code)
181+
elif isinstance(php_code, str):
182+
php_code = dedent(php_code).strip()
183+
else:
184+
raise Exception("php_code must be a list or a string")
185+
186+
full_code = f"""
187+
require_once 'lib/base.php';
188+
{php_code}
189+
if (!isset($result)) {{
190+
$result = null;
191+
}}
192+
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
193+
"""
194+
rc, stdout, stderr = module.run_command(
195+
[module.params.get("php_runtime"), "-r", full_code],
196+
cwd=module.params.get("nextcloud_path"),
197+
)
198+
if rc != 0:
199+
raise PhpScriptException(
200+
msg="Failed to run the given php script.",
201+
stderr=stderr,
202+
stdout=stdout,
203+
rc=rc,
204+
php_script=full_code,
205+
)
206+
207+
stdout = stdout.strip()
208+
try:
209+
result = json.loads(stdout) if stdout and stdout != "null" else None
210+
return result
211+
except json.JSONDecodeError as e:
212+
raise PhpResultJsonException(
213+
msg="Failed to decode JSON from php script stdout",
214+
stderr=stderr,
215+
stdout=stdout,
216+
rc=rc,
217+
JSONDecodeError=str(e),
218+
)
219+
except Exception:
220+
raise PhpInlineExceptions(stderr=stderr, stdout=stdout, rc=rc)

plugins/modules/app.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
- Attention! Use the application `technical` name (available at the end of the app's page url).
4848
type: str
4949
required: true
50+
aliases:
51+
- "id"
5052
5153
state:
5254
description:
@@ -74,7 +76,7 @@
7476
EXAMPLES = r"""
7577
- name: Enable preinstalled contact application
7678
nextcloud.admin.app:
77-
name: contacts
79+
id: contacts
7880
state: present
7981
nextcloud_path: /var/lib/www/nextcloud
8082
@@ -97,9 +99,17 @@
9799
returned: always
98100
type: str
99101
version:
100-
description: App version present of updated on the server.
102+
description: App version present or updated on the server.
101103
returned: always
102104
type: str
105+
miscellaneous:
106+
description: Informative messages sent by the server during app operation.
107+
returned: when not empty
108+
type: list
109+
contains:
110+
misc:
111+
description: Something reported by the server.
112+
type: str
103113
"""
104114

105115
from ansible.module_utils.basic import AnsibleModule
@@ -112,7 +122,7 @@
112122
)
113123

114124
module_args_spec = dict(
115-
name=dict(type="str", required=True),
125+
name=dict(type="str", aliases=["id"], required=True),
116126
state=dict(
117127
type="str",
118128
required=False,

0 commit comments

Comments
 (0)