Compare commits

...

78 Commits

Author SHA1 Message Date
Alex Corey
26a947ed31 Adds functionality to add multiple rrules to a schedule and save the form 2023-01-09 09:27:47 -05:00
Shane McDonald
b99a434dee Merge pull request #13395 from shanemcd/pin-rsyslog
Pin rsyslog to avoid crash
2023-01-04 21:54:34 +08:00
Shane McDonald
6cee99a9f9 Pin rsyslog to prevent crash
With the latest version of rsyslog we had a test failing with:

AssertionError: Response data: {'error': "b'rsyslog internal message (3,-2455): could not transfer  the  specified  internal posix  capabilities settings to the kernel, capng_apply=-5\\n [v8.2102.0-107.el9 try https://www.rsyslog.com/e/2455 ]\\n'"}

Downgrading fixes it
2023-01-04 08:19:20 -05:00
Seth Foster
ee509aea56 Merge pull request #12961 from fosterseth/fix_results_traceback
Result_traceback should not include job stdout
2023-01-03 13:34:23 -05:00
Sarah Akus
b5452a48f8 Merge pull request #13196 from keithjgrant/13189-job-traceback
Fix job error traceback in job output
2023-01-03 11:59:58 -05:00
Seth Foster
0c980fa7d5 Merge pull request #13366 from fosterseth/bump_receptorctl_1.3.0
bump receptorctl version to 1.3.0
2022-12-21 16:27:25 -05:00
Shane McDonald
e34ce8c795 Merge pull request #13365 from dsavineau/downgrade_hiredis
Pin hiredis to 2.0.0
2022-12-21 15:23:15 -05:00
Seth Foster
3543644e0e bump receptorctl version to 1.3.0 2022-12-21 13:36:11 -05:00
Seth Foster
36c0d07b30 Result_traceback should not include job stdout
If a job fails, we do receptor work results and put that output
into result_traceback.

We should only do this if
1. Receptor unit has failed
2. Runner callback processed 0 events

Otherwise we risk putting too much data into this field.
2022-12-21 13:05:44 -05:00
Dimitri Savineau
239827a9cf Pin hiredis to 2.0.0
The hiredis 2.1.0 release doesn't provide source distribution on PyPi so
users can't build that python package from sources.

Signed-off-by: Dimitri Savineau <dsavinea@redhat.com>
2022-12-21 11:57:41 -05:00
Alan Rominger
ac9871b36f Merge pull request #13361 from relrod/sanity
[collection] Run sanity tests outside of our container
2022-12-21 11:00:21 -05:00
Alan Rominger
f739908ccf Add comment about Ansible-core being installed by default
Co-authored-by: John R Barker <john@johnrbarker.com>
2022-12-21 09:57:00 -05:00
Alan Rominger
cf1ec07eab Changes to run sanity tests locally
Use a Makefile arg for the ansible-test sanity CLI args
  defaults to --docker
  in the future we probably need to customize python versions

Copy the rule exception for Ansible 2.15
  this helps people who are running from Ansible devel
2022-12-21 09:53:22 -05:00
Rick Elrod
d968b648de Run sanity tests outside of our container
Also just ignore one sanity test for the export module, instead of
ignoring all of them.

Also use latest ansible-test, and make it work on GHA (by using podman
instead of docker).

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-20 21:40:41 -06:00
Rick Elrod
5dd0eab806 Pin channels-redis to 4.3.1 to fix an async issue (#13348)
Refs django/channels_redis#332
Refs #13313

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-20 17:05:44 -06:00
Alan Rominger
41f3f381ec Merge pull request #13352 from AlanCoding/dont_pass_subtasks
Remove `subtasks` keyword arg that can exceed pg_notify max message length
2022-12-20 16:25:39 -05:00
Alan Rominger
ac8cff75ce Run collection sanity tests in CI (#13356)
* Run collection sanity tests in CI

This requires adding a Makefile install of ansible-core

Fake the version to make semver check happy

* Fixes from ansible-test sanity failures

* Exclude the export module due to awxkit requirement

* Fix broken ansible-test rule exceptions

remove Ansible 2.14 exclusions that make ansible-test ERROR, saying they are not needed
2022-12-20 16:06:25 -05:00
Alan Rominger
94b34b801c Avoid unbounded kwargs by fetching subtasks inside handle_work_error
Update tests to new handle_work_error call pattern

Handle blame correctly with multiple serial deps
  add new test case corresponding to this scenario
2022-12-19 16:02:51 -05:00
Jeff Bradberry
8f6849fc22 Include listener_port in the defaults for Instance.objects.register (#13328) 2022-12-19 14:16:05 -03:00
Sarah Akus
821b1701bf Merge pull request #13340 from gamuniz/change_wf_scmbranch_behavior
Change workflow create/edit to null scm_branch when not provided.
2022-12-19 10:52:21 -05:00
John Westcott IV
b7f2825909 Throw a warning if custom secret key was specified but not given (#13128)
* Throw a warning if custom secret key was specified but not given

* Fixing unit tests
2022-12-17 14:15:27 -03:00
Jeff Bradberry
e87e041a2a Break up and conditionally add the RBAC checks for ActivityStream (#13279)
This should vastly improve the queries executed when accessing any of
the activity stream endpoints as a normal user, in many cases.
2022-12-16 15:11:14 -03:00
Gabe Muniz
cc336e791c fix expected test result 2022-12-16 12:30:57 -05:00
Gabe Muniz
c2a3c3b285 The current behavior of workflow job templates is to pass in an empty string as scm_branch on allsaves and edits. This becomes problematic when using job templates/workflows which allow prompt on launch for scm_branch as it may override the scm_branch set for the individual workflow nodes to an empty string. That behavior limits the usefulness of prompting scm branch as it can no longer by selected while creating workflows as they'll be overwritten. 2022-12-16 12:30:57 -05:00
Jeff Bradberry
7b8dcc98e7 Merge pull request #13308 from jbradberry/rebuild-org-ee-admin-roles
Ensure that the Organization.execution_environment_admin_role always gets built
2022-12-16 11:29:20 -05:00
Satoe Imaishi
d5011492bf Merge pull request #13343 from simaishi/add_pkgconfig
Add back pkgconfig for offline build
2022-12-16 08:07:38 -05:00
Satoe Imaishi
e363ddf470 Add back pkgconfig for offline build 2022-12-15 20:49:28 -05:00
Shane McDonald
987709cdb3 Merge pull request #13344 from shanemcd/fix-tox
Remove unneeded pass_env in tox config
2022-12-15 20:02:31 -05:00
Shane McDonald
f04ac3c798 Remove unneeded pass_env in tox config
I don't recall us ever using Travis so I'm not sure why this is here.

https://tox.wiki/en/latest/changelog.html#v4-0-6-2022-12-10
2022-12-15 19:44:02 -05:00
Jake Jackson
71a6baccdb Fix lookup plugins sanity (#13238)
* fix pytz

* fix NameError

* fix tests and add sanity ignore files for import test until distutils replaced

* change static method to regular method and update test to instantiate class
2022-12-15 16:40:51 -05:00
Alan Rominger
d07076b686 Merge pull request #13330 from AlanCoding/ask_me_for_tags
Fill in rest of ask_tags handling for WFJT module
2022-12-15 10:59:17 -05:00
John Westcott IV
7129f3e8cd Updating python3-saml (#13263)
Moved to forked version to get latest lxml to allow other pacakges to update
2022-12-15 12:15:09 -03:00
Julen Landa Alustiza
df61a5cea1 Merge pull request #13126 from infamousjoeg/cyberark-ccp-branding-webserviceid
CyberArk Central Credential Provider Lookup custom Web Service ID & update branding
2022-12-15 15:54:35 +01:00
Ilija Matoski
a4b950f79b Set AWS_SESSION_TOKEN in addition to AWS_SECURITY_TOKEN (#13297)
* Set AWS_SESSION_TOKEN in addition to AWS_SECURITY_TOKEN

* added AWS_SESSION_TOKEN to inventoryupdate-1 test
2022-12-15 10:09:40 -03:00
Sarah Akus
8be739d255 Merge pull request #13306 from vidyanambiar/aap-7507
Fixes 'Not Found' error on looking up credentials
2022-12-14 16:13:55 -05:00
John Westcott IV
ca54195099 Merge pull request #13324 from mannyci/devel
Fix typo in controller_api lookup plugin
2022-12-14 15:19:53 -05:00
Alex Corey
f0fcfdde39 Merge pull request #13257 from ansible/dependabot/npm_and_yarn/awx/ui/devel/luxon-3.1.1
Bump luxon from 3.0.3 to 3.1.1 in /awx/ui
2022-12-14 09:19:47 -05:00
Alex Corey
80b1ba4a35 Merge pull request #13259 from ansible/dependabot/npm_and_yarn/awx/ui/devel/patternfly/react-core-4.264.0
Bump @patternfly/react-core from 4.250.1 to 4.264.0 in /awx/ui
2022-12-14 09:13:32 -05:00
Alan Rominger
51f8e362dc Add tags prompt to integration test 2022-12-14 09:10:15 -05:00
Sarah Akus
737d6d8c8b Merge pull request #13329 from akus062381/add-new-triage-reply
add new triage reply
2022-12-13 16:45:16 -05:00
Alan Rominger
beaf6b6058 Fill in rest of ask_tags handling for WFJT module 2022-12-13 16:38:25 -05:00
akus062381
aad1fbcef8 add new triage reply 2022-12-13 16:17:42 -05:00
Rick Elrod
0b96d617ac Fix BROADCAST_WEBSOCKET_PORT for Kube dev (#13243)
- `settings/minikube.py` gets imported conditionally, when the
  environment variable `AWX_KUBE_DEVEL` is set. In this imported file,
  we set `BROADCAST_WEBSOCKET_PORT = 8013`, but 8013 is only used in the
  docker-compose dev environment. In Kubernetes environments, 8052 is
  used for everything. This is hardcoded awx-operator's ConfigMap.

- Also rename `minikube.py` because it is used for every kind of
  development Kube environment, including Kind.

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-13 15:07:15 -06:00
Alan Rominger
fe768a159b Merge pull request #13295 from AlanCoding/raw_instance_data
Remove un-editable Instance fields from pre-filled edit data in API browser
2022-12-13 15:16:34 -05:00
Alan Rominger
c1ebea858b Merge pull request #13291 from AlanCoding/policy_want_a_cracker
Add missing disassociate trigger for policy task
2022-12-13 11:35:22 -05:00
Seth Foster
da9b8135e8 Merge pull request #13315 from fosterseth/update_task_manager_md
update task manager docs after refactoring
2022-12-12 12:42:49 -05:00
Elijah DeLee
76cecf3f6b update capacity docs to cover hybrid node case
this came up in conversation and I saw this was not in this doc as an example
2022-12-12 12:11:56 -05:00
Manas Maiti
7b2938f515 fix typo 2022-12-12 18:01:15 +01:00
Seth Foster
916b5642d2 Update task manager docs
- DependencyManager and WorkflowManager
- bulk reschedule
- global task manager timeout
- blocking logic

Co-authored-by: Elijah DeLee <kdelee@redhat.com>
Co-authored-by: John R Barker <john@johnrbarker.com>
2022-12-12 11:56:40 -05:00
Jeff Bradberry
e524d3df3e Replace the role fixup post_migrate handler with a data migration 2022-12-12 10:20:56 -05:00
Rick Elrod
01e9a611ea Add broadcast_websocket to LOG_AGGREGATOR_LOGGERS
... so that errors from it get logged to external loggers by default.

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-08 17:50:20 -06:00
Rick Elrod
ef29589940 Fix duped stats name and Redis for wsbroadcast
This fixes several things related to our wsbroadcast stats handling.
This was found during the ongoing wsrelay work.

There are really three fixes here:

- Logging was not actually enabled for the analytics.broadcast_websocket
  module, so that has been added to our loggers config.

- analytics.broadcast_websocket was not actually able to connect to
  Redis due to 68614b83c0 as part of
  the work in #13187. But there was no easy way to know this because the
  logging issue meant no exceptions showed up anywhere reasonable.

- Relatedly, and also as part of #13187, we jumped from
  `prometheus-client` 0.7.1 up to 0.15.0. This included a breaking
  change where a `Counter` ending with `_total` will clash with a
  `Gauge` of the same name but without `_total`. I am not 100% sure of
  the reasoning here, other than "OpenMetrics compatibility".

Refs #13301
Refs #13187

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-08 12:54:08 -06:00
Vidya Nambiar
cec2d2dfb9 minor rearrangement of imports
Signed-off-by: Vidya Nambiar <vnambiar@redhat.com>
2022-12-08 10:52:20 -05:00
Jeff Bradberry
15b7ad3570 Add a post_migrate signal handler to rebuild the Org roles
particularly, the execution_environment_admin_role.
2022-12-07 15:57:20 -05:00
Vidya Nambiar
36ff9cbc6d revert change to package.json
Signed-off-by: Vidya Nambiar <vnambiar@redhat.com>
2022-12-07 15:03:40 -05:00
Vidya Nambiar
ed74d80ecb Fixes 'Not Found' error on looking up credentials
remove redundant console logs

typo

Signed-off-by: Vidya Nambiar <vnambiar@redhat.com>
2022-12-07 15:00:28 -05:00
Alan Rominger
a0b8215c06 Merge pull request #13296 from AlanCoding/signing_bug
Fix bug, sign work based signing, not verification
2022-12-07 08:44:57 -05:00
Alan Rominger
f88b993b18 Fix bug, sign work based signing, not verification 2022-12-06 16:21:17 -05:00
Alan Rominger
4a7f4d0ed4 Remove uneditable Instance fields from API browser 2022-12-06 15:20:04 -05:00
Alan Rominger
6e08c3567f Add missing disassociate trigger for policy task 2022-12-06 14:43:13 -05:00
Jeff Bradberry
adbcb5c5e4 Merge pull request #13289 from jbradberry/improve-psql-paging
Make sure that the psql pager does not clear the screen afterwards
2022-12-06 13:17:24 -05:00
Jeff Bradberry
8054c6aedc Make sure that the psql pager does not clear the screen afterwards
Also, avoid paging if there is a single page.
2022-12-06 10:46:47 -05:00
dependabot[bot]
58734a33c4 Bump @patternfly/react-core from 4.250.1 to 4.264.0 in /awx/ui
Bumps [@patternfly/react-core](https://github.com/patternfly/patternfly-react) from 4.250.1 to 4.264.0.
- [Release notes](https://github.com/patternfly/patternfly-react/releases)
- [Commits](https://github.com/patternfly/patternfly-react/compare/@patternfly/react-core@4.250.1...@patternfly/react-core@4.264.0)

---
updated-dependencies:
- dependency-name: "@patternfly/react-core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-06 15:33:23 +00:00
dependabot[bot]
2832f28014 Bump luxon from 3.0.3 to 3.1.1 in /awx/ui
Bumps [luxon](https://github.com/moment/luxon) from 3.0.3 to 3.1.1.
- [Release notes](https://github.com/moment/luxon/releases)
- [Changelog](https://github.com/moment/luxon/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moment/luxon/compare/3.0.3...3.1.1)

---
updated-dependencies:
- dependency-name: luxon
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-06 15:30:50 +00:00
Seth Foster
e5057691ee Merge pull request #13252 from max-len/patch-1
Update install.rst
2022-12-02 22:46:26 -05:00
Shane McDonald
a0cfd8501c Merge pull request #13274 from rooftopcellist/fix-messages-cmd
Fix make messages target by specify lang
2022-12-02 19:04:09 -05:00
Shane McDonald
99b643bd77 Merge pull request #13268 from simaishi/fix_static
Copy UI static files to /var/lib/awx only for ui-devel build
2022-12-02 19:03:48 -05:00
Sarah Akus
305b39d8e5 Merge pull request #13209 from marshmalien/5990-related-group-column
Add inventory host list related groups column
2022-12-02 16:23:09 -05:00
Christian M. Adams
642003e207 Fix make messages target by specify lang 2022-12-02 10:46:16 -05:00
Satoe Imaishi
06daebbecf Copy UI static files to /var/lib/awx only for ui-devel build 2022-12-01 08:58:05 -05:00
Max Lendrich
eaccf32aa3 Update install.rst
Fix doc for current pip==22.3
2022-11-30 16:54:42 +01:00
Marliana Lara
f0481d0a60 Add inventory host list related groups column 2022-11-21 12:04:40 -05:00
Keith J. Grant
d34f6af830 fix traceback offset/counter # in UI 2022-11-15 13:35:14 -08:00
Joe Garcia
f9bb26ad33 Merge branch 'devel' into cyberark-ccp-branding-webserviceid 2022-11-10 20:50:02 -05:00
Joe Garcia
878035c13b Fixed webservice_id check to string 2022-10-26 12:45:59 -04:00
Joe Garcia
2cc971a43f default to AIMWebService if no val provided 2022-10-26 12:41:15 -04:00
Joe Garcia
9d77c54612 Remove references to AIM everywhere 2022-10-26 12:32:12 -04:00
Joe Garcia
ef651a3a21 Add Web Service ID & update branding 2022-10-26 11:54:09 -04:00
94 changed files with 2128 additions and 1592 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 $($*)

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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',

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 = {}

View 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),
]

View File

@@ -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):

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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}"}}'

View File

@@ -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.")

View File

@@ -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'},

View File

@@ -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:

View File

@@ -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'

View File

@@ -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",

View File

@@ -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",

View File

@@ -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/`);
}

View File

@@ -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)));
}}
/>

View File

@@ -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 () => {

View File

@@ -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))
);

View File

@@ -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 () => {

View File

@@ -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`

View 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;

View File

@@ -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;

View File

@@ -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 };

View 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;

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -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}
</>
);
}

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -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;
}

View 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,
};

View File

@@ -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",

View File

@@ -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>
)}
</>
);
}

View File

@@ -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!' }));
});
});

View File

@@ -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>
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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: '',

View File

@@ -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;

View File

@@ -7,7 +7,6 @@ __metaclass__ = type
DOCUMENTATION = '''
name: controller
plugin_type: inventory
author:
- Matthew Jones (@matburt)
- Yunfan Zhang (@YunfanZhang42)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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
'''

View File

@@ -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,

View File

@@ -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'),
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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
'''

View File

@@ -35,7 +35,6 @@ options:
- Optional description of this access token.
required: False
type: str
default: ''
application:
description:
- The application tied to this token.

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
---
modules:
python_requires: '>3'

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec

View File

@@ -0,0 +1 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec

View File

@@ -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"

View File

@@ -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

View File

@@ -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 =

View File

@@ -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

View File

@@ -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
View 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
View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %}