diff --git a/plugins/modules/ipaservice.py b/plugins/modules/ipaservice.py
index a6fa9cb129fb6a1bef3a8599d39579850c4402f8..209a031115f66db594a1d18a7c3f4983ffad4509 100644
--- a/plugins/modules/ipaservice.py
+++ b/plugins/modules/ipaservice.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 # Authors:
+#   Denis Karpelevich <dkarpele@redhat.com>
 #   Rafael Guterres Jeffman <rjeffman@redhat.com>
 #   Thomas Woerner <twoerner@redhat.com>
 #
@@ -45,6 +46,127 @@ options:
     elements: str
     required: true
     aliases: ["service"]
+  services:
+    description: The list of service dicts.
+    type: list
+    elements: dict
+    suboptions:
+      name:
+        description: The service to manage
+        type: str
+        required: true
+        aliases: ["service"]
+      certificate:
+        description: Base-64 encoded service certificate.
+        required: false
+        type: list
+        elements: str
+        aliases: ["usercertificate"]
+      pac_type:
+        description: Supported PAC type.
+        required: false
+        choices: ["MS-PAC", "PAD", "NONE", ""]
+        type: list
+        elements: str
+        aliases: ["pac_type", "ipakrbauthzdata"]
+      auth_ind:
+        description: Defines an allow list for Authentication Indicators.
+        type: list
+        elements: str
+        required: false
+        choices: ["otp", "radius", "pkinit", "hardened", ""]
+        aliases: ["krbprincipalauthind"]
+      skip_host_check:
+        description: Skip checking if host object exists.
+        required: False
+        type: bool
+      force:
+        description: Force principal name even if host is not in DNS.
+        required: False
+        type: bool
+      requires_pre_auth:
+        description: Pre-authentication is required for the service.
+        required: false
+        type: bool
+        aliases: ["ipakrbrequirespreauth"]
+      ok_as_delegate:
+        description: Client credentials may be delegated to the service.
+        required: false
+        type: bool
+        aliases: ["ipakrbokasdelegate"]
+      ok_to_auth_as_delegate:
+        description: Allow service to authenticate on behalf of a client.
+        required: false
+        type: bool
+        aliases: ["ipakrboktoauthasdelegate"]
+      principal:
+        description: List of principal aliases for the service.
+        required: false
+        type: list
+        elements: str
+        aliases: ["krbprincipalname"]
+      smb:
+        description: Add a SMB service.
+        required: false
+        type: bool
+      netbiosname:
+        description: NETBIOS name for the SMB service.
+        required: false
+        type: str
+      host:
+        description: Host that can manage the service.
+        required: false
+        type: list
+        elements: str
+        aliases: ["managedby_host"]
+      allow_create_keytab_user:
+        description: Users allowed to create a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_write_keys_user"]
+      allow_create_keytab_group:
+        description: Groups allowed to create a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_write_keys_group"]
+      allow_create_keytab_host:
+        description: Hosts allowed to create a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_write_keys_host"]
+      allow_create_keytab_hostgroup:
+        description: Host group allowed to create a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_write_keys_hostgroup"]
+      allow_retrieve_keytab_user:
+        description: User allowed to retrieve a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_read_keys_user"]
+      allow_retrieve_keytab_group:
+        description: Groups allowed to retrieve a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_read_keys_group"]
+      allow_retrieve_keytab_host:
+        description: Hosts allowed to retrieve a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_read_keys_host"]
+      allow_retrieve_keytab_hostgroup:
+        description: Host groups allowed to retrieve a keytab of this host.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaallowedtoperform_read_keys_hostgroup"]
   certificate:
     description: Base-64 encoded service certificate.
     required: false
@@ -239,6 +361,15 @@ EXAMPLES = """
       - host1.example.com
       - host2.example.com
       action: member
+
+  # Ensure multiple services are present.
+  - ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services:
+      - name: HTTP/www.example.com
+        host:
+        - host1.example.com
+      - name: HTTP/www.service.com
 """
 
 RETURN = """
@@ -248,6 +379,9 @@ from ansible.module_utils.ansible_freeipa_module import \
     IPAAnsibleModule, compare_args_ipa, encode_certificate, \
     gen_add_del_lists, gen_add_list, gen_intersection_list, ipalib_errors, \
     api_get_realm, to_text
+from ansible.module_utils import six
+if six.PY3:
+    unicode = str
 
 
 def find_service(module, name):
@@ -321,8 +455,9 @@ def check_parameters(module, state, action, names):
          'allow_retrieve_keytab_hostgroup']
 
     if state == 'present':
-        if len(names) != 1:
-            module.fail_json(msg="Only one service can be added at a time.")
+        if names is not None and len(names) != 1:
+            module.fail_json(msg="Only one service can be added at a time "
+                                 "using 'name'.")
 
         if action == 'service':
             invalid = ['delete_continue']
@@ -338,9 +473,6 @@ def check_parameters(module, state, action, names):
             invalid.append('delete_continue')
 
     elif state == 'absent':
-        if len(names) < 1:
-            module.fail_json(msg="No name given.")
-
         if action == "service":
             invalid.extend(invalid_not_member)
         else:
@@ -360,67 +492,85 @@ def check_parameters(module, state, action, names):
 
 
 def init_ansible_module():
+    service_spec = dict(
+        # service attributesstr
+        certificate=dict(type="list", elements="str",
+                         aliases=['usercertificate'],
+                         default=None, required=False),
+        principal=dict(type="list", elements="str",
+                       aliases=["krbprincipalname"], default=None),
+        smb=dict(type="bool", required=False),
+        netbiosname=dict(type="str", required=False),
+        pac_type=dict(type="list", elements="str",
+                      aliases=["ipakrbauthzdata"],
+                      choices=["MS-PAC", "PAD", "NONE", ""]),
+        auth_ind=dict(type="list", elements="str",
+                      aliases=["krbprincipalauthind"],
+                      choices=["otp", "radius", "pkinit", "hardened", ""]),
+        skip_host_check=dict(type="bool"),
+        force=dict(type="bool"),
+        requires_pre_auth=dict(
+            type="bool", aliases=["ipakrbrequirespreauth"]),
+        ok_as_delegate=dict(type="bool", aliases=["ipakrbokasdelegate"]),
+        ok_to_auth_as_delegate=dict(type="bool",
+                                    aliases=["ipakrboktoauthasdelegate"]),
+        host=dict(type="list", elements="str", aliases=["managedby_host"],
+                  required=False),
+        allow_create_keytab_user=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_write_keys_user']),
+        allow_retrieve_keytab_user=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_read_keys_user']),
+        allow_create_keytab_group=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_write_keys_group']),
+        allow_retrieve_keytab_group=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_read_keys_group']),
+        allow_create_keytab_host=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_write_keys_host']),
+        allow_retrieve_keytab_host=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_read_keys_host']),
+        allow_create_keytab_hostgroup=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_write_keys_hostgroup']),
+        allow_retrieve_keytab_hostgroup=dict(
+            type="list", elements="str", required=False, no_log=False,
+            aliases=['ipaallowedtoperform_read_keys_hostgroup']),
+        delete_continue=dict(type="bool", required=False,
+                             aliases=['continue']),
+    )
     ansible_module = IPAAnsibleModule(
         argument_spec=dict(
             # general
             name=dict(type="list", elements="str", aliases=["service"],
-                      required=True),
-            # service attributesstr
-            certificate=dict(type="list", elements="str",
-                             aliases=['usercertificate'],
-                             default=None, required=False),
-            principal=dict(type="list", elements="str",
-                           aliases=["krbprincipalname"], default=None),
-            smb=dict(type="bool", required=False),
-            netbiosname=dict(type="str", required=False),
-            pac_type=dict(type="list", elements="str",
-                          aliases=["ipakrbauthzdata"],
-                          choices=["MS-PAC", "PAD", "NONE", ""]),
-            auth_ind=dict(type="list", elements="str",
-                          aliases=["krbprincipalauthind"],
-                          choices=["otp", "radius", "pkinit", "hardened", ""]),
-            skip_host_check=dict(type="bool"),
-            force=dict(type="bool"),
-            requires_pre_auth=dict(
-                type="bool", aliases=["ipakrbrequirespreauth"]),
-            ok_as_delegate=dict(type="bool", aliases=["ipakrbokasdelegate"]),
-            ok_to_auth_as_delegate=dict(type="bool",
-                                        aliases=["ipakrboktoauthasdelegate"]),
-            host=dict(type="list", elements="str", aliases=["managedby_host"],
-                      required=False),
-            allow_create_keytab_user=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_write_keys_user']),
-            allow_retrieve_keytab_user=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_read_keys_user']),
-            allow_create_keytab_group=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_write_keys_group']),
-            allow_retrieve_keytab_group=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_read_keys_group']),
-            allow_create_keytab_host=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_write_keys_host']),
-            allow_retrieve_keytab_host=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_read_keys_host']),
-            allow_create_keytab_hostgroup=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_write_keys_hostgroup']),
-            allow_retrieve_keytab_hostgroup=dict(
-                type="list", elements="str", required=False, no_log=False,
-                aliases=['ipaallowedtoperform_read_keys_hostgroup']),
-            delete_continue=dict(type="bool", required=False,
-                                 aliases=['continue']),
+                      default=None, required=False),
+            services=dict(type="list",
+                          default=None,
+                          options=dict(
+                              # Here name is a simple string
+                              name=dict(type="str", required=True,
+                                        aliases=["service"]),
+                              # Add service specific parameters
+                              **service_spec
+                          ),
+                          elements='dict',
+                          required=False),
             # action
             action=dict(type="str", default="service",
                         choices=["member", "service"]),
             # state
             state=dict(type="str", default="present",
                        choices=["present", "absent", "disabled"]),
+
+            # Add service specific parameters for simple use case
+            **service_spec
         ),
+        mutually_exclusive=[["name", "services"]],
+        required_one_of=[["name", "services"]],
         supports_check_mode=True,
     )
 
@@ -436,10 +586,17 @@ def main():
 
     # general
     names = ansible_module.params_get("name")
+    services = ansible_module.params_get("services")
 
     # service attributes
     principal = ansible_module.params_get("principal")
     certificate = ansible_module.params_get("certificate")
+    # Any leading or trailing whitespace is removed while adding the
+    # certificate with serive_add_cert. To be able to compare the results
+    # from service_show with the given certificates we have to remove the
+    # white space also.
+    if certificate is not None:
+        certificate = [cert.strip() for cert in certificate]
     pac_type = ansible_module.params_get("pac_type", allow_empty_string=True)
     auth_ind = ansible_module.params_get("auth_ind", allow_empty_string=True)
     skip_host_check = ansible_module.params_get("skip_host_check")
@@ -462,8 +619,16 @@ def main():
     state = ansible_module.params_get("state")
 
     # check parameters
+    if (names is None or len(names) < 1) and \
+       (services is None or len(services) < 1):
+        ansible_module.fail_json(msg="At least one name or services is "
+                                     "required")
     check_parameters(ansible_module, state, action, names)
 
+    # Use services if names is None
+    if services is not None:
+        names = services
+
     # Init
 
     changed = False
@@ -480,8 +645,45 @@ def main():
 
         commands = []
         keytab_members = ["user", "group", "host", "hostgroup"]
+        service_set = set()
 
-        for name in names:
+        for service in names:
+            if isinstance(service, dict):
+                name = service.get("name")
+                if name in service_set:
+                    ansible_module.fail_json(
+                        msg="service '%s' is used more than once" % name)
+                service_set.add(name)
+                principal = service.get("principal")
+                certificate = service.get("certificate")
+                # Any leading or trailing whitespace is removed while adding
+                # the certificate with serive_add_cert. To be able to compare
+                # the results from service_show with the given certificates
+                # we have to remove the white space also.
+                if certificate is not None:
+                    certificate = [cert.strip() for cert in certificate]
+                pac_type = service.get("pac_type")
+                auth_ind = service.get("auth_ind")
+                skip_host_check = service.get("skip_host_check")
+                if skip_host_check and not has_skip_host_check:
+                    ansible_module.fail_json(
+                        msg="Skipping host check is not supported by your IPA "
+                            "version")
+                force = service.get("force")
+                requires_pre_auth = service.get("requires_pre_auth")
+                ok_as_delegate = service.get("ok_as_delegate")
+                ok_to_auth_as_delegate = service.get("ok_to_auth_as_delegate")
+                smb = service.get("smb")
+                netbiosname = service.get("netbiosname")
+                host = service.get("host")
+
+                delete_continue = service.get("delete_continue")
+
+            elif isinstance(service, (str, unicode)):
+                name = service
+            else:
+                ansible_module.fail_json(msg="Service '%s' is not valid" %
+                                         repr(service))
             res_find = find_service(ansible_module, name)
             res_principals = []
 
diff --git a/tests/service/certificate/test_service_certificate_newline.yml b/tests/service/certificate/test_service_certificate_newline.yml
new file mode 100644
index 0000000000000000000000000000000000000000..01f7c536d6ab2705510fc42c37d22c6004255e26
--- /dev/null
+++ b/tests/service/certificate/test_service_certificate_newline.yml
@@ -0,0 +1,200 @@
+---
+- name: Test service with certificates with and without trailing new line
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: Include tasks ../../env_freeipa_facts.yml
+    ansible.builtin.include_tasks: ../../env_freeipa_facts.yml
+
+  - name: Setup test environment
+    ansible.builtin.include_tasks: ../env_vars.yml
+
+  - name: Generate self-signed certificates.
+    ansible.builtin.shell:
+      cmd: |
+        openssl req -x509 -newkey rsa:2048 -days 365 -nodes -keyout "private{{ item }}.key" -out "cert{{ item }}.pem" -subj '/CN=test'
+        openssl x509 -outform der -in "cert{{ item }}.pem" -out "cert{{ item }}.der"
+        base64 "cert{{ item }}.der" -w5000 > "cert{{ item }}.b64"
+    with_items: [1, 2, 3]
+    become: no
+    delegate_to: localhost
+
+  # The rstrip=False for lookup will add keep the newline at the end of the
+  # cert and this is automatically revoved in IPA, This is an additional
+  # test of ipaservice later on to behave correctly in both cases.
+  - name: Set fact cert1,2,3 from lookup
+    ansible.builtin.set_fact:
+      cert1: "{{ lookup('file', 'cert1.b64', rstrip=False) }}"
+      cert2: "{{ lookup('file', 'cert2.b64', rstrip=True) }}"
+      cert3: "{{ lookup('file', 'cert3.b64', rstrip=False) }}"
+
+  - name: Host {{ svc_fqdn }} absent
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      state: absent
+
+  - name: Host {{ svc_fqdn }} present
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      force: true
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+
+  - name: Service FOO/{{ svc_fqdn }} present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      force: yes
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2 members present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2 members present again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+      action: member
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2,3 members present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2,3 members present again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      action: member
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 2,3 member absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      state: absent
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 2,3 member absent again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2,3 members absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} certs 1,2,3 members absent again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      certificate:
+        - "{{ cert1 }}"
+        - "{{ cert2 }}"
+        - "{{ cert3 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO/{{ svc_fqdn }} absent again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: "FOO/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Host {{ svc_fqdn }} absent
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Remove certificate files.  # noqa: deprecated-command-syntax
+    ansible.builtin.shell:
+      cmd: rm -f "private{{ item }}.key" "cert{{ item }}.pem" "cert{{ item }}.der" "cert{{ item }}.b64"
+    with_items: [1, 2, 3]
+    become: no
+    delegate_to: localhost
diff --git a/tests/service/certificate/test_services_certificate_newline.yml b/tests/service/certificate/test_services_certificate_newline.yml
new file mode 100644
index 0000000000000000000000000000000000000000..33815a0155e80157b4f06e9253e8f8dd665aab11
--- /dev/null
+++ b/tests/service/certificate/test_services_certificate_newline.yml
@@ -0,0 +1,314 @@
+---
+- name: Test services with certificates with and without trailing new line
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: Include tasks ../../env_freeipa_facts.yml
+    ansible.builtin.include_tasks: ../../env_freeipa_facts.yml
+
+  - name: Setup test environment
+    ansible.builtin.include_tasks: ../env_vars.yml
+
+  - name: Generate self-signed certificates.
+    ansible.builtin.shell:
+      cmd: |
+        openssl req -x509 -newkey rsa:2048 -days 365 -nodes -keyout "private{{ item }}.key" -out "cert{{ item }}.pem" -subj '/CN=test'
+        openssl x509 -outform der -in "cert{{ item }}.pem" -out "cert{{ item }}.der"
+        base64 "cert{{ item }}.der" -w5000 > "cert{{ item }}.b64"
+    with_items: [11, 12, 13, 21, 22, 23, 31, 32, 33]
+    become: no
+    delegate_to: localhost
+
+  # The rstrip=False for lookup will add keep the newline at the end of the
+  # cert and this is automatically revoved in IPA, This is an additional
+  # test of ipaservice later on to behave correctly in both cases.
+  - name: Set fact for certs 11,12,13,21,22,23,31,32,33 from lookup
+    ansible.builtin.set_fact:
+      cert11: "{{ lookup('file', 'cert11.b64', rstrip=True) }}"
+      cert12: "{{ lookup('file', 'cert12.b64', rstrip=False) }}"
+      cert13: "{{ lookup('file', 'cert13.b64', rstrip=True) }}"
+      cert21: "{{ lookup('file', 'cert21.b64', rstrip=False) }}"
+      cert22: "{{ lookup('file', 'cert22.b64', rstrip=False) }}"
+      cert23: "{{ lookup('file', 'cert23.b64', rstrip=True) }}"
+      cert31: "{{ lookup('file', 'cert31.b64', rstrip=False) }}"
+      cert32: "{{ lookup('file', 'cert32.b64', rstrip=True) }}"
+      cert33: "{{ lookup('file', 'cert33.b64', rstrip=False) }}"
+
+  - name: Services FOO,BAR,BAZ/{{ svc_fqdn }} absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name:
+      - "FOO/{{ svc_fqdn }}"
+      - "BAR/{{ svc_fqdn }}"
+      - "BAZ/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+
+  - name: Host {{ svc_fqdn }} absent
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      state: absent
+
+  - name: Host {{ svc_fqdn }} present
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      force: true
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services FOO,BAR,BAZ/{{ svc_fqdn }} present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        force: yes
+      - name: "BAR/{{ svc_fqdn }}"
+        force: yes
+      - name: "BAZ/{{ svc_fqdn }}"
+        force: yes
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services FOO,BAR,BAZ/{{ svc_fqdn }} present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        force: yes
+      - name: "BAR/{{ svc_fqdn }}"
+        force: yes
+      - name: "BAZ/{{ svc_fqdn }}"
+        force: yes
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2 members present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2 members present again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+      action: member
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2,x3 members present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2,x3 members present again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x2,x3 members absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x2,x3 members absent, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2,x3 members absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Service FOO,BAR,BAZ/{{ svc_fqdn }} certs x1,x2,x3 members absent, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      services:
+      - name: "FOO/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert11 }}"
+          - "{{ cert12 }}"
+          - "{{ cert13 }}"
+      - name: "BAR/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert21 }}"
+          - "{{ cert22 }}"
+          - "{{ cert23 }}"
+      - name: "BAZ/{{ svc_fqdn }}"
+        certificate:
+          - "{{ cert31 }}"
+          - "{{ cert32 }}"
+          - "{{ cert33 }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Services FOO,BAR,BAZ/{{ svc_fqdn }} absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name:
+      - "FOO/{{ svc_fqdn }}"
+      - "BAR/{{ svc_fqdn }}"
+      - "BAZ/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services FOO,BAR,BAZ/{{ svc_fqdn }} absent, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name:
+      - "FOO/{{ svc_fqdn }}"
+      - "BAR/{{ svc_fqdn }}"
+      - "BAZ/{{ svc_fqdn }}"
+      continue: true
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Host {{ svc_fqdn }} absent
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ svc_fqdn }}"
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Remove certificate files.  # noqa: deprecated-command-syntax
+    ansible.builtin.shell:
+      cmd: rm -f "private{{ item }}.key" "cert{{ item }}.pem" "cert{{ item }}.der" "cert{{ item }}.b64"
+    with_items: [11, 12, 13, 21, 22, 23, 31, 32, 33]
+    become: no
+    delegate_to: localhost
diff --git a/tests/service/generate_test_data.yml b/tests/service/generate_test_data.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b046617ffe85106fd20190b2e327e1a8e1d6a801
--- /dev/null
+++ b/tests/service/generate_test_data.yml
@@ -0,0 +1,98 @@
+# Generate lists for hosts and services
+---
+- name: Get Domain from server name
+  ansible.builtin.set_fact:
+    ipaserver_domain: "{{ ansible_facts['fqdn'].split('.')[1:] | join('.') }}"
+  when: ipaserver_domain is not defined
+
+- name: Create present services.json data
+  ansible.builtin.shell: |
+    echo "["
+    for i in $(seq 1 "{{ NUM }}"); do
+        echo "  {"
+        echo "    \"name\": \"HTTP/www$i.{{ DOMAIN }}\","
+        echo "    \"principal\": \"host/test$i.{{ DOMAIN }}\","
+        echo "    \"force\": \"true\""
+        if [ "$i" -lt "{{ NUM }}" ]; then
+           echo "  },"
+        else
+           echo "  }"
+        fi
+    done
+    echo "]"
+  vars:
+    NUM: 500
+    DOMAIN: "{{ ipaserver_domain }}"
+  register: command
+
+- name: Set service_list
+  ansible.builtin.set_fact:
+    service_list: "{{ command.stdout | from_json }}"
+
+- name: Create absent services.json data
+  ansible.builtin.shell: |
+    echo "["
+    for i in $(seq 1 "{{ NUM }}"); do
+        echo "  {"
+        echo "    \"name\": \"HTTP/www$i.{{ DOMAIN }}\","
+        echo "    \"continue\": \"true\""
+        if [ "$i" -lt "{{ NUM }}" ]; then
+           echo "  },"
+        else
+           echo "  }"
+        fi
+    done
+    echo "]"
+  vars:
+    NUM: 500
+    DOMAIN: "{{ ipaserver_domain }}"
+  register: command
+
+- name: Set service_absent_list
+  ansible.builtin.set_fact:
+    service_absent_list: "{{ command.stdout | from_json }}"
+
+- name: Create present hosts.json data
+  ansible.builtin.shell: |
+    echo "["
+    for i in $(seq 1 "{{ NUM }}"); do
+        echo "  {"
+        echo "    \"name\": \"www$i.{{ DOMAIN }}\","
+        echo "    \"force\": \"true\""
+        if [ "$i" -lt "{{ NUM }}" ]; then
+           echo "  },"
+        else
+           echo "  }"
+        fi
+    done
+    echo "]"
+  vars:
+    NUM: 500
+    DOMAIN: "{{ ipaserver_domain }}"
+  register: command
+
+- name: Set host_list
+  ansible.builtin.set_fact:
+    host_list: "{{ command.stdout | from_json }}"
+
+- name: Create absent hosts.json data
+  ansible.builtin.shell: |
+    echo "["
+    for i in $(seq 1 "{{ NUM }}"); do
+        echo "  {"
+        echo "    \"name\": \"www$i.{{ DOMAIN }}\""
+        if [ "$i" -lt "{{ NUM }}" ]; then
+           echo "  },"
+        else
+           echo "  }"
+        fi
+    done
+    echo "]"
+  vars:
+    NUM: 500
+    DOMAIN: "{{ ipaserver_domain }}"
+  register: command
+
+- name: Set host_absent_list
+  ansible.builtin.set_fact:
+    host_absent_list: "{{ command.stdout | from_json }}"
diff --git a/tests/service/test_services_absent.yml b/tests/service/test_services_absent.yml
new file mode 100644
index 0000000000000000000000000000000000000000..473cc8e64e2ebd0e24168cb2a9a88b8896e5d7d7
--- /dev/null
+++ b/tests/service/test_services_absent.yml
@@ -0,0 +1,15 @@
+---
+- name: Test services absent
+  hosts: ipaserver
+  become: true
+  gather_facts: false
+
+  tasks:
+  - name: Include generate_test_data.yml
+    ansible.builtin.include_tasks: generate_test_data.yml
+
+  - name: Services absent len:{{ service_list | length }}
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_absent_list }}"
+      state: absent
diff --git a/tests/service/test_services_present.yml b/tests/service/test_services_present.yml
new file mode 100644
index 0000000000000000000000000000000000000000..338737eb238051403948b81a6d77c51b583e16e5
--- /dev/null
+++ b/tests/service/test_services_present.yml
@@ -0,0 +1,71 @@
+---
+- name: Test services present
+  hosts: ipaserver
+  become: true
+  gather_facts: true
+
+  tasks:
+  - name: Include generate_test_data.yml
+    ansible.builtin.include_tasks: generate_test_data.yml
+
+  - name: Hosts present len:{{ host_list | length }}
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_list }}"
+      force: true
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Hosts present len:{{ host_list | length }}, again
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_list }}"
+      force: true
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Services present len:{{ service_list | length }}
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_list }}"
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services present len:{{ service_list | length }}, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_list }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Services absent len:{{ service_list | length }}
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_absent_list }}"
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services absent len:{{ service_list | length }}, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_absent_list }}"
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Hosts absent len:{{ host_list | length }}
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_absent_list }}"
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Hosts absent len:{{ host_list | length }}, again
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_absent_list }}"
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
diff --git a/tests/service/test_services_present_slice.yml b/tests/service/test_services_present_slice.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6a500a39d7c7761b3b056746bd5ef12594baf7d7
--- /dev/null
+++ b/tests/service/test_services_present_slice.yml
@@ -0,0 +1,91 @@
+---
+- name: Test services present slice
+  hosts: ipaserver
+  become: true
+  gather_facts: true
+
+  vars:
+    slice_size: 100
+  tasks:
+  - name: Include generate_test_data.yml
+    ansible.builtin.include_tasks: generate_test_data.yml
+
+  - name: Size of slice
+    ansible.builtin.debug:
+      msg: "{{ slice_size }}"
+
+  - name: Size of services list
+    ansible.builtin.debug:
+      msg: "{{ service_list | length }}"
+
+  - name: Size of hosts list
+    ansible.builtin.debug:
+      msg: "{{ host_list | length }}"
+
+  - name: Hosts present
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_list[item : item + slice_size] }}"
+    loop: "{{ range(0, host_list | length, slice_size) | list }}"
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Hosts present, again
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_list[item : item + slice_size] }}"
+    loop: "{{ range(0, host_list | length, slice_size) | list }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Services present
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_list[item : item + slice_size] }}"
+    loop: "{{ range(0, service_list | length, slice_size) | list }}"
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services present, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_list[item : item + slice_size] }}"
+    loop: "{{ range(0, service_list | length, slice_size) | list }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Services absent
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_absent_list[item : item + slice_size] }}"
+      state: absent
+    loop: "{{ range(0, service_absent_list | length, slice_size) | list }}"
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Services absent, again
+    ipaservice:
+      ipaadmin_password: SomeADMINpassword
+      services: "{{ service_absent_list[item : item + slice_size] }}"
+      state: absent
+    loop: "{{ range(0, service_absent_list | length, slice_size) | list }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Hosts absent
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_absent_list[item : item + slice_size] }}"
+      state: absent
+    loop: "{{ range(0, host_absent_list | length, slice_size) | list }}"
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Hosts absent, again
+    ipahost:
+      ipaadmin_password: SomeADMINpassword
+      hosts: "{{ host_absent_list[item : item + slice_size] }}"
+      state: absent
+    loop: "{{ range(0, host_absent_list | length, slice_size) | list }}"
+    register: result
+    failed_when: result.changed or result.failed
diff --git a/tests/service/test_services_without_skip_host_check.yml b/tests/service/test_services_without_skip_host_check.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0cb71281314e53caa836aeda2078590b1b571ef0
--- /dev/null
+++ b/tests/service/test_services_without_skip_host_check.yml
@@ -0,0 +1,100 @@
+---
+- name: Test services without using option skip_host_check
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  # setup
+  - name: Test services without using option skip_host_check
+    block:
+    - name: Setup test environment
+      ansible.builtin.include_tasks: env_setup.yml
+
+    - name: Services are present
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        services:
+        - name: "HTTP/{{ svc_fqdn }}"
+          principal:
+            - host/test.example.com
+        - name: "mysvc/{{ host1_fqdn }}"
+          pac_type: NONE
+          ok_as_delegate: yes
+          ok_to_auth_as_delegate: yes
+        - name: "HTTP/{{ host1_fqdn }}"
+          allow_create_keytab_user:
+            - user01
+            - user02
+          allow_create_keytab_group:
+            - group01
+            - group02
+          allow_create_keytab_host:
+            - "{{ host1_fqdn }}"
+            - "{{ host2_fqdn }}"
+          allow_create_keytab_hostgroup:
+            - hostgroup01
+            - hostgroup02
+        - name: "mysvc/{{ host2_fqdn }}"
+          auth_ind: otp,radius
+
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Services are present again
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        services:
+        - name: "HTTP/{{ svc_fqdn }}"
+        - name: "mysvc/{{ host1_fqdn }}"
+        - name: "HTTP/{{ host1_fqdn }}"
+        - name: "mysvc/{{ host2_fqdn }}"
+      register: result
+      failed_when: result.changed or result.failed
+
+    # failed_when: not result.failed has been added as this test needs to
+    # fail because two services with the same name should be added in the same
+    # task.
+    - name: Duplicate names in services failure test
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        services:
+        - name: "HTTP/{{ svc_fqdn }}"
+        - name: "mysvc/{{ host1_fqdn }}"
+        - name: "HTTP/{{ nohost_fqdn }}"
+        - name: "HTTP/{{ svc_fqdn }}"
+      register: result
+      failed_when: result.changed or not result.failed or "is used more than once" not in result.msg
+
+    - name: Services/name and name 'service' present
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        name: "HTTP/{{ svc_fqdn }}"
+        services:
+        - name: "HTTP/{{ svc_fqdn }}"
+      register: result
+      failed_when: result.changed or not result.failed or "parameters are mutually exclusive" not in result.msg
+
+    - name: Services/name and name are absent
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+      register: result
+      failed_when: result.changed or not result.failed or "one of the following is required" not in result.msg
+
+    - name: Name is absent
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        name:
+      register: result
+      failed_when: result.changed or not result.failed or "At least one name or services is required" not in result.msg
+
+    - name: Only one service can be added at a time using name.
+      ipaservice:
+        ipaadmin_password: SomeADMINpassword
+        name: example.com,example1.com
+      register: result
+      failed_when: result.changed or not result.failed or "Only one service can be added at a time using 'name'." not in result.msg
+
+    always:
+    # cleanup
+    - name: Cleanup test environment
+      ansible.builtin.include_tasks: env_cleanup.yml