mirror of
https://github.com/ansible/awx.git
synced 2026-02-09 13:44:42 -03:30
Compare commits
78 Commits
21.10.0
...
13089-Sche
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a947ed31 | ||
|
|
b99a434dee | ||
|
|
6cee99a9f9 | ||
|
|
ee509aea56 | ||
|
|
b5452a48f8 | ||
|
|
0c980fa7d5 | ||
|
|
e34ce8c795 | ||
|
|
3543644e0e | ||
|
|
36c0d07b30 | ||
|
|
239827a9cf | ||
|
|
ac9871b36f | ||
|
|
f739908ccf | ||
|
|
cf1ec07eab | ||
|
|
d968b648de | ||
|
|
5dd0eab806 | ||
|
|
41f3f381ec | ||
|
|
ac8cff75ce | ||
|
|
94b34b801c | ||
|
|
8f6849fc22 | ||
|
|
821b1701bf | ||
|
|
b7f2825909 | ||
|
|
e87e041a2a | ||
|
|
cc336e791c | ||
|
|
c2a3c3b285 | ||
|
|
7b8dcc98e7 | ||
|
|
d5011492bf | ||
|
|
e363ddf470 | ||
|
|
987709cdb3 | ||
|
|
f04ac3c798 | ||
|
|
71a6baccdb | ||
|
|
d07076b686 | ||
|
|
7129f3e8cd | ||
|
|
df61a5cea1 | ||
|
|
a4b950f79b | ||
|
|
8be739d255 | ||
|
|
ca54195099 | ||
|
|
f0fcfdde39 | ||
|
|
80b1ba4a35 | ||
|
|
51f8e362dc | ||
|
|
737d6d8c8b | ||
|
|
beaf6b6058 | ||
|
|
aad1fbcef8 | ||
|
|
0b96d617ac | ||
|
|
fe768a159b | ||
|
|
c1ebea858b | ||
|
|
da9b8135e8 | ||
|
|
76cecf3f6b | ||
|
|
7b2938f515 | ||
|
|
916b5642d2 | ||
|
|
e524d3df3e | ||
|
|
01e9a611ea | ||
|
|
ef29589940 | ||
|
|
cec2d2dfb9 | ||
|
|
15b7ad3570 | ||
|
|
36ff9cbc6d | ||
|
|
ed74d80ecb | ||
|
|
a0b8215c06 | ||
|
|
f88b993b18 | ||
|
|
4a7f4d0ed4 | ||
|
|
6e08c3567f | ||
|
|
adbcb5c5e4 | ||
|
|
8054c6aedc | ||
|
|
58734a33c4 | ||
|
|
2832f28014 | ||
|
|
e5057691ee | ||
|
|
a0cfd8501c | ||
|
|
99b643bd77 | ||
|
|
305b39d8e5 | ||
|
|
642003e207 | ||
|
|
06daebbecf | ||
|
|
eaccf32aa3 | ||
|
|
f0481d0a60 | ||
|
|
d34f6af830 | ||
|
|
f9bb26ad33 | ||
|
|
878035c13b | ||
|
|
2cc971a43f | ||
|
|
9d77c54612 | ||
|
|
ef651a3a21 |
10
.github/triage_replies.md
vendored
10
.github/triage_replies.md
vendored
@@ -53,6 +53,16 @@ https://github.com/ansible/awx/#get-involved \
|
||||
Thank you once again for this and your interest in AWX!
|
||||
|
||||
|
||||
### Red Hat Support Team
|
||||
- Hi! \
|
||||
\
|
||||
It appears that you are using an RPM build for RHEL. Please reach out to the Red Hat support team and submit a ticket. \
|
||||
\
|
||||
Here is the link to do so: \
|
||||
\
|
||||
https://access.redhat.com/support \
|
||||
\
|
||||
Thank you for your submission and for supporting AWX!
|
||||
|
||||
|
||||
## Common
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -145,3 +145,22 @@ jobs:
|
||||
env:
|
||||
AWX_TEST_IMAGE: awx
|
||||
AWX_TEST_VERSION: ci
|
||||
|
||||
collection-sanity:
|
||||
name: awx_collection sanity
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# The containers that GitHub Actions use have Ansible installed, so upgrade to make sure we have the latest version.
|
||||
- name: Upgrade ansible-core
|
||||
run: python3 -m pip install --upgrade ansible-core
|
||||
|
||||
- name: Run sanity tests
|
||||
run: make test_collection_sanity
|
||||
env:
|
||||
# needed due to cgroupsv2. This is fixed, but a stable release
|
||||
# with the fix has not been made yet.
|
||||
ANSIBLE_TEST_PREFER_PODMAN: 1
|
||||
|
||||
4
.github/workflows/e2e_test.yml
vendored
4
.github/workflows/e2e_test.yml
vendored
@@ -6,7 +6,7 @@ env:
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
jobs:
|
||||
jobs:
|
||||
e2e-test:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'qe:e2e')
|
||||
runs-on: ubuntu-latest
|
||||
@@ -107,5 +107,3 @@ jobs:
|
||||
with:
|
||||
name: AWX-logs-${{ matrix.job }}
|
||||
path: make-docker-compose-output.log
|
||||
|
||||
|
||||
|
||||
48
Makefile
48
Makefile
@@ -6,7 +6,20 @@ CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
MANAGEMENT_COMMAND ?= awx-manage
|
||||
VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py)
|
||||
COLLECTION_VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
||||
|
||||
# ansible-test requires semver compatable version, so we allow overrides to hack it
|
||||
COLLECTION_VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
||||
# args for the ansible-test sanity command
|
||||
COLLECTION_SANITY_ARGS ?= --docker
|
||||
# collection unit testing directories
|
||||
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
|
||||
# collection integration test directories (defaults to all)
|
||||
COLLECTION_TEST_TARGET ?=
|
||||
# args for collection install
|
||||
COLLECTION_PACKAGE ?= awx
|
||||
COLLECTION_NAMESPACE ?= awx
|
||||
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
||||
COLLECTION_TEMPLATE_VERSION ?= false
|
||||
|
||||
# NOTE: This defaults the container image version to the branch that's active
|
||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||
@@ -288,19 +301,13 @@ test:
|
||||
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
|
||||
awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
|
||||
|
||||
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
|
||||
COLLECTION_TEST_TARGET ?=
|
||||
COLLECTION_PACKAGE ?= awx
|
||||
COLLECTION_NAMESPACE ?= awx
|
||||
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
||||
COLLECTION_TEMPLATE_VERSION ?= false
|
||||
|
||||
test_collection:
|
||||
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
||||
if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/awx/bin/activate; \
|
||||
fi && \
|
||||
pip install ansible-core && \
|
||||
if ! [ -x "$(shell command -v ansible-playbook)" ]; then pip install ansible-core; fi
|
||||
ansible --version
|
||||
py.test $(COLLECTION_TEST_DIRS) -v
|
||||
# The python path needs to be modified so that the tests can find Ansible within the container
|
||||
# First we will use anything expility set as PYTHONPATH
|
||||
@@ -330,8 +337,13 @@ install_collection: build_collection
|
||||
rm -rf $(COLLECTION_INSTALL)
|
||||
ansible-galaxy collection install awx_collection_build/$(COLLECTION_NAMESPACE)-$(COLLECTION_PACKAGE)-$(COLLECTION_VERSION).tar.gz
|
||||
|
||||
test_collection_sanity: install_collection
|
||||
cd $(COLLECTION_INSTALL) && ansible-test sanity
|
||||
test_collection_sanity:
|
||||
rm -rf awx_collection_build/
|
||||
rm -rf $(COLLECTION_INSTALL)
|
||||
if ! [ -x "$(shell command -v ansible-test)" ]; then pip install ansible-core; fi
|
||||
ansible --version
|
||||
COLLECTION_VERSION=1.0.0 make install_collection
|
||||
cd $(COLLECTION_INSTALL) && ansible-test sanity $(COLLECTION_SANITY_ARGS)
|
||||
|
||||
test_collection_integration: install_collection
|
||||
cd $(COLLECTION_INSTALL) && ansible-test integration $(COLLECTION_TEST_TARGET)
|
||||
@@ -389,18 +401,18 @@ $(UI_BUILD_FLAG_FILE):
|
||||
$(PYTHON) tools/scripts/compilemessages.py
|
||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run compile-strings
|
||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run build
|
||||
mkdir -p /var/lib/awx/public/static/css
|
||||
mkdir -p /var/lib/awx/public/static/js
|
||||
mkdir -p /var/lib/awx/public/static/media
|
||||
cp -r awx/ui/build/static/css/* /var/lib/awx/public/static/css
|
||||
cp -r awx/ui/build/static/js/* /var/lib/awx/public/static/js
|
||||
cp -r awx/ui/build/static/media/* /var/lib/awx/public/static/media
|
||||
touch $@
|
||||
|
||||
ui-release: $(UI_BUILD_FLAG_FILE)
|
||||
|
||||
ui-devel: awx/ui/node_modules
|
||||
@$(MAKE) -B $(UI_BUILD_FLAG_FILE)
|
||||
mkdir -p /var/lib/awx/public/static/css
|
||||
mkdir -p /var/lib/awx/public/static/js
|
||||
mkdir -p /var/lib/awx/public/static/media
|
||||
cp -r awx/ui/build/static/css/* /var/lib/awx/public/static/css
|
||||
cp -r awx/ui/build/static/js/* /var/lib/awx/public/static/js
|
||||
cp -r awx/ui/build/static/media/* /var/lib/awx/public/static/media
|
||||
|
||||
ui-devel-instrumented: awx/ui/node_modules
|
||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run start-instrumented
|
||||
@@ -598,7 +610,7 @@ messages:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/awx/bin/activate; \
|
||||
fi; \
|
||||
$(PYTHON) manage.py makemessages -l $(LANG) --keep-pot
|
||||
$(PYTHON) manage.py makemessages -l en_us --keep-pot
|
||||
|
||||
print-%:
|
||||
@echo $($*)
|
||||
|
||||
@@ -344,6 +344,13 @@ class InstanceDetail(RetrieveUpdateAPIView):
|
||||
model = models.Instance
|
||||
serializer_class = serializers.InstanceSerializer
|
||||
|
||||
def update_raw_data(self, data):
|
||||
# these fields are only valid on creation of an instance, so they unwanted on detail view
|
||||
data.pop('listener_port', None)
|
||||
data.pop('node_type', None)
|
||||
data.pop('hostname', None)
|
||||
return super(InstanceDetail, self).update_raw_data(data)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
r = super(InstanceDetail, self).update(request, *args, **kwargs)
|
||||
if status.is_success(r.status_code):
|
||||
|
||||
@@ -16,7 +16,7 @@ from rest_framework import status
|
||||
|
||||
from awx.main.constants import ACTIVE_STATES
|
||||
from awx.main.utils import get_object_or_400
|
||||
from awx.main.models.ha import Instance, InstanceGroup
|
||||
from awx.main.models.ha import Instance, InstanceGroup, schedule_policy_task
|
||||
from awx.main.models.organization import Team
|
||||
from awx.main.models.projects import Project
|
||||
from awx.main.models.inventory import Inventory
|
||||
@@ -107,6 +107,11 @@ class InstanceGroupMembershipMixin(object):
|
||||
if inst_name in ig_obj.policy_instance_list:
|
||||
ig_obj.policy_instance_list.pop(ig_obj.policy_instance_list.index(inst_name))
|
||||
ig_obj.save(update_fields=['policy_instance_list'])
|
||||
|
||||
# sometimes removing an instance has a non-obvious consequence
|
||||
# this is almost always true if policy_instance_percentage or _minimum is non-zero
|
||||
# after removing a single instance, the other memberships need to be re-balanced
|
||||
schedule_policy_task()
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -2697,46 +2697,66 @@ class ActivityStreamAccess(BaseAccess):
|
||||
# 'job_template', 'job', 'project', 'project_update', 'workflow_job',
|
||||
# 'inventory_source', 'workflow_job_template'
|
||||
|
||||
inventory_set = Inventory.accessible_objects(self.user, 'read_role')
|
||||
credential_set = Credential.accessible_objects(self.user, 'read_role')
|
||||
q = Q(user=self.user)
|
||||
inventory_set = Inventory.accessible_pk_qs(self.user, 'read_role')
|
||||
if inventory_set:
|
||||
q |= (
|
||||
Q(ad_hoc_command__inventory__in=inventory_set)
|
||||
| Q(inventory__in=inventory_set)
|
||||
| Q(host__inventory__in=inventory_set)
|
||||
| Q(group__inventory__in=inventory_set)
|
||||
| Q(inventory_source__inventory__in=inventory_set)
|
||||
| Q(inventory_update__inventory_source__inventory__in=inventory_set)
|
||||
)
|
||||
|
||||
credential_set = Credential.accessible_pk_qs(self.user, 'read_role')
|
||||
if credential_set:
|
||||
q |= Q(credential__in=credential_set)
|
||||
|
||||
auditing_orgs = (
|
||||
(Organization.accessible_objects(self.user, 'admin_role') | Organization.accessible_objects(self.user, 'auditor_role'))
|
||||
.distinct()
|
||||
.values_list('id', flat=True)
|
||||
)
|
||||
project_set = Project.accessible_objects(self.user, 'read_role')
|
||||
jt_set = JobTemplate.accessible_objects(self.user, 'read_role')
|
||||
team_set = Team.accessible_objects(self.user, 'read_role')
|
||||
wfjt_set = WorkflowJobTemplate.accessible_objects(self.user, 'read_role')
|
||||
app_set = OAuth2ApplicationAccess(self.user).filtered_queryset()
|
||||
token_set = OAuth2TokenAccess(self.user).filtered_queryset()
|
||||
if auditing_orgs:
|
||||
q |= (
|
||||
Q(user__in=auditing_orgs.values('member_role__members'))
|
||||
| Q(organization__in=auditing_orgs)
|
||||
| Q(notification_template__organization__in=auditing_orgs)
|
||||
| Q(notification__notification_template__organization__in=auditing_orgs)
|
||||
| Q(label__organization__in=auditing_orgs)
|
||||
| Q(role__in=Role.objects.filter(ancestors__in=self.user.roles.all()) if auditing_orgs else [])
|
||||
)
|
||||
|
||||
return qs.filter(
|
||||
Q(ad_hoc_command__inventory__in=inventory_set)
|
||||
| Q(o_auth2_application__in=app_set)
|
||||
| Q(o_auth2_access_token__in=token_set)
|
||||
| Q(user__in=auditing_orgs.values('member_role__members'))
|
||||
| Q(user=self.user)
|
||||
| Q(organization__in=auditing_orgs)
|
||||
| Q(inventory__in=inventory_set)
|
||||
| Q(host__inventory__in=inventory_set)
|
||||
| Q(group__inventory__in=inventory_set)
|
||||
| Q(inventory_source__inventory__in=inventory_set)
|
||||
| Q(inventory_update__inventory_source__inventory__in=inventory_set)
|
||||
| Q(credential__in=credential_set)
|
||||
| Q(team__in=team_set)
|
||||
| Q(project__in=project_set)
|
||||
| Q(project_update__project__in=project_set)
|
||||
| Q(job_template__in=jt_set)
|
||||
| Q(job__job_template__in=jt_set)
|
||||
| Q(workflow_job_template__in=wfjt_set)
|
||||
| Q(workflow_job_template_node__workflow_job_template__in=wfjt_set)
|
||||
| Q(workflow_job__workflow_job_template__in=wfjt_set)
|
||||
| Q(notification_template__organization__in=auditing_orgs)
|
||||
| Q(notification__notification_template__organization__in=auditing_orgs)
|
||||
| Q(label__organization__in=auditing_orgs)
|
||||
| Q(role__in=Role.objects.filter(ancestors__in=self.user.roles.all()) if auditing_orgs else [])
|
||||
).distinct()
|
||||
project_set = Project.accessible_pk_qs(self.user, 'read_role')
|
||||
if project_set:
|
||||
q |= Q(project__in=project_set) | Q(project_update__project__in=project_set)
|
||||
|
||||
jt_set = JobTemplate.accessible_pk_qs(self.user, 'read_role')
|
||||
if jt_set:
|
||||
q |= Q(job_template__in=jt_set) | Q(job__job_template__in=jt_set)
|
||||
|
||||
wfjt_set = WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')
|
||||
if wfjt_set:
|
||||
q |= (
|
||||
Q(workflow_job_template__in=wfjt_set)
|
||||
| Q(workflow_job_template_node__workflow_job_template__in=wfjt_set)
|
||||
| Q(workflow_job__workflow_job_template__in=wfjt_set)
|
||||
)
|
||||
|
||||
team_set = Team.accessible_pk_qs(self.user, 'read_role')
|
||||
if team_set:
|
||||
q |= Q(team__in=team_set)
|
||||
|
||||
app_set = OAuth2ApplicationAccess(self.user).filtered_queryset()
|
||||
if app_set:
|
||||
q |= Q(o_auth2_application__in=app_set)
|
||||
|
||||
token_set = OAuth2TokenAccess(self.user).filtered_queryset()
|
||||
if token_set:
|
||||
q |= Q(o_auth2_access_token__in=token_set)
|
||||
|
||||
return qs.filter(q).distinct()
|
||||
|
||||
def can_add(self, data):
|
||||
return False
|
||||
|
||||
@@ -2,6 +2,7 @@ import datetime
|
||||
import asyncio
|
||||
import logging
|
||||
import redis
|
||||
import redis.asyncio
|
||||
import re
|
||||
|
||||
from prometheus_client import (
|
||||
@@ -81,7 +82,7 @@ class BroadcastWebsocketStatsManager:
|
||||
|
||||
async def run_loop(self):
|
||||
try:
|
||||
redis_conn = await redis.asyncio.create_redis_pool(settings.BROKER_URL)
|
||||
redis_conn = await redis.asyncio.Redis.from_url(settings.BROKER_URL)
|
||||
while True:
|
||||
stats_data_str = ''.join(stat.serialize() for stat in self._stats.values())
|
||||
await redis_conn.set(self._redis_key, stats_data_str)
|
||||
@@ -121,8 +122,8 @@ class BroadcastWebsocketStats:
|
||||
'Number of messages received, to be forwarded, by the broadcast websocket system',
|
||||
registry=self._registry,
|
||||
)
|
||||
self._messages_received = Gauge(
|
||||
f'awx_{self.remote_name}_messages_received',
|
||||
self._messages_received_current_conn = Gauge(
|
||||
f'awx_{self.remote_name}_messages_received_currrent_conn',
|
||||
'Number forwarded messages received by the broadcast websocket system, for the duration of the current connection',
|
||||
registry=self._registry,
|
||||
)
|
||||
@@ -143,13 +144,13 @@ class BroadcastWebsocketStats:
|
||||
|
||||
def record_message_received(self):
|
||||
self._internal_messages_received_per_minute.record()
|
||||
self._messages_received.inc()
|
||||
self._messages_received_current_conn.inc()
|
||||
self._messages_received_total.inc()
|
||||
|
||||
def record_connection_established(self):
|
||||
self._connection.state('connected')
|
||||
self._connection_start.set_to_current_time()
|
||||
self._messages_received.set(0)
|
||||
self._messages_received_current_conn.set(0)
|
||||
|
||||
def record_connection_lost(self):
|
||||
self._connection.state('disconnected')
|
||||
|
||||
@@ -569,7 +569,7 @@ register(
|
||||
register(
|
||||
'LOG_AGGREGATOR_LOGGERS',
|
||||
field_class=fields.StringListField,
|
||||
default=['awx', 'activity_stream', 'job_events', 'system_tracking'],
|
||||
default=['awx', 'activity_stream', 'job_events', 'system_tracking', 'broadcast_websocket'],
|
||||
label=_('Loggers Sending Data to Log Aggregator Form'),
|
||||
help_text=_(
|
||||
'List of loggers that will send HTTP logs to the collector, these can '
|
||||
@@ -577,7 +577,8 @@ register(
|
||||
'awx - service logs\n'
|
||||
'activity_stream - activity stream records\n'
|
||||
'job_events - callback data from Ansible job events\n'
|
||||
'system_tracking - facts gathered from scan jobs.'
|
||||
'system_tracking - facts gathered from scan jobs\n'
|
||||
'broadcast_websocket - errors pertaining to websockets broadcast metrics\n'
|
||||
),
|
||||
category=_('Logging'),
|
||||
category_slug='logging',
|
||||
|
||||
@@ -9,10 +9,16 @@ aim_inputs = {
|
||||
'fields': [
|
||||
{
|
||||
'id': 'url',
|
||||
'label': _('CyberArk AIM URL'),
|
||||
'label': _('CyberArk CCP URL'),
|
||||
'type': 'string',
|
||||
'format': 'url',
|
||||
},
|
||||
{
|
||||
'id': 'webservice_id',
|
||||
'label': _('Web Service ID'),
|
||||
'type': 'string',
|
||||
'help_text': _('The CCP Web Service ID. Leave blank to default to AIMWebService.'),
|
||||
},
|
||||
{
|
||||
'id': 'app_id',
|
||||
'label': _('Application ID'),
|
||||
@@ -64,10 +70,13 @@ def aim_backend(**kwargs):
|
||||
client_cert = kwargs.get('client_cert', None)
|
||||
client_key = kwargs.get('client_key', None)
|
||||
verify = kwargs['verify']
|
||||
webservice_id = kwargs['webservice_id']
|
||||
app_id = kwargs['app_id']
|
||||
object_query = kwargs['object_query']
|
||||
object_query_format = kwargs['object_query_format']
|
||||
reason = kwargs.get('reason', None)
|
||||
if webservice_id == '':
|
||||
webservice_id = 'AIMWebService'
|
||||
|
||||
query_params = {
|
||||
'AppId': app_id,
|
||||
@@ -78,7 +87,7 @@ def aim_backend(**kwargs):
|
||||
query_params['reason'] = reason
|
||||
|
||||
request_qs = '?' + urlencode(query_params, quote_via=quote)
|
||||
request_url = urljoin(url, '/'.join(['AIMWebService', 'api', 'Accounts']))
|
||||
request_url = urljoin(url, '/'.join([webservice_id, 'api', 'Accounts']))
|
||||
|
||||
with CertFiles(client_cert, client_key) as cert:
|
||||
res = requests.get(
|
||||
@@ -92,4 +101,4 @@ def aim_backend(**kwargs):
|
||||
return res.json()['Content']
|
||||
|
||||
|
||||
aim_plugin = CredentialPlugin('CyberArk AIM Central Credential Provider Lookup', inputs=aim_inputs, backend=aim_backend)
|
||||
aim_plugin = CredentialPlugin('CyberArk Central Credential Provider Lookup', inputs=aim_inputs, backend=aim_backend)
|
||||
|
||||
@@ -32,8 +32,14 @@ class Command(BaseCommand):
|
||||
def handle(self, **options):
|
||||
self.old_key = settings.SECRET_KEY
|
||||
custom_key = os.environ.get("TOWER_SECRET_KEY")
|
||||
if options.get("use_custom_key") and custom_key:
|
||||
self.new_key = custom_key
|
||||
if options.get("use_custom_key"):
|
||||
if custom_key:
|
||||
self.new_key = custom_key
|
||||
else:
|
||||
print("Use custom key was specified but the env var TOWER_SECRET_KEY was not available")
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
else:
|
||||
self.new_key = base64.encodebytes(os.urandom(33)).decode().rstrip()
|
||||
self._notification_templates()
|
||||
|
||||
@@ -158,7 +158,11 @@ class InstanceManager(models.Manager):
|
||||
return (False, instance)
|
||||
|
||||
# Create new instance, and fill in default values
|
||||
create_defaults = {'node_state': Instance.States.INSTALLED, 'capacity': 0}
|
||||
create_defaults = {
|
||||
'node_state': Instance.States.INSTALLED,
|
||||
'capacity': 0,
|
||||
'listener_port': 27199,
|
||||
}
|
||||
if defaults is not None:
|
||||
create_defaults.update(defaults)
|
||||
uuid_option = {}
|
||||
|
||||
18
awx/main/migrations/0174_ensure_org_ee_admin_roles.py
Normal file
18
awx/main/migrations/0174_ensure_org_ee_admin_roles.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.16 on 2022-12-07 21:11
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from awx.main.migrations import _rbac as rbac
|
||||
from awx.main.migrations import _migration_utils as migration_utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0173_instancegroup_max_limits'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
|
||||
migrations.RunPython(rbac.create_roles),
|
||||
]
|
||||
@@ -15,6 +15,7 @@ def aws(cred, env, private_data_dir):
|
||||
|
||||
if cred.has_input('security_token'):
|
||||
env['AWS_SECURITY_TOKEN'] = cred.get_input('security_token', default='')
|
||||
env['AWS_SESSION_TOKEN'] = env['AWS_SECURITY_TOKEN']
|
||||
|
||||
|
||||
def gce(cred, env, private_data_dir):
|
||||
|
||||
@@ -507,7 +507,7 @@ class TaskManager(TaskBase):
|
||||
return None
|
||||
|
||||
@timeit
|
||||
def start_task(self, task, instance_group, dependent_tasks=None, instance=None):
|
||||
def start_task(self, task, instance_group, instance=None):
|
||||
# Just like for process_running_tasks, add the job to the dependency graph and
|
||||
# ask the TaskManagerInstanceGroups object to update consumed capacity on all
|
||||
# implicated instances and container groups.
|
||||
@@ -524,14 +524,6 @@ class TaskManager(TaskBase):
|
||||
ScheduleTaskManager().schedule()
|
||||
from awx.main.tasks.system import handle_work_error, handle_work_success
|
||||
|
||||
dependent_tasks = dependent_tasks or []
|
||||
|
||||
task_actual = {
|
||||
'type': get_type_for_model(type(task)),
|
||||
'id': task.id,
|
||||
}
|
||||
dependencies = [{'type': get_type_for_model(type(t)), 'id': t.id} for t in dependent_tasks]
|
||||
|
||||
task.status = 'waiting'
|
||||
|
||||
(start_status, opts) = task.pre_start()
|
||||
@@ -563,6 +555,7 @@ class TaskManager(TaskBase):
|
||||
# apply_async does a NOTIFY to the channel dispatcher is listening to
|
||||
# postgres will treat this as part of the transaction, which is what we want
|
||||
if task.status != 'failed' and type(task) is not WorkflowJob:
|
||||
task_actual = {'type': get_type_for_model(type(task)), 'id': task.id}
|
||||
task_cls = task._get_task_class()
|
||||
task_cls.apply_async(
|
||||
[task.pk],
|
||||
@@ -570,7 +563,7 @@ class TaskManager(TaskBase):
|
||||
queue=task.get_queue_name(),
|
||||
uuid=task.celery_task_id,
|
||||
callbacks=[{'task': handle_work_success.name, 'kwargs': {'task_actual': task_actual}}],
|
||||
errbacks=[{'task': handle_work_error.name, 'args': [task.celery_task_id], 'kwargs': {'subtasks': [task_actual] + dependencies}}],
|
||||
errbacks=[{'task': handle_work_error.name, 'kwargs': {'task_actual': task_actual}}],
|
||||
)
|
||||
|
||||
# In exception cases, like a job failing pre-start checks, we send the websocket status message
|
||||
@@ -609,7 +602,7 @@ class TaskManager(TaskBase):
|
||||
if isinstance(task, WorkflowJob):
|
||||
# Previously we were tracking allow_simultaneous blocking both here and in DependencyGraph.
|
||||
# Double check that using just the DependencyGraph works for Workflows and Sliced Jobs.
|
||||
self.start_task(task, None, task.get_jobs_fail_chain(), None)
|
||||
self.start_task(task, None, None)
|
||||
continue
|
||||
|
||||
found_acceptable_queue = False
|
||||
@@ -637,7 +630,7 @@ class TaskManager(TaskBase):
|
||||
execution_instance = self.tm_models.instances[control_instance.hostname].obj
|
||||
task.log_lifecycle("controller_node_chosen")
|
||||
task.log_lifecycle("execution_node_chosen")
|
||||
self.start_task(task, self.controlplane_ig, task.get_jobs_fail_chain(), execution_instance)
|
||||
self.start_task(task, self.controlplane_ig, execution_instance)
|
||||
found_acceptable_queue = True
|
||||
continue
|
||||
|
||||
@@ -645,7 +638,7 @@ class TaskManager(TaskBase):
|
||||
if not self.tm_models.instance_groups[instance_group.name].has_remaining_capacity(task):
|
||||
continue
|
||||
if instance_group.is_container_group:
|
||||
self.start_task(task, instance_group, task.get_jobs_fail_chain(), None)
|
||||
self.start_task(task, instance_group, None)
|
||||
found_acceptable_queue = True
|
||||
break
|
||||
|
||||
@@ -670,7 +663,7 @@ class TaskManager(TaskBase):
|
||||
)
|
||||
)
|
||||
execution_instance = self.tm_models.instances[execution_instance.hostname].obj
|
||||
self.start_task(task, instance_group, task.get_jobs_fail_chain(), execution_instance)
|
||||
self.start_task(task, instance_group, execution_instance)
|
||||
found_acceptable_queue = True
|
||||
break
|
||||
else:
|
||||
|
||||
@@ -63,7 +63,7 @@ def read_receptor_config():
|
||||
|
||||
def work_signing_enabled(config_data):
|
||||
for section in config_data:
|
||||
if 'work-verification' in section:
|
||||
if 'work-signing' in section:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -411,9 +411,11 @@ class AWXReceptorJob:
|
||||
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
|
||||
detail = unit_status.get('Detail', None)
|
||||
state_name = unit_status.get('StateName', None)
|
||||
stdout_size = unit_status.get('StdoutSize', 0)
|
||||
except Exception:
|
||||
detail = ''
|
||||
state_name = ''
|
||||
stdout_size = 0
|
||||
logger.exception(f'An error was encountered while getting status for work unit {self.unit_id}')
|
||||
|
||||
if 'exceeded quota' in detail:
|
||||
@@ -424,9 +426,16 @@ class AWXReceptorJob:
|
||||
return
|
||||
|
||||
try:
|
||||
resultsock = receptor_ctl.get_work_results(self.unit_id, return_sockfile=True)
|
||||
lines = resultsock.readlines()
|
||||
receptor_output = b"".join(lines).decode()
|
||||
receptor_output = ''
|
||||
if state_name == 'Failed' and self.task.runner_callback.event_ct == 0:
|
||||
# if receptor work unit failed and no events were emitted, work results may
|
||||
# contain useful information about why the job failed. In case stdout is
|
||||
# massive, only ask for last 1000 bytes
|
||||
startpos = max(stdout_size - 1000, 0)
|
||||
resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, startpos=startpos, return_socket=True, return_sockfile=True)
|
||||
resultsock.setblocking(False) # this makes resultfile reads non blocking
|
||||
lines = resultfile.readlines()
|
||||
receptor_output = b"".join(lines).decode()
|
||||
if receptor_output:
|
||||
self.task.runner_callback.delay_update(result_traceback=receptor_output)
|
||||
elif detail:
|
||||
|
||||
@@ -52,6 +52,7 @@ from awx.main.constants import ACTIVE_STATES
|
||||
from awx.main.dispatch.publish import task
|
||||
from awx.main.dispatch import get_local_queuename, reaper
|
||||
from awx.main.utils.common import (
|
||||
get_type_for_model,
|
||||
ignore_inventory_computed_fields,
|
||||
ignore_inventory_group_removal,
|
||||
ScheduleWorkflowManager,
|
||||
@@ -720,45 +721,43 @@ def handle_work_success(task_actual):
|
||||
|
||||
|
||||
@task(queue=get_local_queuename)
|
||||
def handle_work_error(task_id, *args, **kwargs):
|
||||
subtasks = kwargs.get('subtasks', None)
|
||||
logger.debug('Executing error task id %s, subtasks: %s' % (task_id, str(subtasks)))
|
||||
first_instance = None
|
||||
first_instance_type = ''
|
||||
if subtasks is not None:
|
||||
for each_task in subtasks:
|
||||
try:
|
||||
instance = UnifiedJob.get_instance_by_type(each_task['type'], each_task['id'])
|
||||
if not instance:
|
||||
# Unknown task type
|
||||
logger.warning("Unknown task type: {}".format(each_task['type']))
|
||||
continue
|
||||
except ObjectDoesNotExist:
|
||||
logger.warning('Missing {} `{}` in error callback.'.format(each_task['type'], each_task['id']))
|
||||
continue
|
||||
def handle_work_error(task_actual):
|
||||
try:
|
||||
instance = UnifiedJob.get_instance_by_type(task_actual['type'], task_actual['id'])
|
||||
except ObjectDoesNotExist:
|
||||
logger.warning('Missing {} `{}` in error callback.'.format(task_actual['type'], task_actual['id']))
|
||||
return
|
||||
if not instance:
|
||||
return
|
||||
|
||||
if first_instance is None:
|
||||
first_instance = instance
|
||||
first_instance_type = each_task['type']
|
||||
subtasks = instance.get_jobs_fail_chain() # reverse of dependent_jobs mostly
|
||||
logger.debug(f'Executing error task id {task_actual["id"]}, subtasks: {[subtask.id for subtask in subtasks]}')
|
||||
|
||||
if instance.celery_task_id != task_id and not instance.cancel_flag and not instance.status in ('successful', 'failed'):
|
||||
instance.status = 'failed'
|
||||
instance.failed = True
|
||||
if not instance.job_explanation:
|
||||
instance.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||
first_instance_type,
|
||||
first_instance.name,
|
||||
first_instance.id,
|
||||
)
|
||||
instance.save()
|
||||
instance.websocket_emit_status("failed")
|
||||
deps_of_deps = {}
|
||||
|
||||
for subtask in subtasks:
|
||||
if subtask.celery_task_id != instance.celery_task_id and not subtask.cancel_flag and not subtask.status in ('successful', 'failed'):
|
||||
# If there are multiple in the dependency chain, A->B->C, and this was called for A, blame B for clarity
|
||||
blame_job = deps_of_deps.get(subtask.id, instance)
|
||||
subtask.status = 'failed'
|
||||
subtask.failed = True
|
||||
if not subtask.job_explanation:
|
||||
subtask.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||
get_type_for_model(type(blame_job)),
|
||||
blame_job.name,
|
||||
blame_job.id,
|
||||
)
|
||||
subtask.save()
|
||||
subtask.websocket_emit_status("failed")
|
||||
|
||||
for sub_subtask in subtask.get_jobs_fail_chain():
|
||||
deps_of_deps[sub_subtask.id] = subtask
|
||||
|
||||
# We only send 1 job complete message since all the job completion message
|
||||
# handling does is trigger the scheduler. If we extend the functionality of
|
||||
# what the job complete message handler does then we may want to send a
|
||||
# completion event for each job here.
|
||||
if first_instance:
|
||||
schedule_manager_success_or_error(first_instance)
|
||||
schedule_manager_success_or_error(instance)
|
||||
|
||||
|
||||
@task(queue=get_local_queuename)
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
|
||||
"AWS_ACCESS_KEY_ID": "fooo",
|
||||
"AWS_SECRET_ACCESS_KEY": "fooo",
|
||||
"AWS_SECURITY_TOKEN": "fooo"
|
||||
"AWS_SECURITY_TOKEN": "fooo",
|
||||
"AWS_SESSION_TOKEN": "fooo"
|
||||
}
|
||||
@@ -171,13 +171,17 @@ class TestKeyRegeneration:
|
||||
|
||||
def test_use_custom_key_with_empty_tower_secret_key_env_var(self):
|
||||
os.environ['TOWER_SECRET_KEY'] = ''
|
||||
new_key = call_command('regenerate_secret_key', '--use-custom-key')
|
||||
assert settings.SECRET_KEY != new_key
|
||||
with pytest.raises(SystemExit) as e:
|
||||
call_command('regenerate_secret_key', '--use-custom-key')
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == 1
|
||||
|
||||
def test_use_custom_key_with_no_tower_secret_key_env_var(self):
|
||||
os.environ.pop('TOWER_SECRET_KEY', None)
|
||||
new_key = call_command('regenerate_secret_key', '--use-custom-key')
|
||||
assert settings.SECRET_KEY != new_key
|
||||
with pytest.raises(SystemExit) as e:
|
||||
call_command('regenerate_secret_key', '--use-custom-key')
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == 1
|
||||
|
||||
def test_with_tower_secret_key_env_var(self):
|
||||
custom_key = 'MXSq9uqcwezBOChl/UfmbW1k4op+bC+FQtwPqgJ1u9XV'
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_multi_group_basic_job_launch(instance_factory, controlplane_instance_gr
|
||||
mock_task_impact.return_value = 500
|
||||
with mocker.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_has_calls([mock.call(j1, ig1, [], i1), mock.call(j2, ig2, [], i2)])
|
||||
TaskManager.start_task.assert_has_calls([mock.call(j1, ig1, i1), mock.call(j2, ig2, i2)])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -54,7 +54,7 @@ def test_multi_group_with_shared_dependency(instance_factory, controlplane_insta
|
||||
DependencyManager().schedule()
|
||||
TaskManager().schedule()
|
||||
pu = p.project_updates.first()
|
||||
TaskManager.start_task.assert_called_once_with(pu, controlplane_instance_group, [j1, j2], controlplane_instance_group.instances.all()[0])
|
||||
TaskManager.start_task.assert_called_once_with(pu, controlplane_instance_group, controlplane_instance_group.instances.all()[0])
|
||||
pu.finished = pu.created + timedelta(seconds=1)
|
||||
pu.status = "successful"
|
||||
pu.save()
|
||||
@@ -62,8 +62,8 @@ def test_multi_group_with_shared_dependency(instance_factory, controlplane_insta
|
||||
DependencyManager().schedule()
|
||||
TaskManager().schedule()
|
||||
|
||||
TaskManager.start_task.assert_any_call(j1, ig1, [], i1)
|
||||
TaskManager.start_task.assert_any_call(j2, ig2, [], i2)
|
||||
TaskManager.start_task.assert_any_call(j1, ig1, i1)
|
||||
TaskManager.start_task.assert_any_call(j2, ig2, i2)
|
||||
assert TaskManager.start_task.call_count == 2
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_workflow_job_no_instancegroup(workflow_job_template_factory, controlpla
|
||||
wfj.save()
|
||||
with mocker.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(wfj, None, [], None)
|
||||
TaskManager.start_task.assert_called_once_with(wfj, None, None)
|
||||
assert wfj.instance_group is None
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_failover_group_run(instance_factory, controlplane_instance_group, mocke
|
||||
mock_task_impact.return_value = 500
|
||||
with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job:
|
||||
tm.schedule()
|
||||
mock_job.assert_has_calls([mock.call(j1, ig1, [], i1), mock.call(j1_1, ig2, [], i2)])
|
||||
mock_job.assert_has_calls([mock.call(j1, ig1, i1), mock.call(j1_1, ig2, i2)])
|
||||
assert mock_job.call_count == 2
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_single_job_scheduler_launch(hybrid_instance, controlplane_instance_grou
|
||||
j = create_job(objects.job_template)
|
||||
with mocker.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -240,12 +240,12 @@ def test_multi_jt_capacity_blocking(hybrid_instance, job_template_factory, mocke
|
||||
mock_task_impact.return_value = 505
|
||||
with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job:
|
||||
tm.schedule()
|
||||
mock_job.assert_called_once_with(j1, controlplane_instance_group, [], instance)
|
||||
mock_job.assert_called_once_with(j1, controlplane_instance_group, instance)
|
||||
j1.status = "successful"
|
||||
j1.save()
|
||||
with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job:
|
||||
tm.schedule()
|
||||
mock_job.assert_called_once_with(j2, controlplane_instance_group, [], instance)
|
||||
mock_job.assert_called_once_with(j2, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -337,12 +337,12 @@ def test_single_job_dependencies_project_launch(controlplane_instance_group, job
|
||||
pu = [x for x in p.project_updates.all()]
|
||||
assert len(pu) == 1
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(pu[0], controlplane_instance_group, [j], instance)
|
||||
TaskManager.start_task.assert_called_once_with(pu[0], controlplane_instance_group, instance)
|
||||
pu[0].status = "successful"
|
||||
pu[0].save()
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -365,12 +365,12 @@ def test_single_job_dependencies_inventory_update_launch(controlplane_instance_g
|
||||
iu = [x for x in ii.inventory_updates.all()]
|
||||
assert len(iu) == 1
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(iu[0], controlplane_instance_group, [j], instance)
|
||||
TaskManager.start_task.assert_called_once_with(iu[0], controlplane_instance_group, instance)
|
||||
iu[0].status = "successful"
|
||||
iu[0].save()
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -412,7 +412,7 @@ def test_job_dependency_with_already_updated(controlplane_instance_group, job_te
|
||||
mock_iu.assert_not_called()
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(j, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -442,9 +442,7 @@ def test_shared_dependencies_launch(controlplane_instance_group, job_template_fa
|
||||
TaskManager().schedule()
|
||||
pu = p.project_updates.first()
|
||||
iu = ii.inventory_updates.first()
|
||||
TaskManager.start_task.assert_has_calls(
|
||||
[mock.call(iu, controlplane_instance_group, [j1, j2], instance), mock.call(pu, controlplane_instance_group, [j1, j2], instance)]
|
||||
)
|
||||
TaskManager.start_task.assert_has_calls([mock.call(iu, controlplane_instance_group, instance), mock.call(pu, controlplane_instance_group, instance)])
|
||||
pu.status = "successful"
|
||||
pu.finished = pu.created + timedelta(seconds=1)
|
||||
pu.save()
|
||||
@@ -453,9 +451,7 @@ def test_shared_dependencies_launch(controlplane_instance_group, job_template_fa
|
||||
iu.save()
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_has_calls(
|
||||
[mock.call(j1, controlplane_instance_group, [], instance), mock.call(j2, controlplane_instance_group, [], instance)]
|
||||
)
|
||||
TaskManager.start_task.assert_has_calls([mock.call(j1, controlplane_instance_group, instance), mock.call(j2, controlplane_instance_group, instance)])
|
||||
pu = [x for x in p.project_updates.all()]
|
||||
iu = [x for x in ii.inventory_updates.all()]
|
||||
assert len(pu) == 1
|
||||
@@ -479,7 +475,7 @@ def test_job_not_blocking_project_update(controlplane_instance_group, job_templa
|
||||
project_update.status = "pending"
|
||||
project_update.save()
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(project_update, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(project_update, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -503,7 +499,7 @@ def test_job_not_blocking_inventory_update(controlplane_instance_group, job_temp
|
||||
|
||||
DependencyManager().schedule()
|
||||
TaskManager().schedule()
|
||||
TaskManager.start_task.assert_called_once_with(inventory_update, controlplane_instance_group, [], instance)
|
||||
TaskManager.start_task.assert_called_once_with(inventory_update, controlplane_instance_group, instance)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -5,8 +5,8 @@ import tempfile
|
||||
import shutil
|
||||
|
||||
from awx.main.tasks.jobs import RunJob
|
||||
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
|
||||
from awx.main.models import Instance, Job
|
||||
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files, handle_work_error
|
||||
from awx.main.models import Instance, Job, InventoryUpdate, ProjectUpdate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -74,3 +74,17 @@ def test_does_not_run_reaped_job(mocker, mock_me):
|
||||
job.refresh_from_db()
|
||||
assert job.status == 'failed'
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_work_error_nested(project, inventory_source):
|
||||
pu = ProjectUpdate.objects.create(status='failed', project=project, celery_task_id='1234')
|
||||
iu = InventoryUpdate.objects.create(status='pending', inventory_source=inventory_source, source='scm')
|
||||
job = Job.objects.create(status='pending')
|
||||
iu.dependent_jobs.add(pu)
|
||||
job.dependent_jobs.add(pu, iu)
|
||||
handle_work_error({'type': 'project_update', 'id': pu.id})
|
||||
iu.refresh_from_db()
|
||||
job.refresh_from_db()
|
||||
assert iu.job_explanation == f'Previous Task Failed: {{"job_type": "project_update", "job_name": "", "job_id": "{pu.id}"}}'
|
||||
assert job.job_explanation == f'Previous Task Failed: {{"job_type": "inventory_update", "job_name": "", "job_id": "{iu.id}"}}'
|
||||
|
||||
@@ -118,7 +118,7 @@ class WebsocketTask:
|
||||
logger.warning(f"Connection from {self.name} to {self.remote_host} timed out.")
|
||||
except Exception as e:
|
||||
# Early on, this is our canary. I'm not sure what exceptions we can really encounter.
|
||||
logger.warning(f"Connection from {self.name} to {self.remote_host} failed for unknown reason: '{e}'.")
|
||||
logger.exception(f"Connection from {self.name} to {self.remote_host} failed for unknown reason: '{e}'.")
|
||||
else:
|
||||
logger.warning(f"Connection from {self.name} to {self.remote_host} list.")
|
||||
|
||||
|
||||
@@ -853,6 +853,7 @@ LOGGING = {
|
||||
'awx.main.signals': {'level': 'INFO'}, # very verbose debug-level logs
|
||||
'awx.api.permissions': {'level': 'INFO'}, # very verbose debug-level logs
|
||||
'awx.analytics': {'handlers': ['external_logger'], 'level': 'INFO', 'propagate': False},
|
||||
'awx.analytics.broadcast_websocket': {'handlers': ['console', 'file', 'wsbroadcast', 'external_logger'], 'level': 'INFO', 'propagate': False},
|
||||
'awx.analytics.performance': {'handlers': ['console', 'file', 'tower_warnings', 'external_logger'], 'level': 'DEBUG', 'propagate': False},
|
||||
'awx.analytics.job_lifecycle': {'handlers': ['console', 'job_lifecycle'], 'level': 'DEBUG', 'propagate': False},
|
||||
'django_auth_ldap': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'},
|
||||
|
||||
@@ -114,7 +114,7 @@ if 'sqlite3' not in DATABASES['default']['ENGINE']: # noqa
|
||||
# this needs to stay at the bottom of this file
|
||||
try:
|
||||
if os.getenv('AWX_KUBE_DEVEL', False):
|
||||
include(optional('minikube.py'), scope=locals())
|
||||
include(optional('development_kube.py'), scope=locals())
|
||||
else:
|
||||
include(optional('local_*.py'), scope=locals())
|
||||
except ImportError:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
||||
BROADCAST_WEBSOCKET_PORT = 8013
|
||||
BROADCAST_WEBSOCKET_PORT = 8052
|
||||
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
||||
BROADCAST_WEBSOCKET_PROTOCOL = 'http'
|
||||
79
awx/ui/package-lock.json
generated
79
awx/ui/package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@lingui/react": "3.14.0",
|
||||
"@patternfly/patternfly": "4.217.1",
|
||||
"@patternfly/react-core": "^4.250.1",
|
||||
"@patternfly/react-core": "^4.264.0",
|
||||
"@patternfly/react-icons": "4.92.10",
|
||||
"@patternfly/react-table": "4.108.0",
|
||||
"ace-builds": "^1.10.1",
|
||||
@@ -22,7 +22,7 @@
|
||||
"has-ansi": "5.0.1",
|
||||
"html-entities": "2.3.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"luxon": "^3.0.3",
|
||||
"luxon": "^3.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-ace": "^10.1.0",
|
||||
@@ -3752,13 +3752,13 @@
|
||||
"integrity": "sha512-uN7JgfQsyR16YHkuGRCTIcBcnyKIqKjGkB2SGk9x1XXH3yYGenL83kpAavX9Xtozqp17KppOlybJuzcKvZMrgw=="
|
||||
},
|
||||
"node_modules/@patternfly/react-core": {
|
||||
"version": "4.250.1",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.250.1.tgz",
|
||||
"integrity": "sha512-vAOZPQdZzYXl/vkHnHMIt1eC3nrPDdsuuErPatkNPwmSvilXuXmWP5wxoJ36FbSNRRURkprFwx52zMmWS3iHJA==",
|
||||
"version": "4.264.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.264.0.tgz",
|
||||
"integrity": "sha512-tK0BMWxw8nhukev40HZ6q6d02pDnjX7oyA91vHa18aakJUKBWMaerqpG4NZVMoh0tPKX3aLNj+zyCwDALFAZZw==",
|
||||
"dependencies": {
|
||||
"@patternfly/react-icons": "^4.92.6",
|
||||
"@patternfly/react-styles": "^4.91.6",
|
||||
"@patternfly/react-tokens": "^4.93.6",
|
||||
"@patternfly/react-icons": "^4.93.0",
|
||||
"@patternfly/react-styles": "^4.92.0",
|
||||
"@patternfly/react-tokens": "^4.94.0",
|
||||
"focus-trap": "6.9.2",
|
||||
"react-dropzone": "9.0.0",
|
||||
"tippy.js": "5.1.2",
|
||||
@@ -3769,6 +3769,15 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@patternfly/react-core/node_modules/@patternfly/react-icons": {
|
||||
"version": "4.93.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.93.0.tgz",
|
||||
"integrity": "sha512-OH0vORVioL+HLWMEog8/3u8jsiMCeJ0pFpvRKRhy5Uk4CdAe40k1SOBvXJP6opr+O8TLbz0q3bm8Jsh/bPaCuQ==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18",
|
||||
"react-dom": "^16.8 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@patternfly/react-core/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
@@ -3784,9 +3793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@patternfly/react-styles": {
|
||||
"version": "4.91.10",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz",
|
||||
"integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw=="
|
||||
"version": "4.92.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.92.0.tgz",
|
||||
"integrity": "sha512-B/f6iyu8UEN1+wRxdC4sLIhvJeyL8SqInDXZmwOIqK8uPJ8Lze7qrbVhkkVzbMF37/oDPVa6dZH8qZFq062LEA=="
|
||||
},
|
||||
"node_modules/@patternfly/react-table": {
|
||||
"version": "4.108.0",
|
||||
@@ -3811,9 +3820,9 @@
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"node_modules/@patternfly/react-tokens": {
|
||||
"version": "4.93.10",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz",
|
||||
"integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ=="
|
||||
"version": "4.94.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.94.0.tgz",
|
||||
"integrity": "sha512-fYXxUJZnzpn89K2zzHF0cSncZZVGKrohdb5f5T1wzxwU2NZPVGpvr88xhm+V2Y/fSrrTPwXcP3IIdtNOOtJdZw=="
|
||||
},
|
||||
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
|
||||
"version": "0.5.4",
|
||||
@@ -15468,9 +15477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.0.3.tgz",
|
||||
"integrity": "sha512-+EfHWnF+UT7GgTnq5zXg3ldnTKL2zdv7QJgsU5bjjpbH17E3qi/puMhQyJVYuCq+FRkogvB5WB6iVvUr+E4a7w==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
|
||||
"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -25094,19 +25103,25 @@
|
||||
"integrity": "sha512-uN7JgfQsyR16YHkuGRCTIcBcnyKIqKjGkB2SGk9x1XXH3yYGenL83kpAavX9Xtozqp17KppOlybJuzcKvZMrgw=="
|
||||
},
|
||||
"@patternfly/react-core": {
|
||||
"version": "4.250.1",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.250.1.tgz",
|
||||
"integrity": "sha512-vAOZPQdZzYXl/vkHnHMIt1eC3nrPDdsuuErPatkNPwmSvilXuXmWP5wxoJ36FbSNRRURkprFwx52zMmWS3iHJA==",
|
||||
"version": "4.264.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.264.0.tgz",
|
||||
"integrity": "sha512-tK0BMWxw8nhukev40HZ6q6d02pDnjX7oyA91vHa18aakJUKBWMaerqpG4NZVMoh0tPKX3aLNj+zyCwDALFAZZw==",
|
||||
"requires": {
|
||||
"@patternfly/react-icons": "^4.92.6",
|
||||
"@patternfly/react-styles": "^4.91.6",
|
||||
"@patternfly/react-tokens": "^4.93.6",
|
||||
"@patternfly/react-icons": "^4.93.0",
|
||||
"@patternfly/react-styles": "^4.92.0",
|
||||
"@patternfly/react-tokens": "^4.94.0",
|
||||
"focus-trap": "6.9.2",
|
||||
"react-dropzone": "9.0.0",
|
||||
"tippy.js": "5.1.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@patternfly/react-icons": {
|
||||
"version": "4.93.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.93.0.tgz",
|
||||
"integrity": "sha512-OH0vORVioL+HLWMEog8/3u8jsiMCeJ0pFpvRKRhy5Uk4CdAe40k1SOBvXJP6opr+O8TLbz0q3bm8Jsh/bPaCuQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
@@ -25121,9 +25136,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"@patternfly/react-styles": {
|
||||
"version": "4.91.10",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz",
|
||||
"integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw=="
|
||||
"version": "4.92.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.92.0.tgz",
|
||||
"integrity": "sha512-B/f6iyu8UEN1+wRxdC4sLIhvJeyL8SqInDXZmwOIqK8uPJ8Lze7qrbVhkkVzbMF37/oDPVa6dZH8qZFq062LEA=="
|
||||
},
|
||||
"@patternfly/react-table": {
|
||||
"version": "4.108.0",
|
||||
@@ -25146,9 +25161,9 @@
|
||||
}
|
||||
},
|
||||
"@patternfly/react-tokens": {
|
||||
"version": "4.93.10",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz",
|
||||
"integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ=="
|
||||
"version": "4.94.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.94.0.tgz",
|
||||
"integrity": "sha512-fYXxUJZnzpn89K2zzHF0cSncZZVGKrohdb5f5T1wzxwU2NZPVGpvr88xhm+V2Y/fSrrTPwXcP3IIdtNOOtJdZw=="
|
||||
},
|
||||
"@pmmmwh/react-refresh-webpack-plugin": {
|
||||
"version": "0.5.4",
|
||||
@@ -34210,9 +34225,9 @@
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.0.3.tgz",
|
||||
"integrity": "sha512-+EfHWnF+UT7GgTnq5zXg3ldnTKL2zdv7QJgsU5bjjpbH17E3qi/puMhQyJVYuCq+FRkogvB5WB6iVvUr+E4a7w=="
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
|
||||
"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw=="
|
||||
},
|
||||
"lz-string": {
|
||||
"version": "1.4.4",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@lingui/react": "3.14.0",
|
||||
"@patternfly/patternfly": "4.217.1",
|
||||
"@patternfly/react-core": "^4.250.1",
|
||||
"@patternfly/react-core": "^4.264.0",
|
||||
"@patternfly/react-icons": "4.92.10",
|
||||
"@patternfly/react-table": "4.108.0",
|
||||
"ace-builds": "^1.10.1",
|
||||
@@ -22,7 +22,7 @@
|
||||
"has-ansi": "5.0.1",
|
||||
"html-entities": "2.3.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"luxon": "^3.0.3",
|
||||
"luxon": "^3.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-ace": "^10.1.0",
|
||||
|
||||
@@ -20,6 +20,10 @@ class Hosts extends Base {
|
||||
return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params });
|
||||
}
|
||||
|
||||
readGroups(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
|
||||
}
|
||||
|
||||
readGroupsOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/groups/`);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ function CredentialsStep({
|
||||
}))}
|
||||
value={selectedType && selectedType.id}
|
||||
onChange={(e, id) => {
|
||||
// Reset query params when the category of credentials is changed
|
||||
history.replace({
|
||||
search: '',
|
||||
});
|
||||
setSelectedType(types.find((o) => o.id === parseInt(id, 10)));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act } from 'react-dom/test-utils';
|
||||
import { Formik } from 'formik';
|
||||
import { CredentialsAPI, CredentialTypesAPI } from 'api';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import CredentialsStep from './CredentialsStep';
|
||||
|
||||
jest.mock('../../../api/models/CredentialTypes');
|
||||
@@ -164,6 +165,41 @@ describe('CredentialsStep', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should reset query params (credential.page) when selected credential type is changed', async () => {
|
||||
let wrapper;
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['?credential.page=2'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik>
|
||||
<CredentialsStep allowCredentialsWithPasswords />
|
||||
</Formik>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 1,
|
||||
order_by: 'name',
|
||||
page: 2,
|
||||
page_size: 5,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect').invoke('onChange')({}, 3);
|
||||
});
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 3,
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("error should be shown when a credential that prompts for passwords is selected on a step that doesn't allow it", async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
|
||||
@@ -173,6 +173,10 @@ function MultiCredentialsLookup({
|
||||
}))}
|
||||
value={selectedType && selectedType.id}
|
||||
onChange={(e, id) => {
|
||||
// Reset query params when the category of credentials is changed
|
||||
history.replace({
|
||||
search: '',
|
||||
});
|
||||
setSelectedType(
|
||||
credentialTypes.find((o) => o.id === parseInt(id, 10))
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import MultiCredentialsLookup from './MultiCredentialsLookup';
|
||||
|
||||
jest.mock('../../api');
|
||||
@@ -228,6 +229,53 @@ describe('<Formik><MultiCredentialsLookup /></Formik>', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('should reset query params (credentials.page) when selected credential type is changed', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['?credentials.page=2'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik>
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
</Formik>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
const searchButton = await waitForElement(
|
||||
wrapper,
|
||||
'Button[aria-label="Search"]'
|
||||
);
|
||||
await act(async () => {
|
||||
searchButton.invoke('onClick')();
|
||||
});
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 400,
|
||||
order_by: 'name',
|
||||
page: 2,
|
||||
page_size: 5,
|
||||
});
|
||||
|
||||
const select = await waitForElement(wrapper, 'AnsibleSelect');
|
||||
await act(async () => {
|
||||
select.invoke('onChange')({}, 500);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 500,
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('should only add 1 credential per credential type except vault(see below)', async () => {
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
|
||||
@@ -55,7 +55,6 @@ function DateTimePicker({ dateFieldName, timeFieldName, label }) {
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
<TimePicker
|
||||
placeholder="hh:mm AM/PM"
|
||||
stepMinutes={15}
|
||||
aria-label={
|
||||
timeFieldName.startsWith('start') ? t`Start time` : t`End time`
|
||||
|
||||
93
awx/ui/src/components/Schedule/shared/FrequenciesList.js
Normal file
93
awx/ui/src/components/Schedule/shared/FrequenciesList.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
TableComposable,
|
||||
Tbody,
|
||||
Thead,
|
||||
Th,
|
||||
Tr,
|
||||
Td,
|
||||
} from '@patternfly/react-table';
|
||||
|
||||
import { useField } from 'formik';
|
||||
import ContentEmpty from 'components/ContentEmpty';
|
||||
|
||||
function FrequenciesList({ openWizard }) {
|
||||
const [isShowingRules, setIsShowingRules] = useState(true);
|
||||
const [frequencies] = useField('frequencies');
|
||||
const list = (freq) => (
|
||||
<Tr key={freq.rrule}>
|
||||
<Td>{freq.frequency}</Td>
|
||||
<Td>{freq.rrule}</Td>
|
||||
<Td>{t`End`}</Td>
|
||||
<Td>
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={t`Click to toggle default value`}
|
||||
ouiaId={freq ? `${freq}-button` : 'new-freq-button'}
|
||||
onClick={() => {
|
||||
openWizard(true);
|
||||
}}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
onClick={() => {
|
||||
openWizard(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{isShowingRules ? t`Add RRules` : t`Add Exception`}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Switch
|
||||
label={t`Occurances`}
|
||||
labelOff={t`Exceptions`}
|
||||
isChecked={isShowingRules}
|
||||
onChange={(isChecked) => {
|
||||
setIsShowingRules(isChecked);
|
||||
}}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<div css="overflow: auto">
|
||||
{frequencies.value[0].frequency === '' &&
|
||||
frequencies.value.length < 2 ? (
|
||||
<ContentEmpty title={t`RRules`} message={t`Add RRules`} />
|
||||
) : (
|
||||
<TableComposable aria-label={t`RRules`} ouiaId="rrules-list">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t`Frequency`}</Th>
|
||||
<Th>{t`RRule`}</Th>
|
||||
<Th>{t`Ending`}</Th>
|
||||
<Th>{t`Actions`}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>{frequencies.value.map((freq, i) => list(freq, i))}</Tbody>
|
||||
</TableComposable>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FrequenciesList;
|
||||
@@ -1,568 +0,0 @@
|
||||
import 'styled-components/macro';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useField } from 'formik';
|
||||
|
||||
import { t, Trans, Plural } from '@lingui/macro';
|
||||
import { RRule } from 'rrule';
|
||||
import {
|
||||
Checkbox as _Checkbox,
|
||||
FormGroup,
|
||||
Radio,
|
||||
TextInput,
|
||||
} from '@patternfly/react-core';
|
||||
import { required, requiredPositiveInteger } from 'util/validators';
|
||||
import AnsibleSelect from '../../AnsibleSelect';
|
||||
import FormField from '../../FormField';
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
|
||||
const RunOnRadio = styled(Radio)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:not(:last-of-type) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
select:not(:first-of-type) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const RunEveryLabel = styled.p`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Checkbox = styled(_Checkbox)`
|
||||
:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FrequencyDetailSubform = ({ frequency, prefix, isException }) => {
|
||||
const id = prefix.replace('.', '-');
|
||||
const [runOnDayMonth] = useField({
|
||||
name: `${prefix}.runOnDayMonth`,
|
||||
});
|
||||
const [runOnDayNumber] = useField({
|
||||
name: `${prefix}.runOnDayNumber`,
|
||||
});
|
||||
const [runOnTheOccurrence] = useField({
|
||||
name: `${prefix}.runOnTheOccurrence`,
|
||||
});
|
||||
const [runOnTheDay] = useField({
|
||||
name: `${prefix}.runOnTheDay`,
|
||||
});
|
||||
const [runOnTheMonth] = useField({
|
||||
name: `${prefix}.runOnTheMonth`,
|
||||
});
|
||||
const [startDate] = useField(`${prefix}.startDate`);
|
||||
|
||||
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
|
||||
name: `${prefix}.daysOfWeek`,
|
||||
validate: (val) => {
|
||||
if (frequency === 'week') {
|
||||
return required(t`Select a value for this field`)(val?.length > 0);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const [end, endMeta] = useField({
|
||||
name: `${prefix}.end`,
|
||||
validate: required(t`Select a value for this field`),
|
||||
});
|
||||
const [interval, intervalMeta] = useField({
|
||||
name: `${prefix}.interval`,
|
||||
validate: requiredPositiveInteger(),
|
||||
});
|
||||
const [runOn, runOnMeta] = useField({
|
||||
name: `${prefix}.runOn`,
|
||||
validate: (val) => {
|
||||
if (frequency === 'month' || frequency === 'year') {
|
||||
return required(t`Select a value for this field`)(val);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const monthOptions = [
|
||||
{
|
||||
key: 'january',
|
||||
value: 1,
|
||||
label: t`January`,
|
||||
},
|
||||
{
|
||||
key: 'february',
|
||||
value: 2,
|
||||
label: t`February`,
|
||||
},
|
||||
{
|
||||
key: 'march',
|
||||
value: 3,
|
||||
label: t`March`,
|
||||
},
|
||||
{
|
||||
key: 'april',
|
||||
value: 4,
|
||||
label: t`April`,
|
||||
},
|
||||
{
|
||||
key: 'may',
|
||||
value: 5,
|
||||
label: t`May`,
|
||||
},
|
||||
{
|
||||
key: 'june',
|
||||
value: 6,
|
||||
label: t`June`,
|
||||
},
|
||||
{
|
||||
key: 'july',
|
||||
value: 7,
|
||||
label: t`July`,
|
||||
},
|
||||
{
|
||||
key: 'august',
|
||||
value: 8,
|
||||
label: t`August`,
|
||||
},
|
||||
{
|
||||
key: 'september',
|
||||
value: 9,
|
||||
label: t`September`,
|
||||
},
|
||||
{
|
||||
key: 'october',
|
||||
value: 10,
|
||||
label: t`October`,
|
||||
},
|
||||
{
|
||||
key: 'november',
|
||||
value: 11,
|
||||
label: t`November`,
|
||||
},
|
||||
{
|
||||
key: 'december',
|
||||
value: 12,
|
||||
label: t`December`,
|
||||
},
|
||||
];
|
||||
|
||||
const updateDaysOfWeek = (day, checked) => {
|
||||
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
|
||||
daysOfWeekHelpers.setTouched(true);
|
||||
if (checked) {
|
||||
newDaysOfWeek.push(day);
|
||||
daysOfWeekHelpers.setValue(newDaysOfWeek);
|
||||
} else {
|
||||
daysOfWeekHelpers.setValue(
|
||||
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getPeriodLabel = () => {
|
||||
switch (frequency) {
|
||||
case 'minute':
|
||||
return t`Minute`;
|
||||
case 'hour':
|
||||
return t`Hour`;
|
||||
case 'day':
|
||||
return t`Day`;
|
||||
case 'week':
|
||||
return t`Week`;
|
||||
case 'month':
|
||||
return t`Month`;
|
||||
case 'year':
|
||||
return t`Year`;
|
||||
default:
|
||||
throw new Error(t`Frequency did not match an expected value`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRunEveryLabel = () => {
|
||||
const intervalValue = interval.value;
|
||||
|
||||
switch (frequency) {
|
||||
case 'minute':
|
||||
return <Plural value={intervalValue} one="minute" other="minutes" />;
|
||||
case 'hour':
|
||||
return <Plural value={intervalValue} one="hour" other="hours" />;
|
||||
case 'day':
|
||||
return <Plural value={intervalValue} one="day" other="days" />;
|
||||
case 'week':
|
||||
return <Plural value={intervalValue} one="week" other="weeks" />;
|
||||
case 'month':
|
||||
return <Plural value={intervalValue} one="month" other="months" />;
|
||||
case 'year':
|
||||
return <Plural value={intervalValue} one="year" other="years" />;
|
||||
default:
|
||||
throw new Error(t`Frequency did not match an expected value`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p css="grid-column: 1/-1">
|
||||
<b>{getPeriodLabel()}</b>
|
||||
</p>
|
||||
<FormGroup
|
||||
name={`${prefix}.interval`}
|
||||
fieldId={`schedule-run-every-${id}`}
|
||||
helperTextInvalid={intervalMeta.error}
|
||||
isRequired
|
||||
validated={
|
||||
!intervalMeta.touched || !intervalMeta.error ? 'default' : 'error'
|
||||
}
|
||||
label={isException ? t`Skip every` : t`Run every`}
|
||||
>
|
||||
<div css="display: flex">
|
||||
<TextInput
|
||||
css="margin-right: 10px;"
|
||||
id={`schedule-run-every-${id}`}
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
{...interval}
|
||||
onChange={(value, event) => {
|
||||
interval.onChange(event);
|
||||
}}
|
||||
/>
|
||||
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
|
||||
</div>
|
||||
</FormGroup>
|
||||
{frequency === 'week' && (
|
||||
<FormGroup
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
fieldId={`schedule-days-of-week-${id}`}
|
||||
helperTextInvalid={daysOfWeekMeta.error}
|
||||
isRequired
|
||||
validated={
|
||||
!daysOfWeekMeta.touched || !daysOfWeekMeta.error
|
||||
? 'default'
|
||||
: 'error'
|
||||
}
|
||||
label={t`On days`}
|
||||
>
|
||||
<div css="display: flex">
|
||||
<Checkbox
|
||||
label={t`Sun`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.SU)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.SU, checked);
|
||||
}}
|
||||
aria-label={t`Sunday`}
|
||||
id={`schedule-days-of-week-sun-${id}`}
|
||||
ouiaId={`schedule-days-of-week-sun-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Mon`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.MO)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.MO, checked);
|
||||
}}
|
||||
aria-label={t`Monday`}
|
||||
id={`schedule-days-of-week-mon-${id}`}
|
||||
ouiaId={`schedule-days-of-week-mon-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Tue`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.TU)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.TU, checked);
|
||||
}}
|
||||
aria-label={t`Tuesday`}
|
||||
id={`schedule-days-of-week-tue-${id}`}
|
||||
ouiaId={`schedule-days-of-week-tue-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Wed`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.WE)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.WE, checked);
|
||||
}}
|
||||
aria-label={t`Wednesday`}
|
||||
id={`schedule-days-of-week-wed-${id}`}
|
||||
ouiaId={`schedule-days-of-week-wed-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Thu`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.TH)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.TH, checked);
|
||||
}}
|
||||
aria-label={t`Thursday`}
|
||||
id={`schedule-days-of-week-thu-${id}`}
|
||||
ouiaId={`schedule-days-of-week-thu-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Fri`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.FR)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.FR, checked);
|
||||
}}
|
||||
aria-label={t`Friday`}
|
||||
id={`schedule-days-of-week-fri-${id}`}
|
||||
ouiaId={`schedule-days-of-week-fri-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Sat`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.SA)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.SA, checked);
|
||||
}}
|
||||
aria-label={t`Saturday`}
|
||||
id={`schedule-days-of-week-sat-${id}`}
|
||||
ouiaId={`schedule-days-of-week-sat-${id}`}
|
||||
name={`${prefix}.daysOfWeek`}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)}
|
||||
{(frequency === 'month' || frequency === 'year') &&
|
||||
!Number.isNaN(new Date(startDate.value)) && (
|
||||
<FormGroup
|
||||
name={`${prefix}.runOn`}
|
||||
fieldId={`schedule-run-on-${id}`}
|
||||
helperTextInvalid={runOnMeta.error}
|
||||
isRequired
|
||||
validated={
|
||||
!runOnMeta.touched || !runOnMeta.error ? 'default' : 'error'
|
||||
}
|
||||
label={t`Run on`}
|
||||
>
|
||||
<RunOnRadio
|
||||
id={`schedule-run-on-day-${id}`}
|
||||
name={`${prefix}.runOn`}
|
||||
label={
|
||||
<div css="display: flex;align-items: center;">
|
||||
{frequency === 'month' && (
|
||||
<span
|
||||
id="radio-schedule-run-on-day"
|
||||
css="margin-right: 10px;"
|
||||
>
|
||||
<Trans>Day</Trans>
|
||||
</span>
|
||||
)}
|
||||
{frequency === 'year' && (
|
||||
<AnsibleSelect
|
||||
id={`schedule-run-on-day-month-${id}`}
|
||||
css="margin-right: 10px"
|
||||
isDisabled={runOn.value !== 'day'}
|
||||
data={monthOptions}
|
||||
{...runOnDayMonth}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
id={`schedule-run-on-day-number-${id}`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
step="1"
|
||||
isDisabled={runOn.value !== 'day'}
|
||||
{...runOnDayNumber}
|
||||
onChange={(value, event) => {
|
||||
runOnDayNumber.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value="day"
|
||||
isChecked={runOn.value === 'day'}
|
||||
onChange={(value, event) => {
|
||||
event.target.value = 'day';
|
||||
runOn.onChange(event);
|
||||
}}
|
||||
/>
|
||||
<RunOnRadio
|
||||
id={`schedule-run-on-the-${id}`}
|
||||
name={`${prefix}.runOn`}
|
||||
label={
|
||||
<div css="display: flex;align-items: center;">
|
||||
<span
|
||||
id={`radio-schedule-run-on-the-${id}`}
|
||||
css="margin-right: 10px;"
|
||||
>
|
||||
<Trans>The</Trans>
|
||||
</span>
|
||||
<AnsibleSelect
|
||||
id={`schedule-run-on-the-occurrence-${id}`}
|
||||
isDisabled={runOn.value !== 'the'}
|
||||
data={[
|
||||
{ value: 1, key: 'first', label: t`First` },
|
||||
{
|
||||
value: 2,
|
||||
key: 'second',
|
||||
label: t`Second`,
|
||||
},
|
||||
{ value: 3, key: 'third', label: t`Third` },
|
||||
{
|
||||
value: 4,
|
||||
key: 'fourth',
|
||||
label: t`Fourth`,
|
||||
},
|
||||
{ value: 5, key: 'fifth', label: t`Fifth` },
|
||||
{ value: -1, key: 'last', label: t`Last` },
|
||||
]}
|
||||
{...runOnTheOccurrence}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
id={`schedule-run-on-the-day-${id}`}
|
||||
isDisabled={runOn.value !== 'the'}
|
||||
data={[
|
||||
{
|
||||
value: 'sunday',
|
||||
key: 'sunday',
|
||||
label: t`Sunday`,
|
||||
},
|
||||
{
|
||||
value: 'monday',
|
||||
key: 'monday',
|
||||
label: t`Monday`,
|
||||
},
|
||||
{
|
||||
value: 'tuesday',
|
||||
key: 'tuesday',
|
||||
label: t`Tuesday`,
|
||||
},
|
||||
{
|
||||
value: 'wednesday',
|
||||
key: 'wednesday',
|
||||
label: t`Wednesday`,
|
||||
},
|
||||
{
|
||||
value: 'thursday',
|
||||
key: 'thursday',
|
||||
label: t`Thursday`,
|
||||
},
|
||||
{
|
||||
value: 'friday',
|
||||
key: 'friday',
|
||||
label: t`Friday`,
|
||||
},
|
||||
{
|
||||
value: 'saturday',
|
||||
key: 'saturday',
|
||||
label: t`Saturday`,
|
||||
},
|
||||
{ value: 'day', key: 'day', label: t`Day` },
|
||||
{
|
||||
value: 'weekday',
|
||||
key: 'weekday',
|
||||
label: t`Weekday`,
|
||||
},
|
||||
{
|
||||
value: 'weekendDay',
|
||||
key: 'weekendDay',
|
||||
label: t`Weekend day`,
|
||||
},
|
||||
]}
|
||||
{...runOnTheDay}
|
||||
/>
|
||||
{frequency === 'year' && (
|
||||
<>
|
||||
<span
|
||||
id={`of-schedule-run-on-the-month-${id}`}
|
||||
css="margin-left: 10px;"
|
||||
>
|
||||
<Trans>of</Trans>
|
||||
</span>
|
||||
<AnsibleSelect
|
||||
id={`schedule-run-on-the-month-${id}`}
|
||||
isDisabled={runOn.value !== 'the'}
|
||||
data={monthOptions}
|
||||
{...runOnTheMonth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
value="the"
|
||||
isChecked={runOn.value === 'the'}
|
||||
onChange={(value, event) => {
|
||||
event.target.value = 'the';
|
||||
runOn.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup
|
||||
name={`${prefix}.end`}
|
||||
fieldId={`schedule-end-${id}`}
|
||||
helperTextInvalid={endMeta.error}
|
||||
isRequired
|
||||
validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'}
|
||||
label={t`End`}
|
||||
>
|
||||
<Radio
|
||||
id={`end-never-${id}`}
|
||||
name={`${prefix}.end`}
|
||||
label={t`Never`}
|
||||
value="never"
|
||||
isChecked={end.value === 'never'}
|
||||
onChange={(value, event) => {
|
||||
event.target.value = 'never';
|
||||
end.onChange(event);
|
||||
}}
|
||||
ouiaId={`end-never-radio-button-${id}`}
|
||||
/>
|
||||
<Radio
|
||||
id={`end-after-${id}`}
|
||||
name={`${prefix}.end`}
|
||||
label={t`After number of occurrences`}
|
||||
value="after"
|
||||
isChecked={end.value === 'after'}
|
||||
onChange={(value, event) => {
|
||||
event.target.value = 'after';
|
||||
end.onChange(event);
|
||||
}}
|
||||
ouiaId={`end-after-radio-button-${id}`}
|
||||
/>
|
||||
<Radio
|
||||
id={`end-on-date-${id}`}
|
||||
name={`${prefix}.end`}
|
||||
label={t`On date`}
|
||||
value="onDate"
|
||||
isChecked={end.value === 'onDate'}
|
||||
onChange={(value, event) => {
|
||||
event.target.value = 'onDate';
|
||||
end.onChange(event);
|
||||
}}
|
||||
ouiaId={`end-on-radio-button-${id}`}
|
||||
/>
|
||||
</FormGroup>
|
||||
{end?.value === 'after' && (
|
||||
<FormField
|
||||
id={`schedule-occurrences-${id}`}
|
||||
label={t`Occurrences`}
|
||||
name={`${prefix}.occurrences`}
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
isRequired
|
||||
/>
|
||||
)}
|
||||
{end?.value === 'onDate' && (
|
||||
<DateTimePicker
|
||||
dateFieldName={`${prefix}.endDate`}
|
||||
timeFieldName={`${prefix}.endTime`}
|
||||
label={t`End date/time`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrequencyDetailSubform;
|
||||
@@ -1,30 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { arrayOf, string } from 'prop-types';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import { RRule } from 'rrule';
|
||||
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
|
||||
|
||||
export default function FrequencySelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholderText,
|
||||
children,
|
||||
}) {
|
||||
export default function FrequencySelect({ id, onBlur, placeholderText }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const onSelect = (event, selectedValue) => {
|
||||
if (selectedValue === 'none') {
|
||||
onChange([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
const index = value.indexOf(selectedValue);
|
||||
if (index === -1) {
|
||||
onChange(value.concat(selectedValue));
|
||||
} else {
|
||||
onChange(value.slice(0, index).concat(value.slice(index + 1)));
|
||||
}
|
||||
};
|
||||
const [frequency, , frequencyHelpers] = useField('freq');
|
||||
|
||||
const onToggle = (val) => {
|
||||
if (!val) {
|
||||
@@ -35,21 +17,26 @@ export default function FrequencySelect({
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant={SelectVariant.checkbox}
|
||||
onSelect={onSelect}
|
||||
selections={value}
|
||||
onSelect={(e, v) => {
|
||||
frequencyHelpers.setValue(v);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
selections={frequency.value}
|
||||
placeholderText={placeholderText}
|
||||
onToggle={onToggle}
|
||||
value={frequency.value}
|
||||
isOpen={isOpen}
|
||||
ouiaId={`frequency-select-${id}`}
|
||||
onBlur={() => frequencyHelpers.setTouched(true)}
|
||||
>
|
||||
{children}
|
||||
<SelectOption value={RRule.MINUTELY}>{t`Minute`}</SelectOption>
|
||||
<SelectOption value={RRule.HOURLY}>{t`Hour`}</SelectOption>
|
||||
<SelectOption value={RRule.DAILY}>{t`Day`}</SelectOption>
|
||||
<SelectOption value={RRule.WEEKLY}>{t`Week`}</SelectOption>
|
||||
<SelectOption value={RRule.MONTHLY}>{t`Month`}</SelectOption>
|
||||
<SelectOption value={RRule.YEARLY}>{t`Year`}</SelectOption>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
FrequencySelect.propTypes = {
|
||||
value: arrayOf(string).isRequired,
|
||||
};
|
||||
|
||||
export { SelectOption, SelectVariant };
|
||||
|
||||
77
awx/ui/src/components/Schedule/shared/MonthandYearForm.js
Normal file
77
awx/ui/src/components/Schedule/shared/MonthandYearForm.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AnsibleSelect from 'components/AnsibleSelect';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
FormGroup,
|
||||
Checkbox as _Checkbox,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { useField } from 'formik';
|
||||
import { bysetposOptions, monthOptions } from './scheduleFormHelpers';
|
||||
|
||||
const GroupWrapper = styled(FormGroup)`
|
||||
&& .pf-c-form__group-control {
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
}
|
||||
&& .pf-c-form__group-label {
|
||||
padding-top: 20px;
|
||||
}
|
||||
`;
|
||||
const Checkbox = styled(_Checkbox)`
|
||||
:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
`;
|
||||
function MonthandYearForm({ id }) {
|
||||
const [bySetPos, , bySetPosHelpers] = useField('bysetpos');
|
||||
const [byMonth, , byMonthHelpers] = useField('bymonth');
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupWrapper
|
||||
fieldId={`schedule-run-on-${id}`}
|
||||
label={<b>{t`Run on a specific month`}</b>}
|
||||
>
|
||||
<Grid hasGutter>
|
||||
{monthOptions.map((month) => (
|
||||
<GridItem key={month.label} span={2} rowSpan={2}>
|
||||
<Checkbox
|
||||
label={month.label}
|
||||
isChecked={byMonth.value?.includes(month.value)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
byMonthHelpers.setValue([...byMonth.value, month.value]);
|
||||
} else {
|
||||
const removed = byMonth.value.filter(
|
||||
(i) => i !== month.value
|
||||
);
|
||||
byMonthHelpers.setValue(removed);
|
||||
}
|
||||
}}
|
||||
id={`bymonth-${month.label}`}
|
||||
ouiaId={`bymonth-${month.label}`}
|
||||
name="bymonth"
|
||||
/>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</GroupWrapper>
|
||||
<GroupWrapper
|
||||
label={<b>{t`Run on a specific week day at monthly intervals`}</b>}
|
||||
>
|
||||
<AnsibleSelect
|
||||
id={`schedule-run-on-the-occurrence-${id}`}
|
||||
data={bysetposOptions}
|
||||
{...bySetPos}
|
||||
onChange={(e, v) => {
|
||||
bySetPosHelpers.setValue(v);
|
||||
}}
|
||||
/>
|
||||
</GroupWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default MonthandYearForm;
|
||||
45
awx/ui/src/components/Schedule/shared/OrdinalDayForm.js
Normal file
45
awx/ui/src/components/Schedule/shared/OrdinalDayForm.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { useField } from 'formik';
|
||||
import { FormGroup, TextInput } from '@patternfly/react-core';
|
||||
|
||||
const GroupWrapper = styled(FormGroup)`
|
||||
&& .pf-c-form__group-control {
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
}
|
||||
&& .pf-c-form__group-label {
|
||||
padding-top: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
function OrdinalDayForm() {
|
||||
const [byMonthDay] = useField('bymonthday');
|
||||
const [byYearDay] = useField('byyearday');
|
||||
return (
|
||||
<GroupWrapper
|
||||
label={<b>{t`On a specific number day`}</b>}
|
||||
name="ordinalDay"
|
||||
>
|
||||
<TextInput
|
||||
placeholder={t`Run on a day of month`}
|
||||
aria-label={t`Type a numbered day`}
|
||||
type="number"
|
||||
onChange={(value, event) => {
|
||||
byMonthDay.onChange(event);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={t`Run on a day of year`}
|
||||
aria-label={t`Type a numbered day`}
|
||||
type="number"
|
||||
onChange={(value, event) => {
|
||||
byYearDay.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</GroupWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrdinalDayForm;
|
||||
67
awx/ui/src/components/Schedule/shared/ScheduleEndForm.js
Normal file
67
awx/ui/src/components/Schedule/shared/ScheduleEndForm.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useField } from 'formik';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup, Radio } from '@patternfly/react-core';
|
||||
import FormField from 'components/FormField';
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
|
||||
function ScheduleEndForm() {
|
||||
const [endType, , { setValue }] = useField('endingType');
|
||||
const [count] = useField('count');
|
||||
return (
|
||||
<>
|
||||
<FormGroup name="end" label={t`End`}>
|
||||
<Radio
|
||||
id="endNever"
|
||||
name={t`Never End`}
|
||||
label={t`Never`}
|
||||
value="never"
|
||||
isChecked={endType.value === 'never'}
|
||||
onChange={() => {
|
||||
setValue('never');
|
||||
}}
|
||||
/>
|
||||
<Radio
|
||||
name="Count"
|
||||
id="after"
|
||||
label={t`After number of occurrences`}
|
||||
value="after"
|
||||
isChecked={endType.value === 'after'}
|
||||
onChange={() => {
|
||||
setValue('after');
|
||||
}}
|
||||
/>
|
||||
<Radio
|
||||
name="End Date"
|
||||
label={t`On date`}
|
||||
value="onDate"
|
||||
id="endDate"
|
||||
isChecked={endType.value === 'onDate'}
|
||||
onChange={() => {
|
||||
setValue('onDate');
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
{endType.value === 'after' && (
|
||||
<FormField
|
||||
label={t`Occurrences`}
|
||||
name="count"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
isRequired
|
||||
{...count}
|
||||
/>
|
||||
)}
|
||||
{endType.value === 'onDate' && (
|
||||
<DateTimePicker
|
||||
dateFieldName="endDate"
|
||||
timeFieldName="endTime"
|
||||
label={t`End date/time`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleEndForm;
|
||||
@@ -18,14 +18,9 @@ import SchedulePromptableFields from './SchedulePromptableFields';
|
||||
import ScheduleFormFields from './ScheduleFormFields';
|
||||
import UnsupportedScheduleForm from './UnsupportedScheduleForm';
|
||||
import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj';
|
||||
import buildRuleObj from './buildRuleObj';
|
||||
import buildRuleSet from './buildRuleSet';
|
||||
|
||||
const NUM_DAYS_PER_FREQUENCY = {
|
||||
week: 7,
|
||||
month: 31,
|
||||
year: 365,
|
||||
};
|
||||
import ScheduleFormWizard from './ScheduleFormWizard';
|
||||
import FrequenciesList from './FrequenciesList';
|
||||
// import { validateSchedule } from './scheduleFormHelpers';
|
||||
|
||||
function ScheduleForm({
|
||||
hasDaysToKeepField,
|
||||
@@ -40,15 +35,16 @@ function ScheduleForm({
|
||||
}) {
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
|
||||
const [isScheduleWizardOpen, setIsScheduleWizardOpen] = useState(false);
|
||||
const originalLabels = useRef([]);
|
||||
const originalInstanceGroups = useRef([]);
|
||||
|
||||
let rruleError;
|
||||
const now = DateTime.now();
|
||||
|
||||
const closestQuarterHour = DateTime.fromMillis(
|
||||
Math.ceil(now.ts / 900000) * 900000
|
||||
);
|
||||
const tomorrow = closestQuarterHour.plus({ days: 1 });
|
||||
const isTemplate =
|
||||
resource.type === 'workflow_job_template' ||
|
||||
resource.type === 'job_template';
|
||||
@@ -283,69 +279,10 @@ function ScheduleForm({
|
||||
}
|
||||
const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO());
|
||||
|
||||
const [tomorrowDate] = dateToInputDateTime(tomorrow.toISO());
|
||||
const initialFrequencyOptions = {
|
||||
minute: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
},
|
||||
hour: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
},
|
||||
day: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
},
|
||||
week: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
daysOfWeek: [],
|
||||
},
|
||||
month: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
runOn: 'day',
|
||||
runOnTheOccurrence: 1,
|
||||
runOnTheDay: 'sunday',
|
||||
runOnDayNumber: 1,
|
||||
},
|
||||
year: {
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
occurrences: 1,
|
||||
endDate: tomorrowDate,
|
||||
endTime: time,
|
||||
runOn: 'day',
|
||||
runOnTheOccurrence: 1,
|
||||
runOnTheDay: 'sunday',
|
||||
runOnTheMonth: 1,
|
||||
runOnDayMonth: 1,
|
||||
runOnDayNumber: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const initialValues = {
|
||||
description: schedule.description || '',
|
||||
frequency: [],
|
||||
frequencies: [],
|
||||
exceptionFrequency: [],
|
||||
frequencyOptions: initialFrequencyOptions,
|
||||
exceptionOptions: initialFrequencyOptions,
|
||||
name: schedule.name || '',
|
||||
startDate: currentDate,
|
||||
startTime: time,
|
||||
@@ -367,11 +304,9 @@ function ScheduleForm({
|
||||
}
|
||||
initialValues.daysToKeep = initialDaysToKeep;
|
||||
}
|
||||
|
||||
let overriddenValues = {};
|
||||
if (schedule.rrule) {
|
||||
try {
|
||||
overriddenValues = parseRuleObj(schedule);
|
||||
parseRuleObj(schedule);
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedRRuleError) {
|
||||
return (
|
||||
@@ -394,89 +329,33 @@ function ScheduleForm({
|
||||
if (contentLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
const validate = (values) => {
|
||||
const errors = {};
|
||||
|
||||
values.frequency.forEach((freq) => {
|
||||
const options = values.frequencyOptions[freq];
|
||||
const freqErrors = {};
|
||||
|
||||
if (
|
||||
(freq === 'month' || freq === 'year') &&
|
||||
options.runOn === 'day' &&
|
||||
(options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
|
||||
) {
|
||||
freqErrors.runOn = t`Please select a day number between 1 and 31.`;
|
||||
}
|
||||
|
||||
if (options.end === 'after' && !options.occurrences) {
|
||||
freqErrors.occurrences = t`Please enter a number of occurrences.`;
|
||||
}
|
||||
|
||||
if (options.end === 'onDate') {
|
||||
if (
|
||||
DateTime.fromFormat(
|
||||
`${values.startDate} ${values.startTime}`,
|
||||
'yyyy-LL-dd h:mm a'
|
||||
).toMillis() >=
|
||||
DateTime.fromFormat(
|
||||
`${options.endDate} ${options.endTime}`,
|
||||
'yyyy-LL-dd h:mm a'
|
||||
).toMillis()
|
||||
) {
|
||||
freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
|
||||
}
|
||||
|
||||
if (
|
||||
DateTime.fromISO(options.endDate)
|
||||
.diff(DateTime.fromISO(values.startDate), 'days')
|
||||
.toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
|
||||
) {
|
||||
const rule = new RRule(
|
||||
buildRuleObj({
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
frequency: freq,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
if (rule.all().length === 0) {
|
||||
errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
|
||||
freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(freqErrors).length > 0) {
|
||||
if (!errors.frequencyOptions) {
|
||||
errors.frequencyOptions = {};
|
||||
}
|
||||
errors.frequencyOptions[freq] = freqErrors;
|
||||
}
|
||||
});
|
||||
|
||||
if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
|
||||
errors.exceptionFrequency = t`This schedule has no occurrences due to the selected exceptions.`;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const frequencies = [];
|
||||
frequencies.push(parseRuleObj(schedule));
|
||||
return (
|
||||
<Config>
|
||||
{() => (
|
||||
<Formik
|
||||
initialValues={{
|
||||
...initialValues,
|
||||
...overriddenValues,
|
||||
frequencyOptions: {
|
||||
...initialValues.frequencyOptions,
|
||||
...overriddenValues.frequencyOptions,
|
||||
},
|
||||
exceptionOptions: {
|
||||
...initialValues.exceptionOptions,
|
||||
...overriddenValues.exceptionOptions,
|
||||
},
|
||||
name: schedule.name || '',
|
||||
description: schedule.description || '',
|
||||
frequencies: frequencies || [],
|
||||
freq: RRule.DAILY,
|
||||
interval: 1,
|
||||
wkst: RRule.SU,
|
||||
byweekday: [],
|
||||
byweekno: [],
|
||||
bymonth: [],
|
||||
bymonthday: '',
|
||||
byyearday: '',
|
||||
bysetpos: '',
|
||||
until: schedule.until || null,
|
||||
endDate: currentDate,
|
||||
endTime: time,
|
||||
count: 1,
|
||||
endingType: 'never',
|
||||
timezone: schedule.timezone || now.zoneName,
|
||||
startDate: currentDate,
|
||||
startTime: time,
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
submitSchedule(
|
||||
@@ -488,73 +367,90 @@ function ScheduleForm({
|
||||
credentials
|
||||
);
|
||||
}}
|
||||
validate={validate}
|
||||
validate={() => {}}
|
||||
>
|
||||
{(formik) => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ScheduleFormFields
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
zoneOptions={zoneOptions}
|
||||
zoneLinks={zoneLinks}
|
||||
/>
|
||||
{isWizardOpen && (
|
||||
<SchedulePromptableFields
|
||||
schedule={schedule}
|
||||
credentials={credentials}
|
||||
surveyConfig={surveyConfig}
|
||||
launchConfig={launchConfig}
|
||||
resource={resource}
|
||||
onCloseWizard={() => {
|
||||
setIsWizardOpen(false);
|
||||
}}
|
||||
onSave={() => {
|
||||
setIsWizardOpen(false);
|
||||
setIsSaveDisabled(false);
|
||||
}}
|
||||
resourceDefaultCredentials={resourceDefaultCredentials}
|
||||
labels={originalLabels.current}
|
||||
instanceGroups={originalInstanceGroups.current}
|
||||
<>
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ScheduleFormFields
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
zoneOptions={zoneOptions}
|
||||
zoneLinks={zoneLinks}
|
||||
/>
|
||||
)}
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
ouiaId="schedule-form-save-button"
|
||||
aria-label={t`Save`}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={formik.handleSubmit}
|
||||
isDisabled={isSaveDisabled}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
|
||||
{isTemplate && showPromptButton && (
|
||||
{isWizardOpen && (
|
||||
<SchedulePromptableFields
|
||||
schedule={schedule}
|
||||
credentials={credentials}
|
||||
surveyConfig={surveyConfig}
|
||||
launchConfig={launchConfig}
|
||||
resource={resource}
|
||||
onCloseWizard={() => {
|
||||
setIsWizardOpen(false);
|
||||
}}
|
||||
onSave={() => {
|
||||
setIsWizardOpen(false);
|
||||
setIsSaveDisabled(false);
|
||||
}}
|
||||
resourceDefaultCredentials={resourceDefaultCredentials}
|
||||
labels={originalLabels.current}
|
||||
instanceGroups={originalInstanceGroups.current}
|
||||
/>
|
||||
)}
|
||||
<FormFullWidthLayout>
|
||||
<FrequenciesList openWizard={setIsScheduleWizardOpen} />
|
||||
</FormFullWidthLayout>
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
ouiaId="schedule-form-prompt-button"
|
||||
ouiaId="schedule-form-save-button"
|
||||
aria-label={t`Save`}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={formik.handleSubmit}
|
||||
isDisabled={isSaveDisabled}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
>{t`Preview occurances`}</Button>
|
||||
|
||||
{isTemplate && showPromptButton && (
|
||||
<Button
|
||||
ouiaId="schedule-form-prompt-button"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
aria-label={t`Prompt`}
|
||||
onClick={() => setIsWizardOpen(true)}
|
||||
>
|
||||
{t`Prompt`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ouiaId="schedule-form-cancel-button"
|
||||
aria-label={t`Cancel`}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
aria-label={t`Prompt`}
|
||||
onClick={() => setIsWizardOpen(true)}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t`Prompt`}
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ouiaId="schedule-form-cancel-button"
|
||||
aria-label={t`Cancel`}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
{isScheduleWizardOpen && (
|
||||
<ScheduleFormWizard
|
||||
staticFormFormkik={formik}
|
||||
isOpen={isScheduleWizardOpen}
|
||||
handleSave={() => {}}
|
||||
setIsOpen={setIsScheduleWizardOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
@@ -575,24 +471,3 @@ ScheduleForm.defaultProps = {
|
||||
};
|
||||
|
||||
export default ScheduleForm;
|
||||
|
||||
function scheduleHasInstances(values) {
|
||||
let rangeToCheck = 1;
|
||||
values.frequency.forEach((freq) => {
|
||||
if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
|
||||
rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
|
||||
}
|
||||
});
|
||||
|
||||
const ruleSet = buildRuleSet(values, true);
|
||||
const startDate = DateTime.fromISO(values.startDate);
|
||||
const endDate = startDate.plus({ days: rangeToCheck });
|
||||
const instances = ruleSet.between(
|
||||
startDate.toJSDate(),
|
||||
endDate.toJSDate(),
|
||||
true,
|
||||
(date, i) => i === 0
|
||||
);
|
||||
|
||||
return instances.length > 0;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useField } from 'formik';
|
||||
import { FormGroup, Title } from '@patternfly/react-core';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import 'styled-components/macro';
|
||||
import FormField from 'components/FormField';
|
||||
import { required } from 'util/validators';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import Popover from '../../Popover';
|
||||
import AnsibleSelect from '../../AnsibleSelect';
|
||||
import FrequencySelect, { SelectOption } from './FrequencySelect';
|
||||
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
|
||||
import { SubFormLayout, FormColumnLayout } from '../../FormLayout';
|
||||
import FrequencyDetailSubform from './FrequencyDetailSubform';
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
import sortFrequencies from './sortFrequencies';
|
||||
|
||||
const SelectClearOption = styled(SelectOption)`
|
||||
& > input[type='checkbox'] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ScheduleFormFields({
|
||||
hasDaysToKeepField,
|
||||
zoneOptions,
|
||||
zoneLinks,
|
||||
setTimeZone,
|
||||
}) {
|
||||
const helpText = getHelpText();
|
||||
const [timezone, timezoneMeta] = useField({
|
||||
name: 'timezone',
|
||||
validate: required(t`Select a value for this field`),
|
||||
});
|
||||
const [frequency, frequencyMeta, frequencyHelper] = useField({
|
||||
name: 'frequency',
|
||||
validate: required(t`Select a value for this field`),
|
||||
});
|
||||
|
||||
const [timezoneMessage, setTimezoneMessage] = useState('');
|
||||
const warnLinkedTZ = (event, selectedValue) => {
|
||||
if (zoneLinks[selectedValue]) {
|
||||
@@ -46,6 +32,7 @@ export default function ScheduleFormFields({
|
||||
setTimezoneMessage('');
|
||||
}
|
||||
timezone.onChange(event, selectedValue);
|
||||
setTimeZone(zoneLinks(selectedValue));
|
||||
};
|
||||
let timezoneValidatedStatus = 'default';
|
||||
if (timezoneMeta.touched && timezoneMeta.error) {
|
||||
@@ -55,16 +42,6 @@ export default function ScheduleFormFields({
|
||||
}
|
||||
const config = useConfig();
|
||||
|
||||
const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] =
|
||||
useField({
|
||||
name: 'exceptionFrequency',
|
||||
validate: required(t`Select a value for this field`),
|
||||
});
|
||||
|
||||
const updateFrequency = (setFrequency) => (values) => {
|
||||
setFrequency(values.sort(sortFrequencies));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@@ -103,33 +80,7 @@ export default function ScheduleFormFields({
|
||||
onChange={warnLinkedTZ}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
name="frequency"
|
||||
fieldId="schedule-frequency"
|
||||
helperTextInvalid={frequencyMeta.error}
|
||||
validated={
|
||||
!frequencyMeta.touched || !frequencyMeta.error ? 'default' : 'error'
|
||||
}
|
||||
label={t`Repeat frequency`}
|
||||
>
|
||||
<FrequencySelect
|
||||
id="schedule-frequency"
|
||||
onChange={updateFrequency(frequencyHelper.setValue)}
|
||||
value={frequency.value}
|
||||
placeholderText={
|
||||
frequency.value.length ? t`Select frequency` : t`None (run once)`
|
||||
}
|
||||
onBlur={frequencyHelper.setTouched}
|
||||
>
|
||||
<SelectClearOption value="none">{t`None (run once)`}</SelectClearOption>
|
||||
<SelectOption value="minute">{t`Minute`}</SelectOption>
|
||||
<SelectOption value="hour">{t`Hour`}</SelectOption>
|
||||
<SelectOption value="day">{t`Day`}</SelectOption>
|
||||
<SelectOption value="week">{t`Week`}</SelectOption>
|
||||
<SelectOption value="month">{t`Month`}</SelectOption>
|
||||
<SelectOption value="year">{t`Year`}</SelectOption>
|
||||
</FrequencySelect>
|
||||
</FormGroup>
|
||||
|
||||
{hasDaysToKeepField ? (
|
||||
<FormField
|
||||
id="schedule-days-to-keep"
|
||||
@@ -140,68 +91,6 @@ export default function ScheduleFormFields({
|
||||
isRequired
|
||||
/>
|
||||
) : null}
|
||||
{frequency.value.length ? (
|
||||
<SubFormLayout>
|
||||
<Title size="md" headingLevel="h4">
|
||||
{t`Frequency Details`}
|
||||
</Title>
|
||||
{frequency.value.map((val) => (
|
||||
<FormColumnLayout key={val} stacked>
|
||||
<FrequencyDetailSubform
|
||||
frequency={val}
|
||||
prefix={`frequencyOptions.${val}`}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
))}
|
||||
<Title
|
||||
size="md"
|
||||
headingLevel="h4"
|
||||
css="margin-top: var(--pf-c-card--child--PaddingRight)"
|
||||
>{t`Exceptions`}</Title>
|
||||
<FormColumnLayout stacked>
|
||||
<FormGroup
|
||||
name="exceptions"
|
||||
fieldId="exception-frequency"
|
||||
helperTextInvalid={exceptionFrequencyMeta.error}
|
||||
validated={
|
||||
!exceptionFrequencyMeta.touched || !exceptionFrequencyMeta.error
|
||||
? 'default'
|
||||
: 'error'
|
||||
}
|
||||
label={t`Add exceptions`}
|
||||
>
|
||||
<FrequencySelect
|
||||
id="exception-frequency"
|
||||
onChange={updateFrequency(exceptionFrequencyHelper.setValue)}
|
||||
value={exceptionFrequency.value}
|
||||
placeholderText={
|
||||
exceptionFrequency.value.length
|
||||
? t`Select frequency`
|
||||
: t`None`
|
||||
}
|
||||
onBlur={exceptionFrequencyHelper.setTouched}
|
||||
>
|
||||
<SelectClearOption value="none">{t`None`}</SelectClearOption>
|
||||
<SelectOption value="minute">{t`Minute`}</SelectOption>
|
||||
<SelectOption value="hour">{t`Hour`}</SelectOption>
|
||||
<SelectOption value="day">{t`Day`}</SelectOption>
|
||||
<SelectOption value="week">{t`Week`}</SelectOption>
|
||||
<SelectOption value="month">{t`Month`}</SelectOption>
|
||||
<SelectOption value="year">{t`Year`}</SelectOption>
|
||||
</FrequencySelect>
|
||||
</FormGroup>
|
||||
</FormColumnLayout>
|
||||
{exceptionFrequency.value.map((val) => (
|
||||
<FormColumnLayout key={val} stacked>
|
||||
<FrequencyDetailSubform
|
||||
frequency={val}
|
||||
prefix={`exceptionOptions.${val}`}
|
||||
isException
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
))}
|
||||
</SubFormLayout>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
199
awx/ui/src/components/Schedule/shared/ScheduleFormWizard.js
Normal file
199
awx/ui/src/components/Schedule/shared/ScheduleFormWizard.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormGroup,
|
||||
TextInput,
|
||||
Title,
|
||||
Wizard,
|
||||
WizardContextConsumer,
|
||||
WizardFooter,
|
||||
} from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { RRule } from 'rrule';
|
||||
import { useField, useFormikContext } from 'formik';
|
||||
import { DateTime } from 'luxon';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import FrequencySelect from './FrequencySelect';
|
||||
import MonthandYearForm from './MonthandYearForm';
|
||||
import OrdinalDayForm from './OrdinalDayForm';
|
||||
import WeekdayForm from './WeekdayForm';
|
||||
import ScheduleEndForm from './ScheduleEndForm';
|
||||
import parseRuleObj from './parseRuleObj';
|
||||
import { buildDtStartObj } from './buildRuleObj';
|
||||
|
||||
const GroupWrapper = styled(FormGroup)`
|
||||
&& .pf-c-form__group-control {
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
}
|
||||
&& .pf-c-form__group-label {
|
||||
padding-top: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
function ScheduleFormWizard({ isOpen, setIsOpen }) {
|
||||
const { values, resetForm, initialValues } = useFormikContext();
|
||||
const [freq, freqMeta] = useField('freq');
|
||||
const [{ value: frequenciesValue }] = useField('frequencies');
|
||||
const [interval, , intervalHelpers] = useField('interval');
|
||||
|
||||
const handleSubmit = (goToStepById) => {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
endingType,
|
||||
endTime,
|
||||
endDate,
|
||||
timezone,
|
||||
startDate,
|
||||
startTime,
|
||||
frequencies,
|
||||
...rest
|
||||
} = values;
|
||||
if (endingType === 'onDate') {
|
||||
const dt = DateTime.fromFormat(
|
||||
`${endDate} ${endTime}`,
|
||||
'yyyy-MM-dd h:mm a',
|
||||
{
|
||||
zone: timezone,
|
||||
}
|
||||
);
|
||||
rest.until = formatDateString(dt, timezone);
|
||||
|
||||
delete rest.count;
|
||||
}
|
||||
if (endingType === 'never') delete rest.count;
|
||||
|
||||
const rule = new RRule(rest);
|
||||
|
||||
const start = buildDtStartObj({
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequency: values.freq,
|
||||
});
|
||||
const newFrequency = parseRuleObj({
|
||||
timezone,
|
||||
frequency: freq.value,
|
||||
rrule: rule.toString(),
|
||||
dtstart: start,
|
||||
});
|
||||
if (goToStepById) {
|
||||
goToStepById(1);
|
||||
}
|
||||
|
||||
resetForm({
|
||||
values: {
|
||||
...initialValues,
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequencies: frequenciesValue[0].frequency.length
|
||||
? [...frequenciesValue, newFrequency]
|
||||
: [newFrequency],
|
||||
},
|
||||
});
|
||||
};
|
||||
const CustomFooter = (
|
||||
<WizardFooter>
|
||||
<WizardContextConsumer>
|
||||
{({ activeStep, onNext, onBack, goToStepById }) => (
|
||||
<>
|
||||
{activeStep.id === 2 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
handleSubmit(true, goToStepById);
|
||||
}}
|
||||
>{t`Finish and create new`}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleSubmit(false);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>{t`Finish and close`}</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onNext}>{t`Next`}</Button>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" onClick={onBack}>{t`Back`}</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
resetForm({
|
||||
values: {
|
||||
...initialValues,
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequencies: values.frequencies,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>{t`Cancel`}</Button>
|
||||
</>
|
||||
)}
|
||||
</WizardContextConsumer>
|
||||
</WizardFooter>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
onClose={() => setIsOpen(false)}
|
||||
isOpen={isOpen}
|
||||
footer={CustomFooter}
|
||||
steps={[
|
||||
{
|
||||
key: 'frequency',
|
||||
name: 'Frequency',
|
||||
id: 1,
|
||||
component: (
|
||||
<>
|
||||
<Title size="md" headingLevel="h4">{t`Repeat frequency`}</Title>
|
||||
<GroupWrapper
|
||||
name="freq"
|
||||
fieldId="schedule-frequency"
|
||||
isRequired
|
||||
helperTextInvalid={freqMeta.error}
|
||||
validated={
|
||||
!freqMeta.touched || !freqMeta.error ? 'default' : 'error'
|
||||
}
|
||||
label={<b>{t`Frequency`}</b>}
|
||||
>
|
||||
<FrequencySelect />
|
||||
</GroupWrapper>
|
||||
<GroupWrapper isRequired label={<b>{t`Interval`}</b>}>
|
||||
<TextInput
|
||||
type="number"
|
||||
value={interval.value}
|
||||
placeholder={t`Choose an interval for the schedule`}
|
||||
aria-label={t`Choose an interval for the schedule`}
|
||||
onChange={(v) => intervalHelpers.setValue(v)}
|
||||
/>
|
||||
</GroupWrapper>
|
||||
<WeekdayForm />
|
||||
<MonthandYearForm />
|
||||
<OrdinalDayForm />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'End',
|
||||
key: 'end',
|
||||
id: 2,
|
||||
component: <ScheduleEndForm />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default ScheduleFormWizard;
|
||||
164
awx/ui/src/components/Schedule/shared/WeekdayForm.js
Normal file
164
awx/ui/src/components/Schedule/shared/WeekdayForm.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Checkbox as _Checkbox,
|
||||
FormGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from '@patternfly/react-core';
|
||||
import { useField } from 'formik';
|
||||
import { RRule } from 'rrule';
|
||||
import styled from 'styled-components';
|
||||
import { weekdayOptions } from './scheduleFormHelpers';
|
||||
|
||||
const Checkbox = styled(_Checkbox)`
|
||||
:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
`;
|
||||
const GroupWrapper = styled(FormGroup)`
|
||||
&& .pf-c-form__group-control {
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
}
|
||||
&& .pf-c-form__group-label {
|
||||
padding-top: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
function WeekdayForm({ id }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField('byweekday');
|
||||
const [weekStartDay, , weekStartDayHelpers] = useField('wkst');
|
||||
const updateDaysOfWeek = (day, checked) => {
|
||||
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
|
||||
daysOfWeekHelpers.setTouched(true);
|
||||
|
||||
if (checked) {
|
||||
newDaysOfWeek.push(day);
|
||||
daysOfWeekHelpers.setValue(newDaysOfWeek);
|
||||
} else {
|
||||
daysOfWeekHelpers.setValue(
|
||||
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<GroupWrapper
|
||||
name="wkst"
|
||||
label={<b>{t`Select the first day of the week`}</b>}
|
||||
>
|
||||
<Select
|
||||
onSelect={(e, value) => {
|
||||
weekStartDayHelpers.setValue(value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
selections={weekStartDay.value}
|
||||
onToggle={(isopen) => setIsOpen(isopen)}
|
||||
isOpen={isOpen}
|
||||
id={`schedule-run-on-the-day-${id}`}
|
||||
onChange={(e, v) => {
|
||||
weekStartDayHelpers.setValue(v);
|
||||
}}
|
||||
{...weekStartDay}
|
||||
>
|
||||
{weekdayOptions.map(({ key, value, label }) => (
|
||||
<SelectOption key={key} value={value}>
|
||||
{label}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</GroupWrapper>
|
||||
<GroupWrapper
|
||||
name="byweekday"
|
||||
fieldId={`schedule-days-of-week-${id}`}
|
||||
helperTextInvalid={daysOfWeekMeta.error}
|
||||
validated={
|
||||
!daysOfWeekMeta.touched || !daysOfWeekMeta.error ? 'default' : 'error'
|
||||
}
|
||||
label={<b>{t`On selected day(s) of the week`}</b>}
|
||||
>
|
||||
<Checkbox
|
||||
label={t`Sun`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.SU)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.SU, checked);
|
||||
}}
|
||||
aria-label={t`Sunday`}
|
||||
id={`schedule-days-of-week-sun-${id}`}
|
||||
ouiaId={`schedule-days-of-week-sun-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Mon`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.MO)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.MO, checked);
|
||||
}}
|
||||
aria-label={t`Monday`}
|
||||
id={`schedule-days-of-week-mon-${id}`}
|
||||
ouiaId={`schedule-days-of-week-mon-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Tue`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.TU)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.TU, checked);
|
||||
}}
|
||||
aria-label={t`Tuesday`}
|
||||
id={`schedule-days-of-week-tue-${id}`}
|
||||
ouiaId={`schedule-days-of-week-tue-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Wed`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.WE)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.WE, checked);
|
||||
}}
|
||||
aria-label={t`Wednesday`}
|
||||
id={`schedule-days-of-week-wed-${id}`}
|
||||
ouiaId={`schedule-days-of-week-wed-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Thu`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.TH)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.TH, checked);
|
||||
}}
|
||||
aria-label={t`Thursday`}
|
||||
id={`schedule-days-of-week-thu-${id}`}
|
||||
ouiaId={`schedule-days-of-week-thu-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Fri`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.FR)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.FR, checked);
|
||||
}}
|
||||
aria-label={t`Friday`}
|
||||
id={`schedule-days-of-week-fri-${id}`}
|
||||
ouiaId={`schedule-days-of-week-fri-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
<Checkbox
|
||||
label={t`Sat`}
|
||||
isChecked={daysOfWeek.value?.includes(RRule.SA)}
|
||||
onChange={(checked) => {
|
||||
updateDaysOfWeek(RRule.SA, checked);
|
||||
}}
|
||||
aria-label={t`Saturday`}
|
||||
id={`schedule-days-of-week-sat-${id}`}
|
||||
ouiaId={`schedule-days-of-week-sat-${id}`}
|
||||
name="daysOfWeek"
|
||||
/>
|
||||
</GroupWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default WeekdayForm;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { RRule } from 'rrule';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getRRuleDayConstants } from 'util/dates';
|
||||
|
||||
window.RRule = RRule;
|
||||
window.DateTime = DateTime;
|
||||
@@ -22,7 +20,7 @@ export function buildDtStartObj(values) {
|
||||
startHour
|
||||
)}${pad(startMinute)}00`;
|
||||
const rruleString = values.timezone
|
||||
? `DTSTART;TZID=${values.timezone}:${dateString}`
|
||||
? `DTSTART;TZID=${values.timezone}${dateString}`
|
||||
: `DTSTART:${dateString}Z`;
|
||||
const rule = RRule.fromString(rruleString);
|
||||
|
||||
@@ -38,7 +36,8 @@ function pad(num) {
|
||||
|
||||
export default function buildRuleObj(values, includeStart) {
|
||||
const ruleObj = {
|
||||
interval: values.interval,
|
||||
interval: values.interval || 1,
|
||||
freq: values.freq,
|
||||
};
|
||||
|
||||
if (includeStart) {
|
||||
@@ -49,68 +48,6 @@ export default function buildRuleObj(values, includeStart) {
|
||||
);
|
||||
}
|
||||
|
||||
switch (values.frequency) {
|
||||
case 'none':
|
||||
ruleObj.count = 1;
|
||||
ruleObj.freq = RRule.MINUTELY;
|
||||
break;
|
||||
case 'minute':
|
||||
ruleObj.freq = RRule.MINUTELY;
|
||||
break;
|
||||
case 'hour':
|
||||
ruleObj.freq = RRule.HOURLY;
|
||||
break;
|
||||
case 'day':
|
||||
ruleObj.freq = RRule.DAILY;
|
||||
break;
|
||||
case 'week':
|
||||
ruleObj.freq = RRule.WEEKLY;
|
||||
ruleObj.byweekday = values.daysOfWeek;
|
||||
break;
|
||||
case 'month':
|
||||
ruleObj.freq = RRule.MONTHLY;
|
||||
if (values.runOn === 'day') {
|
||||
ruleObj.bymonthday = values.runOnDayNumber;
|
||||
} else if (values.runOn === 'the') {
|
||||
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
|
||||
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
|
||||
}
|
||||
break;
|
||||
case 'year':
|
||||
ruleObj.freq = RRule.YEARLY;
|
||||
if (values.runOn === 'day') {
|
||||
ruleObj.bymonth = parseInt(values.runOnDayMonth, 10);
|
||||
ruleObj.bymonthday = values.runOnDayNumber;
|
||||
} else if (values.runOn === 'the') {
|
||||
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
|
||||
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
|
||||
ruleObj.bymonth = parseInt(values.runOnTheMonth, 10);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(t`Frequency did not match an expected value`);
|
||||
}
|
||||
|
||||
if (values.frequency !== 'none') {
|
||||
switch (values.end) {
|
||||
case 'never':
|
||||
break;
|
||||
case 'after':
|
||||
ruleObj.count = values.occurrences;
|
||||
break;
|
||||
case 'onDate': {
|
||||
ruleObj.until = buildDateTime(
|
||||
values.endDate,
|
||||
values.endTime,
|
||||
values.timezone
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(t`End did not match an expected value (${values.end})`);
|
||||
}
|
||||
}
|
||||
|
||||
return ruleObj;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RRule, RRuleSet } from 'rrule';
|
||||
import buildRuleObj, { buildDtStartObj } from './buildRuleObj';
|
||||
import { FREQUENCIESCONSTANTS } from './scheduleFormHelpers';
|
||||
|
||||
window.RRuleSet = RRuleSet;
|
||||
|
||||
@@ -12,42 +13,31 @@ export default function buildRuleSet(values, useUTCStart) {
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequency: values.freq,
|
||||
});
|
||||
set.rrule(startRule);
|
||||
}
|
||||
|
||||
if (values.frequency.length === 0) {
|
||||
const rule = buildRuleObj(
|
||||
{
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequency: 'none',
|
||||
interval: 1,
|
||||
},
|
||||
useUTCStart
|
||||
);
|
||||
set.rrule(new RRule(rule));
|
||||
}
|
||||
|
||||
frequencies.forEach((frequency) => {
|
||||
if (!values.frequency.includes(frequency)) {
|
||||
values.frequencies.forEach(({ frequency, rrule }) => {
|
||||
if (!frequencies.includes(frequency)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = buildRuleObj(
|
||||
{
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequency,
|
||||
...values.frequencyOptions[frequency],
|
||||
freq: FREQUENCIESCONSTANTS[frequency],
|
||||
rrule,
|
||||
},
|
||||
useUTCStart
|
||||
true
|
||||
);
|
||||
|
||||
set.rrule(new RRule(rule));
|
||||
});
|
||||
|
||||
frequencies.forEach((frequency) => {
|
||||
values.exceptions?.forEach(({ frequency, rrule }) => {
|
||||
if (!values.exceptionFrequency?.includes(frequency)) {
|
||||
return;
|
||||
}
|
||||
@@ -56,8 +46,8 @@ export default function buildRuleSet(values, useUTCStart) {
|
||||
startDate: values.startDate,
|
||||
startTime: values.startTime,
|
||||
timezone: values.timezone,
|
||||
frequency,
|
||||
...values.exceptionOptions[frequency],
|
||||
freq: FREQUENCIESCONSTANTS[frequency],
|
||||
rrule,
|
||||
},
|
||||
useUTCStart
|
||||
);
|
||||
|
||||
@@ -12,12 +12,14 @@ export class UnsupportedRRuleError extends Error {
|
||||
|
||||
export default function parseRuleObj(schedule) {
|
||||
let values = {
|
||||
frequency: [],
|
||||
frequencyOptions: {},
|
||||
exceptionFrequency: [],
|
||||
exceptionOptions: {},
|
||||
frequency: '',
|
||||
rrules: '',
|
||||
timezone: schedule.timezone,
|
||||
};
|
||||
if (Object.values(schedule).length === 0) {
|
||||
return values;
|
||||
}
|
||||
|
||||
const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), {
|
||||
forceset: true,
|
||||
});
|
||||
@@ -40,25 +42,9 @@ export default function parseRuleObj(schedule) {
|
||||
}
|
||||
});
|
||||
|
||||
if (isSingleOccurrence(values)) {
|
||||
values.frequency = [];
|
||||
values.frequencyOptions = {};
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function isSingleOccurrence(values) {
|
||||
if (values.frequency.length > 1) {
|
||||
return false;
|
||||
}
|
||||
if (values.frequency[0] !== 'minute') {
|
||||
return false;
|
||||
}
|
||||
const options = values.frequencyOptions.minute;
|
||||
return options.end === 'after' && options.occurrences === 1;
|
||||
}
|
||||
|
||||
function parseDtstart(schedule, values) {
|
||||
// TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart?
|
||||
const [startDate, startTime] = dateToInputDateTime(
|
||||
@@ -81,27 +67,12 @@ const frequencyTypes = {
|
||||
[RRule.YEARLY]: 'year',
|
||||
};
|
||||
|
||||
function parseRrule(rruleString, schedule, values) {
|
||||
const { frequency, options } = parseRule(
|
||||
rruleString,
|
||||
schedule,
|
||||
values.exceptionFrequency
|
||||
);
|
||||
function parseRrule(rruleString, schedule) {
|
||||
const { frequency } = parseRule(rruleString, schedule);
|
||||
|
||||
if (values.frequencyOptions[frequency]) {
|
||||
throw new UnsupportedRRuleError(
|
||||
'Duplicate exception frequency types not supported'
|
||||
);
|
||||
}
|
||||
const freq = { frequency, rrule: rruleString };
|
||||
|
||||
return {
|
||||
...values,
|
||||
frequency: [...values.frequency, frequency].sort(sortFrequencies),
|
||||
frequencyOptions: {
|
||||
...values.frequencyOptions,
|
||||
[frequency]: options,
|
||||
},
|
||||
};
|
||||
return freq;
|
||||
}
|
||||
|
||||
function parseExRule(exruleString, schedule, values) {
|
||||
@@ -129,20 +100,10 @@ function parseExRule(exruleString, schedule, values) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseRule(ruleString, schedule, frequencies) {
|
||||
function parseRule(ruleString, schedule) {
|
||||
const {
|
||||
origOptions: {
|
||||
bymonth,
|
||||
bymonthday,
|
||||
bysetpos,
|
||||
byweekday,
|
||||
count,
|
||||
freq,
|
||||
interval,
|
||||
until,
|
||||
},
|
||||
origOptions: { count, freq, interval, until, ...rest },
|
||||
} = RRule.fromString(ruleString);
|
||||
|
||||
const now = DateTime.now();
|
||||
const closestQuarterHour = DateTime.fromMillis(
|
||||
Math.ceil(now.ts / 900000) * 900000
|
||||
@@ -156,17 +117,17 @@ function parseRule(ruleString, schedule, frequencies) {
|
||||
endTime: time,
|
||||
occurrences: 1,
|
||||
interval: 1,
|
||||
end: 'never',
|
||||
endingType: 'never',
|
||||
};
|
||||
|
||||
if (until) {
|
||||
options.end = 'onDate';
|
||||
if (until?.length) {
|
||||
options.endingType = 'onDate';
|
||||
const end = DateTime.fromISO(until.toISOString());
|
||||
const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone);
|
||||
options.endDate = endDate;
|
||||
options.endTime = endTime;
|
||||
} else if (count) {
|
||||
options.end = 'after';
|
||||
options.endingType = 'after';
|
||||
options.occurrences = count;
|
||||
}
|
||||
|
||||
@@ -178,101 +139,10 @@ function parseRule(ruleString, schedule, frequencies) {
|
||||
throw new Error(`Unexpected rrule frequency: ${freq}`);
|
||||
}
|
||||
const frequency = frequencyTypes[freq];
|
||||
if (frequencies.includes(frequency)) {
|
||||
throw new Error(`Duplicate frequency types not supported (${frequency})`);
|
||||
}
|
||||
|
||||
if (freq === RRule.WEEKLY && byweekday) {
|
||||
options.daysOfWeek = byweekday;
|
||||
}
|
||||
|
||||
if (freq === RRule.MONTHLY) {
|
||||
options.runOn = 'day';
|
||||
options.runOnTheOccurrence = 1;
|
||||
options.runOnTheDay = 'sunday';
|
||||
options.runOnDayNumber = 1;
|
||||
|
||||
if (bymonthday) {
|
||||
options.runOnDayNumber = bymonthday;
|
||||
}
|
||||
if (bysetpos) {
|
||||
options.runOn = 'the';
|
||||
options.runOnTheOccurrence = bysetpos;
|
||||
options.runOnTheDay = generateRunOnTheDay(byweekday);
|
||||
}
|
||||
}
|
||||
|
||||
if (freq === RRule.YEARLY) {
|
||||
options.runOn = 'day';
|
||||
options.runOnTheOccurrence = 1;
|
||||
options.runOnTheDay = 'sunday';
|
||||
options.runOnTheMonth = 1;
|
||||
options.runOnDayMonth = 1;
|
||||
options.runOnDayNumber = 1;
|
||||
|
||||
if (bymonthday) {
|
||||
options.runOnDayNumber = bymonthday;
|
||||
options.runOnDayMonth = bymonth;
|
||||
}
|
||||
if (bysetpos) {
|
||||
options.runOn = 'the';
|
||||
options.runOnTheOccurrence = bysetpos;
|
||||
options.runOnTheDay = generateRunOnTheDay(byweekday);
|
||||
options.runOnTheMonth = bymonth;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frequency,
|
||||
options,
|
||||
...options,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function generateRunOnTheDay(days = []) {
|
||||
if (
|
||||
[
|
||||
RRule.MO,
|
||||
RRule.TU,
|
||||
RRule.WE,
|
||||
RRule.TH,
|
||||
RRule.FR,
|
||||
RRule.SA,
|
||||
RRule.SU,
|
||||
].every((element) => days.indexOf(element) > -1)
|
||||
) {
|
||||
return 'day';
|
||||
}
|
||||
if (
|
||||
[RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every(
|
||||
(element) => days.indexOf(element) > -1
|
||||
)
|
||||
) {
|
||||
return 'weekday';
|
||||
}
|
||||
if ([RRule.SA, RRule.SU].every((element) => days.indexOf(element) > -1)) {
|
||||
return 'weekendDay';
|
||||
}
|
||||
if (days.indexOf(RRule.MO) > -1) {
|
||||
return 'monday';
|
||||
}
|
||||
if (days.indexOf(RRule.TU) > -1) {
|
||||
return 'tuesday';
|
||||
}
|
||||
if (days.indexOf(RRule.WE) > -1) {
|
||||
return 'wednesday';
|
||||
}
|
||||
if (days.indexOf(RRule.TH) > -1) {
|
||||
return 'thursday';
|
||||
}
|
||||
if (days.indexOf(RRule.FR) > -1) {
|
||||
return 'friday';
|
||||
}
|
||||
if (days.indexOf(RRule.SA) > -1) {
|
||||
return 'saturday';
|
||||
}
|
||||
if (days.indexOf(RRule.SU) > -1) {
|
||||
return 'sunday';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
232
awx/ui/src/components/Schedule/shared/scheduleFormHelpers.js
Normal file
232
awx/ui/src/components/Schedule/shared/scheduleFormHelpers.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
import { RRule } from 'rrule';
|
||||
import buildRuleObj from './buildRuleObj';
|
||||
import buildRuleSet from './buildRuleSet';
|
||||
|
||||
// const NUM_DAYS_PER_FREQUENCY = {
|
||||
// week: 7,
|
||||
// month: 31,
|
||||
// year: 365,
|
||||
// };
|
||||
// const validateSchedule = () =>
|
||||
// const errors = {};
|
||||
|
||||
// values.frequencies.forEach((freq) => {
|
||||
// const options = values.frequencyOptions[freq];
|
||||
// const freqErrors = {};
|
||||
|
||||
// if (
|
||||
// (freq === 'month' || freq === 'year') &&
|
||||
// options.runOn === 'day' &&
|
||||
// (options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
|
||||
// ) {
|
||||
// freqErrors.runOn = t`Please select a day number between 1 and 31.`;
|
||||
// }
|
||||
|
||||
// if (options.end === 'after' && !options.occurrences) {
|
||||
// freqErrors.occurrences = t`Please enter a number of occurrences.`;
|
||||
// }
|
||||
|
||||
// if (options.end === 'onDate') {
|
||||
// if (
|
||||
// DateTime.fromFormat(
|
||||
// `${values.startDate} ${values.startTime}`,
|
||||
// 'yyyy-LL-dd h:mm a'
|
||||
// ).toMillis() >=
|
||||
// DateTime.fromFormat(
|
||||
// `${options.endDate} ${options.endTime}`,
|
||||
// 'yyyy-LL-dd h:mm a'
|
||||
// ).toMillis()
|
||||
// ) {
|
||||
// freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
|
||||
// }
|
||||
|
||||
// if (
|
||||
// DateTime.fromISO(options.endDate)
|
||||
// .diff(DateTime.fromISO(values.startDate), 'days')
|
||||
// .toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
|
||||
// ) {
|
||||
// const rule = new RRule(
|
||||
// buildRuleObj({
|
||||
// startDate: values.startDate,
|
||||
// startTime: values.startTime,
|
||||
// frequencies: freq,
|
||||
// ...options,
|
||||
// })
|
||||
// );
|
||||
// if (rule.all().length === 0) {
|
||||
// errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
|
||||
// freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (Object.keys(freqErrors).length > 0) {
|
||||
// if (!errors.frequencyOptions) {
|
||||
// errors.frequencyOptions = {};
|
||||
// }
|
||||
// errors.frequencyOptions[freq] = freqErrors;
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
|
||||
// errors.exceptionFrequency = t`This schedule has no occurrences due to the
|
||||
// selected exceptions.`;
|
||||
// }
|
||||
|
||||
// ({});
|
||||
// function scheduleHasInstances(values) {
|
||||
// let rangeToCheck = 1;
|
||||
// values.frequencies.forEach((freq) => {
|
||||
// if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
|
||||
// rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
|
||||
// }
|
||||
// });
|
||||
|
||||
// const ruleSet = buildRuleSet(values, true);
|
||||
// const startDate = DateTime.fromISO(values.startDate);
|
||||
// const endDate = startDate.plus({ days: rangeToCheck });
|
||||
// const instances = ruleSet.between(
|
||||
// startDate.toJSDate(),
|
||||
// endDate.toJSDate(),
|
||||
// true,
|
||||
// (date, i) => i === 0
|
||||
// );
|
||||
|
||||
// return instances.length > 0;
|
||||
// }
|
||||
|
||||
const bysetposOptions = [
|
||||
{ value: '', key: 'none', label: 'None' },
|
||||
{ value: 1, key: 'first', label: t`First` },
|
||||
{
|
||||
value: 2,
|
||||
key: 'second',
|
||||
label: t`Second`,
|
||||
},
|
||||
{ value: 3, key: 'third', label: t`Third` },
|
||||
{
|
||||
value: 4,
|
||||
key: 'fourth',
|
||||
label: t`Fourth`,
|
||||
},
|
||||
{ value: 5, key: 'fifth', label: t`Fifth` },
|
||||
{ value: -1, key: 'last', label: t`Last` },
|
||||
];
|
||||
|
||||
const monthOptions = [
|
||||
{
|
||||
key: 'january',
|
||||
value: 1,
|
||||
label: t`January`,
|
||||
},
|
||||
{
|
||||
key: 'february',
|
||||
value: 2,
|
||||
label: t`February`,
|
||||
},
|
||||
{
|
||||
key: 'march',
|
||||
value: 3,
|
||||
label: t`March`,
|
||||
},
|
||||
{
|
||||
key: 'april',
|
||||
value: 4,
|
||||
label: t`April`,
|
||||
},
|
||||
{
|
||||
key: 'may',
|
||||
value: 5,
|
||||
label: t`May`,
|
||||
},
|
||||
{
|
||||
key: 'june',
|
||||
value: 6,
|
||||
label: t`June`,
|
||||
},
|
||||
{
|
||||
key: 'july',
|
||||
value: 7,
|
||||
label: t`July`,
|
||||
},
|
||||
{
|
||||
key: 'august',
|
||||
value: 8,
|
||||
label: t`August`,
|
||||
},
|
||||
{
|
||||
key: 'september',
|
||||
value: 9,
|
||||
label: t`September`,
|
||||
},
|
||||
{
|
||||
key: 'october',
|
||||
value: 10,
|
||||
label: t`October`,
|
||||
},
|
||||
{
|
||||
key: 'november',
|
||||
value: 11,
|
||||
label: t`November`,
|
||||
},
|
||||
{
|
||||
key: 'december',
|
||||
value: 12,
|
||||
label: t`December`,
|
||||
},
|
||||
];
|
||||
|
||||
const weekdayOptions = [
|
||||
{
|
||||
value: RRule.SU,
|
||||
key: 'sunday',
|
||||
label: t`Sunday`,
|
||||
},
|
||||
{
|
||||
value: RRule.MO,
|
||||
key: 'monday',
|
||||
label: t`Monday`,
|
||||
},
|
||||
{
|
||||
value: RRule.TU,
|
||||
key: 'tuesday',
|
||||
label: t`Tuesday`,
|
||||
},
|
||||
{
|
||||
value: RRule.WE,
|
||||
key: 'wednesday',
|
||||
label: t`Wednesday`,
|
||||
},
|
||||
{
|
||||
value: RRule.TH,
|
||||
key: 'thursday',
|
||||
label: t`Thursday`,
|
||||
},
|
||||
{
|
||||
value: RRule.FR,
|
||||
key: 'friday',
|
||||
label: t`Friday`,
|
||||
},
|
||||
{
|
||||
value: RRule.SA,
|
||||
key: 'saturday',
|
||||
label: t`Saturday`,
|
||||
},
|
||||
];
|
||||
|
||||
const FREQUENCIESCONSTANTS = {
|
||||
minute: RRule.MINUTELY,
|
||||
hour: RRule.HOURLY,
|
||||
day: RRule.DAILY,
|
||||
week: RRule.WEEKLY,
|
||||
month: RRule.MONTHLY,
|
||||
year: RRule.YEARLY,
|
||||
};
|
||||
export {
|
||||
monthOptions,
|
||||
weekdayOptions,
|
||||
bysetposOptions,
|
||||
// validateSchedule,
|
||||
FREQUENCIESCONSTANTS,
|
||||
};
|
||||
@@ -465,7 +465,7 @@
|
||||
},
|
||||
"created": "2020-05-18T21:53:35.370730Z",
|
||||
"modified": "2020-05-18T21:54:05.436400Z",
|
||||
"name": "CyberArk AIM Central Credential Provider Lookup",
|
||||
"name": "CyberArk Central Credential Provider Lookup",
|
||||
"description": "",
|
||||
"kind": "external",
|
||||
"namespace": "aim",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable';
|
||||
|
||||
import { Button, Chip } from '@patternfly/react-core';
|
||||
import { HostsAPI } from 'api';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ChipGroup from 'components/ChipGroup';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import HostToggle from 'components/HostToggle';
|
||||
import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import { Host } from 'types';
|
||||
|
||||
function InventoryHostItem({
|
||||
@@ -19,45 +23,106 @@ function InventoryHostItem({
|
||||
rowIndex,
|
||||
}) {
|
||||
const labelId = `check-action-${host.id}`;
|
||||
const initialGroups = host?.summary_fields?.groups ?? {
|
||||
results: [],
|
||||
count: 0,
|
||||
};
|
||||
|
||||
const {
|
||||
error,
|
||||
request: fetchRelatedGroups,
|
||||
result: relatedGroups,
|
||||
} = useRequest(
|
||||
useCallback(async (hostId) => {
|
||||
const { data } = await HostsAPI.readGroups(hostId);
|
||||
return data.results;
|
||||
}, []),
|
||||
initialGroups.results
|
||||
);
|
||||
|
||||
const { error: dismissableError, dismissError } = useDismissableError(error);
|
||||
|
||||
const handleOverflowChipClick = (hostId) => {
|
||||
if (relatedGroups.length === initialGroups.count) {
|
||||
return;
|
||||
}
|
||||
fetchRelatedGroups(hostId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
||||
<Td
|
||||
data-cy={labelId}
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
/>
|
||||
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-description-${host.id}`}
|
||||
dataLabel={t`Description`}
|
||||
>
|
||||
{host.description}
|
||||
</TdBreakWord>
|
||||
<ActionsTd dataLabel={t`Actions`} gridColumns="auto 40px">
|
||||
<HostToggle host={host} />
|
||||
<ActionItem
|
||||
visible={host.summary_fields.user_capabilities?.edit}
|
||||
tooltip={t`Edit host`}
|
||||
<>
|
||||
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
||||
<Td
|
||||
data-cy={labelId}
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
/>
|
||||
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-description-${host.id}`}
|
||||
dataLabel={t`Description`}
|
||||
>
|
||||
<Button
|
||||
ouiaId={`${host.id}-edit-button`}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${editUrl}`}
|
||||
{host.description}
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-related-groups-${host.id}`}
|
||||
dataLabel={t`Related Groups`}
|
||||
>
|
||||
<ChipGroup
|
||||
aria-label={t`Related Groups`}
|
||||
numChips={4}
|
||||
totalChips={initialGroups.count}
|
||||
ouiaId="host-related-groups-chips"
|
||||
onOverflowChipClick={() => handleOverflowChipClick(host.id)}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{relatedGroups.map((group) => (
|
||||
<Chip key={group.name} isReadOnly>
|
||||
{group.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
</TdBreakWord>
|
||||
<ActionsTd
|
||||
aria-label={t`Actions`}
|
||||
dataLabel={t`Actions`}
|
||||
gridColumns="auto 40px"
|
||||
>
|
||||
<HostToggle host={host} />
|
||||
<ActionItem
|
||||
visible={host.summary_fields.user_capabilities?.edit}
|
||||
tooltip={t`Edit host`}
|
||||
>
|
||||
<Button
|
||||
aria-label={t`Edit host`}
|
||||
ouiaId={`${host.id}-edit-button`}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${editUrl}`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{dismissableError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableError}
|
||||
onClose={dismissError}
|
||||
title={t`Error!`}
|
||||
variant="error"
|
||||
>
|
||||
{t`Failed to load related groups.`}
|
||||
<ErrorDetail error={dismissableError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { Router } from 'react-router-dom';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { HostsAPI } from 'api';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en } from 'make-plural/plurals';
|
||||
import InventoryHostItem from './InventoryHostItem';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import english from '../../../locales/en/messages';
|
||||
|
||||
jest.mock('api');
|
||||
|
||||
const mockHost = {
|
||||
id: 1,
|
||||
@@ -24,58 +39,194 @@ const mockHost = {
|
||||
finished: '2020-02-26T22:38:41.037991Z',
|
||||
},
|
||||
],
|
||||
groups: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 11,
|
||||
name: 'group_11',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('<InventoryHostItem />', () => {
|
||||
let wrapper;
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/hosts'],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
const getChips = (currentScreen) => {
|
||||
const list = currentScreen.getByRole('list', {
|
||||
name: 'Related Groups',
|
||||
});
|
||||
const { getAllByRole } = within(list);
|
||||
const items = getAllByRole('listitem');
|
||||
return items.map((item) => item.textContent);
|
||||
};
|
||||
|
||||
const Component = (props) => (
|
||||
<Router history={history}>
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryHostItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
editUrl={`/inventories/inventory/1/hosts/1/edit`}
|
||||
host={mockHost}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
</Router>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
});
|
||||
|
||||
test('should display expected details', () => {
|
||||
expect(wrapper.find('InventoryHostItem').length).toBe(1);
|
||||
expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe(
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Bar' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Toggle host' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Host 1' })).toHaveAttribute(
|
||||
'href',
|
||||
'/host/1'
|
||||
);
|
||||
expect(wrapper.find('Td[dataLabel="Description"]').text()).toBe('Bar');
|
||||
});
|
||||
expect(screen.getByRole('link', { name: 'Edit host' })).toHaveAttribute(
|
||||
'href',
|
||||
'/inventories/inventory/1/hosts/1/edit'
|
||||
);
|
||||
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
const relatedGroupChips = getChips(screen);
|
||||
expect(relatedGroupChips).toEqual(['group_11']);
|
||||
});
|
||||
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const copyMockHost = { ...mockHost };
|
||||
copyMockHost.summary_fields.user_capabilities.edit = false;
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryHostItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={copyMockHost}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
|
||||
render(<Component host={copyMockHost} />);
|
||||
expect(screen.queryByText('Edit host')).toBeNull();
|
||||
});
|
||||
|
||||
test('should display host toggle', () => {
|
||||
expect(wrapper.find('HostToggle').length).toBe(1);
|
||||
test('should show and hide related groups on overflow button click', async () => {
|
||||
const copyMockHost = { ...mockHost };
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'group_1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'group_2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'group_3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'group_4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'group_5',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'group_6',
|
||||
},
|
||||
];
|
||||
copyMockHost.summary_fields.groups = {
|
||||
count: 6,
|
||||
results: mockGroups.slice(0, 5),
|
||||
};
|
||||
HostsAPI.readGroups.mockReturnValue({
|
||||
data: {
|
||||
results: mockGroups,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Component host={copyMockHost} />);
|
||||
|
||||
const initialRelatedGroupChips = getChips(screen);
|
||||
expect(initialRelatedGroupChips).toEqual([
|
||||
'group_1',
|
||||
'group_2',
|
||||
'group_3',
|
||||
'group_4',
|
||||
'2 more',
|
||||
]);
|
||||
|
||||
const overflowGroupsButton = screen.queryByText('2 more');
|
||||
fireEvent.click(overflowGroupsButton);
|
||||
|
||||
await waitFor(() => expect(HostsAPI.readGroups).toHaveBeenCalledWith(1));
|
||||
|
||||
const expandedRelatedGroupChips = getChips(screen);
|
||||
expect(expandedRelatedGroupChips).toEqual([
|
||||
'group_1',
|
||||
'group_2',
|
||||
'group_3',
|
||||
'group_4',
|
||||
'group_5',
|
||||
'group_6',
|
||||
'Show less',
|
||||
]);
|
||||
|
||||
const collapseGroupsButton = await screen.findByText('Show less');
|
||||
fireEvent.click(collapseGroupsButton);
|
||||
|
||||
const collapsedRelatedGroupChips = getChips(screen);
|
||||
expect(collapsedRelatedGroupChips).toEqual(initialRelatedGroupChips);
|
||||
});
|
||||
|
||||
test('should show error modal when related groups api request fails', async () => {
|
||||
const copyMockHost = { ...mockHost };
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'group_1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'group_2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'group_3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'group_4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'group_5',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'group_6',
|
||||
},
|
||||
];
|
||||
copyMockHost.summary_fields.groups = {
|
||||
count: 6,
|
||||
results: mockGroups.slice(0, 5),
|
||||
};
|
||||
HostsAPI.readGroups.mockRejectedValueOnce(new Error());
|
||||
|
||||
render(<Component host={copyMockHost} />);
|
||||
await waitFor(() => {
|
||||
const overflowGroupsButton = screen.queryByText('2 more');
|
||||
fireEvent.click(overflowGroupsButton);
|
||||
});
|
||||
expect(screen.getByRole('dialog', { name: 'Alert modal Error!' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,7 @@ function InventoryHostList() {
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell sortKey="description">{t`Description`}</HeaderCell>
|
||||
<HeaderCell>{t`Related Groups`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function JobEvent({
|
||||
if (lineNumber < 0) {
|
||||
return null;
|
||||
}
|
||||
const canToggle = index === toggleLineIndex;
|
||||
const canToggle = index === toggleLineIndex && !event.isTracebackOnly;
|
||||
return (
|
||||
<JobEventLine
|
||||
onClick={isClickable ? onJobEventClick : undefined}
|
||||
@@ -55,7 +55,7 @@ function JobEvent({
|
||||
onToggle={onToggleCollapsed}
|
||||
/>
|
||||
<JobEventLineNumber>
|
||||
{lineNumber}
|
||||
{!event.isTracebackOnly ? lineNumber : ''}
|
||||
<JobEventEllipsis isCollapsed={isCollapsed && canToggle} />
|
||||
</JobEventLineNumber>
|
||||
<JobEventLineText
|
||||
|
||||
@@ -29,8 +29,11 @@ export function prependTraceback(job, events) {
|
||||
start_line: 0,
|
||||
};
|
||||
const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1);
|
||||
if (firstIndex && events[firstIndex]?.stdout) {
|
||||
const stdoutLines = events[firstIndex].stdout.split('\r\n');
|
||||
if (firstIndex > -1) {
|
||||
if (!events[firstIndex].stdout) {
|
||||
events[firstIndex].isTracebackOnly = true;
|
||||
}
|
||||
const stdoutLines = events[firstIndex].stdout?.split('\r\n') || [];
|
||||
stdoutLines[0] = tracebackEvent.stdout;
|
||||
events[firstIndex].stdout = stdoutLines.join('\r\n');
|
||||
} else {
|
||||
|
||||
@@ -24,6 +24,7 @@ function WorkflowJobTemplateAdd() {
|
||||
limit,
|
||||
job_tags,
|
||||
skip_tags,
|
||||
scm_branch,
|
||||
...templatePayload
|
||||
} = values;
|
||||
templatePayload.inventory = inventory?.id;
|
||||
@@ -32,6 +33,7 @@ function WorkflowJobTemplateAdd() {
|
||||
templatePayload.limit = limit === '' ? null : limit;
|
||||
templatePayload.job_tags = job_tags === '' ? null : job_tags;
|
||||
templatePayload.skip_tags = skip_tags === '' ? null : skip_tags;
|
||||
templatePayload.scm_branch = scm_branch === '' ? null : scm_branch;
|
||||
const organizationId =
|
||||
organization?.id || inventory?.summary_fields?.organization.id;
|
||||
try {
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('<WorkflowJobTemplateAdd/>', () => {
|
||||
job_tags: null,
|
||||
limit: null,
|
||||
organization: undefined,
|
||||
scm_branch: '',
|
||||
scm_branch: null,
|
||||
skip_tags: null,
|
||||
webhook_credential: undefined,
|
||||
webhook_service: '',
|
||||
|
||||
@@ -30,6 +30,7 @@ function WorkflowJobTemplateEdit({ template }) {
|
||||
limit,
|
||||
job_tags,
|
||||
skip_tags,
|
||||
scm_branch,
|
||||
...templatePayload
|
||||
} = values;
|
||||
templatePayload.inventory = inventory?.id || null;
|
||||
@@ -38,6 +39,7 @@ function WorkflowJobTemplateEdit({ template }) {
|
||||
templatePayload.limit = limit === '' ? null : limit;
|
||||
templatePayload.job_tags = job_tags === '' ? null : job_tags;
|
||||
templatePayload.skip_tags = skip_tags === '' ? null : skip_tags;
|
||||
templatePayload.scm_branch = scm_branch === '' ? null : scm_branch;
|
||||
|
||||
const formOrgId =
|
||||
organization?.id || inventory?.summary_fields?.organization.id || null;
|
||||
|
||||
@@ -7,7 +7,6 @@ __metaclass__ = type
|
||||
|
||||
DOCUMENTATION = '''
|
||||
name: controller
|
||||
plugin_type: inventory
|
||||
author:
|
||||
- Matthew Jones (@matburt)
|
||||
- Yunfan Zhang (@YunfanZhang42)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
lookup: controller_api
|
||||
name: controller_api
|
||||
author: John Westcott IV (@john-westcott-iv)
|
||||
short_description: Search the API for objects
|
||||
requirements:
|
||||
@@ -74,7 +74,7 @@ EXAMPLES = """
|
||||
|
||||
- name: Load the UI settings specifying the connection info
|
||||
set_fact:
|
||||
controller_settings: "{{ lookup('awx.awx.controller_api', 'settings/ui' host='controller.example.com',
|
||||
controller_settings: "{{ lookup('awx.awx.controller_api', 'settings/ui', host='controller.example.com',
|
||||
username='admin', password=my_pass_var, verify_ssl=False) }}"
|
||||
|
||||
- name: Report the usernames of all users with admin privs
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
lookup: schedule_rrule
|
||||
name: schedule_rrule
|
||||
author: John Westcott IV (@john-westcott-iv)
|
||||
short_description: Generate an rrule string which can be used for Schedules
|
||||
requirements:
|
||||
@@ -101,39 +101,39 @@ else:
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
frequencies = {
|
||||
'none': rrule.DAILY,
|
||||
'minute': rrule.MINUTELY,
|
||||
'hour': rrule.HOURLY,
|
||||
'day': rrule.DAILY,
|
||||
'week': rrule.WEEKLY,
|
||||
'month': rrule.MONTHLY,
|
||||
}
|
||||
|
||||
weekdays = {
|
||||
'monday': rrule.MO,
|
||||
'tuesday': rrule.TU,
|
||||
'wednesday': rrule.WE,
|
||||
'thursday': rrule.TH,
|
||||
'friday': rrule.FR,
|
||||
'saturday': rrule.SA,
|
||||
'sunday': rrule.SU,
|
||||
}
|
||||
|
||||
set_positions = {
|
||||
'first': 1,
|
||||
'second': 2,
|
||||
'third': 3,
|
||||
'fourth': 4,
|
||||
'last': -1,
|
||||
}
|
||||
|
||||
# plugin constructor
|
||||
def __init__(self, *args, **kwargs):
|
||||
if LIBRARY_IMPORT_ERROR:
|
||||
raise_from(AnsibleError('{0}'.format(LIBRARY_IMPORT_ERROR)), LIBRARY_IMPORT_ERROR)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.frequencies = {
|
||||
'none': rrule.DAILY,
|
||||
'minute': rrule.MINUTELY,
|
||||
'hour': rrule.HOURLY,
|
||||
'day': rrule.DAILY,
|
||||
'week': rrule.WEEKLY,
|
||||
'month': rrule.MONTHLY,
|
||||
}
|
||||
|
||||
self.weekdays = {
|
||||
'monday': rrule.MO,
|
||||
'tuesday': rrule.TU,
|
||||
'wednesday': rrule.WE,
|
||||
'thursday': rrule.TH,
|
||||
'friday': rrule.FR,
|
||||
'saturday': rrule.SA,
|
||||
'sunday': rrule.SU,
|
||||
}
|
||||
|
||||
self.set_positions = {
|
||||
'first': 1,
|
||||
'second': 2,
|
||||
'third': 3,
|
||||
'fourth': 4,
|
||||
'last': -1,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_date_time(date_string):
|
||||
try:
|
||||
@@ -149,14 +149,13 @@ class LookupModule(LookupBase):
|
||||
|
||||
return self.get_rrule(frequency, kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_rrule(frequency, kwargs):
|
||||
def get_rrule(self, frequency, kwargs):
|
||||
|
||||
if frequency not in LookupModule.frequencies:
|
||||
if frequency not in self.frequencies:
|
||||
raise AnsibleError('Frequency of {0} is invalid'.format(frequency))
|
||||
|
||||
rrule_kwargs = {
|
||||
'freq': LookupModule.frequencies[frequency],
|
||||
'freq': self.frequencies[frequency],
|
||||
'interval': kwargs.get('every', 1),
|
||||
}
|
||||
|
||||
@@ -187,9 +186,9 @@ class LookupModule(LookupBase):
|
||||
days = []
|
||||
for day in kwargs['on_days'].split(','):
|
||||
day = day.strip()
|
||||
if day not in LookupModule.weekdays:
|
||||
raise AnsibleError('Parameter on_days must only contain values {0}'.format(', '.join(LookupModule.weekdays.keys())))
|
||||
days.append(LookupModule.weekdays[day])
|
||||
if day not in self.weekdays:
|
||||
raise AnsibleError('Parameter on_days must only contain values {0}'.format(', '.join(self.weekdays.keys())))
|
||||
days.append(self.weekdays[day])
|
||||
|
||||
rrule_kwargs['byweekday'] = days
|
||||
|
||||
@@ -214,13 +213,13 @@ class LookupModule(LookupBase):
|
||||
except Exception as e:
|
||||
raise_from(AnsibleError('on_the parameter must be two words separated by a space'), e)
|
||||
|
||||
if weekday not in LookupModule.weekdays:
|
||||
if weekday not in self.weekdays:
|
||||
raise AnsibleError('Weekday portion of on_the parameter is not valid')
|
||||
if occurance not in LookupModule.set_positions:
|
||||
if occurance not in self.set_positions:
|
||||
raise AnsibleError('The first string of the on_the parameter is not valid')
|
||||
|
||||
rrule_kwargs['byweekday'] = LookupModule.weekdays[weekday]
|
||||
rrule_kwargs['bysetpos'] = LookupModule.set_positions[occurance]
|
||||
rrule_kwargs['byweekday'] = self.weekdays[weekday]
|
||||
rrule_kwargs['bysetpos'] = self.set_positions[occurance]
|
||||
|
||||
my_rule = rrule.rrule(**rrule_kwargs)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
lookup: schedule_rruleset
|
||||
name: schedule_rruleset
|
||||
author: John Westcott IV (@john-westcott-iv)
|
||||
short_description: Generate an rruleset string
|
||||
requirements:
|
||||
@@ -31,7 +31,8 @@ DOCUMENTATION = """
|
||||
rules:
|
||||
description:
|
||||
- Array of rules in the rruleset
|
||||
type: array
|
||||
type: list
|
||||
elements: dict
|
||||
required: True
|
||||
suboptions:
|
||||
frequency:
|
||||
@@ -136,40 +137,44 @@ try:
|
||||
import pytz
|
||||
from dateutil import rrule
|
||||
except ImportError as imp_exc:
|
||||
raise_from(AnsibleError('{0}'.format(imp_exc)), imp_exc)
|
||||
LIBRARY_IMPORT_ERROR = imp_exc
|
||||
else:
|
||||
LIBRARY_IMPORT_ERROR = None
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
frequencies = {
|
||||
'none': rrule.DAILY,
|
||||
'minute': rrule.MINUTELY,
|
||||
'hour': rrule.HOURLY,
|
||||
'day': rrule.DAILY,
|
||||
'week': rrule.WEEKLY,
|
||||
'month': rrule.MONTHLY,
|
||||
}
|
||||
|
||||
weekdays = {
|
||||
'monday': rrule.MO,
|
||||
'tuesday': rrule.TU,
|
||||
'wednesday': rrule.WE,
|
||||
'thursday': rrule.TH,
|
||||
'friday': rrule.FR,
|
||||
'saturday': rrule.SA,
|
||||
'sunday': rrule.SU,
|
||||
}
|
||||
|
||||
set_positions = {
|
||||
'first': 1,
|
||||
'second': 2,
|
||||
'third': 3,
|
||||
'fourth': 4,
|
||||
'last': -1,
|
||||
}
|
||||
|
||||
# plugin constructor
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if LIBRARY_IMPORT_ERROR:
|
||||
raise_from(AnsibleError('{0}'.format(LIBRARY_IMPORT_ERROR)), LIBRARY_IMPORT_ERROR)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.frequencies = {
|
||||
'none': rrule.DAILY,
|
||||
'minute': rrule.MINUTELY,
|
||||
'hour': rrule.HOURLY,
|
||||
'day': rrule.DAILY,
|
||||
'week': rrule.WEEKLY,
|
||||
'month': rrule.MONTHLY,
|
||||
}
|
||||
|
||||
self.weekdays = {
|
||||
'monday': rrule.MO,
|
||||
'tuesday': rrule.TU,
|
||||
'wednesday': rrule.WE,
|
||||
'thursday': rrule.TH,
|
||||
'friday': rrule.FR,
|
||||
'saturday': rrule.SA,
|
||||
'sunday': rrule.SU,
|
||||
}
|
||||
|
||||
self.set_positions = {
|
||||
'first': 1,
|
||||
'second': 2,
|
||||
'third': 3,
|
||||
'fourth': 4,
|
||||
'last': -1,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_date_time(date_string):
|
||||
@@ -188,14 +193,14 @@ class LookupModule(LookupBase):
|
||||
# something: [1,2,3] - A list of ints
|
||||
return_values = []
|
||||
# If they give us a single int, lets make it a list of ints
|
||||
if type(rule[field_name]) == int:
|
||||
if isinstance(rule[field_name], int):
|
||||
rule[field_name] = [rule[field_name]]
|
||||
# If its not a list, we need to split it into a list
|
||||
if type(rule[field_name]) != list:
|
||||
if isinstance(rule[field_name], list):
|
||||
rule[field_name] = rule[field_name].split(',')
|
||||
for value in rule[field_name]:
|
||||
# If they have a list of strs we want to strip the str incase its space delineated
|
||||
if type(value) == str:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
# If value happens to be an int (from a list of ints) we need to coerce it into a str for the re.match
|
||||
if not re.match(r"^\d+$", str(value)) or int(value) < min_value or int(value) > max_value:
|
||||
@@ -205,7 +210,7 @@ class LookupModule(LookupBase):
|
||||
|
||||
def process_list(self, field_name, rule, valid_list, rule_number):
|
||||
return_values = []
|
||||
if type(rule[field_name]) != list:
|
||||
if isinstance(rule[field_name], list):
|
||||
rule[field_name] = rule[field_name].split(',')
|
||||
for value in rule[field_name]:
|
||||
value = value.strip()
|
||||
@@ -260,11 +265,11 @@ class LookupModule(LookupBase):
|
||||
frequency = rule.get('frequency', None)
|
||||
if not frequency:
|
||||
raise AnsibleError("Rule {0} is missing a frequency".format(rule_number))
|
||||
if frequency not in LookupModule.frequencies:
|
||||
if frequency not in self.frequencies:
|
||||
raise AnsibleError('Frequency of rule {0} is invalid {1}'.format(rule_number, frequency))
|
||||
|
||||
rrule_kwargs = {
|
||||
'freq': LookupModule.frequencies[frequency],
|
||||
'freq': self.frequencies[frequency],
|
||||
'interval': rule.get('interval', 1),
|
||||
'dtstart': start_date,
|
||||
}
|
||||
@@ -287,7 +292,7 @@ class LookupModule(LookupBase):
|
||||
)
|
||||
|
||||
if 'bysetpos' in rule:
|
||||
rrule_kwargs['bysetpos'] = self.process_list('bysetpos', rule, LookupModule.set_positions, rule_number)
|
||||
rrule_kwargs['bysetpos'] = self.process_list('bysetpos', rule, self.set_positions, rule_number)
|
||||
|
||||
if 'bymonth' in rule:
|
||||
rrule_kwargs['bymonth'] = self.process_integer('bymonth', rule, 1, 12, rule_number)
|
||||
@@ -302,7 +307,7 @@ class LookupModule(LookupBase):
|
||||
rrule_kwargs['byweekno'] = self.process_integer('byweekno', rule, 1, 52, rule_number)
|
||||
|
||||
if 'byweekday' in rule:
|
||||
rrule_kwargs['byweekday'] = self.process_list('byweekday', rule, LookupModule.weekdays, rule_number)
|
||||
rrule_kwargs['byweekday'] = self.process_list('byweekday', rule, self.weekdays, rule_number)
|
||||
|
||||
if 'byhour' in rule:
|
||||
rrule_kwargs['byhour'] = self.process_integer('byhour', rule, 0, 23, rule_number)
|
||||
|
||||
@@ -4,6 +4,7 @@ __metaclass__ = type
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, env_fallback
|
||||
from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError
|
||||
from ansible.module_utils.parsing.convert_bool import boolean as strtobool
|
||||
from ansible.module_utils.six import PY2
|
||||
from ansible.module_utils.six import raise_from, string_types
|
||||
from ansible.module_utils.six.moves import StringIO
|
||||
@@ -11,14 +12,21 @@ from ansible.module_utils.six.moves.urllib.error import HTTPError
|
||||
from ansible.module_utils.six.moves.http_cookiejar import CookieJar
|
||||
from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode
|
||||
from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError
|
||||
from distutils.version import LooseVersion as Version
|
||||
from socket import getaddrinfo, IPPROTO_TCP
|
||||
import time
|
||||
import re
|
||||
from json import loads, dumps
|
||||
from os.path import isfile, expanduser, split, join, exists, isdir
|
||||
from os import access, R_OK, getcwd
|
||||
from distutils.util import strtobool
|
||||
|
||||
|
||||
try:
|
||||
from ansible.module_utils.compat.version import LooseVersion as Version
|
||||
except ImportError:
|
||||
try:
|
||||
from distutils.version import LooseVersion as Version
|
||||
except ImportError:
|
||||
raise AssertionError('To use this plugin or module with ansible-core 2.11, you need to use Python < 3.12 with distutils.version present')
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
@@ -55,7 +55,6 @@ options:
|
||||
description:
|
||||
- The arguments to pass to the module.
|
||||
type: str
|
||||
default: ""
|
||||
forks:
|
||||
description:
|
||||
- The number of forks to use for this ad hoc execution.
|
||||
|
||||
@@ -42,6 +42,7 @@ options:
|
||||
- Maximum time in seconds to wait for a job to finish.
|
||||
- Not specifying means the task will wait until the controller cancels the command.
|
||||
type: int
|
||||
default: 0
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ options:
|
||||
- The credential type being created.
|
||||
- Can be a built-in credential type such as "Machine", or a custom credential type such as "My Credential Type"
|
||||
- Choices include Amazon Web Services, Ansible Galaxy/Automation Hub API Token, Centrify Vault Credential Provider Lookup,
|
||||
Container Registry, CyberArk AIM Central Credential Provider Lookup, CyberArk Conjur Secrets Manager Lookup, Google Compute Engine,
|
||||
Container Registry, CyberArk Central Credential Provider Lookup, CyberArk Conjur Secret Lookup, Google Compute Engine,
|
||||
GitHub Personal Access Token, GitLab Personal Access Token, GPG Public Key, HashiCorp Vault Secret Lookup, HashiCorp Vault Signed SSH,
|
||||
Insights, Machine, Microsoft Azure Key Vault, Microsoft Azure Resource Manager, Network, OpenShift or Kubernetes API
|
||||
Bearer Token, OpenStack, Red Hat Ansible Automation Platform, Red Hat Satellite 6, Red Hat Virtualization, Source Control,
|
||||
|
||||
@@ -80,9 +80,9 @@ def main():
|
||||
name=dict(required=True),
|
||||
new_name=dict(),
|
||||
image=dict(required=True),
|
||||
description=dict(default=''),
|
||||
description=dict(),
|
||||
organization=dict(),
|
||||
credential=dict(default=''),
|
||||
credential=dict(),
|
||||
state=dict(choices=['present', 'absent'], default='present'),
|
||||
pull=dict(choices=['always', 'missing', 'never'], default='missing'),
|
||||
)
|
||||
|
||||
@@ -86,6 +86,16 @@ options:
|
||||
- workflow names to export
|
||||
type: list
|
||||
elements: str
|
||||
applications:
|
||||
description:
|
||||
- OAuth2 application names to export
|
||||
type: list
|
||||
elements: str
|
||||
schedules:
|
||||
description:
|
||||
- schedule names to export
|
||||
type: list
|
||||
elements: str
|
||||
requirements:
|
||||
- "awxkit >= 9.3.0"
|
||||
notes:
|
||||
|
||||
@@ -266,6 +266,7 @@ options:
|
||||
description:
|
||||
- Maximum time in seconds to wait for a job to finish (server-side).
|
||||
type: int
|
||||
default: 0
|
||||
job_slice_count:
|
||||
description:
|
||||
- The number of jobs to slice into at runtime. Will cause the Job Template to launch a workflow if value is greater than 1.
|
||||
@@ -287,7 +288,6 @@ options:
|
||||
description:
|
||||
- Branch to use in job run. Project default used if blank. Only allowed if project allow_override field is set to true.
|
||||
type: str
|
||||
default: ''
|
||||
labels:
|
||||
description:
|
||||
- The labels applied to this job template
|
||||
|
||||
@@ -60,12 +60,10 @@ options:
|
||||
description:
|
||||
- The branch to use for the SCM resource.
|
||||
type: str
|
||||
default: ''
|
||||
scm_refspec:
|
||||
description:
|
||||
- The refspec to use for the SCM resource.
|
||||
type: str
|
||||
default: ''
|
||||
credential:
|
||||
description:
|
||||
- Name of the credential to use with this SCM resource.
|
||||
|
||||
@@ -51,7 +51,6 @@ options:
|
||||
- Specify C(extra_vars) for the template.
|
||||
required: False
|
||||
type: dict
|
||||
default: {}
|
||||
forks:
|
||||
description:
|
||||
- Forks applied as a prompt, assuming job template prompts for forks
|
||||
|
||||
@@ -39,6 +39,7 @@ options:
|
||||
- Note This is a client side search, not an API side search
|
||||
required: False
|
||||
type: dict
|
||||
default: {}
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ options:
|
||||
- Optional description of this access token.
|
||||
required: False
|
||||
type: str
|
||||
default: ''
|
||||
application:
|
||||
description:
|
||||
- The application tied to this token.
|
||||
|
||||
@@ -214,7 +214,8 @@ options:
|
||||
type: int
|
||||
job_slice_count:
|
||||
description:
|
||||
- The number of jobs to slice into at runtime, if job template prompts for job slices. Will cause the Job Template to launch a workflow if value is greater than 1.
|
||||
- The number of jobs to slice into at runtime, if job template prompts for job slices.
|
||||
- Will cause the Job Template to launch a workflow if value is greater than 1.
|
||||
type: int
|
||||
default: '1'
|
||||
timeout:
|
||||
@@ -328,42 +329,46 @@ options:
|
||||
- Nodes that will run after this node completes.
|
||||
- List of node identifiers.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
identifier:
|
||||
description:
|
||||
- Identifier of Node that will run after this node completes given this option.
|
||||
elements: str
|
||||
type: str
|
||||
success_nodes:
|
||||
description:
|
||||
- Nodes that will run after this node on success.
|
||||
- List of node identifiers.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
identifier:
|
||||
description:
|
||||
- Identifier of Node that will run after this node completes given this option.
|
||||
elements: str
|
||||
type: str
|
||||
failure_nodes:
|
||||
description:
|
||||
- Nodes that will run after this node on failure.
|
||||
- List of node identifiers.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
identifier:
|
||||
description:
|
||||
- Identifier of Node that will run after this node completes given this option.
|
||||
elements: str
|
||||
type: str
|
||||
credentials:
|
||||
description:
|
||||
- Credentials to be applied to job as launch-time prompts.
|
||||
- List of credential names.
|
||||
- Uniqueness is not handled rigorously.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
name:
|
||||
description:
|
||||
- Name Credentials to be applied to job as launch-time prompts.
|
||||
elements: str
|
||||
type: str
|
||||
organization:
|
||||
description:
|
||||
- Name of key for use in model for organizational reference
|
||||
@@ -379,11 +384,12 @@ options:
|
||||
- List of Label names.
|
||||
- Uniqueness is not handled rigorously.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
name:
|
||||
description:
|
||||
- Name Labels to be applied to job as launch-time prompts.
|
||||
elements: str
|
||||
type: str
|
||||
organization:
|
||||
description:
|
||||
- Name of key for use in model for organizational reference
|
||||
@@ -399,11 +405,12 @@ options:
|
||||
- List of Instance group names.
|
||||
- Uniqueness is not handled rigorously.
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
name:
|
||||
description:
|
||||
- Name of Instance groups to be applied to job as launch-time prompts.
|
||||
elements: str
|
||||
type: str
|
||||
destroy_current_nodes:
|
||||
description:
|
||||
- Set in order to destroy current workflow_nodes on the workflow.
|
||||
@@ -789,6 +796,7 @@ def main():
|
||||
allow_simultaneous=dict(type='bool'),
|
||||
ask_variables_on_launch=dict(type='bool'),
|
||||
ask_labels_on_launch=dict(type='bool', aliases=['ask_labels']),
|
||||
ask_tags_on_launch=dict(type='bool', aliases=['ask_tags']),
|
||||
ask_skip_tags_on_launch=dict(type='bool', aliases=['ask_skip_tags']),
|
||||
inventory=dict(),
|
||||
limit=dict(),
|
||||
@@ -873,6 +881,7 @@ def main():
|
||||
'ask_limit_on_launch',
|
||||
'ask_variables_on_launch',
|
||||
'ask_labels_on_launch',
|
||||
'ask_tags_on_launch',
|
||||
'ask_skip_tags_on_launch',
|
||||
'webhook_service',
|
||||
'job_tags',
|
||||
|
||||
@@ -30,7 +30,6 @@ options:
|
||||
- Variables to apply at launch time.
|
||||
- Will only be accepted if job template prompts for vars or has a survey asking for those vars.
|
||||
type: dict
|
||||
default: {}
|
||||
inventory:
|
||||
description:
|
||||
- Inventory applied as a prompt, if job template prompts for inventory
|
||||
|
||||
@@ -159,7 +159,7 @@ def run_module(request, collection_import):
|
||||
elif getattr(resource_module, 'TowerLegacyModule', None):
|
||||
resource_class = resource_module.TowerLegacyModule
|
||||
else:
|
||||
raise ("The module has neither a TowerLegacyModule, ControllerAWXKitModule or a ControllerAPIModule")
|
||||
raise RuntimeError("The module has neither a TowerLegacyModule, ControllerAWXKitModule or a ControllerAPIModule")
|
||||
|
||||
with mock.patch.object(resource_class, '_load_params', new=mock_load_params):
|
||||
# Call the test utility (like a mock server) instead of issuing HTTP requests
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_create_project(run_module, admin_user, organization, silence_warning):
|
||||
dict(name='foo', organization=organization.name, scm_type='git', scm_url='https://foo.invalid', wait=False, scm_update_cache_timeout=5),
|
||||
admin_user,
|
||||
)
|
||||
silence_warning.assert_called_once_with('scm_update_cache_timeout will be ignored since scm_update_on_launch ' 'was not set to true')
|
||||
silence_warning.assert_called_once_with('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true')
|
||||
|
||||
assert result.pop('changed', None), result
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_delete_same_named_schedule(run_module, project, inventory, admin_user):
|
||||
],
|
||||
)
|
||||
def test_rrule_lookup_plugin(collection_import, freq, kwargs, expect):
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule()
|
||||
generated_rule = LookupModule.get_rrule(freq, kwargs)
|
||||
assert generated_rule == expect
|
||||
rrule_checker = SchedulePreviewSerializer()
|
||||
@@ -92,7 +92,7 @@ def test_rrule_lookup_plugin(collection_import, freq, kwargs, expect):
|
||||
|
||||
@pytest.mark.parametrize("freq", ('none', 'minute', 'hour', 'day', 'week', 'month'))
|
||||
def test_empty_schedule_rrule(collection_import, freq):
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule()
|
||||
if freq == 'day':
|
||||
pfreq = 'DAILY'
|
||||
elif freq == 'none':
|
||||
@@ -136,7 +136,7 @@ def test_empty_schedule_rrule(collection_import, freq):
|
||||
],
|
||||
)
|
||||
def test_rrule_lookup_plugin_failure(collection_import, freq, kwargs, msg):
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule
|
||||
LookupModule = collection_import('plugins.lookup.schedule_rrule').LookupModule()
|
||||
with pytest.raises(AnsibleError) as e:
|
||||
assert LookupModule.get_rrule(freq, kwargs)
|
||||
assert msg in str(e.value)
|
||||
|
||||
3
awx_collection/tests/config.yml
Normal file
3
awx_collection/tests/config.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
modules:
|
||||
python_requires: '>3'
|
||||
@@ -14,7 +14,7 @@
|
||||
credential:
|
||||
description: Credential for Testing Source
|
||||
name: "{{ src_cred_name }}"
|
||||
credential_type: CyberArk AIM Central Credential Provider Lookup
|
||||
credential_type: CyberArk Central Credential Provider Lookup
|
||||
inputs:
|
||||
url: "https://cyberark.example.com"
|
||||
app_id: "My-App-ID"
|
||||
@@ -58,7 +58,7 @@
|
||||
credential:
|
||||
description: Credential for Testing Source Change
|
||||
name: "{{ src_cred_name }}-2"
|
||||
credential_type: CyberArk AIM Central Credential Provider Lookup
|
||||
credential_type: CyberArk Central Credential Provider Lookup
|
||||
inputs:
|
||||
url: "https://cyberark-prod.example.com"
|
||||
app_id: "My-App-ID"
|
||||
@@ -92,7 +92,7 @@
|
||||
credential:
|
||||
name: "{{ src_cred_name }}"
|
||||
organization: Default
|
||||
credential_type: CyberArk AIM Central Credential Provider Lookup
|
||||
credential_type: CyberArk Central Credential Provider Lookup
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
credential:
|
||||
name: "{{ src_cred_name }}-2"
|
||||
organization: Default
|
||||
credential_type: CyberArk AIM Central Credential Provider Lookup
|
||||
credential_type: CyberArk Central Credential Provider Lookup
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
ask_inventory_on_launch: true
|
||||
ask_scm_branch_on_launch: true
|
||||
ask_limit_on_launch: true
|
||||
ask_tags_on_launch: true
|
||||
ask_variables_on_launch: true
|
||||
register: result
|
||||
|
||||
@@ -263,6 +264,7 @@
|
||||
ask_inventory_on_launch: true
|
||||
ask_scm_branch_on_launch: true
|
||||
ask_limit_on_launch: true
|
||||
ask_tags_on_launch: true
|
||||
ask_variables_on_launch: true
|
||||
register: bad_label_results
|
||||
ignore_errors: true
|
||||
@@ -278,6 +280,7 @@
|
||||
ask_inventory_on_launch: false
|
||||
ask_scm_branch_on_launch: false
|
||||
ask_limit_on_launch: false
|
||||
ask_tags_on_launch: false
|
||||
ask_variables_on_launch: false
|
||||
state: present
|
||||
|
||||
|
||||
1
awx_collection/tests/sanity/ignore-2.14.txt
Normal file
1
awx_collection/tests/sanity/ignore-2.14.txt
Normal file
@@ -0,0 +1 @@
|
||||
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec
|
||||
1
awx_collection/tests/sanity/ignore-2.15.txt
Normal file
1
awx_collection/tests/sanity/ignore-2.15.txt
Normal file
@@ -0,0 +1 @@
|
||||
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec
|
||||
@@ -1,4 +1,11 @@
|
||||
---
|
||||
- name: Sanity assertions, that some variables have a non-blank value
|
||||
assert:
|
||||
that:
|
||||
- collection_version
|
||||
- collection_package
|
||||
- collection_path
|
||||
|
||||
- name: Set the collection version in the controller_api.py file
|
||||
replace:
|
||||
path: "{{ collection_path }}/plugins/module_utils/controller_api.py"
|
||||
|
||||
@@ -2,6 +2,6 @@ The preferred way to install the AWX CLI is through pip:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
pip install "https://github.com/ansible/awx/archive/$VERSION.tar.gz#egg=awxkit&subdirectory=awxkit"
|
||||
pip install "git+https://github.com/ansible/awx.git@$VERSION#egg=awxkit&subdirectory=awxkit"
|
||||
|
||||
...where ``$VERSION`` is the version of AWX you're running. To see a list of all available releases, visit: https://github.com/ansible/awx/releases
|
||||
|
||||
@@ -9,7 +9,6 @@ skip_missing_interpreters = true
|
||||
|
||||
[testenv]
|
||||
basepython = python3.9
|
||||
passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH
|
||||
setenv =
|
||||
PYTHONPATH = {toxinidir}:{env:PYTHONPATH:}:.
|
||||
deps =
|
||||
|
||||
@@ -98,6 +98,7 @@ Examples:
|
||||
Given settings.AWX_CONTROL_NODE_TASK_IMPACT is 1:
|
||||
- Project updates (where the execution_node is always the same as the controller_node), have a total impact of 2.
|
||||
- Container group jobs (where the execution node is not a member of the cluster) only control impact applies, and the controller node has a total task impact of 1.
|
||||
- A job executing on a "hybrid" node where both control and execution will occur on the same node has the task impact of (1 overhead for ansible main process) + (min(forks,hosts)) + (1 control node task impact). Meaning a Job running on a hybrid node with forks set to 1 would have a total task impact of 3.
|
||||
|
||||
### Selecting the Right settings.AWX_CONTROL_NODE_TASK_IMPACT
|
||||
|
||||
|
||||
@@ -1,18 +1,108 @@
|
||||
# Task Manager Overview
|
||||
# Task Manager System Overview
|
||||
|
||||
The task manager is responsible for deciding when jobs should be scheduled to run. When choosing a task to run, the considerations are:
|
||||
The task management system is made up of three separate components:
|
||||
1. Dependency Manager
|
||||
2. Task Manager
|
||||
3. Workflow Manager
|
||||
|
||||
Each of these run in a separate dispatched task and can run at the same time as one another.
|
||||
|
||||
This system is responsible for deciding when tasks should be scheduled to run. When choosing a task to run, the considerations are:
|
||||
1. Creation time
|
||||
2. Job dependencies
|
||||
3. Capacity
|
||||
|
||||
Independent jobs are run in order of creation time, earliest first. Jobs with dependencies are also run in creation time order within the group of job dependencies. Capacity is the final consideration when deciding to release a job to be run by the task dispatcher.
|
||||
Independent tasks are run in order of creation time, earliest first. Tasks with dependencies are also run in creation time order within the group of task dependencies. Capacity is the final consideration when deciding to release a task to be run by the dispatcher.
|
||||
|
||||
## Task Manager Architecture
|
||||
|
||||
The task manager has a single entry point, `Scheduler().schedule()`. The method may be called in parallel, at any time, as many times as the user wants. The `schedule()` function tries to acquire a single, global lock using the Instance table first recorded in the database. If the lock cannot be acquired, the method returns. The failure to acquire the lock indicates that there is another instance currently running `schedule()`.
|
||||
## Dependency Manager
|
||||
|
||||
Responsible for looking at each pending task and determining whether it should create a dependency for that task.
|
||||
For example, if `update_on_launch` is enabled of a task, a project update will be created as a dependency of that task. The Dependency Manager is responsible for creating that project update.
|
||||
|
||||
Dependencies can also have their own dependencies, for example,
|
||||
|
||||
```
|
||||
+-----------+
|
||||
| | created by web API call
|
||||
| Job A |
|
||||
| |
|
||||
+-----------+---+
|
||||
|
|
||||
|
|
||||
+-------v----+
|
||||
| Inventory | dependency of Job A
|
||||
| Source | created by Dependency Manager
|
||||
| Update B |
|
||||
+------------+-------+
|
||||
|
|
||||
|
|
||||
+------v------+
|
||||
| Project | dependency of Inventory Source Update B
|
||||
| Update C | created by Dependency Manager
|
||||
+-------------+
|
||||
```
|
||||
|
||||
|
||||
### Dependency Manager Steps
|
||||
|
||||
1. Get pending tasks (parent tasks) that have `dependencies_processed = False`
|
||||
2. Create project update if
|
||||
a. not already created
|
||||
b. last project update outside of cache timeout window
|
||||
3. Create inventory source update if
|
||||
a. not already created
|
||||
b. last inventory source update outside of cache timeout window
|
||||
4. Check and create dependencies for these newly created dependencies
|
||||
a. inventory source updates can have a project update dependency
|
||||
5. All dependencies are linked to the parent task via the `dependent_jobs` field
|
||||
a. This allows us to cancel the parent task if the dependency fails or is canceled
|
||||
6. Update the parent tasks with `dependencies_processed = True`
|
||||
|
||||
|
||||
## Task Manager
|
||||
|
||||
Responsible for looking at each pending task and determining whether Task Manager can start that task.
|
||||
|
||||
### Task Manager Steps
|
||||
|
||||
1. Get pending, waiting, and running tasks that have `dependencies_processed = True`
|
||||
2. Before processing pending tasks, the task manager first processes running tasks. This allows it to build a dependency graph and account for the currently consumed capacity in the system.
|
||||
a. dependency graph is just an internal data structure that tracks which jobs are currently running. It also handles "soft" blocking logic
|
||||
b. the capacity is tracked in memory on the `TaskManagerInstances` and `TaskManagerInstanceGroups` objects which are in-memory representations of the instances and instance groups. These data structures are used to help track what consumed capacity will be as we decide that we will start new tasks, and until such time that we actually commit the state changes to the database.
|
||||
3. For each pending task:
|
||||
a. Check if total number of tasks started on this task manager cycle is > `start_task_limit`
|
||||
b. Check if [timed out](#Timing Out)
|
||||
b. Check if task is blocked
|
||||
c. Check if preferred instances have enough capacity to run the task
|
||||
4. Start the task by changing status to `waiting` and submitting task to dispatcher
|
||||
|
||||
|
||||
## Workflow Manager
|
||||
|
||||
Responsible for looking at each workflow job and determining if next node can run
|
||||
|
||||
### Worflow Manager Steps
|
||||
|
||||
1. Get all running workflow jobs
|
||||
2. Build up a workflow DAG for each workflow job
|
||||
3. For each workflow job:
|
||||
a. Check if [timed out](#Timing Out)
|
||||
b. Check if next node can start based on previous node status and the associated success / failure / always logic
|
||||
4. Create new task and signal start
|
||||
|
||||
|
||||
## Task Manager System Architecture
|
||||
|
||||
Each of the three managers has a single entry point, `schedule()`. The `schedule()` function tries to acquire a single, global lock recorded in the database. If the lock cannot be acquired, the method returns. The failure to acquire the lock indicates that there is another instance currently running `schedule()`.
|
||||
|
||||
Each manager runs inside of an atomic DB transaction. If the dispatcher task that is running the manager is killed, none of the created tasks or updates will take effect.
|
||||
|
||||
### Hybrid Scheduler: Periodic + Event
|
||||
The `schedule()` function is run (a) periodically by a background task and (b) on job creation or completion. The task manager system would behave correctly if it ran, exclusively, via (a) or (b).
|
||||
|
||||
Each manager's `schedule()` function is run (a) periodically by a background task and (b) on job creation or completion. The task manager system would behave correctly if it ran, exclusively, via (a) or (b).
|
||||
|
||||
Special note -- the workflow manager is not scheduled to run periodically *directly*, but piggy-backs off the task manager. That is, if task manager sees at least one running workflow job, it will schedule the workflow manager to run.
|
||||
|
||||
`schedule()` is triggered via both mechanisms because of the following properties:
|
||||
1. It reduces the time from launch to running, resulting a better user experience.
|
||||
@@ -20,21 +110,34 @@ The `schedule()` function is run (a) periodically by a background task and (b) o
|
||||
|
||||
Empirically, the periodic task manager has been effective in the past and will continue to be relied upon with the added event-triggered `schedule()`.
|
||||
|
||||
### Scheduler Algorithm
|
||||
### Bulk Reschedule
|
||||
|
||||
* Get all non-completed jobs, `all_tasks`
|
||||
* Detect finished workflow jobs
|
||||
* Spawn next workflow jobs if needed
|
||||
* For each pending job, start with the oldest created job
|
||||
* If the job is not blocked, and there is capacity in the instance group queue, then mark it as `waiting` and submit the job.
|
||||
Typically each manager is ran asynchronously via the dispatcher system. Dispatcher tasks take resources, so it is important to not schedule tasks unnecessarily. We also need a mechanism to run the manager *after* an atomic transaction block.
|
||||
|
||||
Scheduling the managers are facilitated through the `ScheduleTaskManager`, `ScheduleDependencyManager`, and `ScheduleWorkflowManager` classes. These are utilities that help prevent too many managers from being started via the dispatcher system. Think of it as a "do once" mechanism.
|
||||
|
||||
```python3
|
||||
with transaction.atomic()
|
||||
for t in tasks:
|
||||
if condition:
|
||||
ScheduleTaskManager.schedule()
|
||||
```
|
||||
|
||||
In the above code, we only want to schedule the TaskManager once after all `tasks` have been processed. `ScheduleTaskManager.schedule()` will handle that logic correctly.
|
||||
|
||||
### Timing out
|
||||
|
||||
Because of the global lock of the each manager, only one manager can run at a time. If that manager gets stuck for whatever reason, it is important to kill it and let a new one take its place. As such, there is special code in the parent dispatcher process to SIGKILL any of the task system managers after a few minutes.
|
||||
|
||||
There is an important side effect to this. Because the manager `schedule()` runs in a transaction, the next run will have re-process the same tasks again. This could lead a manager never being able to progress from one run to the next, as each time it times out. In this situation the task system is effectively stuck as new tasks cannot start. To mitigate this, each manager will check if is is about to hit the time out period and bail out early if so. This gives the manager enough time to commit the DB transaction, and the next manager cycle will be able to start with the next set of unprocessed tasks. This ensures that the system can still make incremental progress under high workloads (i.e. many pending tasks).
|
||||
|
||||
|
||||
### Job Lifecycle
|
||||
|
||||
| Job Status | State |
|
||||
|:----------:|:------------------------------------------------------------------------------------------------------------------:|
|
||||
|:-----------|:-------------------------------------------------------------------------------------------------------------------|
|
||||
| pending | Job has been launched. <br>1. Hasn't yet been seen by the scheduler <br>2. Is blocked by another task <br>3. Not enough capacity |
|
||||
| waiting | Job published to an AMQP queue.
|
||||
| waiting | Job submitted to dispatcher via pg_notify
|
||||
| running | Job is running on a AWX node.
|
||||
| successful | Job finished with `ansible-playbook` return code 0. |
|
||||
| failed | Job finished with `ansible-playbook` return code other than 0. |
|
||||
@@ -46,19 +149,20 @@ Empirically, the periodic task manager has been effective in the past and will c
|
||||
The Task Manager decides which exact node a job will run on. It does so by considering user-configured group execution policy and user-configured capacity. First, the set of groups on which a job _can_ run on is constructed (see the AWX document on [Clustering](https://github.com/ansible/awx/blob/devel/docs/clustering.md)). The groups are traversed until a node within that group is found. The node with the largest remaining capacity that is idle is chosen first. If there are no idle nodes, then the node with the largest remaining capacity greater than or equal to the job capacity requirements is chosen.
|
||||
|
||||
|
||||
## Code Composition
|
||||
## Managers are short-lived
|
||||
|
||||
The main goal of the new task manager is to run in our HA environment. This translates to making the task manager logic run on any AWX node. To support this, we need to remove any reliance on the state between task manager schedule logic runs. A future goal of AWX is to design the task manager to have limited/no access to the database for this feature. This secondary requirement, combined with performance needs, led to the creation of partial models that wrap dict database model data.
|
||||
Manager instances are short lived. Each time it runs, a new instance of the manager class is created, relevant data is pulled in from database, and the manager processes the data. After running, the instance is cleaned up.
|
||||
|
||||
|
||||
### Blocking Logic
|
||||
|
||||
The blocking logic is handled by a mixture of ORM instance references and task manager local tracking data in the scheduler instance.
|
||||
|
||||
There is a distinction between so-called "hard" vs "soft" blocking.
|
||||
|
||||
## Acceptance Tests
|
||||
**Hard blocking** refers to dependencies that are represented in the database via the task `dependent_jobs` field. That is, Job A will not run if any of its `dependent_jobs` are still running.
|
||||
|
||||
The new task manager should, in essence, work like the old one. Old task manager features were identified while new ones were discovered in the process of creating the new task manager. Rules for the new task manager behavior are iterated below; testing should ensure that those rules are followed.
|
||||
**Soft blocking** refers to blocking logic that doesn't have a database representation. Imagine Job A and B are both based on the same job template, and concurrent jobs is `disabled`. Job B will be blocked from running if Job A is already running. This is determined purely by the task manager tracking running jobs via the Dependency Graph.
|
||||
|
||||
|
||||
### Task Manager Rules
|
||||
|
||||
22
licenses/aioredis.txt
Normal file
22
licenses/aioredis.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2017 Alexey Popravka
|
||||
Copyright (c) 2021 Sean Stewart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
29
licenses/hiredis.txt
Normal file
29
licenses/hiredis.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
Copyright (c) 2009-2011, Salvatore Sanfilippo <antirez at gmail dot com>
|
||||
Copyright (c) 2010-2011, Pieter Noordhuis <pcnoordhuis at gmail dot com>
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of Redis nor the names of its contributors may be used
|
||||
to endorse or promote products derived from this software without specific
|
||||
prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -148,6 +148,24 @@ in the top-level Makefile.
|
||||
|
||||
If modifying this library make sure testing with the offline build is performed to confirm it is functionally working.
|
||||
|
||||
### channels-redis
|
||||
|
||||
Due to an upstream bug (linked below), we see `RuntimeError: Event loop is closed` errors with newer versions of `channels-redis`.
|
||||
Upstream is aware of the bug and it is likely to be fixed in the next release according to the issue linked below.
|
||||
For now, we pin to the old version, 3.4.1
|
||||
|
||||
* https://github.com/django/channels_redis/issues/332
|
||||
* https://github.com/ansible/awx/issues/13313
|
||||
|
||||
### hiredis
|
||||
|
||||
The hiredis 2.1.0 release doesn't provide source distribution on PyPI which prevents users to build that python package from the
|
||||
sources.
|
||||
Downgrading to 2.0.0 (which provides source distribution) until the channels-redis issue is fixed or a newer hiredis version is
|
||||
available on PyPi with source distribution.
|
||||
|
||||
* https://github.com/redis/hiredis-py/issues/138
|
||||
|
||||
## Library Notes
|
||||
|
||||
### pexpect
|
||||
|
||||
@@ -4,7 +4,7 @@ asciichartpy
|
||||
asn1
|
||||
azure-keyvault==1.1.0 # see UPGRADE BLOCKERs
|
||||
channels
|
||||
channels-redis
|
||||
channels-redis==3.4.1 # see UPGRADE BLOCKERs
|
||||
cryptography
|
||||
Cython<3 # Since the bump to PyYAML 5.4.1 this is now a mandatory dep
|
||||
daphne
|
||||
@@ -26,6 +26,7 @@ djangorestframework==3.13.1
|
||||
djangorestframework-yaml
|
||||
filelock
|
||||
GitPython
|
||||
hiredis==2.0.0 # see UPGRADE BLOCKERs
|
||||
irc
|
||||
jinja2
|
||||
JSON-log-formatter
|
||||
@@ -38,12 +39,11 @@ psycopg2
|
||||
psutil
|
||||
pygerduty
|
||||
pyparsing==2.4.6 # Upgrading to v3 of pyparsing introduce errors on smart host filtering: Expected 'or' term, found 'or' (at char 15), (line:1, col:16)
|
||||
python3-saml==1.13.0
|
||||
python-dsv-sdk
|
||||
python-tss-sdk==1.0.0
|
||||
python-ldap
|
||||
pyyaml
|
||||
receptorctl==1.2.3
|
||||
receptorctl==1.3.0
|
||||
schedule==0.6.0
|
||||
social-auth-core[openidconnect]==4.3.0 # see UPGRADE BLOCKERs
|
||||
social-auth-app-django==5.0.0 # see UPGRADE BLOCKERs
|
||||
@@ -59,10 +59,8 @@ wheel
|
||||
pip==21.2.4 # see UPGRADE BLOCKERs
|
||||
setuptools # see UPGRADE BLOCKERs
|
||||
setuptools_scm[toml] # see UPGRADE BLOCKERs, xmlsec build dep
|
||||
xmlsec==1.3.12 # xmlsec 1.3.13 removed the ability to use lxml 4.7.0 but python3-saml requires lxml 4.7.0 so we need to pin xmlsec
|
||||
lxml>=3.8 # xmlsec build dep
|
||||
pkgconfig>=1.5.1 # xmlsec build dep
|
||||
setuptools-rust >= 0.11.4 # cryptography build dep
|
||||
pkgconfig>=1.5.1 # xmlsec build dep - needed for offline build
|
||||
|
||||
# Temporarily added to use ansible-runner from git branch, to be removed
|
||||
# when ansible-runner moves from requirements_git.txt to here
|
||||
|
||||
@@ -2,6 +2,8 @@ adal==1.2.7
|
||||
# via msrestazure
|
||||
aiohttp==3.8.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
aioredis==1.3.1
|
||||
# via channels-redis
|
||||
aiosignal==1.3.1
|
||||
# via aiohttp
|
||||
# via -r /awx_devel/requirements/requirements_git.txt
|
||||
@@ -20,6 +22,7 @@ asn1==2.6.0
|
||||
async-timeout==4.0.2
|
||||
# via
|
||||
# aiohttp
|
||||
# aioredis
|
||||
# redis
|
||||
attrs==22.1.0
|
||||
# via
|
||||
@@ -51,11 +54,11 @@ cachetools==5.2.0
|
||||
# requests
|
||||
cffi==1.15.1
|
||||
# via cryptography
|
||||
channels==4.0.0
|
||||
channels==3.0.5
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels-redis
|
||||
channels-redis==4.0.0
|
||||
channels-redis==3.4.1
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
charset-normalizer==2.1.1
|
||||
# via
|
||||
@@ -76,8 +79,10 @@ cryptography==38.0.4
|
||||
# social-auth-core
|
||||
cython==0.29.32
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
daphne==4.0.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
daphne==3.0.2
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels
|
||||
dataclasses==0.6
|
||||
# via
|
||||
# python-dsv-sdk
|
||||
@@ -153,6 +158,10 @@ gitpython==3.1.29
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
google-auth==2.14.1
|
||||
# via kubernetes
|
||||
hiredis==2.0.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# aioredis
|
||||
hyperlink==21.0.0
|
||||
# via
|
||||
# autobahn
|
||||
@@ -198,15 +207,14 @@ jinja2==3.1.2
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
json-log-formatter==0.5.1
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
jsonschema==4.17.1
|
||||
jsonschema==4.17.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
kubernetes==25.3.0
|
||||
# via openshift
|
||||
lockfile==0.12.2
|
||||
# via python-daemon
|
||||
lxml==4.7.0
|
||||
lxml==4.9.1
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# python3-saml
|
||||
# xmlsec
|
||||
markdown==3.4.1
|
||||
@@ -315,8 +323,7 @@ python-tss-sdk==1.0.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
python3-openid==3.2.0
|
||||
# via social-auth-core
|
||||
python3-saml==1.13.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
# via -r /awx_devel/requirements/requirements_git.txt
|
||||
pytz==2022.6
|
||||
# via
|
||||
# django
|
||||
@@ -331,12 +338,11 @@ pyyaml==6.0
|
||||
# djangorestframework-yaml
|
||||
# kubernetes
|
||||
# receptorctl
|
||||
receptorctl==1.2.3
|
||||
receptorctl==1.3.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
redis==4.3.5
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels-redis
|
||||
# django-redis
|
||||
requests==2.28.1
|
||||
# via
|
||||
@@ -435,10 +441,8 @@ websocket-client==1.4.2
|
||||
# via kubernetes
|
||||
wheel==0.38.4
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
xmlsec==1.3.12
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# python3-saml
|
||||
xmlsec==1.3.13
|
||||
# via python3-saml
|
||||
yarl==1.8.1
|
||||
# via aiohttp
|
||||
zipp==3.11.0
|
||||
|
||||
@@ -4,3 +4,4 @@ git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner
|
||||
# django-radius has an aggressive pin of future==0.16.0, see https://github.com/robgolding/django-radius/pull/25
|
||||
git+https://github.com/ansible/django-radius.git@develop#egg=django-radius
|
||||
git+https://github.com/PythonCharmers/python-future@master#egg=future
|
||||
git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml
|
||||
|
||||
@@ -116,7 +116,7 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
|
||||
python3-psycopg2 \
|
||||
python3-setuptools \
|
||||
rsync \
|
||||
"rsyslog >= 8.1911.0" \
|
||||
rsyslog-8.2102.0-106.el9 \
|
||||
subversion \
|
||||
sudo \
|
||||
vim-minimal \
|
||||
@@ -274,7 +274,7 @@ RUN for dir in \
|
||||
/var/run/nginx.pid \
|
||||
/var/lib/awx/venv/awx/lib/python3.9/site-packages/awx.egg-link ; \
|
||||
do touch $file ; chmod g+rw $file ; done && \
|
||||
echo "\setenv PAGER 'less -S'" > /var/lib/awx/.psqlrc
|
||||
echo "\setenv PAGER 'less -SXF'" > /var/lib/awx/.psqlrc
|
||||
{% endif %}
|
||||
|
||||
{% if not build_dev|bool %}
|
||||
|
||||
Reference in New Issue
Block a user