However you batch the hosts up per plays and with serial, there are tradeoffs between doing more in parallel and going faster, and doing fewer in small batches but having less impact.
Your option #3, estimating runtime, could be improved by checking whether the window has expired before starting each batch. Including a buffer of estimated time a batch takes.
---
- name: Very first play
hosts: localhost
gather_facts: false
tasks:
# Unfortunately cannot keep actual datetime objects
# as Jinja converts them to strings
# Using set_fact to not lazy evaluate; need the time now not later
- name: playbook start!
set_fact:
playbook_start: "{{ now().timestamp() }}"
- debug:
var: now
verbosity: 1
# TODO Consider checking whether now is in a downtime window on some calendar
- name: Time window respecting play
hosts: localhost,127.0.0.2
gather_facts: false
serial: 1
vars:
# Configuration, in seconds
# Realistically would be much longer
# Total duration for all hosts:
downtime_planned_duration: 5
# Estimate of time for each batch to take:
downtime_buffer: 3
pre_tasks:
- name: host start!
set_fact:
host_start: "{{ now().timestamp() }}"
# Default behavior of failed hosts is to stop and not proceed with play
# Checking this before doing anything means
# hosts are not interrupted in the middle of their work
# Failed hosts can be reported on, and run again such as with retry files
- name: check if time window expired
assert:
that: "{{ (playbook_duration | int) + (downtime_buffer | int) < (downtime_planned_duration | int) }}"
success_msg: "Still in time window, proceeding with host"
fail_msg: "Insufficent buffer in time window, not starting host"
vars:
playbook_duration: "{{ (host_start | int) - (hostvars['localhost'].playbook_start | int) }}"
tasks:
# Do work here, run some roles
- name: sleep a bit to simulate doing things
pause:
seconds: 3
Unfortunately, when implemented as a play, this is a few lines of time math nonsense that's not easy to reuse. In theory this could be written as something like a callback plugin, and automatically triggered on play events.