mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-07-07 09:05:15 +02:00
General optimations and refactorings in preparation for simpleicon role implementation
This commit is contained in:
parent
eed72368c1
commit
cfeb8a5bf8
@ -1,3 +1,4 @@
|
|||||||
[defaults]
|
[defaults]
|
||||||
lookup_plugins = ./lookup_plugins
|
lookup_plugins = ./lookup_plugins
|
||||||
filter_plugins = ./filter_plugins
|
filter_plugins = ./filter_plugins
|
||||||
|
module_utils = ./module_utils
|
144
cli/create_docker_role.py
Normal file
144
cli/create_docker_role.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import yaml
|
||||||
|
import ipaddress
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
# Paths to the group-vars files
|
||||||
|
PORTS_FILE = './group_vars/all/09_ports.yml'
|
||||||
|
NETWORKS_FILE = './group_vars/all/10_networks.yml'
|
||||||
|
ROLE_TEMPLATE_DIR = './docker-template'
|
||||||
|
ROLES_DIR = './roles'
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path):
|
||||||
|
with open(path) as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_yaml(data, path):
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
yaml.safe_dump(data, f, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_network(networks_dict, prefixlen):
|
||||||
|
# Collect all local subnets matching the given prefix length
|
||||||
|
nets = []
|
||||||
|
for name, info in networks_dict['defaults_networks']['local'].items():
|
||||||
|
net = ipaddress.ip_network(info['subnet'])
|
||||||
|
if net.prefixlen == prefixlen:
|
||||||
|
nets.append(net)
|
||||||
|
# Sort by network address and return the first one
|
||||||
|
nets.sort(key=lambda n: int(n.network_address))
|
||||||
|
return nets[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_port(ports_dict, category, service):
|
||||||
|
used = set()
|
||||||
|
# Gather already taken ports under localhost.category
|
||||||
|
for svc, port in ports_dict['ports']['localhost'].get(category, {}).items():
|
||||||
|
used.add(int(port))
|
||||||
|
# Start searching from port 1 upwards
|
||||||
|
candidate = 1
|
||||||
|
while candidate in used:
|
||||||
|
candidate += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(src_dir, dst_dir, context):
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(src_dir),
|
||||||
|
keep_trailing_newline=True,
|
||||||
|
autoescape=False,
|
||||||
|
)
|
||||||
|
for root, _, files in os.walk(src_dir):
|
||||||
|
rel_path = os.path.relpath(root, src_dir)
|
||||||
|
target_path = os.path.join(dst_dir, rel_path)
|
||||||
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
for filename in files:
|
||||||
|
template = env.get_template(os.path.join(rel_path, filename))
|
||||||
|
rendered = template.render(**context)
|
||||||
|
out_name = filename[:-3] if filename.endswith('.j2') else filename
|
||||||
|
with open(os.path.join(target_path, out_name), 'w') as f:
|
||||||
|
f.write(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create a Docker Ansible role with Jinja2 templates, and assign network and ports"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--application-id', '-a', required=True,
|
||||||
|
help="Unique ID of the application (used in the role name)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--network', '-n', choices=['24', '28'], required=True,
|
||||||
|
help="Network prefix length to assign (/24 or /28)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--ports', '-p', nargs='+', metavar="CATEGORY.SERVICE", required=True,
|
||||||
|
help="List of ports in the format category.service (e.g. http.nextcloud)"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app_id = args.application_id
|
||||||
|
role_name = f"docker-{app_id}"
|
||||||
|
|
||||||
|
# 1) Create the role from the template
|
||||||
|
role_dir = os.path.join(ROLES_DIR, role_name)
|
||||||
|
if os.path.exists(role_dir):
|
||||||
|
parser.error(f"Role {role_name} already exists at {role_dir}")
|
||||||
|
render_template(ROLE_TEMPLATE_DIR, role_dir, {
|
||||||
|
'application_id': app_id,
|
||||||
|
'role_name': role_name,
|
||||||
|
})
|
||||||
|
print(f"→ Role {role_name} created at {role_dir}")
|
||||||
|
|
||||||
|
# 2) Assign network
|
||||||
|
networks = load_yaml(NETWORKS_FILE)
|
||||||
|
prefix = int(args.network)
|
||||||
|
chosen_net = get_next_network(networks, prefix)
|
||||||
|
out_net = {
|
||||||
|
'defaults_networks': {
|
||||||
|
'application': {
|
||||||
|
app_id: str(chosen_net)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
net_file = f'./group_vars/{app_id}_network.yml'
|
||||||
|
dump_yaml(out_net, net_file)
|
||||||
|
print(f"→ Assigned network {chosen_net} (/{prefix}) and wrote to {net_file}")
|
||||||
|
|
||||||
|
# 3) Assign ports
|
||||||
|
ports_yaml = load_yaml(PORTS_FILE)
|
||||||
|
assigned = {}
|
||||||
|
for entry in args.ports:
|
||||||
|
try:
|
||||||
|
category, service = entry.split('.', 1)
|
||||||
|
except ValueError:
|
||||||
|
parser.error(f"Invalid port spec: {entry}. Must be CATEGORY.SERVICE")
|
||||||
|
port = get_next_port(ports_yaml, category, service)
|
||||||
|
# Insert into the in-memory ports data under localhost
|
||||||
|
ports_yaml['ports']['localhost'].setdefault(category, {})[service] = port
|
||||||
|
assigned[entry] = port
|
||||||
|
|
||||||
|
# Backup and write updated all/09_ports.yml
|
||||||
|
backup_file = PORTS_FILE + '.bak'
|
||||||
|
shutil.copy(PORTS_FILE, backup_file)
|
||||||
|
dump_yaml(ports_yaml, PORTS_FILE)
|
||||||
|
print(f"→ Assigned ports: {assigned}. Updated {PORTS_FILE} (backup at {backup_file})")
|
||||||
|
|
||||||
|
# Also write ports to the application’s own vars file
|
||||||
|
out_ports = {'ports': {'localhost': {}}}
|
||||||
|
for entry, port in assigned.items():
|
||||||
|
category, service = entry.split('.', 1)
|
||||||
|
out_ports['ports']['localhost'].setdefault(category, {})[service] = port
|
||||||
|
ports_file = f'./group_vars/{app_id}_ports.yml'
|
||||||
|
dump_yaml(out_ports, ports_file)
|
||||||
|
print(f"→ Wrote assigned ports to {ports_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -1,2 +0,0 @@
|
|||||||
from pkgutil import extend_path
|
|
||||||
__path__ = extend_path(__path__, __name__)
|
|
21
filter_plugins/get_domain.py
Normal file
21
filter_plugins/get_domain.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
# module_utils-Verzeichnis ermitteln und zum Import-Pfad hinzufügen
|
||||||
|
plugin_dir = os.path.dirname(__file__)
|
||||||
|
project_root = os.path.dirname(plugin_dir)
|
||||||
|
module_utils = os.path.join(project_root, 'module_utils')
|
||||||
|
if module_utils not in sys.path:
|
||||||
|
sys.path.append(module_utils)
|
||||||
|
|
||||||
|
# jetzt kannst Du domain_utils importieren
|
||||||
|
try:
|
||||||
|
from domain_utils import get_domain
|
||||||
|
except ImportError as e:
|
||||||
|
raise AnsibleFilterError(f"could not import domain_utils: {e}")
|
||||||
|
|
||||||
|
return {'get_domain': get_domain}
|
@ -1,60 +0,0 @@
|
|||||||
from ansible.errors import AnsibleFilterError
|
|
||||||
|
|
||||||
class FilterModule(object):
|
|
||||||
'''Ansible filter plugin to retrieve the correct domain for a given application_id.'''
|
|
||||||
|
|
||||||
def filters(self):
|
|
||||||
return {
|
|
||||||
'get_domain': self.get_domain,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_domain(self, domains, application_id):
|
|
||||||
"""
|
|
||||||
Return the domain for application_id from the domains mapping:
|
|
||||||
- If value is a string, return it.
|
|
||||||
- If value is a dict, return its first value.
|
|
||||||
- If value is a list, return its first element.
|
|
||||||
- Otherwise, raise an error.
|
|
||||||
"""
|
|
||||||
# Ensure domains is a mapping
|
|
||||||
if not isinstance(domains, dict):
|
|
||||||
raise AnsibleFilterError(f"'domains' must be a dict, got {type(domains).__name__}")
|
|
||||||
|
|
||||||
if application_id not in domains:
|
|
||||||
raise AnsibleFilterError(f"application_id '{application_id}' not found in domains mapping")
|
|
||||||
|
|
||||||
val = domains[application_id]
|
|
||||||
|
|
||||||
# String case
|
|
||||||
if isinstance(val, str):
|
|
||||||
if not val:
|
|
||||||
raise AnsibleFilterError(f"domains['{application_id}'] is an empty string")
|
|
||||||
return val
|
|
||||||
|
|
||||||
# Dict case
|
|
||||||
if isinstance(val, dict):
|
|
||||||
try:
|
|
||||||
first_val = next(iter(val.values()))
|
|
||||||
except StopIteration:
|
|
||||||
raise AnsibleFilterError(f"domains['{application_id}'] dict is empty")
|
|
||||||
if not isinstance(first_val, str) or not first_val:
|
|
||||||
raise AnsibleFilterError(
|
|
||||||
f"first value of domains['{application_id}'] must be a non-empty string, got {first_val!r}"
|
|
||||||
)
|
|
||||||
return first_val
|
|
||||||
|
|
||||||
# List case
|
|
||||||
if isinstance(val, list):
|
|
||||||
if not val:
|
|
||||||
raise AnsibleFilterError(f"domains['{application_id}'] list is empty")
|
|
||||||
first = val[0]
|
|
||||||
if not isinstance(first, str) or not first:
|
|
||||||
raise AnsibleFilterError(
|
|
||||||
f"first element of domains['{application_id}'] must be a non-empty string, got {first!r}"
|
|
||||||
)
|
|
||||||
return first
|
|
||||||
|
|
||||||
# Other types
|
|
||||||
raise AnsibleFilterError(
|
|
||||||
f"domains['{application_id}'] has unsupported type {type(val).__name__}, must be str, dict or list"
|
|
||||||
)
|
|
27
filter_plugins/get_url.py
Normal file
27
filter_plugins/get_url.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {'get_url': self.get_url}
|
||||||
|
|
||||||
|
def get_url(self, domains, application_id, protocol):
|
||||||
|
# 1) module_utils-Verzeichnis in den Pfad aufnehmen
|
||||||
|
plugin_dir = os.path.dirname(__file__)
|
||||||
|
project_root = os.path.dirname(plugin_dir)
|
||||||
|
module_utils = os.path.join(project_root, 'module_utils')
|
||||||
|
if module_utils not in sys.path:
|
||||||
|
sys.path.append(module_utils)
|
||||||
|
|
||||||
|
# 2) jetzt domain_utils importieren
|
||||||
|
try:
|
||||||
|
from domain_utils import get_domain
|
||||||
|
except ImportError as e:
|
||||||
|
raise AnsibleFilterError(f"could not import domain_utils: {e}")
|
||||||
|
|
||||||
|
# 3) Validierung und Aufruf
|
||||||
|
if not isinstance(protocol, str):
|
||||||
|
raise AnsibleFilterError("Protocol must be a string")
|
||||||
|
return f"{protocol}://{ get_domain(domains, application_id) }"
|
52
module_utils/domain_utils.py
Normal file
52
module_utils/domain_utils.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# filter_plugins/domain_utils.py
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
def get_domain(domains, application_id):
|
||||||
|
"""
|
||||||
|
Return the domain for application_id from the domains mapping:
|
||||||
|
- If value is a string, return it.
|
||||||
|
- If value is a dict, return its first value.
|
||||||
|
- If value is a list, return its first element.
|
||||||
|
- Otherwise, raise an error.
|
||||||
|
"""
|
||||||
|
if not isinstance(domains, dict):
|
||||||
|
raise AnsibleFilterError(f"'domains' must be a dict, got {type(domains).__name__}")
|
||||||
|
|
||||||
|
if application_id not in domains:
|
||||||
|
raise AnsibleFilterError(f"application_id '{application_id}' not found in domains mapping")
|
||||||
|
|
||||||
|
val = domains[application_id]
|
||||||
|
|
||||||
|
# String case
|
||||||
|
if isinstance(val, str):
|
||||||
|
if not val:
|
||||||
|
raise AnsibleFilterError(f"domains['{application_id}'] is an empty string")
|
||||||
|
return val
|
||||||
|
|
||||||
|
# Dict case
|
||||||
|
if isinstance(val, dict):
|
||||||
|
try:
|
||||||
|
first_val = next(iter(val.values()))
|
||||||
|
except StopIteration:
|
||||||
|
raise AnsibleFilterError(f"domains['{application_id}'] dict is empty")
|
||||||
|
if not isinstance(first_val, str) or not first_val:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"first value of domains['{application_id}'] must be a non-empty string, got {first_val!r}"
|
||||||
|
)
|
||||||
|
return first_val
|
||||||
|
|
||||||
|
# List case
|
||||||
|
if isinstance(val, list):
|
||||||
|
if not val:
|
||||||
|
raise AnsibleFilterError(f"domains['{application_id}'] list is empty")
|
||||||
|
first = val[0]
|
||||||
|
if not isinstance(first, str) or not first:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"first element of domains['{application_id}'] must be a non-empty string, got {first!r}"
|
||||||
|
)
|
||||||
|
return first
|
||||||
|
|
||||||
|
# Unsupported type
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"domains['{application_id}'] has unsupported type {type(val).__name__}, must be str, dict or list"
|
||||||
|
)
|
@ -1,5 +1,5 @@
|
|||||||
# You should change this to match your reverse proxy DNS name and protocol
|
# You should change this to match your reverse proxy DNS name and protocol
|
||||||
APP_URL={{ web_protocol }}://{{domains | get_domain(application_id)}}
|
APP_URL={{ domains | get_url(application_id, web_protocol) }}
|
||||||
LOCALE={{ HOST_LL }}
|
LOCALE={{ HOST_LL }}
|
||||||
|
|
||||||
# Don't change this unless you rename your database container or use rootless podman, in case of using rootless podman you should set it to 127.0.0.1 (NOT localhost)
|
# Don't change this unless you rename your database container or use rootless podman, in case of using rootless podman you should set it to 127.0.0.1 (NOT localhost)
|
||||||
|
@ -290,6 +290,6 @@ DEFAULT_REGISTRATION=invite
|
|||||||
OPENID_CONNECT_CLIENT_ID={{oidc.client.id}}
|
OPENID_CONNECT_CLIENT_ID={{oidc.client.id}}
|
||||||
OPENID_CONNECT_CLIENT_SECRET={{oidc.client.secret}}
|
OPENID_CONNECT_CLIENT_SECRET={{oidc.client.secret}}
|
||||||
OPENID_CONNECT_ISSUER={{oidc.client.issuer_url}}
|
OPENID_CONNECT_ISSUER={{oidc.client.issuer_url}}
|
||||||
OPENID_CONNECT_REDIRECT=https://{{domains | get_domain(application_id)}}
|
OPENID_CONNECT_REDIRECT={{ domains | get_url(application_id, web_protocol) }}
|
||||||
# OPENID_CONNECT_UID_FIELD=sub default
|
# OPENID_CONNECT_UID_FIELD=sub default
|
||||||
{% endif %}
|
{% endif %}
|
@ -1,23 +1,27 @@
|
|||||||
- name: "Create (optional) '{{ docker_compose.files.dockerfile }}'"
|
- name: Create (optional) Dockerfile
|
||||||
template:
|
template:
|
||||||
src: "{{ playbook_dir }}/roles/{{ role_name }}/templates/Dockerfile.j2"
|
src: "{{ item }}"
|
||||||
dest: "{{ docker_compose.files.dockerfile }}"
|
dest: "{{ docker_compose.files.dockerfile }}"
|
||||||
notify: docker compose up
|
with_first_found:
|
||||||
ignore_errors: false
|
- "{{ playbook_dir }}/roles/{{ role_name }}/templates/Dockerfile.j2"
|
||||||
register: create_dockerfile_result
|
- "{{ playbook_dir }}/roles/{{ role_name }}/files/Dockerfile"
|
||||||
|
notify: docker compose up
|
||||||
|
register: create_dockerfile_result
|
||||||
failed_when:
|
failed_when:
|
||||||
- create_dockerfile_result is failed
|
- create_dockerfile_result is failed
|
||||||
- "'Could not find or access' not in create_dockerfile_result.msg"
|
- "'Could not find or access' not in create_dockerfile_result.msg"
|
||||||
|
|
||||||
- name: "Create (optional) '{{ docker_compose.files.env }}'"
|
- name: "Create (optional) '{{ docker_compose.files.env }}'"
|
||||||
template:
|
template:
|
||||||
src: "env.j2"
|
src: "{{ item }}"
|
||||||
dest: "{{ docker_compose.files.env }}"
|
dest: "{{ docker_compose.files.env }}"
|
||||||
mode: '770'
|
mode: '770'
|
||||||
force: yes
|
force: yes
|
||||||
notify: docker compose up
|
notify: docker compose up
|
||||||
register: env_template
|
register: env_template
|
||||||
ignore_errors: false
|
with_first_found:
|
||||||
|
- "{{ playbook_dir }}/roles/{{ role_name }}/templates/env.j2"
|
||||||
|
- "{{ playbook_dir }}/roles/{{ role_name }}/files/env"
|
||||||
failed_when:
|
failed_when:
|
||||||
- env_template is failed
|
- env_template is failed
|
||||||
- "'Could not find or access' not in env_template.msg"
|
- "'Could not find or access' not in env_template.msg"
|
||||||
|
@ -23,7 +23,7 @@ ESPOCRM_ADMIN_USERNAME={{ applications[application_id].users.administrator.usern
|
|||||||
ESPOCRM_ADMIN_PASSWORD={{ applications[application_id].credentials.administrator_password }}
|
ESPOCRM_ADMIN_PASSWORD={{ applications[application_id].credentials.administrator_password }}
|
||||||
|
|
||||||
# Public base URL of the EspoCRM instance
|
# Public base URL of the EspoCRM instance
|
||||||
ESPOCRM_SITE_URL={{ web_protocol }}://{{ domains | get_domain(application_id) }}
|
ESPOCRM_SITE_URL={{ domains | get_url(application_id, web_protocol) }}
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
# General UI & locale settings
|
# General UI & locale settings
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
# General
|
# General
|
||||||
DOMAIN={{domains | get_domain(application_id)}}
|
DOMAIN={{domains | get_domain(application_id)}}
|
||||||
RUN_MODE="{{ 'dev' if (CYMAIS_ENVIRONMENT | lower) == 'development' else 'prod' }}"
|
RUN_MODE="{{ 'dev' if (CYMAIS_ENVIRONMENT | lower) == 'development' else 'prod' }}"
|
||||||
ROOT_URL="{{ web_protocol }}://{{domains | get_domain(application_id)}}/"
|
ROOT_URL="{{ domains | get_url(application_id, web_protocol) }}/"
|
||||||
APP_NAME="{{ applications[application_id].title }}"
|
APP_NAME="{{ applications[application_id].title }}"
|
||||||
USER_UID=1000
|
USER_UID=1000
|
||||||
USER_GID=1000
|
USER_GID=1000
|
||||||
|
@ -517,7 +517,7 @@
|
|||||||
"/realms/{{ keycloak_realm }}/account/*"
|
"/realms/{{ keycloak_realm }}/account/*"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"{{ web_protocol }}://{{domains | get_domain('keycloak')}}"
|
"{{ domains | get_url('keycloak', web_protocol) }}"
|
||||||
],
|
],
|
||||||
"notBefore": 0,
|
"notBefore": 0,
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
||||||
|
@ -3,7 +3,7 @@ database_type: "postgres"
|
|||||||
|
|
||||||
listmonk_settings:
|
listmonk_settings:
|
||||||
- key: "app.root_url"
|
- key: "app.root_url"
|
||||||
value: '"{{ web_protocol }}://{{ domains | get_domain(application_id) }}"'
|
value: '"{{ domains | get_url(application_id, web_protocol) }}"'
|
||||||
|
|
||||||
- key: "app.notify_emails"
|
- key: "app.notify_emails"
|
||||||
value: "{{ [ users.administrator.email ] | to_json }}"
|
value: "{{ [ users.administrator.email ] | to_json }}"
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
application_id: "matomo"
|
application_id: "matomo"
|
||||||
database_type: "mariadb"
|
database_type: "mariadb"
|
||||||
matomo_excluded_ips: "{{ applications.matomo.excluded_ips }}"
|
matomo_excluded_ips: "{{ applications.matomo.excluded_ips }}"
|
||||||
matomo_index_php_url: "{{ web_protocol }}://{{ domains | get_domain('matomo') }}/index.php"
|
matomo_index_php_url: "{{ domains | get_url('matomo', web_protocol) }}/index.php"
|
||||||
matomo_auth_token: "{{ applications.matomo.credentials.auth_token }}"
|
matomo_auth_token: "{{ applications.matomo.credentials.auth_token }}"
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,6 +3,6 @@ application_id: mobilizon
|
|||||||
database_type: "postgres"
|
database_type: "postgres"
|
||||||
database_gis_enabled: true
|
database_gis_enabled: true
|
||||||
|
|
||||||
mobilizon_oidc_callback_url: "{{ web_protocol }}://{{ domains | get_domain(application_id) }}/auth/openid_connect/callback"
|
mobilizon_oidc_callback_url: "{{ domains | get_url(application_id, web_protocol) }}/auth/openid_connect/callback"
|
||||||
mobilizon_exposed_docker_port: 4000
|
mobilizon_exposed_docker_port: 4000
|
||||||
mobilizon_host_conf_exs_file: "{{docker_compose.directories.config}}config.exs"
|
mobilizon_host_conf_exs_file: "{{docker_compose.directories.config}}config.exs"
|
@ -39,7 +39,7 @@
|
|||||||
- { name: "field_lock_lastname", value: "locked" }
|
- { name: "field_lock_lastname", value: "locked" }
|
||||||
- { name: "field_map_email", value: "locked" }
|
- { name: "field_map_email", value: "locked" }
|
||||||
#- { name: "showloginform", value: 0 } # Deactivate if OIDC is active
|
#- { name: "showloginform", value: 0 } # Deactivate if OIDC is active
|
||||||
- { name: "alternateloginurl", value: "{{ web_protocol }}://{{ domains | get_domain(application_id) }}/auth/oidc/" }
|
- { name: "alternateloginurl", value: "{{ domains | get_url(application_id, web_protocol) }}/auth/oidc/" }
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.name }}"
|
label: "{{ item.name }}"
|
||||||
command: >
|
command: >
|
||||||
|
@ -32,7 +32,7 @@ NEXTCLOUD_ADMIN_PASSWORD= "{{applications[application_id].credentials.admi
|
|||||||
NEXTCLOUD_TRUSTED_DOMAINS= "{{domains | get_domain(application_id)}}"
|
NEXTCLOUD_TRUSTED_DOMAINS= "{{domains | get_domain(application_id)}}"
|
||||||
# Whitelist local docker gateway in Nextcloud to prevent brute-force throtteling
|
# Whitelist local docker gateway in Nextcloud to prevent brute-force throtteling
|
||||||
TRUSTED_PROXIES= "{{ networks.internet.values() | select | join(',') }}"
|
TRUSTED_PROXIES= "{{ networks.internet.values() | select | join(',') }}"
|
||||||
OVERWRITECLIURL= "{{ web_protocol }}://{{domains | get_domain(application_id)}}"
|
OVERWRITECLIURL= "{{ domains | get_url(application_id, web_protocol) }}"
|
||||||
OVERWRITEPROTOCOL= "https"
|
OVERWRITEPROTOCOL= "https"
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
|
@ -4,4 +4,4 @@ plugin_configuration:
|
|||||||
configvalue: "{{ applications.bigbluebutton.credentials.shared_secret }}"
|
configvalue: "{{ applications.bigbluebutton.credentials.shared_secret }}"
|
||||||
- appid: "bbb"
|
- appid: "bbb"
|
||||||
configkey: "api.url"
|
configkey: "api.url"
|
||||||
configvalue: "{{ web_protocol }}://{{domains | get_domain('bigbluebutton')}}{{applications.bigbluebutton.api_suffix}}"
|
configvalue: "{{ domains | get_url('bigbluebutton', web_protocol) }}{{applications.bigbluebutton.api_suffix}}"
|
@ -18,4 +18,4 @@ nextcloud_system_config:
|
|||||||
value: "{{domains | get_domain(application_id)}}"
|
value: "{{domains | get_domain(application_id)}}"
|
||||||
|
|
||||||
- parameter: "overwrite.cli.url"
|
- parameter: "overwrite.cli.url"
|
||||||
value: "{{ web_protocol }}://{{domains | get_domain(application_id)}}"
|
value: "{{ domains | get_url(application_id, web_protocol) }}"
|
@ -1,3 +1,3 @@
|
|||||||
# @See https://github.com/leenooks/phpLDAPadmin/wiki/Docker-Container
|
# @See https://github.com/leenooks/phpLDAPadmin/wiki/Docker-Container
|
||||||
APP_URL= {{ web_protocol }}://{{domains | get_domain(application_id)}}
|
APP_URL= {{ domains | get_url(application_id, web_protocol) }}
|
||||||
LDAP_HOST= {{ldap.server.domain}}
|
LDAP_HOST= {{ldap.server.domain}}
|
@ -5,7 +5,7 @@ APP_KEY={{applications[application_id].credentials.app_key}}
|
|||||||
APP_NAME="{{applications.pixelfed.titel}}"
|
APP_NAME="{{applications.pixelfed.titel}}"
|
||||||
APP_ENV={{ CYMAIS_ENVIRONMENT | lower }}
|
APP_ENV={{ CYMAIS_ENVIRONMENT | lower }}
|
||||||
APP_DEBUG={{enable_debug | string | lower }}
|
APP_DEBUG={{enable_debug | string | lower }}
|
||||||
APP_URL={{ web_protocol }}://{{domains | get_domain(application_id)}}
|
APP_URL={{ domains | get_url(application_id, web_protocol) }}
|
||||||
APP_DOMAIN="{{domains | get_domain(application_id)}}"
|
APP_DOMAIN="{{domains | get_domain(application_id)}}"
|
||||||
ADMIN_DOMAIN="{{domains | get_domain(application_id)}}"
|
ADMIN_DOMAIN="{{domains | get_domain(application_id)}}"
|
||||||
SESSION_DOMAIN="{{domains | get_domain(application_id)}}"
|
SESSION_DOMAIN="{{domains | get_domain(application_id)}}"
|
||||||
|
16
roles/docker-simpleicons/files/Dockerfile
Normal file
16
roles/docker-simpleicons/files/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM node:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM node:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY server.js .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
0
roles/docker-simpleicons/files/env
Normal file
0
roles/docker-simpleicons/files/env
Normal file
@ -1,25 +0,0 @@
|
|||||||
# ---- Builder Stage ----
|
|
||||||
FROM node:latest AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
# Nur package.json und package-lock.json kopieren für schnellere Caching-Layers
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# simple-icons installieren
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# ---- Runtime Stage ----
|
|
||||||
FROM node:latest
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
# Nur node_modules aus dem Builder übernehmen
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
# Kopiere den Server-Code
|
|
||||||
COPY server.js .
|
|
||||||
|
|
||||||
# Port, auf dem der Server lauscht
|
|
||||||
ENV PORT=3000
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Startbefehl
|
|
||||||
CMD ["node", "server.js"]
|
|
@ -1,14 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
icons:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: simpleicons-server:latest
|
|
||||||
container_name: simpleicons-server
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
|
||||||
- PORT=3000
|
|
||||||
restart: unless-stopped
|
|
14
roles/docker-simpleicons/templates/docker-compose.yml.j2
Normal file
14
roles/docker-simpleicons/templates/docker-compose.yml.j2
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
application:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: simpleicons-server:latest
|
||||||
|
container_name: simpleicons-server
|
||||||
|
ports:
|
||||||
|
- "{{ports.localhost.http[application_id]}}:3000"
|
||||||
|
{% include 'roles/docker-compose/templates/services/base.yml.j2' %}
|
||||||
|
{% include 'templates/docker/container/networks.yml.j2' %}
|
||||||
|
|
||||||
|
{% include 'templates/docker/compose/networks.yml.j2' %}
|
||||||
|
|
@ -5,7 +5,7 @@ import sharp from 'sharp';
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
// Helper: turn 'nextcloud' → 'siNextcloud'
|
// Helper: convert 'nextcloud' → 'siNextcloud'
|
||||||
function getExportName(slug) {
|
function getExportName(slug) {
|
||||||
return 'si' + slug
|
return 'si' + slug
|
||||||
.split('-')
|
.split('-')
|
||||||
@ -13,8 +13,13 @@ function getExportName(slug) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /icons/:slug.svg
|
// Root: redirect to your documentation
|
||||||
app.get('/icons/:slug.svg', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('https://docs.cymais.cloud/roles/docker-{{ application_id }}/README.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:slug.svg
|
||||||
|
app.get('/:slug.svg', (req, res) => {
|
||||||
const slug = req.params.slug.toLowerCase();
|
const slug = req.params.slug.toLowerCase();
|
||||||
const exportName = getExportName(slug);
|
const exportName = getExportName(slug);
|
||||||
const icon = icons[exportName];
|
const icon = icons[exportName];
|
||||||
@ -23,11 +28,12 @@ app.get('/icons/:slug.svg', (req, res) => {
|
|||||||
return res.status(404).send('Icon not found');
|
return res.status(404).send('Icon not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.type('image/svg+xml').send(icon.svg);
|
res.type('image/svg+xml');
|
||||||
|
res.send(icon.svg);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /icons/:slug.png?size=...
|
// GET /:slug.png?size=...
|
||||||
app.get('/icons/:slug.png', async (req, res) => {
|
app.get('/:slug.png', async (req, res) => {
|
||||||
const slug = req.params.slug.toLowerCase();
|
const slug = req.params.slug.toLowerCase();
|
||||||
const size = parseInt(req.query.size, 10) || 128;
|
const size = parseInt(req.query.size, 10) || 128;
|
||||||
const exportName = getExportName(slug);
|
const exportName = getExportName(slug);
|
||||||
@ -38,12 +44,13 @@ app.get('/icons/:slug.png', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const png = await sharp(Buffer.from(icon.svg))
|
const pngBuffer = await sharp(Buffer.from(icon.svg))
|
||||||
.resize(size, size)
|
.resize(size, size)
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
res.type('image/png').send(png);
|
res.type('image/png');
|
||||||
|
res.send(pngBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PNG generation error:', err);
|
console.error('PNG generation error:', err);
|
||||||
res.status(500).send('PNG generation error');
|
res.status(500).send('PNG generation error');
|
1
roles/docker-simpleicons/vars/main.yml
Normal file
1
roles/docker-simpleicons/vars/main.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
application_id: simpleicons
|
@ -1,4 +1,4 @@
|
|||||||
application_id: "snipe-it"
|
application_id: "snipe-it"
|
||||||
database_password: "{{ applications[application_id].credentials.database_password }}"
|
database_password: "{{ applications[application_id].credentials.database_password }}"
|
||||||
database_type: "mariadb"
|
database_type: "mariadb"
|
||||||
snipe_it_url: "{{ web_protocol }}://{{domains | get_domain(application_id)}}"
|
snipe_it_url: "{{ domains | get_url(application_id, web_protocol) }}"
|
@ -13,7 +13,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: docker,postgresql,saml2
|
SPRING_PROFILES_ACTIVE: docker,postgresql,saml2
|
||||||
OPENJPA_REMOTE_COMMIT: sjvm
|
OPENJPA_REMOTE_COMMIT: sjvm
|
||||||
SERVICE_DISCOVERY_ADDRESS: {{ web_protocol }}://{{ domains | get_domain(application_id) }}/{{syncope_paths[rest]}}/
|
SERVICE_DISCOVERY_ADDRESS: {{ domains | get_url(application_id, web_protocol) }}/{{syncope_paths[rest]}}/
|
||||||
# database variablen auslesen
|
# database variablen auslesen
|
||||||
|
|
||||||
console:
|
console:
|
||||||
@ -25,7 +25,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: docker,saml2
|
SPRING_PROFILES_ACTIVE: docker,saml2
|
||||||
SERVICE_DISCOVERY_ADDRESS: {{ web_protocol }}://{{ domains | get_domain(application_id) }}/{{syncope_paths[console]}}/
|
SERVICE_DISCOVERY_ADDRESS: {{ domains | get_url(application_id, web_protocol) }}/{{syncope_paths[console]}}/
|
||||||
|
|
||||||
enduser:
|
enduser:
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -36,5 +36,5 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: docker,saml2
|
SPRING_PROFILES_ACTIVE: docker,saml2
|
||||||
SERVICE_DISCOVERY_ADDRESS: {{ web_protocol }}://{{ domains | get_domain(application_id) }}/{{syncope_paths[enduser]}}/
|
SERVICE_DISCOVERY_ADDRESS: {{ domains | get_url(application_id, web_protocol) }}/{{syncope_paths[enduser]}}/
|
||||||
|
|
2
roles/docker-template/README.md
Normal file
2
roles/docker-template/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Docker Role Template
|
||||||
|
This folder contains a template to setup docker roles
|
1
roles/docker-template/vars/main.yml
Normal file
1
roles/docker-template/vars/main.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
application_id: template
|
@ -2,7 +2,7 @@
|
|||||||
command: >
|
command: >
|
||||||
docker-compose exec -T -u www-data application
|
docker-compose exec -T -u www-data application
|
||||||
wp core install
|
wp core install
|
||||||
--url="{{ web_protocol }}://{{ domains | get_domain(application_id) }}"
|
--url="{{ domains | get_url(application_id, web_protocol) }}"
|
||||||
--title="{{ applications[application_id].title }}"
|
--title="{{ applications[application_id].title }}"
|
||||||
--admin_user="{{ applications[application_id].users.administrator.username }}"
|
--admin_user="{{ applications[application_id].users.administrator.username }}"
|
||||||
--admin_password="{{ applications[application_id].credentials.administrator_password }}"
|
--admin_password="{{ applications[application_id].credentials.administrator_password }}"
|
||||||
|
@ -11,7 +11,7 @@ discourse_settings:
|
|||||||
|
|
||||||
- name: discourse_connect
|
- name: discourse_connect
|
||||||
key: url
|
key: url
|
||||||
value: "{{ web_protocol }}://{{ domains | get_domain('discourse') }}"
|
value: "{{ domains | get_url('discourse', web_protocol) }}"
|
||||||
- name: discourse_connect
|
- name: discourse_connect
|
||||||
key: api-key
|
key: api-key
|
||||||
value: "{{ vault_discourse_api_key }}"
|
value: "{{ vault_discourse_api_key }}"
|
||||||
|
@ -2,7 +2,7 @@ YOURLS_DB_HOST: "{{database_host}}"
|
|||||||
YOURLS_DB_USER: "{{database_username}}"
|
YOURLS_DB_USER: "{{database_username}}"
|
||||||
YOURLS_DB_PASS: "{{database_password}}"
|
YOURLS_DB_PASS: "{{database_password}}"
|
||||||
YOURLS_DB_NAME: "{{database_name}}"
|
YOURLS_DB_NAME: "{{database_name}}"
|
||||||
YOURLS_SITE: "{{ web_protocol }}://{{domains | get_domain(application_id)}}"
|
YOURLS_SITE: "{{ domains | get_url(application_id, web_protocol) }}"
|
||||||
YOURLS_USER: "{{applications.yourls.users.administrator.username}}"
|
YOURLS_USER: "{{applications.yourls.users.administrator.username}}"
|
||||||
YOURLS_PASS: "{{applications[application_id].credentials.administrator_password}}"
|
YOURLS_PASS: "{{applications[application_id].credentials.administrator_password}}"
|
||||||
# The following deactivates the login mask for admins, if the oauth2 proxy is activated
|
# The following deactivates the login mask for admins, if the oauth2 proxy is activated
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
base_domain: "{{ domain | regex_replace('^(?:.*\\.)?(.+\\..+)$', '\\1') }}"
|
base_domain: "{{ domain | regex_replace('^(?:.*\\.)?(.+\\..+)$', '\\1') }}"
|
||||||
matomo_index_php_url: "{{ web_protocol }}://{{ domains | get_domain('matomo') }}/index.php"
|
matomo_index_php_url: "{{ domains | get_url('matomo', web_protocol) }}/index.php"
|
||||||
matomo_auth_token: "{{ applications.matomo.credentials.auth_token }}"
|
matomo_auth_token: "{{ applications.matomo.credentials.auth_token }}"
|
||||||
matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url=https://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}"
|
matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url=https://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}"
|
@ -1,4 +1,17 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
- name: Show effective filter_plugins setting
|
||||||
|
shell: ansible-config dump --only-changed | grep -i filter_plugins || echo "using default"
|
||||||
|
register: filter_cfg
|
||||||
|
|
||||||
|
- name: Debug filter_plugins config
|
||||||
|
debug:
|
||||||
|
msg: "{{ filter_cfg.stdout_lines }}"
|
||||||
|
|
||||||
|
- name: "Debug: show which ansible.cfg was used"
|
||||||
|
debug:
|
||||||
|
msg: "{{ ansible_config_file }}"
|
||||||
|
|
||||||
- name: Merge variables
|
- name: Merge variables
|
||||||
block:
|
block:
|
||||||
- name: Merge users
|
- name: Merge users
|
||||||
|
@ -1,17 +1,8 @@
|
|||||||
# tests/unit/test_get_domain_filter.py
|
# tests/unit/test_get_domain.py
|
||||||
import unittest
|
import unittest
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
from filter_plugins.get_domain import FilterModule
|
||||||
# Ensure filter_plugins directory is on the path
|
|
||||||
sys.path.insert(
|
|
||||||
0,
|
|
||||||
os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
from get_domain_filter import FilterModule
|
|
||||||
from ansible.errors import AnsibleFilterError
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
class TestGetDomainFilter(unittest.TestCase):
|
class TestGetDomainFilter(unittest.TestCase):
|
77
tests/unit/filter_plugins/test_get_url.py
Normal file
77
tests/unit/filter_plugins/test_get_url.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# tests/unit/filter_plugins/test_get_url.py
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure filter_plugins directory is on the path
|
||||||
|
sys.path.insert(
|
||||||
|
0,
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
from get_url import FilterModule
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
class TestGetUrlFilter(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Retrieve the get_url filter function
|
||||||
|
self.get_url = FilterModule().filters()['get_url']
|
||||||
|
|
||||||
|
def test_string_domain(self):
|
||||||
|
domains = {'app': 'example.com'}
|
||||||
|
url = self.get_url(domains, 'app', 'https')
|
||||||
|
self.assertEqual(url, 'https://example.com')
|
||||||
|
|
||||||
|
def test_dict_domain(self):
|
||||||
|
domains = {'app': {'primary': 'primary.com', 'secondary': 'secondary.com'}}
|
||||||
|
url = self.get_url(domains, 'app', 'http')
|
||||||
|
self.assertEqual(url, 'http://primary.com')
|
||||||
|
|
||||||
|
def test_list_domain(self):
|
||||||
|
domains = {'app': ['first.com', 'second.com']}
|
||||||
|
url = self.get_url(domains, 'app', 'ftp')
|
||||||
|
self.assertEqual(url, 'ftp://first.com')
|
||||||
|
|
||||||
|
def test_missing_application_id(self):
|
||||||
|
domains = {'app': 'example.com'}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'missing', 'https')
|
||||||
|
|
||||||
|
def test_domains_not_dict(self):
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(['not', 'a', 'dict'], 'app', 'https')
|
||||||
|
|
||||||
|
def test_empty_string_domain(self):
|
||||||
|
domains = {'app': ''}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 'https')
|
||||||
|
|
||||||
|
def test_empty_dict_domain(self):
|
||||||
|
domains = {'app': {}}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 'https')
|
||||||
|
|
||||||
|
def test_empty_list_domain(self):
|
||||||
|
domains = {'app': []}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 'https')
|
||||||
|
|
||||||
|
def test_non_string_in_dict_domain(self):
|
||||||
|
domains = {'app': {'key': 123}}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 'https')
|
||||||
|
|
||||||
|
def test_non_string_in_list_domain(self):
|
||||||
|
domains = {'app': [123]}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 'https')
|
||||||
|
|
||||||
|
def test_protocol_not_string(self):
|
||||||
|
domains = {'app': 'example.com'}
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.get_url(domains, 'app', 123)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user