Skip to content

Commit ab6b8f6

Browse files
authored
Merge pull request #232 from kysrpex/themes_symlink_clone_module
Template subdomain static dirs using an Ansible module
2 parents 54d7d45 + 5148452 commit ab6b8f6

File tree

6 files changed

+313
-103
lines changed

6 files changed

+313
-103
lines changed

defaults/main.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -527,17 +527,7 @@ galaxy_themes_subdomains: []
527527
# rgba(165, 204, 210, 0.9676562309265136) 74%,
528528
# rgb(228, 195, 131) 92%,
529529
# rgb(203, 119, 79) 100%)
530-
galaxy_themes_static_keys:
531-
static_dir: ""
532-
static_images_dir: "images/"
533-
static_scripts_dir: "scripts/"
534-
static_welcome_html: "welcome.html/"
535-
static_favicon_dir: "favicon.ico"
536-
static_robots_txt: "robots.txt"
537530
galaxy_themes_static_path: "{{ galaxy_root }}/server"
538-
galaxy_themes_static_dir: "{{ galaxy_root }}/server/static"
539-
galaxy_themes_symlinks: true
540-
galaxy_themes_symlinks_no_log: false # Hides extended logs for the symlink task in static/dist
541531
galaxy_themes_welcome_url_prefix: https://usegalaxy-eu.github.io/index-
542532
galaxy_themes_default_welcome: https://galaxyproject.org
543533
galaxy_themes_ansible_file_path: files/galaxy/static

library/symlink_clone.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/python
2+
"""Ansible module that clones a directory tree using symlinks.
3+
4+
Clone a directory tree using symlinks. Creates the target directory if it does
5+
not exist, otherwise merges the source tree with the target directory tree.
6+
Only files are symlinked; subdirectories within the target directory are
7+
created as normal directories.
8+
9+
Permissions and ownership of the target directory and its subdirectories can
10+
be set using the standard file attributes of Ansible. This does not affect the
11+
symlinked files, which retain their original permissions and ownership.
12+
13+
For example, if the source directory looks like
14+
15+
```
16+
source/
17+
├── dir1
18+
│ ├── file1.txt
19+
│ └── file2.txt
20+
└── dir2
21+
```
22+
23+
then after running the module, the target directory will look like
24+
25+
```
26+
target/
27+
├── dir1
28+
│ ├── file1.txt -> /absolute/path/to/source/dir1/file1.txt
29+
│ └── file2.txt -> /absolute/path/to/source/dir1/file2.txt
30+
└── dir2
31+
```
32+
"""
33+
from __future__ import annotations
34+
35+
import filecmp
36+
import grp
37+
import os
38+
import pwd
39+
import shutil
40+
import tempfile
41+
from pathlib import Path
42+
43+
from ansible.module_utils.basic import AnsibleModule
44+
45+
46+
def merge_using_symlinks(src: Path, dst: Path) -> None:
47+
"""Merge a directory tree with a target directory using symlinks.
48+
49+
Creates the target directory if it does not exist. Only files are
50+
symlinked; directories are created as normal directories. Existing files
51+
in the target directory (or any of its subdirectories) are replaced,
52+
as well as symlinks to directories.
53+
"""
54+
for root, dirs, files in os.walk(src):
55+
root = Path(root)
56+
dirs = [Path(d) for d in dirs]
57+
files = [Path(f) for f in files]
58+
59+
rel_path = root.relative_to(src)
60+
dst_dir = dst / rel_path
61+
if not dst_dir.exists():
62+
dst_dir.mkdir()
63+
shutil.copystat(root, dst_dir)
64+
65+
for name in dirs + files:
66+
src_path = root / name
67+
dst_path = dst_dir / name
68+
if src_path.is_dir():
69+
if dst_path.is_symlink():
70+
dst_path.unlink()
71+
if not dst_path.exists():
72+
dst_path.mkdir()
73+
shutil.copystat(src_path, dst_path)
74+
else:
75+
if dst_path.exists():
76+
dst_path.unlink()
77+
dst_path.symlink_to(src_path.absolute())
78+
79+
80+
def compare_permissions(src: Path, dest: Path) -> bool:
81+
"""Compare permissions of a source and destination directory tree.
82+
83+
Compares permissions, ownership, and type (file or directory) of all
84+
contents of the source directory tree with the ones in the destination
85+
directory tree. Extra files in the destination directory are ignored.
86+
"""
87+
for root, dirs, files in os.walk(src, followlinks=False):
88+
root = Path(root)
89+
dirs = [Path(d) for d in dirs]
90+
files = [Path(f) for f in files]
91+
92+
for name in [root] + dirs + files:
93+
src_path = root / name if name != root else root
94+
dst_path = dest / src_path.relative_to(src)
95+
96+
if src_path.is_symlink() or dst_path.is_symlink():
97+
continue
98+
99+
src_stat = src_path.lstat()
100+
dst_stat = dst_path.lstat()
101+
if src_path.is_dir() != dst_path.is_dir():
102+
return True
103+
if src_stat.st_mode != dst_stat.st_mode:
104+
return True
105+
if src_stat.st_uid != dst_stat.st_uid:
106+
return True
107+
if src_stat.st_gid != dst_stat.st_gid:
108+
return True
109+
110+
return False
111+
112+
113+
def set_permissions(
114+
path: Path, owner: int | None, group: int | None, mode: int | None
115+
) -> None:
116+
"""Set permissions on a directory recursively, ignoring symlinks.
117+
118+
Ignores executable bits for files.
119+
120+
Warning: This function does not handle SELinux contexts or extended
121+
attributes.
122+
"""
123+
for root, dirs, files in os.walk(path, followlinks=False):
124+
root = Path(root)
125+
dirs = [Path(d) for d in dirs]
126+
files = [Path(f) for f in files]
127+
128+
for name in [root] + dirs + files:
129+
path = root / name if name != root else root
130+
if path.is_symlink():
131+
continue
132+
if mode is not None:
133+
if path.is_file():
134+
path.chmod(
135+
mode & ~0o111
136+
) # remove executable bits for files
137+
else:
138+
path.chmod(mode)
139+
if owner is not None or group is not None:
140+
os.chown(
141+
path,
142+
owner if owner is not None else -1,
143+
group if group is not None else -1,
144+
)
145+
146+
147+
def run_module():
148+
"""Run the Ansible module."""
149+
module = AnsibleModule(
150+
argument_spec=dict(
151+
src=dict(type="path", required=True),
152+
path=dict(type="path", required=True, aliases=["dest", "name"]),
153+
state=dict(
154+
type="str",
155+
required=False,
156+
choices=["directory"],
157+
default="directory",
158+
),
159+
),
160+
add_file_common_args=True,
161+
supports_check_mode=False,
162+
)
163+
164+
# load common file arguments
165+
file_args = module.load_file_common_arguments(
166+
module.params, path=module.params["path"]
167+
)
168+
module.params.update(file_args)
169+
170+
path_info = module.add_path_info(dict(path=module.params["path"]))
171+
result = {
172+
"changed": False,
173+
"src": module.params["src"],
174+
**path_info,
175+
"diff": {
176+
"before": {**path_info},
177+
"after": {**path_info},
178+
},
179+
}
180+
181+
# validate source
182+
source = Path(module.params["src"])
183+
if source.exists() and not source.is_dir():
184+
module.fail_json(
185+
msg=f"{source} exists but it is not a directory", **result
186+
)
187+
elif not source.exists():
188+
module.fail_json(
189+
msg=f"source directory {source} does not exist", **result
190+
)
191+
192+
# validate target
193+
path = Path(module.params["path"])
194+
if not path.parent.exists():
195+
module.fail_json(
196+
msg=f"parent directory {path.parent} does not exist", **result
197+
)
198+
if path.exists() and not path.is_dir():
199+
module.fail_json(msg=f"{path} exists and is not a directory", **result)
200+
201+
source = Path(module.params["src"])
202+
target = Path(module.params["path"])
203+
# determine if anything will change after merging
204+
with tempfile.TemporaryDirectory(
205+
dir=path.parent, prefix=".ansible_tmp_"
206+
) as temp_dir:
207+
temp_path = Path(temp_dir)
208+
209+
# clone using symlinks
210+
merge_using_symlinks(source, temp_path)
211+
mode = (
212+
int(module.params["mode"], 8)
213+
if not isinstance(module.params["mode"], int)
214+
else module.params["mode"]
215+
)
216+
owner = (
217+
module.params["owner"]
218+
if isinstance(module.params["owner"], int)
219+
else pwd.getpwnam(module.params["owner"]).pw_uid
220+
)
221+
group = (
222+
module.params["group"]
223+
if isinstance(module.params["group"], int)
224+
else grp.getgrnam(module.params["group"]).gr_gid
225+
)
226+
if mode is not None or owner is not None or group is not None:
227+
set_permissions(temp_path, owner=owner, group=group, mode=mode)
228+
229+
# determine if anything was changed
230+
if target.exists():
231+
comparison = filecmp.dircmp(temp_path, target)
232+
permissions = compare_permissions(temp_path, target)
233+
changed = bool(
234+
comparison.left_only
235+
# or comparison.right_only # target contain have extra files
236+
or comparison.diff_files
237+
or comparison.funny_files
238+
or permissions
239+
)
240+
else:
241+
changed = True
242+
# merge source with target if anything changed
243+
if changed:
244+
merge_using_symlinks(source, target)
245+
mode = (
246+
int(module.params["mode"], 8)
247+
if not isinstance(module.params["mode"], int)
248+
else module.params["mode"]
249+
)
250+
owner = (
251+
module.params["owner"]
252+
if isinstance(module.params["owner"], int)
253+
else pwd.getpwnam(module.params["owner"]).pw_uid
254+
)
255+
group = (
256+
module.params["group"]
257+
if isinstance(module.params["group"], int)
258+
else grp.getgrnam(module.params["group"]).gr_gid
259+
)
260+
if mode is not None or owner is not None or group is not None:
261+
set_permissions(target, owner=owner, group=group, mode=mode)
262+
result["changed"] = True
263+
264+
path_info = module.add_path_info(dict(path=module.params["path"]))
265+
result.update(path_info)
266+
result["diff"]["after"].update(path_info)
267+
268+
# adjust attributes
269+
result["changed"] = module.set_fs_attributes_if_different(
270+
file_args, changed=result["changed"], diff=result["diff"]
271+
)
272+
path_info = module.add_path_info(dict(path=module.params["path"]))
273+
result.update(path_info)
274+
result["diff"]["after"].update(path_info)
275+
276+
module.exit_json(**result)
277+
278+
279+
if __name__ == "__main__":
280+
run_module()

tasks/copy_static_files.yml

Lines changed: 0 additions & 37 deletions
This file was deleted.

tasks/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
tags:
162162
- galaxy_manage_cleanup
163163

164-
- name: Inlcude static directory setup
164+
- name: Include static directory setup
165165
ansible.builtin.include_tasks: static_dirs.yml
166166
when: galaxy_manage_subdomain_static
167167
tags:

tasks/static_dirs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
group: "{{ __galaxy_privsep_user_group }}"
1616
mode: '0644'
1717

18-
- name: Include create subdomain static dirs and copy static files
18+
- name: Include create subdomain static dirs and link/copy static files
1919
ansible.builtin.include_tasks: static_subdomain_dirs.yml
2020
loop: "{{ galaxy_themes_subdomains if galaxy_themes_subdomains | length or \
2121
galaxy_manage_subdomain_static else [] }}"

0 commit comments

Comments
 (0)