Compare commits

...

113 Commits

Author SHA1 Message Date
Jeff Bradberry
2ae9156a4a Merge pull request #12587 from ansible/mesh-scaling-backend
Allow for adding external execution nodes via API
2022-08-03 11:09:37 -04:00
Jeff Bradberry
4890c15eeb Update task management to only do things with ready instances 2022-08-02 15:58:43 -04:00
Jeff Bradberry
bc6b8fc4ae Check state when processing receptorctl advertisements
Nodes that show up and were in one of the unready states need to be
transitioned to ready, even if the logic in Instance.is_lost was not
met.
2022-08-02 15:58:20 -04:00
Jeff Bradberry
03c70077f9 Make sure that the health checks handle the state transitions properly
- nodes with states Provisioning, Provisioning Fail, Deprovisioning,
  and Deprovisioning Fail should bypass health checks and should never
  transition due to the existing machinery
- nodes with states Unavailable and Installed can transition to Ready
  if they check out as healthy
- nodes in the Ready state should transition to Unavailable if they
  fail a check
2022-08-02 13:55:35 -04:00
Jeff Bradberry
dab8c3ef55 Update node and link registration to put them in the right state
'Installed' for the nodes, 'Established' for the links.
2022-08-02 13:55:35 -04:00
Jeff Bradberry
d2a6be7ca9 Add the state fields and the peer relationships to the serializers 2022-08-02 13:55:35 -04:00
Jeff Bradberry
170795ab76 Add state fields to Instance and InstanceLink
Also, listener_port to Instance.
2022-08-02 13:55:03 -04:00
Shane McDonald
6446b627ad Merge pull request #12608 from shanemcd/fix-k8s-dev-env
Fix Kubernetes dev environment + update docs
2022-08-01 11:11:45 -04:00
Shane McDonald
fcebd188a6 Fix Kubernetes dev environment + update docs 2022-08-01 10:45:10 -04:00
Shane McDonald
65771b7629 Merge pull request #12562 from shanemcd/auto-install-setuptools-scm
Automatically install setuptools-scm in script called from Makefile
2022-07-31 17:17:26 -04:00
Keith Grant
86a67abbce Merge pull request #12531 from jtmelhorn/devel
[#12478] Change Inventory "Status" column header to "Sync Status"
2022-07-29 15:50:08 -07:00
Keith Grant
d555093325 Fix job output follow mode & scrolling (#12555)
* reworks/fixes follow mode

* reduces batch size for better job output perceived performance

* improves job output scroll button behavior
2022-07-28 15:26:25 -04:00
John Westcott IV
95a099acc5 Adding remove_superuser and remove_system_auditors to the SAML user attribute map (#12522) 2022-07-28 14:38:16 -04:00
John Westcott IV
d1fc2702ec Adding subscriptions module and adding pool_id to license module (#12560) 2022-07-28 12:16:47 -04:00
John Westcott IV
734899228b Updating CONTRIBUTING guide (#12565) 2022-07-27 09:59:09 -04:00
Rick Elrod
87f729c642 [FieldLookupBackend] limit iexact to string fields (#12569)
Change:
- Case-insensitive search only makes sense on strings, so check the
  type of the field we are searching and ensure it is a string field
  (TextField, CharField, or some subclass thereof).

- This prevents a 500 error when a user uses iexact on, e.g., an
  integer field. Now, a 400 Bad Request is returned instead.

Test Plan:
- Added simple unit tests for iexact

Tickets:
- Fixes #9222

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-07-26 12:46:50 -05:00
John Westcott IV
62fc3994fb Modifying SAML adapter to not auto-add default galaxy creds to orgs on login (#12504)
* Modifying SAML adapter to not auto-add default galaxy creds to orgs on login

* Adding test, fixing old tests and moving add_default_galaxy_credential to pipeline
2022-07-25 17:16:22 -03:00
Shane McDonald
0d097964be Automatically install setuptools-scm in script called from Makefile 2022-07-22 12:59:39 -04:00
Christian Adams
9f8b3948e1 Merge pull request #12147 from rooftopcellist/bump-receptor-1.2.3
Bump Receptorctl to 1.2.3
2022-07-21 11:45:27 -04:00
Jessica Steurer
1ce8240192 Merge pull request #12528 from vedaperi/12436-RemoveUpdateOnProjectUpdate
Remove update_on_project_update
2022-07-20 16:14:23 -03:00
Jeff Bradberry
1bcfc8f28e Merge pull request #12544 from jbradberry/awxkit-fix-no-content
Suppress 204 No Content results causing an error during import
2022-07-20 10:48:02 -04:00
vedaperi
71925de902 Enhanced detail component (#12432)
* Enhanced detail component to handle cases with no values, and refactored components that use detail component.

* Add optional chaining operators where necessary to pass test cases

* add test cases to test suites of modified files

Co-authored-by: Veda Periwal <vperiwal@vperiwal-mac.attlocal.net>
2022-07-19 17:17:27 -04:00
Aditya Mulik
54057f1c80 Merge pull request #12467 from adityamulik/localization_scripts
Localization Scripts for AWX UI & API
2022-07-19 16:40:10 -04:00
Aditya Mulik
ae388d943d Merge pull request #12541 from adityamulik/translations_updated_2022-07-18_20_51_59
Pushing updated strings for localization
2022-07-19 16:39:44 -04:00
Alan Rominger
2d310dc4e5 Optimize object creation by getting fewer empty relationships (#12508)
This optimizes the ActivityStreamSerializer by only getting many-to-many
  relationships that are speculatively non-empty
  based on information we have in other fields

We run this every time we create an object as an on_commit action
  so it is expected this will have a major impact on response times for launching jobs
2022-07-19 14:27:51 -04:00
Jeff Bradberry
fe1a767f4f Suppress 204 No Content results causing an error during import 2022-07-19 12:25:24 -04:00
adityamulik
8c6581d80a Pushing updated strings for localization 2022-07-18 20:52:59 -04:00
Jessica Steurer
33e445f4f6 Merge pull request #12489 from kialam/vendor-d3.js-webworker
Remove external script call to D3.js.
2022-07-18 19:10:50 -03:00
Kia Lam
9bcb60d9e0 Remove d3 csp declaration. 2022-07-18 08:57:03 -07:00
Kia Lam
40109d58c7 Host d3 files needed for webworker. 2022-07-18 08:57:02 -07:00
Kia Lam
2ef3f5f9e8 Remove external script call to D3.js. 2022-07-18 08:57:02 -07:00
John Westcott IV
389c4a3180 Adding fields to job_metadata for workflows and approval nodes (#12255) 2022-07-18 16:53:49 +02:00
Justin Melhorn
bee48671cd [#12478] Change Inventory "Status" column header to "Sync Status"
Signed-off-by: Justin Melhorn <jtmelhorn@gmail.com>
2022-07-17 16:38:24 -04:00
Veda Periwal
21f551f48a Remove update_on_project_update from inventory sources form and corresponding files 2022-07-15 11:18:16 -07:00
Alex Corey
cbb019ed09 Merge pull request #12510 from AlexSCorey/11822-JobOutputDocumentation-Overview
Adds Overview of job output with some images to help.
2022-07-15 10:52:47 -04:00
Alex Corey
bf5dfdaba7 Adds Overview of job output with some images to help. 2022-07-15 10:32:41 -04:00
Jessica Steurer
0f7f8af9b8 Merge pull request #12346 from john-westcott-iv/dependabot_fixes
Updating pyjwt per dependabot
2022-07-15 10:42:24 -03:00
Sarabraj Singh
0237402390 Merge pull request #12509 from sarabrajsingh/docs/awx-release-docs-refactoring
buffed docs for awx release and canonical triage responses
2022-07-15 08:21:58 -04:00
Hao Liu
84d7fa882d Merge pull request #12513 from TheRealHaoLiu/fix-workflow-job-template-export
fix WorkflowJobTemplate export
2022-07-14 14:44:58 -04:00
Sarabraj Singh
cd2fae3471 buffed docs for AWX Release and canonical Triage responses 2022-07-14 14:13:18 -04:00
John Westcott IV
8be64145f9 Updating pyjwt per dependabot 2022-07-14 08:35:46 -04:00
djyasin
23d28fb4c8 Merge pull request #12457 from djyasin/feature/bu-metrics-added-forks-in-unified-jobs-table
Added forks to unified jobs table.
2022-07-13 11:33:19 -04:00
Lila
aeffd6f393 Bumped up version number of the collector. 2022-07-13 09:59:41 -04:00
djyasin
ab6b4bad03 Merge branch 'ansible:devel' into devel 2022-07-13 09:53:22 -04:00
Hao Liu
769c253ac2 fix WorkflowJobTemplate export where WorkflowApprovalTemplate is not properly exported
fixes https://github.com/ansible/awx/issues/7946
- added WorkflowApprovalTemplate page type to allow URL registration
- added resources regex that’s associated resource URL with WorkflowApprovalTemplate
- registered the new resource regex with WorkflowApprovalTemplate page type
- modified `DEPENDENT_EXPORT` handling (insisted by @jbradberry)
- added special case handling for WorkflowApprovalTemplate due to its unique nature

unique nature of WorkflowApprovalTemplate
- when exporting WorkflowJobTemplate with approval node the WorkflowJobTemplateNode need to contain a related "create_approval_template" the POST data for "create_approval_template" need to come from the "workflow_approval_template"
- during the export of a WorkflowJobTemplateNode that is an approval node we need to get the data from "workflow_approval_template" and use that to populate the "create_approval_template"

Co-Authored-By: Jeff Bradberry <685957+jbradberry@users.noreply.github.com>
Signed-off-by: Hao Liu <haoli@redhat.com>
2022-07-12 19:48:02 -04:00
Michael Abashian
8031b3d402 Translate contents of Hosts Automated field as a single string (#12480)
* Translate contents of Hosts Automated field as a single string

* Adds unit test case for hiding Hosts automated detail when no value is present
2022-07-12 15:24:33 -04:00
Sarabraj Singh
bd93ac7edd Merge pull request #12505 from sarabrajsingh/bugfix/add-setuptools-scm-dependency-to-workflow
added setuptools-scm dependency to promote.yml workflow
2022-07-12 10:21:10 -04:00
John Westcott IV
37ff9913d3 Adding GOOGLE_APPLICATION_CREDENTIALS env var (#12389)
* Adding GOOGLE_APPLICATION_CREDENTIALS env var
* Updating tests
2022-07-12 08:51:02 -04:00
Sarabraj Singh
9cb44a7e52 added setuptools-scm dependency to promote.yml workflow 2022-07-11 17:10:29 -04:00
John Westcott IV
6279295541 Updating workflow job template collection test (#12468)
Adding additional use case

Fixing error with workflow calling itslef

Adding better cleanup of assets created as part of the test
2022-07-11 17:07:07 -03:00
John Westcott IV
de17cff39c Modified triage replied (#12473)
Split no progress into issue and pr

added community.general standard response
2022-07-11 12:43:30 -04:00
Alex Corey
22ca49e673 Merge pull request #12493 from AlexSCorey/bumpCodeMirror
Bump code mirror
2022-07-11 09:43:54 -04:00
Tom Page
008a4b4d30 Fix workflow job template webhook credential bug - #12324 (#12325)
Signed-off-by: tompage1994@hotmail.co.uk <tpage@redhat.com>
2022-07-11 09:13:44 -03:00
Alex Corey
8d4089c7f3 Bumps code mirror and adds license files 2022-07-08 15:09:54 -04:00
vedaperi
e296d0adad Add Help Text with documentation link to Schedules page (#12448)
* Added help text to schedule form and detail with link to documentation

* Added test cases for help text in schedule form and detail

* Add help text to schedule form and detail with link to documentation

Co-authored-by: Veda Periwal <vperiwal@vperiwal-mac.attlocal.net>
2022-07-08 15:06:50 -04:00
Aditya Mulik
df38650aee Localization Scripts for AWX UI & API 2022-07-08 11:44:56 -04:00
Alex Corey
401b30b3ed Merge pull request #12451 from ansible/dependabot/npm_and_yarn/awx/ui/devel/patternfly/patternfly-4.202.1
Bump @patternfly/patternfly from 4.196.7 to 4.202.1 in /awx/ui
2022-07-08 08:13:30 -04:00
Alex Corey
20cc54694c Merge pull request #12454 from ansible/dependabot/npm_and_yarn/awx/ui/devel/patternfly/react-icons-4.75.1
Bump @patternfly/react-icons from 4.49.19 to 4.75.1 in /awx/ui
2022-07-08 08:12:58 -04:00
dependabot[bot]
e6ec0952fb Bump @patternfly/patternfly from 4.196.7 to 4.202.1 in /awx/ui
Bumps [@patternfly/patternfly](https://github.com/patternfly/patternfly) from 4.196.7 to 4.202.1.
- [Release notes](https://github.com/patternfly/patternfly/releases)
- [Changelog](https://github.com/patternfly/patternfly/blob/main/RELEASE-NOTES.md)
- [Commits](https://github.com/patternfly/patternfly/compare/prerelease-v4.196.7...prerelease-v4.202.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-06 20:03:52 +00:00
dependabot[bot]
db1dec3a98 Bump @patternfly/react-icons from 4.49.19 to 4.75.1 in /awx/ui
Bumps [@patternfly/react-icons](https://github.com/patternfly/patternfly-react) from 4.49.19 to 4.75.1.
- [Release notes](https://github.com/patternfly/patternfly-react/releases)
- [Commits](https://github.com/patternfly/patternfly-react/compare/@patternfly/react-icons@4.49.19...@patternfly/react-icons@4.75.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-06 20:03:40 +00:00
Alex Corey
1853d3850e Merge pull request #12450 from ansible/dependabot/npm_and_yarn/awx/ui/devel/patternfly/react-table-4.93.1
Bump @patternfly/react-table from 4.83.1 to 4.93.1 in /awx/ui
2022-07-06 16:02:18 -04:00
Lila
1e57c84383 Added forks to unified jobs table.
Co-authored-by: sarabrajsingh <singh.sarabraj@gmail.com>
2022-07-01 10:30:48 -04:00
dependabot[bot]
3cf120c6a7 Bump @patternfly/react-table from 4.83.1 to 4.93.1 in /awx/ui
Bumps [@patternfly/react-table](https://github.com/patternfly/patternfly-react) from 4.83.1 to 4.93.1.
- [Release notes](https://github.com/patternfly/patternfly-react/releases)
- [Commits](https://github.com/patternfly/patternfly-react/compare/@patternfly/react-table@4.83.1...@patternfly/react-table@4.93.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-01 08:06:59 +00:00
Alan Rominger
fd671ecc9d Give specific messages if job was killed due to SIGTERM or SIGKILL (#12435)
* Reap jobs on dispatcher startup to increase clarity, replace existing reaping logic

* Exit jobs if receiving SIGTERM signal

* Fix unwanted reaping on shutdown, let subprocess close out

* Add some sanity tests for signal module

* Add a log for an unhandled dispatcher error

* Refine wording of error messages

Co-authored-by: Elijah DeLee <kdelee@redhat.com>
2022-06-30 13:20:08 -04:00
Shane McDonald
a0d5f1fb03 Merge pull request #12428 from djyasin/updating_setuppy
Updated setup.py --version to python3 -m setuptools_scm.
2022-06-30 12:17:54 -04:00
Alex Corey
ff882a322b Merge pull request #12412 from AlexSCorey/11994-FailedJobErrorMessage
Adds a failure message to job output when job failed and no events exist
2022-06-29 11:40:44 -04:00
Tom Page
b70231f7d0 Allow modification of schedule if there are two of the same name (#12407) 2022-06-28 20:23:54 -03:00
Alex Corey
93d1aa0a9d Adds a failure message to job output when job failed and no events exist. 2022-06-28 18:30:37 -04:00
Alex Corey
c586f8bbc6 Removes references to Ansible Tower in favor of Ansible Controller (#12422) 2022-06-28 14:35:32 -04:00
Alex Corey
26912a06d1 Merge pull request #12424 from AlexSCorey/11433-UpdateLaunchButtonTest
Updates irrelevant test
2022-06-28 14:31:26 -04:00
Alex Corey
218a3d333b updates test 2022-06-28 14:14:12 -04:00
Seth Foster
d2013bd416 Merge pull request #12366 from fosterseth/remove_update_on_project_update
Remove deprecated field update_on_project_update
2022-06-28 13:15:57 -04:00
Shane McDonald
6a3f9690b0 Remove setup.py entirely 2022-06-27 14:15:32 -04:00
Jeff Bradberry
d59b6f834c Merge pull request #12431 from jbradberry/fix-ugettext-deprecation
Fix a ugettext deprecation that snuck back in
2022-06-27 13:58:07 -04:00
Shane McDonald
cbea36745e Transition from setup.py to setup.cfg 2022-06-27 13:30:01 -04:00
Jeff Bradberry
ae7be525e1 Fix a ugettext deprecation that snuck back in
at some point after the Django 3.2 upgrade.
2022-06-27 13:27:35 -04:00
jainnikhil30
5062ce1e61 add database connection to the metrics endpoint (#12427)
* add database connection to the metrics endpoint

* bump the counts collector version to 1.2

* check for postgresql as database so to not break the tests
2022-06-27 09:37:23 -04:00
Alex Corey
566665ee8c Merge pull request #12417 from ansible/dependabot/npm_and_yarn/awx/ui/devel/patternfly/react-core-4.221.3
Bump @patternfly/react-core from 4.214.1 to 4.221.3 in /awx/ui
2022-06-27 09:36:58 -04:00
Alex Corey
96423af160 Merge pull request #12419 from ansible/dependabot/npm_and_yarn/awx/ui/devel/react-router-dom-5.3.3
Bump react-router-dom from 5.2.0 to 5.3.3 in /awx/ui
2022-06-27 09:36:22 -04:00
Alex Corey
a01bef8d2c Merge pull request #12420 from ansible/dependabot/npm_and_yarn/awx/ui/devel/lingui/react-3.14.0
Bump @lingui/react from 3.13.3 to 3.14.0 in /awx/ui
2022-06-27 09:35:40 -04:00
Seth Foster
0522233892 remove update_on_project_update from InventorySource 2022-06-24 15:27:08 -04:00
Lila
63ea6bb5b3 Updated setup.py --version to python3 -m setuptools_scm. 2022-06-24 10:22:56 -04:00
Sarah Akus
c2715d7c29 Merge pull request #12378 from john-westcott-iv/winrm_debug_5925
Making verbosity list and options a constant and adding WinRM debug
2022-06-24 09:06:14 -04:00
Alan Rominger
783b744bdb Pass combined artifacts from nested workflows into downstream nodes (#12223)
* Track combined artifacts on workflow jobs

* Avoid schema change for passing nested workflow artifacts

* Basic support for nested workflow artifacts, add test

* Forgot that only does not work with polymorphic

* Remove incorrect field

* Consolidate logic and prevent recursion with UJ artifacts method

* Stop trying to do precedence by status, filter for obvious ones

* Review comments about sets

* Fix up bug with convergence node paths and artifacts
2022-06-23 16:54:53 -03:00
Alex Corey
f7982a0d64 Merge pull request #12421 from AlexSCorey/updateAxios
Bumps Axios and Adds license files
2022-06-23 13:07:28 -04:00
Sarabraj Singh
2147ac226e Merge pull request #12408 from sarabrajsingh/feature/new-awx-cli-import-export-error-codes
[new] bubble up an error code when something goes wrong with import/export
2022-06-23 10:58:14 -04:00
Alex Corey
6cc22786bc Adds license files 2022-06-23 09:26:34 -04:00
dependabot[bot]
861a9f581e Bump @lingui/react from 3.13.3 to 3.14.0 in /awx/ui
Bumps [@lingui/react](https://github.com/lingui/js-lingui) from 3.13.3 to 3.14.0.
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v3.13.3...v3.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-23 12:34:58 +00:00
dependabot[bot]
e57a8183ba Bump react-router-dom from 5.2.0 to 5.3.3 in /awx/ui
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 5.2.0 to 5.3.3.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Commits](https://github.com/remix-run/react-router/commits/v5.3.3/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-23 12:34:20 +00:00
dependabot[bot]
8a7163ffad Bump @patternfly/react-core from 4.214.1 to 4.221.3 in /awx/ui
Bumps [@patternfly/react-core](https://github.com/patternfly/patternfly-react) from 4.214.1 to 4.221.3.
- [Release notes](https://github.com/patternfly/patternfly-react/releases)
- [Commits](https://github.com/patternfly/patternfly-react/compare/@patternfly/react-core@4.214.1...@patternfly/react-core@4.221.3)

---
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-06-23 12:32:57 +00:00
Alex Corey
439b351c95 Merge pull request #12392 from nixocio/update_bot_user
Update user dependabot
2022-06-23 08:31:16 -04:00
Alex Corey
14afab918e Creates a verbosity select dropdowns and moves options constant into same file 2022-06-23 08:28:37 -04:00
Alex Corey
ef8d4e73ae Creates a verbosity select dropdowns and moves options constant into same file 2022-06-22 14:04:12 -04:00
John Westcott IV
61f483ae32 Fixing UI general test 2022-06-22 14:04:12 -04:00
John Westcott IV
21bed7473d Making verbosity list and options a constant and adding WinRM debug to everything 2022-06-22 14:04:11 -04:00
John Westcott IV
31d8ddcf84 Updating release docs (#12403)
Adding standard subject line to triage_replies.md

Removing PR commit generated change log in favor of github auto-commit log

Updating some images

Adding AWX matrix chanel to IRC notifications

Adding references between operator and AWX releases
2022-06-22 12:36:54 -04:00
Seth Foster
9419270897 Merge pull request #12393 from fosterseth/subsystem_metrics_delete_redis_keys
Subsystem metrics reset_values should remove all redis keys
2022-06-22 11:34:20 -04:00
Alex Corey
f755d93a58 Merge pull request #12373 from AlexSCorey/updateJS-Yaml
Updates js-yaml to 4.x and updates files.
2022-06-22 11:25:52 -04:00
Sarabraj Singh
05df2ebad2 bubble up an error code when something goes wrong with import/export
(cherry picked from commit babd6f0975)
2022-06-22 10:29:01 -04:00
Jeff Bradberry
b44442c460 Merge pull request #12351 from AlexSCorey/5673-t-importExportSchedules
Adds import export to awx cli for schedules as a top level object
2022-06-22 10:13:56 -04:00
Shane McDonald
989b389ba4 Merge pull request #12397 from sean-m-sullivan/awx_license_delete
add state to awx license module
2022-06-22 09:20:29 -04:00
Sarabraj Singh
5bd4aade0e Merge pull request #12404 from ansible/revert-12335-feature/awx-cli-import-export-error-codes
Revert "import/export error codes when something bad happens"
2022-06-21 22:01:46 -04:00
Jessica Steurer
470910b612 Merge pull request #12309 from jbradberry/cli-multiple-extra-vars
Allow for multiple --extra_vars or --variables flags in awx-cli
2022-06-21 19:34:25 -03:00
Sarabraj Singh
dbb81551c8 Revert "import/export error codes when something bad happens" 2022-06-21 17:36:21 -04:00
Sarabraj Singh
f7c5cb2979 Merge pull request #12335 from sarabrajsingh/feature/awx-cli-import-export-error-codes
import/export error codes when something bad happens
2022-06-21 16:49:03 -04:00
Sarabraj Singh
babd6f0975 bubble up an error code when something goes wrong with import/export 2022-06-21 15:53:59 -04:00
sean-m-sullivan
7bcceb7e98 add state to awx license module 2022-06-21 13:07:16 -04:00
Seth Foster
c92619a2dc Subsystem metrics reset_values should remove all redis keys 2022-06-16 16:54:37 -04:00
nixocio
e7d37b26f3 Update user dependabot
Update user dependabot
2022-06-16 15:31:39 -04:00
Alex Corey
bda335cb19 Updates js-yaml to 4.x and updates files. 2022-06-14 12:24:40 -04:00
Alex Corey
02e7424f51 Adds import export to awx cli for schedules as a top level object 2022-06-09 09:47:50 -04:00
Jeff Bradberry
127016d36b Allow for multiple --extra_vars or --variables flags in awx-cli
This is particularly useful when you are using the @filepath version
of the flag, since otherwise there would be no way to issue the
command with multiple vars files.

Also, add `-e` as an alias to `--extra_vars`
2022-06-01 13:24:24 -04:00
Christian M. Adams
2b0846e8a2 Bump Receptorctl to 1.2.3 2022-05-02 14:41:04 -04:00
240 changed files with 26963 additions and 20806 deletions

View File

@@ -13,7 +13,6 @@ updates:
- "kialam" - "kialam"
- "mabashian" - "mabashian"
- "marshmalien" - "marshmalien"
- "nixocio"
labels: labels:
- "component:ui" - "component:ui"
- "dependencies" - "dependencies"

View File

@@ -29,12 +29,24 @@ In the future, sometimes starting a discussion on the development list prior to
Thank you once again for this and your interest in AWX! Thank you once again for this and your interest in AWX!
### No Progress ### No Progress Issue
- Hi! \
\
Thank you very much for for this issue. It means a lot to us that you have taken time to contribute by opening this report. \
\
On this issue, there were comments added but it has been some time since then without response. At this time we are closing this issue. If you get time to address the comments we can reopen the issue if you can contact us by using any of the communication methods listed in the page below: \
\
https://github.com/ansible/awx/#get-involved \
\
Thank you once again for this and your interest in AWX!
### No Progress PR
- Hi! \ - Hi! \
\ \
Thank you very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \ Thank you very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \
\ \
On this PR, changes were requested but it has been some time since then. We think this PR has merit but without the requested changes we are unable to merge it. At this time we are closing you PR. If you get time to address the changes you are welcome to open another PR or we can reopen this PR upon request if you contact us by using any of the communication methods listed in the page below: \ On this PR, changes were requested but it has been some time since then. We think this PR has merit but without the requested changes we are unable to merge it. At this time we are closing your PR. If you get time to address the changes you are welcome to open another PR or we can reopen this PR upon request if you contact us by using any of the communication methods listed in the page below: \
\ \
https://github.com/ansible/awx/#get-involved \ https://github.com/ansible/awx/#get-involved \
\ \
@@ -51,6 +63,10 @@ Thank you once again for this and your interest in AWX!
### Code of Conduct ### Code of Conduct
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html - Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
### EE Contents / Community General
- Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://ansible-builder.readthedocs.io/en/stable/ \
\
The Ansible Community is looking at building an EE that corresponds to all of the collections inside the ansible package. That may help you if and when it happens; see https://github.com/ansible-community/community-topics/issues/31 for details.
@@ -78,14 +94,16 @@ Thank you once again for this and your interest in AWX!
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md - AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
### AWX Release ### AWX Release
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb
- Hi all, \ - Hi all, \
\ \
We're happy to announce that the next release of AWX, version <X> is now available! \ We're happy to announce that the next release of AWX, version <b>`Xa.Ya.za`</b> is now available! \
In addition AWX Operator version <Y> has also been release! \ In addition AWX Operator version <b>`Xb.Yb.zb`</b> has also been released! \
\ \
Please see the releases pages for more details: \ Please see the releases pages for more details: \
AWX: https://github.com/ansible/awx/releases/tag/<X> \ AWX: https://github.com/ansible/awx/releases/tag/Xa.Ya.za \
Operator: https://github.com/ansible/awx-operator/releases/tag/<Y> \ Operator: https://github.com/ansible/awx-operator/releases/tag/Xb.Yb.zb \
\ \
The AWX team. The AWX team.

View File

@@ -111,6 +111,15 @@ jobs:
repository: ansible/awx-operator repository: ansible/awx-operator
path: awx-operator path: awx-operator
- name: Get python version from Makefile
working-directory: awx
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
- name: Install python ${{ env.py_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.py_version }}
- name: Install playbook dependencies - name: Install playbook dependencies
run: | run: |
python3 -m pip install docker python3 -m pip install docker

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python${{ env.py_version }} -m pip install wheel twine python${{ env.py_version }} -m pip install wheel twine setuptools-scm
- name: Set official collection namespace - name: Set official collection namespace
run: echo collection_namespace=awx >> $GITHUB_ENV run: echo collection_namespace=awx >> $GITHUB_ENV

View File

@@ -19,16 +19,17 @@ Have questions about this document or anything not covered here? Come chat with
- [Purging containers and images](#purging-containers-and-images) - [Purging containers and images](#purging-containers-and-images)
- [Pre commit hooks](#pre-commit-hooks) - [Pre commit hooks](#pre-commit-hooks)
- [What should I work on?](#what-should-i-work-on) - [What should I work on?](#what-should-i-work-on)
- [Translations](#translations)
- [Submitting Pull Requests](#submitting-pull-requests) - [Submitting Pull Requests](#submitting-pull-requests)
- [PR Checks run by Zuul](#pr-checks-run-by-zuul)
- [Reporting Issues](#reporting-issues) - [Reporting Issues](#reporting-issues)
- [Getting Help](#getting-help)
## Things to know prior to submitting code ## Things to know prior to submitting code
- All code submissions are done through pull requests against the `devel` branch. - All code submissions are done through pull requests against the `devel` branch.
- You must use `git commit --signoff` for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md). - You must use `git commit --signoff` for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md).
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason. - Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt - If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see [git push docs](https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt).
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.libera.chat, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed. - If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.libera.chat, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
- We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) - We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
@@ -42,8 +43,7 @@ The AWX development environment workflow and toolchain uses Docker and the docke
Prior to starting the development services, you'll need `docker` and `docker-compose`. On Linux, you can generally find these in your distro's packaging, but you may find that Docker themselves maintain a separate repo that tracks more closely to the latest releases. Prior to starting the development services, you'll need `docker` and `docker-compose`. On Linux, you can generally find these in your distro's packaging, but you may find that Docker themselves maintain a separate repo that tracks more closely to the latest releases.
For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows) For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows) respectively.
respectively.
For Linux platforms, refer to the following from Docker: For Linux platforms, refer to the following from Docker:
@@ -79,17 +79,13 @@ See the [README.md](./tools/docker-compose/README.md) for docs on how to build t
### Building API Documentation ### Building API Documentation
AWX includes support for building [Swagger/OpenAPI AWX includes support for building [Swagger/OpenAPI documentation](https://swagger.io). To build the documentation locally, run:
documentation](https://swagger.io). To build the documentation locally, run:
```bash ```bash
(container)/awx_devel$ make swagger (container)/awx_devel$ make swagger
``` ```
This will write a file named `swagger.json` that contains the API specification This will write a file named `swagger.json` that contains the API specification in OpenAPI format. A variety of online tools are available for translating this data into more consumable formats (such as HTML). http://editor.swagger.io is an example of one such service.
in OpenAPI format. A variety of online tools are available for translating
this data into more consumable formats (such as HTML). http://editor.swagger.io
is an example of one such service.
### Accessing the AWX web interface ### Accessing the AWX web interface
@@ -115,20 +111,30 @@ While you can use environment variables to skip the pre-commit hooks GitHub will
## What should I work on? ## What should I work on?
We have a ["good first issue" label](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) we put on some issues that might be a good starting point for new contributors.
Fixing bugs and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start.
For feature work, take a look at the current [Enhancements](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3Atype%3Aenhancement). For feature work, take a look at the current [Enhancements](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3Atype%3Aenhancement).
If it has someone assigned to it then that person is the person responsible for working the enhancement. If you feel like you could contribute then reach out to that person. If it has someone assigned to it then that person is the person responsible for working the enhancement. If you feel like you could contribute then reach out to that person.
Fixing bugs, adding translations, and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start. For extra information on debugging tools, see [Debugging](./docs/debugging/). **NOTES**
> Issue assignment will only be done for maintainers of the project. If you decide to work on an issue, please feel free to add a comment in the issue to let others know that you are working on it; but know that we will accept the first pull request from whomever is able to fix an issue. Once your PR is accepted we can add you as an assignee to an issue upon request.
**NOTE**
> If you work in a part of the codebase that is going through active development, your changes may be rejected, or you may be asked to `rebase`. A good idea before starting work is to have a discussion with us in the `#ansible-awx` channel on irc.libera.chat, or on the [mailing list](https://groups.google.com/forum/#!forum/awx-project). > If you work in a part of the codebase that is going through active development, your changes may be rejected, or you may be asked to `rebase`. A good idea before starting work is to have a discussion with us in the `#ansible-awx` channel on irc.libera.chat, or on the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
**NOTE**
> If you're planning to develop features or fixes for the UI, please review the [UI Developer doc](./awx/ui/README.md). > If you're planning to develop features or fixes for the UI, please review the [UI Developer doc](./awx/ui/README.md).
### Translations
At this time we do not accept PRs for adding additional language translations as we have an automated process for generating our translations. This is because translations require constant care as new strings are added and changed in the code base. Because of this the .po files are overwritten during every translation release cycle. We also can't support a lot of translations on AWX as its an open source project and each language adds time and cost to maintain. If you would like to see AWX translated into a new language please create an issue and ask others you know to upvote the issue. Our translation team will review the needs of the community and see what they can do around supporting additional language.
If you find an issue with an existing translation, please see the [Reporting Issues](#reporting-issues) section to open an issue and our translation team will work with you on a resolution.
## Submitting Pull Requests ## Submitting Pull Requests
Fixes and Features for AWX will go through the Github pull request process. Submit your pull request (PR) against the `devel` branch. Fixes and Features for AWX will go through the Github pull request process. Submit your pull request (PR) against the `devel` branch.
@@ -152,28 +158,14 @@ We like to keep our commit history clean, and will require resubmission of pull
Sometimes it might take us a while to fully review your PR. We try to keep the `devel` branch in good working order, and so we review requests carefully. Please be patient. Sometimes it might take us a while to fully review your PR. We try to keep the `devel` branch in good working order, and so we review requests carefully. Please be patient.
All submitted PRs will have the linter and unit tests run against them via Zuul, and the status reported in the PR. When your PR is initially submitted the checks will not be run until a maintainer allows them to be. Once a maintainer has done a quick review of your work the PR will have the linter and unit tests run against them via GitHub Actions, and the status reported in the PR.
## PR Checks run by Zuul
Zuul jobs for awx are defined in the [zuul-jobs](https://github.com/ansible/zuul-jobs) repo.
Zuul runs the following checks that must pass:
1. `tox-awx-api-lint`
2. `tox-awx-ui-lint`
3. `tox-awx-api`
4. `tox-awx-ui`
5. `tox-awx-swagger`
Zuul runs the following checks that are non-voting (can not pass but serve to inform PR reviewers):
1. `tox-awx-detect-schema-change`
This check generates the schema and diffs it against a reference copy of the `devel` version of the schema.
Reviewers should inspect the `job-output.txt.gz` related to the check if their is a failure (grep for `diff -u -b` to find beginning of diff).
If the schema change is expected and makes sense in relation to the changes made by the PR, then you are good to go!
If not, the schema changes should be fixed, but this decision must be enforced by reviewers.
## Reporting Issues ## Reporting Issues
We welcome your feedback, and encourage you to file an issue when you run into a problem. But before opening a new issues, we ask that you please view our [Issues guide](./ISSUES.md). We welcome your feedback, and encourage you to file an issue when you run into a problem. But before opening a new issues, we ask that you please view our [Issues guide](./ISSUES.md).
## Getting Help
If you require additional assistance, please reach out to us at `#ansible-awx` on irc.libera.chat, or submit your question to the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
For extra information on debugging tools, see [Debugging](./docs/debugging/).

View File

@@ -5,8 +5,8 @@ NPM_BIN ?= npm
CHROMIUM_BIN=/tmp/chrome-linux/chrome CHROMIUM_BIN=/tmp/chrome-linux/chrome
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
MANAGEMENT_COMMAND ?= awx-manage MANAGEMENT_COMMAND ?= awx-manage
VERSION := $(shell $(PYTHON) setup.py --version) VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py)
COLLECTION_VERSION := $(shell $(PYTHON) setup.py --version | cut -d . -f 1-3) COLLECTION_VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
# NOTE: This defaults the container image version to the branch that's active # NOTE: This defaults the container image version to the branch that's active
COMPOSE_TAG ?= $(GIT_BRANCH) COMPOSE_TAG ?= $(GIT_BRANCH)
@@ -49,7 +49,7 @@ I18N_FLAG_FILE = .i18n_built
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \ .PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
develop refresh adduser migrate dbchange \ develop refresh adduser migrate dbchange \
receiver test test_unit test_coverage coverage_html \ receiver test test_unit test_coverage coverage_html \
dev_build release_build sdist \ sdist \
ui-release ui-devel \ ui-release ui-devel \
VERSION PYTHON_VERSION docker-compose-sources \ VERSION PYTHON_VERSION docker-compose-sources \
.git/hooks/pre-commit .git/hooks/pre-commit
@@ -273,7 +273,7 @@ api-lint:
yamllint -s . yamllint -s .
awx-link: awx-link:
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/setup.py egg_info_dev [ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/tools/scripts/egg_info_dev
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
@@ -424,21 +424,13 @@ ui-test-general:
$(NPM_BIN) run --prefix awx/ui pretest $(NPM_BIN) run --prefix awx/ui pretest
$(NPM_BIN) run --prefix awx/ui/ test-general --runInBand $(NPM_BIN) run --prefix awx/ui/ test-general --runInBand
# Build a pip-installable package into dist/ with a timestamped version number.
dev_build:
$(PYTHON) setup.py dev_build
# Build a pip-installable package into dist/ with the release version number.
release_build:
$(PYTHON) setup.py release_build
HEADLESS ?= no HEADLESS ?= no
ifeq ($(HEADLESS), yes) ifeq ($(HEADLESS), yes)
dist/$(SDIST_TAR_FILE): dist/$(SDIST_TAR_FILE):
else else
dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE) dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE)
endif endif
$(PYTHON) setup.py $(SDIST_COMMAND) $(PYTHON) -m build -s
ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz
sdist: dist/$(SDIST_TAR_FILE) sdist: dist/$(SDIST_TAR_FILE)

View File

@@ -6,9 +6,40 @@ import os
import sys import sys
import warnings import warnings
from pkg_resources import get_distribution
__version__ = get_distribution('awx').version def get_version():
version_from_file = get_version_from_file()
if version_from_file:
return version_from_file
else:
from setuptools_scm import get_version
version = get_version(root='..', relative_to=__file__)
return version
def get_version_from_file():
vf = version_file()
if vf:
with open(vf, 'r') as file:
return file.read().strip()
def version_file():
current_dir = os.path.dirname(os.path.abspath(__file__))
version_file = os.path.join(current_dir, '..', 'VERSION')
if os.path.exists(version_file):
return version_file
try:
import pkg_resources
__version__ = pkg_resources.get_distribution('awx').version
except pkg_resources.DistributionNotFound:
__version__ = get_version()
__all__ = ['__version__'] __all__ = ['__version__']
@@ -21,7 +52,6 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
MODE = 'production' MODE = 'production'
import hashlib import hashlib
try: try:

View File

@@ -232,6 +232,9 @@ class FieldLookupBackend(BaseFilterBackend):
re.compile(value) re.compile(value)
except re.error as e: except re.error as e:
raise ValueError(e.args[0]) raise ValueError(e.args[0])
elif new_lookup.endswith('__iexact'):
if not isinstance(field, (CharField, TextField)):
raise ValueError(f'{field.name} is not a text field and cannot be filtered by case-insensitive search')
elif new_lookup.endswith('__search'): elif new_lookup.endswith('__search'):
related_model = getattr(field, 'related_model', None) related_model = getattr(field, 'related_model', None)
if not related_model: if not related_model:
@@ -258,8 +261,8 @@ class FieldLookupBackend(BaseFilterBackend):
search_filters = {} search_filters = {}
needs_distinct = False needs_distinct = False
# Can only have two values: 'AND', 'OR' # Can only have two values: 'AND', 'OR'
# If 'AND' is used, an iterm must satisfy all condition to show up in the results. # If 'AND' is used, an item must satisfy all conditions to show up in the results.
# If 'OR' is used, an item just need to satisfy one condition to appear in results. # If 'OR' is used, an item just needs to satisfy one condition to appear in results.
search_filter_relation = 'OR' search_filter_relation = 'OR'
for key, values in request.query_params.lists(): for key, values in request.query_params.lists():
if key in self.RESERVED_NAMES: if key in self.RESERVED_NAMES:

View File

@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.text import capfirst from django.utils.text import capfirst
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.functional import cached_property
# Django REST Framework # Django REST Framework
from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.exceptions import ValidationError, PermissionDenied
@@ -2073,7 +2072,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
class Meta: class Meta:
model = InventorySource model = InventorySource
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project', 'update_on_project_update') + ( fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project') + (
'last_update_failed', 'last_update_failed',
'last_updated', 'last_updated',
) # Backwards compatibility. ) # Backwards compatibility.
@@ -2136,11 +2135,6 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory.")) raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory."))
return value return value
def validate_update_on_project_update(self, value):
if value and self.instance and self.instance.schedules.exists():
raise serializers.ValidationError(_("Setting not compatible with existing schedules."))
return value
def validate_inventory(self, value): def validate_inventory(self, value):
if value and value.kind == 'smart': if value and value.kind == 'smart':
raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")}) raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")})
@@ -2191,7 +2185,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None: if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")}) raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
else: else:
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'update_on_project_update'])) redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path']))
if redundant_scm_fields: if redundant_scm_fields:
raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))}) raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))})
@@ -4745,13 +4739,6 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.')) raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
elif type(value) == Project and value.scm_type == '': elif type(value) == Project and value.scm_type == '':
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.')) raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))
elif type(value) == InventorySource and value.source == 'scm' and value.update_on_project_update:
raise serializers.ValidationError(
_(
'Inventory sources with `update_on_project_update` cannot be scheduled. '
'Schedule its source project `{}` instead.'.format(value.source_project.name)
)
)
return value return value
def validate(self, attrs): def validate(self, attrs):
@@ -4766,7 +4753,7 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
class InstanceLinkSerializer(BaseSerializer): class InstanceLinkSerializer(BaseSerializer):
class Meta: class Meta:
model = InstanceLink model = InstanceLink
fields = ('source', 'target') fields = ('source', 'target', 'link_state')
source = serializers.SlugRelatedField(slug_field="hostname", read_only=True) source = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
target = serializers.SlugRelatedField(slug_field="hostname", read_only=True) target = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
@@ -4775,31 +4762,25 @@ class InstanceLinkSerializer(BaseSerializer):
class InstanceNodeSerializer(BaseSerializer): class InstanceNodeSerializer(BaseSerializer):
class Meta: class Meta:
model = Instance model = Instance
fields = ('id', 'hostname', 'node_type', 'node_state') fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled')
node_state = serializers.SerializerMethodField()
def get_node_state(self, obj):
if not obj.enabled:
return "disabled"
return "error" if obj.errors else "healthy"
class InstanceSerializer(BaseSerializer): class InstanceSerializer(BaseSerializer):
consumed_capacity = serializers.SerializerMethodField() consumed_capacity = serializers.SerializerMethodField()
percent_capacity_remaining = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField()
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that ' 'are targeted for this instance'), read_only=True) jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True)
jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True) jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True)
class Meta: class Meta:
model = Instance model = Instance
read_only_fields = ('uuid', 'hostname', 'version', 'node_type') read_only_fields = ('uuid', 'hostname', 'version', 'node_type', 'node_state')
fields = ( fields = (
"id", "id",
"type", "type",
"url", "url",
"related", "related",
"summary_fields",
"uuid", "uuid",
"hostname", "hostname",
"created", "created",
@@ -4821,6 +4802,7 @@ class InstanceSerializer(BaseSerializer):
"enabled", "enabled",
"managed_by_policy", "managed_by_policy",
"node_type", "node_type",
"node_state",
) )
def get_related(self, obj): def get_related(self, obj):
@@ -4832,6 +4814,14 @@ class InstanceSerializer(BaseSerializer):
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
return res return res
def get_summary_fields(self, obj):
summary = super().get_summary_fields(obj)
if self.is_detail_view:
summary['links'] = InstanceLinkSerializer(InstanceLink.objects.select_related('target', 'source').filter(source=obj), many=True).data
return summary
def get_consumed_capacity(self, obj): def get_consumed_capacity(self, obj):
return obj.consumed_capacity return obj.consumed_capacity
@@ -5020,8 +5010,7 @@ class ActivityStreamSerializer(BaseSerializer):
object_association = serializers.SerializerMethodField(help_text=_("When present, shows the field name of the role or relationship that changed.")) object_association = serializers.SerializerMethodField(help_text=_("When present, shows the field name of the role or relationship that changed."))
object_type = serializers.SerializerMethodField(help_text=_("When present, shows the model on which the role or relationship was defined.")) object_type = serializers.SerializerMethodField(help_text=_("When present, shows the model on which the role or relationship was defined."))
@cached_property def _local_summarizable_fk_fields(self, obj):
def _local_summarizable_fk_fields(self):
summary_dict = copy.copy(SUMMARIZABLE_FK_FIELDS) summary_dict = copy.copy(SUMMARIZABLE_FK_FIELDS)
# Special requests # Special requests
summary_dict['group'] = summary_dict['group'] + ('inventory_id',) summary_dict['group'] = summary_dict['group'] + ('inventory_id',)
@@ -5041,7 +5030,13 @@ class ActivityStreamSerializer(BaseSerializer):
('workflow_approval', ('id', 'name', 'unified_job_id')), ('workflow_approval', ('id', 'name', 'unified_job_id')),
('instance', ('id', 'hostname')), ('instance', ('id', 'hostname')),
] ]
return field_list # Optimization - do not attempt to summarize all fields, pair down to only relations that exist
if not obj:
return field_list
existing_association_types = [obj.object1, obj.object2]
if 'user' in existing_association_types:
existing_association_types.append('role')
return [entry for entry in field_list if entry[0] in existing_association_types]
class Meta: class Meta:
model = ActivityStream model = ActivityStream
@@ -5125,7 +5120,7 @@ class ActivityStreamSerializer(BaseSerializer):
data = {} data = {}
if obj.actor is not None: if obj.actor is not None:
data['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk}) data['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk})
for fk, __ in self._local_summarizable_fk_fields: for fk, __ in self._local_summarizable_fk_fields(obj):
if not hasattr(obj, fk): if not hasattr(obj, fk):
continue continue
m2m_list = self._get_related_objects(obj, fk) m2m_list = self._get_related_objects(obj, fk)
@@ -5182,7 +5177,7 @@ class ActivityStreamSerializer(BaseSerializer):
def get_summary_fields(self, obj): def get_summary_fields(self, obj):
summary_fields = OrderedDict() summary_fields = OrderedDict()
for fk, related_fields in self._local_summarizable_fk_fields: for fk, related_fields in self._local_summarizable_fk_fields(obj):
try: try:
if not hasattr(obj, fk): if not hasattr(obj, fk):
continue continue

View File

@@ -115,7 +115,6 @@ from awx.api.metadata import RoleMetadata
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
from awx.main.scheduler.dag_workflow import WorkflowDAG from awx.main.scheduler.dag_workflow import WorkflowDAG
from awx.api.views.mixin import ( from awx.api.views.mixin import (
ControlledByScmMixin,
InstanceGroupMembershipMixin, InstanceGroupMembershipMixin,
OrganizationCountsMixin, OrganizationCountsMixin,
RelatedJobsPreventDeleteMixin, RelatedJobsPreventDeleteMixin,
@@ -441,6 +440,7 @@ class InstanceHealthCheck(GenericAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
# Note: hop nodes are already excluded by the get_queryset method
if obj.node_type == 'execution': if obj.node_type == 'execution':
from awx.main.tasks.system import execution_node_health_check from awx.main.tasks.system import execution_node_health_check
@@ -1675,7 +1675,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView):
return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST) return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST)
class HostDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): class HostDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
always_allow_superuser = False always_allow_superuser = False
model = models.Host model = models.Host
@@ -1709,7 +1709,7 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie
return qs return qs
class HostGroupsList(ControlledByScmMixin, SubListCreateAttachDetachAPIView): class HostGroupsList(SubListCreateAttachDetachAPIView):
'''the list of groups a host is directly a member of''' '''the list of groups a host is directly a member of'''
model = models.Group model = models.Group
@@ -1825,7 +1825,7 @@ class EnforceParentRelationshipMixin(object):
return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs) return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs)
class GroupChildrenList(ControlledByScmMixin, EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView): class GroupChildrenList(EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
model = models.Group model = models.Group
serializer_class = serializers.GroupSerializer serializer_class = serializers.GroupSerializer
@@ -1871,7 +1871,7 @@ class GroupPotentialChildrenList(SubListAPIView):
return qs.exclude(pk__in=except_pks) return qs.exclude(pk__in=except_pks)
class GroupHostsList(HostRelatedSearchMixin, ControlledByScmMixin, SubListCreateAttachDetachAPIView): class GroupHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView):
'''the list of hosts directly below a group''' '''the list of hosts directly below a group'''
model = models.Host model = models.Host
@@ -1935,7 +1935,7 @@ class GroupActivityStreamList(SubListAPIView):
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all())) return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): class GroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = models.Group model = models.Group
serializer_class = serializers.GroupSerializer serializer_class = serializers.GroupSerializer

View File

@@ -41,7 +41,7 @@ from awx.api.serializers import (
JobTemplateSerializer, JobTemplateSerializer,
LabelSerializer, LabelSerializer,
) )
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, ControlledByScmMixin from awx.api.views.mixin import RelatedJobsPreventDeleteMixin
from awx.api.pagination import UnifiedJobEventPagination from awx.api.pagination import UnifiedJobEventPagination
@@ -75,7 +75,7 @@ class InventoryList(ListCreateAPIView):
serializer_class = InventorySerializer serializer_class = InventorySerializer
class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Inventory model = Inventory
serializer_class = InventorySerializer serializer_class = InventorySerializer

View File

@@ -10,13 +10,12 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.permissions import SAFE_METHODS
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from awx.main.constants import ACTIVE_STATES from awx.main.constants import ACTIVE_STATES
from awx.main.utils import get_object_or_400, parse_yaml_or_json 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
from awx.main.models.organization import Team from awx.main.models.organization import Team
from awx.main.models.projects import Project from awx.main.models.projects import Project
@@ -186,35 +185,6 @@ class OrganizationCountsMixin(object):
return full_context return full_context
class ControlledByScmMixin(object):
"""
Special method to reset SCM inventory commit hash
if anything that it manages changes.
"""
def _reset_inv_src_rev(self, obj):
if self.request.method in SAFE_METHODS or not obj:
return
project_following_sources = obj.inventory_sources.filter(update_on_project_update=True, source='scm')
if project_following_sources:
# Allow inventory changes unrelated to variables
if self.model == Inventory and (
not self.request or not self.request.data or parse_yaml_or_json(self.request.data.get('variables', '')) == parse_yaml_or_json(obj.variables)
):
return
project_following_sources.update(scm_last_revision='')
def get_object(self):
obj = super(ControlledByScmMixin, self).get_object()
self._reset_inv_src_rev(obj)
return obj
def get_parent_object(self):
obj = super(ControlledByScmMixin, self).get_parent_object()
self._reset_inv_src_rev(obj)
return obj
class NoTruncateMixin(object): class NoTruncateMixin(object):
def get_serializer_context(self): def get_serializer_context(self):
context = super().get_serializer_context() context = super().get_serializer_context()

View File

@@ -1440,7 +1440,7 @@ msgstr "指定した認証情報は無効 (HTTP 401) です。"
#: awx/api/views/root.py:193 awx/api/views/root.py:234 #: awx/api/views/root.py:193 awx/api/views/root.py:234
msgid "Unable to connect to proxy server." msgid "Unable to connect to proxy server."
msgstr "プロキシサーバーに接続できません。" msgstr "プロキシサーバーに接続できません。"
#: awx/api/views/root.py:195 awx/api/views/root.py:236 #: awx/api/views/root.py:195 awx/api/views/root.py:236
msgid "Could not connect to subscription service." msgid "Could not connect to subscription service."
@@ -1976,7 +1976,7 @@ msgstr "リモートホスト名または IP を判別するために検索す
#: awx/main/conf.py:85 #: awx/main/conf.py:85
msgid "Proxy IP Allowed List" msgid "Proxy IP Allowed List"
msgstr "プロキシ IP 許可リスト" msgstr "プロキシ IP 許可リスト"
#: awx/main/conf.py:87 #: awx/main/conf.py:87
msgid "" msgid ""
@@ -2198,7 +2198,7 @@ msgid ""
"Follow symbolic links when scanning for playbooks. Be aware that setting " "Follow symbolic links when scanning for playbooks. Be aware that setting "
"this to True can lead to infinite recursion if a link points to a parent " "this to True can lead to infinite recursion if a link points to a parent "
"directory of itself." "directory of itself."
msgstr "Playbook スキャンするときは、シンボリックリンクをたどってください。リンクがそれ自体の親ディレクトリーをしている場合は、こを True に定すると無限再帰が発生する可能性があることに注意してください。" msgstr "Playbook スキャン時にシンボリックリンクをたどります。リンクが親ディレクトリーを参照している場合は、この設定を True に定すると無限再帰が発生する可能性があります。"
#: awx/main/conf.py:337 #: awx/main/conf.py:337
msgid "Ignore Ansible Galaxy SSL Certificate Verification" msgid "Ignore Ansible Galaxy SSL Certificate Verification"
@@ -2499,7 +2499,7 @@ msgstr "Insights for Ansible Automation Platform の最終収集日。"
msgid "" msgid ""
"Last gathered entries for expensive collectors for Insights for Ansible " "Last gathered entries for expensive collectors for Insights for Ansible "
"Automation Platform." "Automation Platform."
msgstr "Insights for Ansible Automation Platform の高価なコレクター最後に収集されたエントリー" msgstr "Insights for Ansible Automation Platform でコストがかかっているコレクターに関して最後に収集されたエントリー"
#: awx/main/conf.py:686 #: awx/main/conf.py:686
msgid "Insights for Ansible Automation Platform Gather Interval" msgid "Insights for Ansible Automation Platform Gather Interval"
@@ -3692,7 +3692,7 @@ msgstr "タスクの開始"
#: awx/main/models/events.py:189 #: awx/main/models/events.py:189
msgid "Variables Prompted" msgid "Variables Prompted"
msgstr "変数のプロモート" msgstr "提示される変数"
#: awx/main/models/events.py:190 #: awx/main/models/events.py:190
msgid "Gathering Facts" msgid "Gathering Facts"
@@ -3741,15 +3741,15 @@ msgstr "エラー"
#: awx/main/models/execution_environments.py:17 #: awx/main/models/execution_environments.py:17
msgid "Always pull container before running." msgid "Always pull container before running."
msgstr "実行前に必ずコンテナーをプルしてください。" msgstr "実行前に必ずコンテナーをプルする"
#: awx/main/models/execution_environments.py:18 #: awx/main/models/execution_environments.py:18
msgid "Only pull the image if not present before running." msgid "Only pull the image if not present before running."
msgstr "実行する前に、存在しない場合のみイメージをプルしてください。" msgstr "イメージが存在しない場合のみ実行前にプルする"
#: awx/main/models/execution_environments.py:19 #: awx/main/models/execution_environments.py:19
msgid "Never pull container before running." msgid "Never pull container before running."
msgstr "実行前にコンテナーをプルしないでください。" msgstr "実行前にコンテナーをプルしない"
#: awx/main/models/execution_environments.py:29 #: awx/main/models/execution_environments.py:29
msgid "" msgid ""
@@ -5228,7 +5228,7 @@ msgid ""
"SSL) or \"ldaps://ldap.example.com:636\" (SSL). Multiple LDAP servers may be " "SSL) or \"ldaps://ldap.example.com:636\" (SSL). Multiple LDAP servers may be "
"specified by separating with spaces or commas. LDAP authentication is " "specified by separating with spaces or commas. LDAP authentication is "
"disabled if this parameter is empty." "disabled if this parameter is empty."
msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。" msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。"
#: awx/sso/conf.py:170 awx/sso/conf.py:187 awx/sso/conf.py:198 #: awx/sso/conf.py:170 awx/sso/conf.py:187 awx/sso/conf.py:198
#: awx/sso/conf.py:209 awx/sso/conf.py:226 awx/sso/conf.py:244 #: awx/sso/conf.py:209 awx/sso/conf.py:226 awx/sso/conf.py:244
@@ -6237,3 +6237,4 @@ msgstr "%s が現在アップグレード中です。"
#: awx/ui/urls.py:24 #: awx/ui/urls.py:24
msgid "This page will refresh when complete." msgid "This page will refresh when complete."
msgstr "このページは完了すると更新されます。" msgstr "このページは完了すると更新されます。"

View File

@@ -956,7 +956,7 @@ msgstr "인스턴스 그룹의 인스턴스"
#: awx/api/views/__init__.py:450 #: awx/api/views/__init__.py:450
msgid "Schedules" msgid "Schedules"
msgstr "일정" msgstr "스케줄"
#: awx/api/views/__init__.py:464 #: awx/api/views/__init__.py:464
msgid "Schedule Recurrence Rule Preview" msgid "Schedule Recurrence Rule Preview"
@@ -3261,7 +3261,7 @@ msgstr "JSON 또는 YAML 구문을 사용하여 인젝터를 입력합니다.
#: awx/main/models/credential/__init__.py:412 #: awx/main/models/credential/__init__.py:412
#, python-format #, python-format
msgid "adding %s credential type" msgid "adding %s credential type"
msgstr "인증 정보 유형 %s 추가 중" msgstr "인증 정보 유형 %s 추가 중"
#: awx/main/models/credential/__init__.py:590 #: awx/main/models/credential/__init__.py:590
#: awx/main/models/credential/__init__.py:672 #: awx/main/models/credential/__init__.py:672
@@ -6237,3 +6237,4 @@ msgstr "%s 현재 업그레이드 중입니다."
#: awx/ui/urls.py:24 #: awx/ui/urls.py:24
msgid "This page will refresh when complete." msgid "This page will refresh when complete."
msgstr "완료되면 이 페이지가 새로 고침됩니다." msgstr "완료되면 이 페이지가 새로 고침됩니다."

View File

@@ -348,7 +348,7 @@ msgstr "SCM track_submodules 只能用于 git 项目。"
msgid "" msgid ""
"Only Container Registry credentials can be associated with an Execution " "Only Container Registry credentials can be associated with an Execution "
"Environment" "Environment"
msgstr "只有容器 registry 凭证可以与执行环境关联" msgstr "只有容器注册表凭证可以与执行环境关联"
#: awx/api/serializers.py:1440 #: awx/api/serializers.py:1440
msgid "Cannot change the organization of an execution environment" msgid "Cannot change the organization of an execution environment"
@@ -629,7 +629,7 @@ msgstr "不支持在不替换的情况下在启动时删除 {} 凭证。提供
#: awx/api/serializers.py:4338 #: awx/api/serializers.py:4338
msgid "The inventory associated with this Workflow is being deleted." msgid "The inventory associated with this Workflow is being deleted."
msgstr "与此 Workflow 关联的清单将被删除。" msgstr "与此工作流关联的清单将被删除。"
#: awx/api/serializers.py:4405 #: awx/api/serializers.py:4405
msgid "Message type '{}' invalid, must be either 'message' or 'body'" msgid "Message type '{}' invalid, must be either 'message' or 'body'"
@@ -3229,7 +3229,7 @@ msgstr "云"
#: awx/main/models/credential/__init__.py:336 #: awx/main/models/credential/__init__.py:336
#: awx/main/models/credential/__init__.py:1113 #: awx/main/models/credential/__init__.py:1113
msgid "Container Registry" msgid "Container Registry"
msgstr "容器 Registry" msgstr "容器注册表"
#: awx/main/models/credential/__init__.py:337 #: awx/main/models/credential/__init__.py:337
msgid "Personal Access Token" msgid "Personal Access Token"
@@ -3560,7 +3560,7 @@ msgstr "身份验证 URL"
#: awx/main/models/credential/__init__.py:1120 #: awx/main/models/credential/__init__.py:1120
msgid "Authentication endpoint for the container registry." msgid "Authentication endpoint for the container registry."
msgstr "容器 registry 的身份验证端点。" msgstr "容器注册表的身份验证端点。"
#: awx/main/models/credential/__init__.py:1130 #: awx/main/models/credential/__init__.py:1130
msgid "Password or Token" msgid "Password or Token"
@@ -3764,7 +3764,7 @@ msgstr "镜像位置"
msgid "" msgid ""
"The full image location, including the container registry, image name, and " "The full image location, including the container registry, image name, and "
"version tag." "version tag."
msgstr "完整镜像位置,包括容器 registry、镜像名称和版本标签。" msgstr "完整镜像位置,包括容器注册表、镜像名称和版本标签。"
#: awx/main/models/execution_environments.py:51 #: awx/main/models/execution_environments.py:51
msgid "Pull image before running?" msgid "Pull image before running?"
@@ -6239,3 +6239,4 @@ msgstr "%s 当前正在升级。"
#: awx/ui/urls.py:24 #: awx/ui/urls.py:24
msgid "This page will refresh when complete." msgid "This page will refresh when complete."
msgstr "完成后,此页面会刷新。" msgstr "完成后,此页面会刷新。"

View File

@@ -129,7 +129,7 @@ def config(since, **kwargs):
} }
@register('counts', '1.1', description=_('Counts of objects such as organizations, inventories, and projects')) @register('counts', '1.2', description=_('Counts of objects such as organizations, inventories, and projects'))
def counts(since, **kwargs): def counts(since, **kwargs):
counts = {} counts = {}
for cls in ( for cls in (
@@ -172,6 +172,13 @@ def counts(since, **kwargs):
.count() .count()
) )
counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count() counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count()
if connection.vendor == 'postgresql':
with connection.cursor() as cursor:
cursor.execute(f"select count(*) from pg_stat_activity where datname=\'{connection.settings_dict['NAME']}\'")
counts['database_connections'] = cursor.fetchone()[0]
else:
# We should be using postgresql, but if we do that change that ever we should change the below value
counts['database_connections'] = 1
return counts return counts
@@ -389,7 +396,7 @@ def events_table_partitioned_modified(since, full_path, until, **kwargs):
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs) return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
@register('unified_jobs_table', '1.3', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing) @register('unified_jobs_table', '1.4', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
def unified_jobs_table(since, full_path, until, **kwargs): def unified_jobs_table(since, full_path, until, **kwargs):
unified_job_query = '''COPY (SELECT main_unifiedjob.id, unified_job_query = '''COPY (SELECT main_unifiedjob.id,
main_unifiedjob.polymorphic_ctype_id, main_unifiedjob.polymorphic_ctype_id,
@@ -415,7 +422,8 @@ def unified_jobs_table(since, full_path, until, **kwargs):
main_unifiedjob.job_explanation, main_unifiedjob.job_explanation,
main_unifiedjob.instance_group_id, main_unifiedjob.instance_group_id,
main_unifiedjob.installed_collections, main_unifiedjob.installed_collections,
main_unifiedjob.ansible_version main_unifiedjob.ansible_version,
main_job.forks
FROM main_unifiedjob FROM main_unifiedjob
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id

View File

@@ -126,6 +126,8 @@ def metrics():
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY) LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY) LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
DATABASE_CONNECTIONS = Gauge('awx_database_connections_total', 'Number of connections to database', registry=REGISTRY)
license_info = get_license() license_info = get_license()
SYSTEM_INFO.info( SYSTEM_INFO.info(
{ {
@@ -163,6 +165,8 @@ def metrics():
USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions']) USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions'])
USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions']) USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions'])
DATABASE_CONNECTIONS.set(current_counts['database_connections'])
all_job_data = job_counts(None) all_job_data = job_counts(None)
statuses = all_job_data.get('status', {}) statuses = all_job_data.get('status', {})
for status, value in statuses.items(): for status, value in statuses.items():

View File

@@ -213,6 +213,8 @@ class Metrics:
m.reset_value(self.conn) m.reset_value(self.conn)
self.metrics_have_changed = True self.metrics_have_changed = True
self.conn.delete(root_key + "_lock") self.conn.delete(root_key + "_lock")
for m in self.conn.scan_iter(root_key + '_instance_*'):
self.conn.delete(m)
def inc(self, field, value): def inc(self, field, value):
if value != 0: if value != 0:

View File

@@ -10,6 +10,27 @@ from awx.main.models import Instance, UnifiedJob, WorkflowJob
logger = logging.getLogger('awx.main.dispatch') logger = logging.getLogger('awx.main.dispatch')
def startup_reaping():
"""
If this particular instance is starting, then we know that any running jobs are invalid
so we will reap those jobs as a special action here
"""
me = Instance.objects.me()
jobs = UnifiedJob.objects.filter(status='running', controller_node=me.hostname)
job_ids = []
for j in jobs:
job_ids.append(j.id)
j.status = 'failed'
j.start_args = ''
j.job_explanation += 'Task was marked as running at system start up. The system must have not shut down properly, so it has been marked as failed.'
j.save(update_fields=['status', 'start_args', 'job_explanation'])
if hasattr(j, 'send_notification_templates'):
j.send_notification_templates('failed')
j.websocket_emit_status('failed')
if job_ids:
logger.error(f'Unified jobs {job_ids} were reaped on dispatch startup')
def reap_job(j, status): def reap_job(j, status):
if UnifiedJob.objects.get(id=j.id).status not in ('running', 'waiting'): if UnifiedJob.objects.get(id=j.id).status not in ('running', 'waiting'):
# just in case, don't reap jobs that aren't running # just in case, don't reap jobs that aren't running

View File

@@ -169,8 +169,9 @@ class AWXConsumerPG(AWXConsumerBase):
logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s") logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s")
self.pg_down_time = time.time() self.pg_down_time = time.time()
self.pg_is_down = True self.pg_is_down = True
if time.time() - self.pg_down_time > self.pg_max_wait: current_downtime = time.time() - self.pg_down_time
logger.warning(f"Postgres event consumer has not recovered in {self.pg_max_wait} s, exiting") if current_downtime > self.pg_max_wait:
logger.exception(f"Postgres event consumer has not recovered in {current_downtime} s, exiting")
raise raise
# Wait for a second before next attempt, but still listen for any shutdown signals # Wait for a second before next attempt, but still listen for any shutdown signals
for i in range(10): for i in range(10):
@@ -179,6 +180,10 @@ class AWXConsumerPG(AWXConsumerBase):
time.sleep(0.1) time.sleep(0.1)
for conn in db.connections.all(): for conn in db.connections.all():
conn.close_if_unusable_or_obsolete() conn.close_if_unusable_or_obsolete()
except Exception:
# Log unanticipated exception in addition to writing to stderr to get timestamps and other metadata
logger.exception('Encountered unhandled error in dispatcher main loop')
raise
class BaseWorker(object): class BaseWorker(object):

View File

@@ -27,7 +27,9 @@ class Command(BaseCommand):
) )
def handle(self, **options): def handle(self, **options):
# provides a mapping of hostname to Instance objects
nodes = Instance.objects.in_bulk(field_name='hostname') nodes = Instance.objects.in_bulk(field_name='hostname')
if options['source'] not in nodes: if options['source'] not in nodes:
raise CommandError(f"Host {options['source']} is not a registered instance.") raise CommandError(f"Host {options['source']} is not a registered instance.")
if not (options['peers'] or options['disconnect'] or options['exact'] is not None): if not (options['peers'] or options['disconnect'] or options['exact'] is not None):
@@ -57,7 +59,9 @@ class Command(BaseCommand):
results = 0 results = 0
for target in options['peers']: for target in options['peers']:
_, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) _, created = InstanceLink.objects.update_or_create(
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
)
if created: if created:
results += 1 results += 1
@@ -80,7 +84,9 @@ class Command(BaseCommand):
links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True)) links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True))
removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete() removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete()
for target in peers - links: for target in peers - links:
_, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) _, created = InstanceLink.objects.update_or_create(
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
)
if created: if created:
additions += 1 additions += 1

View File

@@ -53,7 +53,7 @@ class Command(BaseCommand):
# (like the node heartbeat) # (like the node heartbeat)
periodic.run_continuously() periodic.run_continuously()
reaper.reap() reaper.startup_reaping()
consumer = None consumer = None
try: try:

View File

@@ -129,10 +129,13 @@ class InstanceManager(models.Manager):
# if instance was not retrieved by uuid and hostname was, use the hostname # if instance was not retrieved by uuid and hostname was, use the hostname
instance = self.filter(hostname=hostname) instance = self.filter(hostname=hostname)
from awx.main.models import Instance
# Return existing instance # Return existing instance
if instance.exists(): if instance.exists():
instance = instance.first() # in the unusual occasion that there is more than one, only get one instance = instance.first() # in the unusual occasion that there is more than one, only get one
update_fields = [] instance.node_state = Instance.States.INSTALLED # Wait for it to show up on the mesh
update_fields = ['node_state']
# if instance was retrieved by uuid and hostname has changed, update hostname # if instance was retrieved by uuid and hostname has changed, update hostname
if instance.hostname != hostname: if instance.hostname != hostname:
logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname)) logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname))
@@ -141,6 +144,7 @@ class InstanceManager(models.Manager):
# if any other fields are to be updated # if any other fields are to be updated
if instance.ip_address != ip_address: if instance.ip_address != ip_address:
instance.ip_address = ip_address instance.ip_address = ip_address
update_fields.append('ip_address')
if instance.node_type != node_type: if instance.node_type != node_type:
instance.node_type = node_type instance.node_type = node_type
update_fields.append('node_type') update_fields.append('node_type')
@@ -151,12 +155,12 @@ class InstanceManager(models.Manager):
return (False, instance) return (False, instance)
# Create new instance, and fill in default values # Create new instance, and fill in default values
create_defaults = dict(capacity=0) create_defaults = {'node_state': Instance.States.INSTALLED, 'capacity': 0}
if defaults is not None: if defaults is not None:
create_defaults.update(defaults) create_defaults.update(defaults)
uuid_option = {} uuid_option = {}
if uuid is not None: if uuid is not None:
uuid_option = dict(uuid=uuid) uuid_option = {'uuid': uuid}
if node_type == 'execution' and 'version' not in create_defaults: if node_type == 'execution' and 'version' not in create_defaults:
create_defaults['version'] = RECEPTOR_PENDING create_defaults['version'] = RECEPTOR_PENDING
instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option) instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option)

View File

@@ -0,0 +1,40 @@
# Generated by Django 3.2.13 on 2022-06-21 21:29
from django.db import migrations
import logging
logger = logging.getLogger("awx")
def forwards(apps, schema_editor):
InventorySource = apps.get_model('main', 'InventorySource')
sources = InventorySource.objects.filter(update_on_project_update=True)
for src in sources:
if src.update_on_launch == False:
src.update_on_launch = True
src.save(update_fields=['update_on_launch'])
logger.info(f"Setting update_on_launch to True for {src}")
proj = src.source_project
if proj and proj.scm_update_on_launch is False:
proj.scm_update_on_launch = True
proj.save(update_fields=['scm_update_on_launch'])
logger.warning(f"Setting scm_update_on_launch to True for {proj}")
class Migration(migrations.Migration):
dependencies = [
('main', '0163_convert_job_tags_to_textfield'),
]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
migrations.RemoveField(
model_name='inventorysource',
name='scm_last_revision',
),
migrations.RemoveField(
model_name='inventorysource',
name='update_on_project_update',
),
]

View File

@@ -0,0 +1,79 @@
# Generated by Django 3.2.13 on 2022-08-02 17:53
import django.core.validators
from django.db import migrations, models
def forwards(apps, schema_editor):
# All existing InstanceLink objects need to be in the state
# 'Established', which is the default, so nothing needs to be done
# for that.
Instance = apps.get_model('main', 'Instance')
for instance in Instance.objects.all():
instance.node_state = 'ready' if not instance.errors else 'unavailable'
instance.save(update_fields=['node_state'])
class Migration(migrations.Migration):
dependencies = [
('main', '0164_remove_inventorysource_update_on_project_update'),
]
operations = [
migrations.AddField(
model_name='instance',
name='listener_port',
field=models.PositiveIntegerField(
blank=True,
default=27199,
help_text='Port that Receptor will listen for incoming connections on.',
validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)],
),
),
migrations.AddField(
model_name='instance',
name='node_state',
field=models.CharField(
choices=[
('provisioning', 'Provisioning'),
('provision-fail', 'Provisioning Failure'),
('installed', 'Installed'),
('ready', 'Ready'),
('unavailable', 'Unavailable'),
('deprovisioning', 'De-provisioning'),
('deprovision-fail', 'De-provisioning Failure'),
],
default='ready',
help_text='Indicates the current life cycle stage of this instance.',
max_length=16,
),
),
migrations.AddField(
model_name='instancelink',
name='link_state',
field=models.CharField(
choices=[('adding', 'Adding'), ('established', 'Established'), ('removing', 'Removing')],
default='established',
help_text='Indicates the current life cycle stage of this peer link.',
max_length=16,
),
),
migrations.AlterField(
model_name='instance',
name='node_type',
field=models.CharField(
choices=[
('control', 'Control plane node'),
('execution', 'Execution plane node'),
('hybrid', 'Controller and execution'),
('hop', 'Message-passing node, no execution capability'),
],
default='hybrid',
help_text='Role that this node plays in the mesh.',
max_length=16,
),
),
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
]

View File

@@ -35,6 +35,7 @@ def gce(cred, env, private_data_dir):
container_path = to_container_path(path, private_data_dir) container_path = to_container_path(path, private_data_dir)
env['GCE_CREDENTIALS_FILE_PATH'] = container_path env['GCE_CREDENTIALS_FILE_PATH'] = container_path
env['GCP_SERVICE_ACCOUNT_FILE'] = container_path env['GCP_SERVICE_ACCOUNT_FILE'] = container_path
env['GOOGLE_APPLICATION_CREDENTIALS'] = container_path
# Handle env variables for new module types. # Handle env variables for new module types.
# This includes gcp_compute inventory plugin and # This includes gcp_compute inventory plugin and

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
import logging import logging
import os import os
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models, connection from django.db import models, connection
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -58,6 +58,15 @@ class InstanceLink(BaseModel):
source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+') source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+')
target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers') target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers')
class States(models.TextChoices):
ADDING = 'adding', _('Adding')
ESTABLISHED = 'established', _('Established')
REMOVING = 'removing', _('Removing')
link_state = models.CharField(
choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
)
class Meta: class Meta:
unique_together = ('source', 'target') unique_together = ('source', 'target')
@@ -126,13 +135,33 @@ class Instance(HasPolicyEditsMixin, BaseModel):
default=0, default=0,
editable=False, editable=False,
) )
NODE_TYPE_CHOICES = [
("control", "Control plane node"), class Types(models.TextChoices):
("execution", "Execution plane node"), CONTROL = 'control', _("Control plane node")
("hybrid", "Controller and execution"), EXECUTION = 'execution', _("Execution plane node")
("hop", "Message-passing node, no execution capability"), HYBRID = 'hybrid', _("Controller and execution")
] HOP = 'hop', _("Message-passing node, no execution capability")
node_type = models.CharField(default='hybrid', choices=NODE_TYPE_CHOICES, max_length=16)
node_type = models.CharField(default=Types.HYBRID, choices=Types.choices, max_length=16, help_text=_("Role that this node plays in the mesh."))
class States(models.TextChoices):
PROVISIONING = 'provisioning', _('Provisioning')
PROVISION_FAIL = 'provision-fail', _('Provisioning Failure')
INSTALLED = 'installed', _('Installed')
READY = 'ready', _('Ready')
UNAVAILABLE = 'unavailable', _('Unavailable')
DEPROVISIONING = 'deprovisioning', _('De-provisioning')
DEPROVISION_FAIL = 'deprovision-fail', _('De-provisioning Failure')
node_state = models.CharField(
choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.")
)
listener_port = models.PositiveIntegerField(
blank=True,
default=27199,
validators=[MinValueValidator(1), MaxValueValidator(65535)],
help_text=_("Port that Receptor will listen for incoming connections on."),
)
peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target')) peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target'))
@@ -209,15 +238,18 @@ class Instance(HasPolicyEditsMixin, BaseModel):
return self.last_seen < ref_time - timedelta(seconds=grace_period) return self.last_seen < ref_time - timedelta(seconds=grace_period)
def mark_offline(self, update_last_seen=False, perform_save=True, errors=''): def mark_offline(self, update_last_seen=False, perform_save=True, errors=''):
if self.cpu_capacity == 0 and self.mem_capacity == 0 and self.capacity == 0 and self.errors == errors and (not update_last_seen): if self.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
return return
if self.node_state == Instance.States.UNAVAILABLE and self.errors == errors and (not update_last_seen):
return
self.node_state = Instance.States.UNAVAILABLE
self.cpu_capacity = self.mem_capacity = self.capacity = 0 self.cpu_capacity = self.mem_capacity = self.capacity = 0
self.errors = errors self.errors = errors
if update_last_seen: if update_last_seen:
self.last_seen = now() self.last_seen = now()
if perform_save: if perform_save:
update_fields = ['capacity', 'cpu_capacity', 'mem_capacity', 'errors'] update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors']
if update_last_seen: if update_last_seen:
update_fields += ['last_seen'] update_fields += ['last_seen']
self.save(update_fields=update_fields) self.save(update_fields=update_fields)
@@ -274,6 +306,9 @@ class Instance(HasPolicyEditsMixin, BaseModel):
if not errors: if not errors:
self.refresh_capacity_fields() self.refresh_capacity_fields()
self.errors = '' self.errors = ''
if self.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
self.node_state = Instance.States.READY
update_fields.append('node_state')
else: else:
self.mark_offline(perform_save=False, errors=errors) self.mark_offline(perform_save=False, errors=errors)
update_fields.extend(['cpu_capacity', 'mem_capacity', 'capacity']) update_fields.extend(['cpu_capacity', 'mem_capacity', 'capacity'])
@@ -292,7 +327,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
# playbook event data; we should consider this a zero capacity event # playbook event data; we should consider this a zero capacity event
redis.Redis.from_url(settings.BROKER_URL).ping() redis.Redis.from_url(settings.BROKER_URL).ping()
except redis.ConnectionError: except redis.ConnectionError:
errors = _('Failed to connect ot Redis') errors = _('Failed to connect to Redis')
self.save_health_data(awx_application_version, get_cpu_count(), get_mem_in_bytes(), update_last_seen=True, errors=errors) self.save_health_data(awx_application_version, get_cpu_count(), get_mem_in_bytes(), update_last_seen=True, errors=errors)

View File

@@ -985,22 +985,11 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
default=None, default=None,
null=True, null=True,
) )
scm_last_revision = models.CharField(
max_length=1024,
blank=True,
default='',
editable=False,
)
update_on_project_update = models.BooleanField(
default=False,
help_text=_(
'This field is deprecated and will be removed in a future release. '
'In future release, functionality will be migrated to source project update_on_launch.'
),
)
update_on_launch = models.BooleanField( update_on_launch = models.BooleanField(
default=False, default=False,
) )
update_cache_timeout = models.PositiveIntegerField( update_cache_timeout = models.PositiveIntegerField(
default=0, default=0,
) )
@@ -1038,14 +1027,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
self.name = 'inventory source (%s)' % replace_text self.name = 'inventory source (%s)' % replace_text
if 'name' not in update_fields: if 'name' not in update_fields:
update_fields.append('name') update_fields.append('name')
# Reset revision if SCM source has changed parameters
if self.source == 'scm' and not is_new_instance:
before_is = self.__class__.objects.get(pk=self.pk)
if before_is.source_path != self.source_path or before_is.source_project_id != self.source_project_id:
# Reset the scm_revision if file changed to force update
self.scm_last_revision = ''
if 'scm_last_revision' not in update_fields:
update_fields.append('scm_last_revision')
# Do the actual save. # Do the actual save.
super(InventorySource, self).save(*args, **kwargs) super(InventorySource, self).save(*args, **kwargs)
@@ -1054,10 +1035,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
if replace_text in self.name: if replace_text in self.name:
self.name = self.name.replace(replace_text, str(self.pk)) self.name = self.name.replace(replace_text, str(self.pk))
super(InventorySource, self).save(update_fields=['name']) super(InventorySource, self).save(update_fields=['name'])
if self.source == 'scm' and is_new_instance and self.update_on_project_update:
# Schedule a new Project update if one is not already queued
if self.source_project and not self.source_project.project_updates.filter(status__in=['new', 'pending', 'waiting']).exists():
self.update()
if not getattr(_inventory_updates, 'is_updating', False): if not getattr(_inventory_updates, 'is_updating', False):
if self.inventory is not None: if self.inventory is not None:
self.inventory.update_computed_fields() self.inventory.update_computed_fields()
@@ -1147,25 +1124,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
) )
return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates)) return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates))
def clean_update_on_project_update(self):
if (
self.update_on_project_update is True
and self.source == 'scm'
and InventorySource.objects.filter(Q(inventory=self.inventory, update_on_project_update=True, source='scm') & ~Q(id=self.id)).exists()
):
raise ValidationError(_("More than one SCM-based inventory source with update on project update per-inventory not allowed."))
return self.update_on_project_update
def clean_update_on_launch(self):
if self.update_on_project_update is True and self.source == 'scm' and self.update_on_launch is True:
raise ValidationError(
_(
"Cannot update SCM-based inventory source on launch if set to update on project update. "
"Instead, configure the corresponding source project to update on launch."
)
)
return self.update_on_launch
def clean_source_path(self): def clean_source_path(self):
if self.source != 'scm' and self.source_path: if self.source != 'scm' and self.source_path:
raise ValidationError(_("Cannot set source_path if not SCM type.")) raise ValidationError(_("Cannot set source_path if not SCM type."))
@@ -1301,13 +1259,6 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
return self.global_instance_groups return self.global_instance_groups
return selected_groups return selected_groups
def cancel(self, job_explanation=None, is_chain=False):
res = super(InventoryUpdate, self).cancel(job_explanation=job_explanation, is_chain=is_chain)
if res:
if self.launch_type != 'scm' and self.source_project_update:
self.source_project_update.cancel(job_explanation=job_explanation)
return res
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin): class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
class Meta: class Meta:

View File

@@ -743,6 +743,12 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
return "$hidden due to Ansible no_log flag$" return "$hidden due to Ansible no_log flag$"
return artifacts return artifacts
def get_effective_artifacts(self, **kwargs):
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
if isinstance(self.artifacts, dict):
return self.artifacts
return {}
@property @property
def is_container_group_task(self): def is_container_group_task(self):
return bool(self.instance_group and self.instance_group.is_container_group) return bool(self.instance_group and self.instance_group.is_container_group)

View File

@@ -408,6 +408,7 @@ class JobNotificationMixin(object):
'inventory': 'Stub Inventory', 'inventory': 'Stub Inventory',
'id': 42, 'id': 42,
'hosts': {}, 'hosts': {},
'extra_vars': {},
'friendly_name': 'Job', 'friendly_name': 'Job',
'finished': False, 'finished': False,
'credential': 'Stub credential', 'credential': 'Stub credential',

View File

@@ -114,13 +114,6 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
def _get_related_jobs(self): def _get_related_jobs(self):
return UnifiedJob.objects.non_polymorphic().filter(organization=self) return UnifiedJob.objects.non_polymorphic().filter(organization=self)
def create_default_galaxy_credential(self):
from awx.main.models import Credential
public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first()
if public_galaxy_credential is not None and public_galaxy_credential not in self.galaxy_credentials.all():
self.galaxy_credentials.add(public_galaxy_credential)
class OrganizationGalaxyCredentialMembership(models.Model): class OrganizationGalaxyCredentialMembership(models.Model):

View File

@@ -533,7 +533,7 @@ class UnifiedJob(
('workflow', _('Workflow')), # Job was started from a workflow job. ('workflow', _('Workflow')), # Job was started from a workflow job.
('webhook', _('Webhook')), # Job was started from a webhook event. ('webhook', _('Webhook')), # Job was started from a webhook event.
('sync', _('Sync')), # Job was started from a project sync. ('sync', _('Sync')), # Job was started from a project sync.
('scm', _('SCM Update')), # Job was created as an Inventory SCM sync. ('scm', _('SCM Update')), # (deprecated) Job was created as an Inventory SCM sync.
] ]
PASSWORD_FIELDS = ('start_args',) PASSWORD_FIELDS = ('start_args',)
@@ -1204,6 +1204,10 @@ class UnifiedJob(
pass pass
return None return None
def get_effective_artifacts(self, **kwargs):
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
return {}
def get_passwords_needed_to_start(self): def get_passwords_needed_to_start(self):
return [] return []

View File

@@ -318,8 +318,8 @@ class WorkflowJobNode(WorkflowNodeBase):
for parent_node in self.get_parent_nodes(): for parent_node in self.get_parent_nodes():
is_root_node = False is_root_node = False
aa_dict.update(parent_node.ancestor_artifacts) aa_dict.update(parent_node.ancestor_artifacts)
if parent_node.job and hasattr(parent_node.job, 'artifacts'): if parent_node.job:
aa_dict.update(parent_node.job.artifacts) aa_dict.update(parent_node.job.get_effective_artifacts(parents_set=set([self.workflow_job_id])))
if aa_dict and not is_root_node: if aa_dict and not is_root_node:
self.ancestor_artifacts = aa_dict self.ancestor_artifacts = aa_dict
self.save(update_fields=['ancestor_artifacts']) self.save(update_fields=['ancestor_artifacts'])
@@ -659,6 +659,13 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status) node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description)) str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
result['body'] = '\n'.join(str_arr) result['body'] = '\n'.join(str_arr)
result.update(
dict(
inventory=self.inventory.name if self.inventory else None,
limit=self.limit,
extra_vars=self.display_extra_vars(),
)
)
return result return result
@property @property
@@ -682,6 +689,27 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
wj = wj.get_workflow_job() wj = wj.get_workflow_job()
return ancestors return ancestors
def get_effective_artifacts(self, **kwargs):
"""
For downstream jobs of a workflow nested inside of a workflow,
we send aggregated artifacts from the nodes inside of the nested workflow
"""
artifacts = {}
job_queryset = (
UnifiedJob.objects.filter(unified_job_node__workflow_job=self)
.defer('job_args', 'job_cwd', 'start_args', 'result_traceback')
.order_by('finished', 'id')
.filter(status__in=['successful', 'failed'])
.iterator()
)
parents_set = kwargs.get('parents_set', set())
new_parents_set = parents_set | {self.id}
for job in job_queryset:
if job.id in parents_set:
continue
artifacts.update(job.get_effective_artifacts(parents_set=new_parents_set))
return artifacts
def get_notification_templates(self): def get_notification_templates(self):
return self.workflow_job_template.notification_templates return self.workflow_job_template.notification_templates
@@ -885,3 +913,12 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
@property @property
def workflow_job(self): def workflow_job(self):
return self.unified_job_node.workflow_job return self.unified_job_node.workflow_job
def notification_data(self):
result = super(WorkflowApproval, self).notification_data()
result.update(
dict(
extra_vars=self.workflow_job.display_extra_vars(),
)
)
return result

View File

@@ -248,11 +248,11 @@ class TaskManager:
workflow_job.save(update_fields=update_fields) workflow_job.save(update_fields=update_fields)
status_changed = True status_changed = True
if status_changed: if status_changed:
if workflow_job.spawned_by_workflow:
schedule_task_manager()
workflow_job.websocket_emit_status(workflow_job.status) workflow_job.websocket_emit_status(workflow_job.status)
# Operations whose queries rely on modifications made during the atomic scheduling session # Operations whose queries rely on modifications made during the atomic scheduling session
workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed') workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed')
if workflow_job.spawned_by_workflow:
schedule_task_manager()
return result return result
@timeit @timeit

View File

@@ -38,7 +38,9 @@ class TaskManagerInstances:
self.instances_by_hostname = dict() self.instances_by_hostname = dict()
if instances is None: if instances is None:
instances = ( instances = (
Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only('node_type', 'capacity', 'hostname', 'enabled') Instance.objects.filter(hostname__isnull=False, node_state=Instance.States.READY, enabled=True)
.exclude(node_type='hop')
.only('node_type', 'node_state', 'capacity', 'hostname', 'enabled')
) )
for instance in instances: for instance in instances:
self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance) self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance)

View File

@@ -409,7 +409,7 @@ def emit_activity_stream_change(instance):
from awx.api.serializers import ActivityStreamSerializer from awx.api.serializers import ActivityStreamSerializer
actor = None actor = None
if instance.actor: if instance.actor_id:
actor = instance.actor.username actor = instance.actor.username
summary_fields = ActivityStreamSerializer(instance).get_summary_fields(instance) summary_fields = ActivityStreamSerializer(instance).get_summary_fields(instance)
analytics_logger.info( analytics_logger.info(

View File

@@ -16,6 +16,7 @@ from awx.main.redact import UriCleaner
from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
from awx.main.utils.update_model import update_model from awx.main.utils.update_model import update_model
from awx.main.queue import CallbackQueueDispatcher from awx.main.queue import CallbackQueueDispatcher
from awx.main.tasks.signals import signal_callback
logger = logging.getLogger('awx.main.tasks.callback') logger = logging.getLogger('awx.main.tasks.callback')
@@ -179,7 +180,13 @@ class RunnerCallback:
Ansible runner callback to tell the job when/if it is canceled Ansible runner callback to tell the job when/if it is canceled
""" """
unified_job_id = self.instance.pk unified_job_id = self.instance.pk
self.instance = self.update_model(unified_job_id) if signal_callback():
return True
try:
self.instance = self.update_model(unified_job_id)
except Exception:
logger.exception(f'Encountered error during cancel check for {unified_job_id}, canceling now')
return True
if not self.instance: if not self.instance:
logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id)) logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id))
return True return True

View File

@@ -19,7 +19,6 @@ from uuid import uuid4
# Django # Django
from django.conf import settings from django.conf import settings
from django.db import transaction
# Runner # Runner
@@ -34,7 +33,6 @@ from gitdb.exc import BadName as BadGitName
from awx.main.dispatch.publish import task from awx.main.dispatch.publish import task
from awx.main.dispatch import get_local_queuename from awx.main.dispatch import get_local_queuename
from awx.main.constants import ( from awx.main.constants import (
ACTIVE_STATES,
PRIVILEGE_ESCALATION_METHODS, PRIVILEGE_ESCALATION_METHODS,
STANDARD_INVENTORY_UPDATE_ENV, STANDARD_INVENTORY_UPDATE_ENV,
JOB_FOLDER_PREFIX, JOB_FOLDER_PREFIX,
@@ -64,6 +62,7 @@ from awx.main.tasks.callback import (
RunnerCallbackForProjectUpdate, RunnerCallbackForProjectUpdate,
RunnerCallbackForSystemJob, RunnerCallbackForSystemJob,
) )
from awx.main.tasks.signals import with_signal_handling, signal_callback
from awx.main.tasks.receptor import AWXReceptorJob from awx.main.tasks.receptor import AWXReceptorJob
from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
from awx.main.utils.ansible import read_ansible_config from awx.main.utils.ansible import read_ansible_config
@@ -394,6 +393,7 @@ class BaseTask(object):
instance.save(update_fields=['ansible_version']) instance.save(update_fields=['ansible_version'])
@with_path_cleanup @with_path_cleanup
@with_signal_handling
def run(self, pk, **kwargs): def run(self, pk, **kwargs):
""" """
Run the job/task and capture its output. Run the job/task and capture its output.
@@ -425,7 +425,7 @@ class BaseTask(object):
private_data_dir = self.build_private_data_dir(self.instance) private_data_dir = self.build_private_data_dir(self.instance)
self.pre_run_hook(self.instance, private_data_dir) self.pre_run_hook(self.instance, private_data_dir)
self.instance.log_lifecycle("preparing_playbook") self.instance.log_lifecycle("preparing_playbook")
if self.instance.cancel_flag: if self.instance.cancel_flag or signal_callback():
self.instance = self.update_model(self.instance.pk, status='canceled') self.instance = self.update_model(self.instance.pk, status='canceled')
if self.instance.status != 'running': if self.instance.status != 'running':
# Stop the task chain and prevent starting the job if it has # Stop the task chain and prevent starting the job if it has
@@ -547,6 +547,11 @@ class BaseTask(object):
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation=f"Job terminated due to {status}") self.runner_callback.delay_update(skip_if_already_set=True, job_explanation=f"Job terminated due to {status}")
if status == 'timeout': if status == 'timeout':
status = 'failed' status = 'failed'
elif status == 'canceled':
self.instance = self.update_model(pk)
if (getattr(self.instance, 'cancel_flag', False) is False) and signal_callback():
self.runner_callback.delay_update(job_explanation="Task was canceled due to receiving a shutdown signal.")
status = 'failed'
except ReceptorNodeNotFound as exc: except ReceptorNodeNotFound as exc:
self.runner_callback.delay_update(job_explanation=str(exc)) self.runner_callback.delay_update(job_explanation=str(exc))
except Exception: except Exception:
@@ -1168,64 +1173,6 @@ class RunProjectUpdate(BaseTask):
d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes' d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes'
return d return d
def _update_dependent_inventories(self, project_update, dependent_inventory_sources):
scm_revision = project_update.project.scm_revision
inv_update_class = InventoryUpdate._get_task_class()
for inv_src in dependent_inventory_sources:
if not inv_src.update_on_project_update:
continue
if inv_src.scm_last_revision == scm_revision:
logger.debug('Skipping SCM inventory update for `{}` because ' 'project has not changed.'.format(inv_src.name))
continue
logger.debug('Local dependent inventory update for `{}`.'.format(inv_src.name))
with transaction.atomic():
if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists():
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
continue
if settings.IS_K8S:
instance_group = InventoryUpdate(inventory_source=inv_src).preferred_instance_groups[0]
else:
instance_group = project_update.instance_group
local_inv_update = inv_src.create_inventory_update(
_eager_fields=dict(
launch_type='scm',
status='running',
instance_group=instance_group,
execution_node=project_update.execution_node,
controller_node=project_update.execution_node,
source_project_update=project_update,
celery_task_id=project_update.celery_task_id,
)
)
local_inv_update.log_lifecycle("controller_node_chosen")
local_inv_update.log_lifecycle("execution_node_chosen")
try:
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
inv_update_class().run(local_inv_update.id)
except Exception:
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
try:
project_update.refresh_from_db()
except ProjectUpdate.DoesNotExist:
logger.warning('Project update deleted during updates of dependent SCM inventory sources.')
break
try:
local_inv_update.refresh_from_db()
except InventoryUpdate.DoesNotExist:
logger.warning('%s Dependent inventory update deleted during execution.', project_update.log_format)
continue
if project_update.cancel_flag:
logger.info('Project update {} was canceled while updating dependent inventories.'.format(project_update.log_format))
break
if local_inv_update.cancel_flag:
logger.info('Continuing to process project dependencies after {} was canceled'.format(local_inv_update.log_format))
if local_inv_update.status == 'successful':
inv_src.scm_last_revision = scm_revision
inv_src.save(update_fields=['scm_last_revision'])
def release_lock(self, instance): def release_lock(self, instance):
try: try:
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN) fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
@@ -1435,12 +1382,6 @@ class RunProjectUpdate(BaseTask):
p.inventory_files = p.inventories p.inventory_files = p.inventories
p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files']) p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files'])
# Update any inventories that depend on this project
dependent_inventory_sources = p.scm_inventory_sources.filter(update_on_project_update=True)
if len(dependent_inventory_sources) > 0:
if status == 'successful' and instance.launch_type != 'sync':
self._update_dependent_inventories(instance, dependent_inventory_sources)
def build_execution_environment_params(self, instance, private_data_dir): def build_execution_environment_params(self, instance, private_data_dir):
if settings.IS_K8S: if settings.IS_K8S:
return {} return {}
@@ -1620,9 +1561,7 @@ class RunInventoryUpdate(BaseTask):
source_project = None source_project = None
if inventory_update.inventory_source: if inventory_update.inventory_source:
source_project = inventory_update.inventory_source.source_project source_project = inventory_update.inventory_source.source_project
if ( if inventory_update.source == 'scm' and source_project and source_project.scm_type: # never ever update manual projects
inventory_update.source == 'scm' and inventory_update.launch_type != 'scm' and source_project and source_project.scm_type
): # never ever update manual projects
# Check if the content cache exists, so that we do not unnecessarily re-download roles # Check if the content cache exists, so that we do not unnecessarily re-download roles
sync_needs = ['update_{}'.format(source_project.scm_type)] sync_needs = ['update_{}'.format(source_project.scm_type)]
@@ -1655,8 +1594,6 @@ class RunInventoryUpdate(BaseTask):
sync_task = project_update_task(job_private_data_dir=private_data_dir) sync_task = project_update_task(job_private_data_dir=private_data_dir)
sync_task.run(local_project_sync.id) sync_task.run(local_project_sync.id)
local_project_sync.refresh_from_db() local_project_sync.refresh_from_db()
inventory_update.inventory_source.scm_last_revision = local_project_sync.scm_revision
inventory_update.inventory_source.save(update_fields=['scm_last_revision'])
except Exception: except Exception:
inventory_update = self.update_model( inventory_update = self.update_model(
inventory_update.pk, inventory_update.pk,
@@ -1667,9 +1604,6 @@ class RunInventoryUpdate(BaseTask):
), ),
) )
raise raise
elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project:
# This follows update, not sync, so make copy here
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
def post_run_hook(self, inventory_update, status): def post_run_hook(self, inventory_update, status):
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status) super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)

63
awx/main/tasks/signals.py Normal file
View File

@@ -0,0 +1,63 @@
import signal
import functools
import logging
logger = logging.getLogger('awx.main.tasks.signals')
__all__ = ['with_signal_handling', 'signal_callback']
class SignalState:
def reset(self):
self.sigterm_flag = False
self.is_active = False
self.original_sigterm = None
self.original_sigint = None
def __init__(self):
self.reset()
def set_flag(self, *args):
"""Method to pass into the python signal.signal method to receive signals"""
self.sigterm_flag = True
def connect_signals(self):
self.original_sigterm = signal.getsignal(signal.SIGTERM)
self.original_sigint = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGTERM, self.set_flag)
signal.signal(signal.SIGINT, self.set_flag)
self.is_active = True
def restore_signals(self):
signal.signal(signal.SIGTERM, self.original_sigterm)
signal.signal(signal.SIGINT, self.original_sigint)
self.reset()
signal_state = SignalState()
def signal_callback():
return signal_state.sigterm_flag
def with_signal_handling(f):
"""
Change signal handling to make signal_callback return True in event of SIGTERM or SIGINT.
"""
@functools.wraps(f)
def _wrapped(*args, **kwargs):
try:
this_is_outermost_caller = False
if not signal_state.is_active:
signal_state.connect_signals()
this_is_outermost_caller = True
return f(*args, **kwargs)
finally:
if this_is_outermost_caller:
signal_state.restore_signals()
return _wrapped

View File

@@ -114,11 +114,7 @@ def inform_cluster_of_shutdown():
try: try:
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID) this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal')) this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
try: logger.warning('Normal shutdown signal for instance {}, removed self from capacity pool.'.format(this_inst.hostname))
reaper.reap(this_inst)
except Exception:
logger.exception('failed to reap jobs for {}'.format(this_inst.hostname))
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
except Exception: except Exception:
logger.exception('Encountered problem with normal shutdown signal.') logger.exception('Encountered problem with normal shutdown signal.')
@@ -345,9 +341,13 @@ def _cleanup_images_and_files(**kwargs):
logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}') logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}')
# if we are the first instance alphabetically, then run cleanup on execution nodes # if we are the first instance alphabetically, then run cleanup on execution nodes
checker_instance = Instance.objects.filter(node_type__in=['hybrid', 'control'], enabled=True, capacity__gt=0).order_by('-hostname').first() checker_instance = (
Instance.objects.filter(node_type__in=['hybrid', 'control'], node_state=Instance.States.READY, enabled=True, capacity__gt=0)
.order_by('-hostname')
.first()
)
if checker_instance and this_inst.hostname == checker_instance.hostname: if checker_instance and this_inst.hostname == checker_instance.hostname:
for inst in Instance.objects.filter(node_type='execution', enabled=True, capacity__gt=0): for inst in Instance.objects.filter(node_type='execution', node_state=Instance.States.READY, enabled=True, capacity__gt=0):
runner_cleanup_kwargs = inst.get_cleanup_task_kwargs(**kwargs) runner_cleanup_kwargs = inst.get_cleanup_task_kwargs(**kwargs)
if not runner_cleanup_kwargs: if not runner_cleanup_kwargs:
continue continue
@@ -403,6 +403,9 @@ def execution_node_health_check(node):
if instance.node_type != 'execution': if instance.node_type != 'execution':
raise RuntimeError(f'Execution node health check ran against {instance.node_type} node {instance.hostname}') raise RuntimeError(f'Execution node health check ran against {instance.node_type} node {instance.hostname}')
if instance.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
raise RuntimeError(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}")
data = worker_info(node) data = worker_info(node)
prior_capacity = instance.capacity prior_capacity = instance.capacity
@@ -436,6 +439,7 @@ def inspect_execution_nodes(instance_list):
nowtime = now() nowtime = now()
workers = mesh_status['Advertisements'] workers = mesh_status['Advertisements']
for ad in workers: for ad in workers:
hostname = ad['NodeID'] hostname = ad['NodeID']
@@ -449,9 +453,7 @@ def inspect_execution_nodes(instance_list):
if instance.node_type in ('control', 'hybrid'): if instance.node_type in ('control', 'hybrid'):
continue continue
was_lost = instance.is_lost(ref_time=nowtime)
last_seen = parse_date(ad['Time']) last_seen = parse_date(ad['Time'])
if instance.last_seen and instance.last_seen >= last_seen: if instance.last_seen and instance.last_seen >= last_seen:
continue continue
instance.last_seen = last_seen instance.last_seen = last_seen
@@ -459,12 +461,12 @@ def inspect_execution_nodes(instance_list):
# Only execution nodes should be dealt with by execution_node_health_check # Only execution nodes should be dealt with by execution_node_health_check
if instance.node_type == 'hop': if instance.node_type == 'hop':
if was_lost and (not instance.is_lost(ref_time=nowtime)): if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
logger.warning(f'Hop node {hostname}, has rejoined the receptor mesh') logger.warning(f'Hop node {hostname}, has rejoined the receptor mesh')
instance.save_health_data(errors='') instance.save_health_data(errors='')
continue continue
if was_lost: if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
# if the instance *was* lost, but has appeared again, # if the instance *was* lost, but has appeared again,
# attempt to re-establish the initial capacity and version # attempt to re-establish the initial capacity and version
# check # check
@@ -483,7 +485,7 @@ def inspect_execution_nodes(instance_list):
def cluster_node_heartbeat(): def cluster_node_heartbeat():
logger.debug("Cluster node heartbeat task.") logger.debug("Cluster node heartbeat task.")
nowtime = now() nowtime = now()
instance_list = list(Instance.objects.all()) instance_list = list(Instance.objects.filter(node_state__in=(Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED)))
this_inst = None this_inst = None
lost_instances = [] lost_instances = []
@@ -534,9 +536,9 @@ def cluster_node_heartbeat():
try: try:
if settings.AWX_AUTO_DEPROVISION_INSTANCES: if settings.AWX_AUTO_DEPROVISION_INSTANCES:
deprovision_hostname = other_inst.hostname deprovision_hostname = other_inst.hostname
other_inst.delete() other_inst.delete() # FIXME: what about associated inbound links?
logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname)) logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname))
elif other_inst.capacity != 0 or (not other_inst.errors): elif other_inst.node_state == Instance.States.READY:
other_inst.mark_offline(errors=_('Another cluster node has determined this instance to be unresponsive')) other_inst.mark_offline(errors=_('Another cluster node has determined this instance to be unresponsive'))
logger.error("Host {} last checked in at {}, marked as lost.".format(other_inst.hostname, other_inst.last_seen)) logger.error("Host {} last checked in at {}, marked as lost.".format(other_inst.hostname, other_inst.last_seen))

View File

@@ -2,6 +2,7 @@
"ANSIBLE_JINJA2_NATIVE": "True", "ANSIBLE_JINJA2_NATIVE": "True",
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never", "ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}", "GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
"GOOGLE_APPLICATION_CREDENTIALS": "{{ file_reference }}",
"GCP_AUTH_KIND": "serviceaccount", "GCP_AUTH_KIND": "serviceaccount",
"GCP_ENV_TYPE": "tower", "GCP_ENV_TYPE": "tower",
"GCP_PROJECT": "fooo", "GCP_PROJECT": "fooo",

View File

@@ -26,6 +26,7 @@ def test_empty():
"workflow_job_template": 0, "workflow_job_template": 0,
"unified_job": 0, "unified_job": 0,
"pending_jobs": 0, "pending_jobs": 0,
"database_connections": 1,
} }

View File

@@ -31,6 +31,7 @@ EXPECTED_VALUES = {
'awx_license_instance_total': 0, 'awx_license_instance_total': 0,
'awx_license_instance_free': 0, 'awx_license_instance_free': 0,
'awx_pending_jobs_total': 0, 'awx_pending_jobs_total': 0,
'awx_database_connections_total': 1,
} }

View File

@@ -9,9 +9,7 @@ from awx.api.versioning import reverse
@pytest.fixture @pytest.fixture
def ec2_source(inventory, project): def ec2_source(inventory, project):
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
return inventory.inventory_sources.create( return inventory.inventory_sources.create(name='some_source', source='ec2', source_project=project)
name='some_source', update_on_project_update=True, source='ec2', source_project=project, scm_last_revision=project.scm_revision
)
@pytest.fixture @pytest.fixture

View File

@@ -13,9 +13,7 @@ from awx.main.models import InventorySource, Inventory, ActivityStream
@pytest.fixture @pytest.fixture
def scm_inventory(inventory, project): def scm_inventory(inventory, project):
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
inventory.inventory_sources.create( inventory.inventory_sources.create(name='foobar', source='scm', source_project=project)
name='foobar', update_on_project_update=True, source='scm', source_project=project, scm_last_revision=project.scm_revision
)
return inventory return inventory
@@ -23,9 +21,7 @@ def scm_inventory(inventory, project):
def factory_scm_inventory(inventory, project): def factory_scm_inventory(inventory, project):
def fn(**kwargs): def fn(**kwargs):
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
return inventory.inventory_sources.create( return inventory.inventory_sources.create(source_project=project, overwrite_vars=True, source='scm', **kwargs)
source_project=project, overwrite_vars=True, source='scm', scm_last_revision=project.scm_revision, **kwargs
)
return fn return fn
@@ -544,15 +540,12 @@ class TestControlledBySCM:
def test_safe_method_works(self, get, options, scm_inventory, admin_user): def test_safe_method_works(self, get, options, scm_inventory, admin_user):
get(scm_inventory.get_absolute_url(), admin_user, expect=200) get(scm_inventory.get_absolute_url(), admin_user, expect=200)
options(scm_inventory.get_absolute_url(), admin_user, expect=200) options(scm_inventory.get_absolute_url(), admin_user, expect=200)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision != ''
def test_vars_edit_reset(self, patch, scm_inventory, admin_user): def test_vars_edit_reset(self, patch, scm_inventory, admin_user):
patch(scm_inventory.get_absolute_url(), {'variables': 'hello: world'}, admin_user, expect=200) patch(scm_inventory.get_absolute_url(), {'variables': 'hello: world'}, admin_user, expect=200)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
def test_name_edit_allowed(self, patch, scm_inventory, admin_user): def test_name_edit_allowed(self, patch, scm_inventory, admin_user):
patch(scm_inventory.get_absolute_url(), {'variables': '---', 'name': 'newname'}, admin_user, expect=200) patch(scm_inventory.get_absolute_url(), {'variables': '---', 'name': 'newname'}, admin_user, expect=200)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision != ''
def test_host_associations_reset(self, post, scm_inventory, admin_user): def test_host_associations_reset(self, post, scm_inventory, admin_user):
inv_src = scm_inventory.inventory_sources.first() inv_src = scm_inventory.inventory_sources.first()
@@ -560,14 +553,12 @@ class TestControlledBySCM:
g = inv_src.groups.create(name='fooland', inventory=scm_inventory) g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
post(reverse('api:host_groups_list', kwargs={'pk': h.id}), {'id': g.id}, admin_user, expect=204) post(reverse('api:host_groups_list', kwargs={'pk': h.id}), {'id': g.id}, admin_user, expect=204)
post(reverse('api:group_hosts_list', kwargs={'pk': g.id}), {'id': h.id}, admin_user, expect=204) post(reverse('api:group_hosts_list', kwargs={'pk': g.id}), {'id': h.id}, admin_user, expect=204)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
def test_group_group_associations_reset(self, post, scm_inventory, admin_user): def test_group_group_associations_reset(self, post, scm_inventory, admin_user):
inv_src = scm_inventory.inventory_sources.first() inv_src = scm_inventory.inventory_sources.first()
g1 = inv_src.groups.create(name='barland', inventory=scm_inventory) g1 = inv_src.groups.create(name='barland', inventory=scm_inventory)
g2 = inv_src.groups.create(name='fooland', inventory=scm_inventory) g2 = inv_src.groups.create(name='fooland', inventory=scm_inventory)
post(reverse('api:group_children_list', kwargs={'pk': g1.id}), {'id': g2.id}, admin_user, expect=204) post(reverse('api:group_children_list', kwargs={'pk': g1.id}), {'id': g2.id}, admin_user, expect=204)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
def test_host_group_delete_reset(self, delete, scm_inventory, admin_user): def test_host_group_delete_reset(self, delete, scm_inventory, admin_user):
inv_src = scm_inventory.inventory_sources.first() inv_src = scm_inventory.inventory_sources.first()
@@ -575,7 +566,6 @@ class TestControlledBySCM:
g = inv_src.groups.create(name='fooland', inventory=scm_inventory) g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
delete(h.get_absolute_url(), admin_user, expect=204) delete(h.get_absolute_url(), admin_user, expect=204)
delete(g.get_absolute_url(), admin_user, expect=204) delete(g.get_absolute_url(), admin_user, expect=204)
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
def test_remove_scm_inv_src(self, delete, scm_inventory, admin_user): def test_remove_scm_inv_src(self, delete, scm_inventory, admin_user):
inv_src = scm_inventory.inventory_sources.first() inv_src = scm_inventory.inventory_sources.first()
@@ -588,7 +578,6 @@ class TestControlledBySCM:
{ {
'name': 'new inv src', 'name': 'new inv src',
'source_project': project.pk, 'source_project': project.pk,
'update_on_project_update': False,
'source': 'scm', 'source': 'scm',
'overwrite_vars': True, 'overwrite_vars': True,
'source_vars': 'plugin: a.b.c', 'source_vars': 'plugin: a.b.c',
@@ -597,27 +586,6 @@ class TestControlledBySCM:
expect=201, expect=201,
) )
def test_adding_inv_src_prohibited(self, post, scm_inventory, project, admin_user):
post(
reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
{'name': 'new inv src', 'source_project': project.pk, 'update_on_project_update': True, 'source': 'scm', 'overwrite_vars': True},
admin_user,
expect=400,
)
def test_two_update_on_project_update_inv_src_prohibited(self, patch, scm_inventory, factory_scm_inventory, project, admin_user):
scm_inventory2 = factory_scm_inventory(name="scm_inventory2")
res = patch(
reverse('api:inventory_source_detail', kwargs={'pk': scm_inventory2.id}),
{
'update_on_project_update': True,
},
admin_user,
expect=400,
)
content = json.loads(res.content)
assert content['update_on_project_update'] == ["More than one SCM-based inventory source with update on project update " "per-inventory not allowed."]
def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando): def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando):
inventory.admin_role.members.add(rando) inventory.admin_role.members.add(rando)
post( post(

View File

@@ -347,9 +347,7 @@ def scm_inventory_source(inventory, project):
source_project=project, source_project=project,
source='scm', source='scm',
source_path='inventory_file', source_path='inventory_file',
update_on_project_update=True,
inventory=inventory, inventory=inventory,
scm_last_revision=project.scm_revision,
) )
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
inv_src.save() inv_src.save()

View File

@@ -3,8 +3,6 @@
import pytest import pytest
from unittest import mock from unittest import mock
from django.core.exceptions import ValidationError
# AWX # AWX
from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job
from awx.main.constants import CLOUD_PROVIDERS from awx.main.constants import CLOUD_PROVIDERS
@@ -123,19 +121,6 @@ class TestActiveCount:
@pytest.mark.django_db @pytest.mark.django_db
class TestSCMUpdateFeatures: class TestSCMUpdateFeatures:
def test_automatic_project_update_on_create(self, inventory, project):
inv_src = InventorySource(source_project=project, source_path='inventory_file', inventory=inventory, update_on_project_update=True, source='scm')
with mock.patch.object(inv_src, 'update') as mck_update:
inv_src.save()
mck_update.assert_called_once_with()
def test_reset_scm_revision(self, scm_inventory_source):
starting_rev = scm_inventory_source.scm_last_revision
assert starting_rev != ''
scm_inventory_source.source_path = '/newfolder/newfile.ini'
scm_inventory_source.save()
assert scm_inventory_source.scm_last_revision == ''
def test_source_location(self, scm_inventory_source): def test_source_location(self, scm_inventory_source):
# Combines project directory with the inventory file specified # Combines project directory with the inventory file specified
inventory_update = InventoryUpdate(inventory_source=scm_inventory_source, source_path=scm_inventory_source.source_path) inventory_update = InventoryUpdate(inventory_source=scm_inventory_source, source_path=scm_inventory_source.source_path)
@@ -167,22 +152,6 @@ class TestRelatedJobs:
assert job.id in [jerb.id for jerb in group._get_related_jobs()] assert job.id in [jerb.id for jerb in group._get_related_jobs()]
@pytest.mark.django_db
class TestSCMClean:
def test_clean_update_on_project_update_multiple(self, inventory):
inv_src1 = InventorySource(inventory=inventory, update_on_project_update=True, source='scm')
inv_src1.clean_update_on_project_update()
inv_src1.save()
inv_src1.source_vars = '---\nhello: world'
inv_src1.clean_update_on_project_update()
inv_src2 = InventorySource(inventory=inventory, update_on_project_update=True, source='scm')
with pytest.raises(ValidationError):
inv_src2.clean_update_on_project_update()
@pytest.mark.django_db @pytest.mark.django_db
class TestInventorySourceInjectors: class TestInventorySourceInjectors:
def test_extra_credentials(self, project, credential): def test_extra_credentials(self, project, credential):

View File

@@ -19,6 +19,7 @@ from awx.api.views import WorkflowJobTemplateNodeSuccessNodesList
# Django # Django
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.timezone import now
class TestWorkflowDAGFunctional(TransactionTestCase): class TestWorkflowDAGFunctional(TransactionTestCase):
@@ -381,3 +382,38 @@ def test_workflow_ancestors_recursion_prevention(organization):
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj) # well, this is a problem WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj) # well, this is a problem
# mostly, we just care that this assertion finishes in finite time # mostly, we just care that this assertion finishes in finite time
assert wfj.get_ancestor_workflows() == [] assert wfj.get_ancestor_workflows() == []
@pytest.mark.django_db
class TestCombinedArtifacts:
@pytest.fixture
def wfj_artifacts(self, job_template, organization):
wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='has_artifacts')
wfj = WorkflowJob.objects.create(workflow_job_template=wfjt, launch_type='workflow')
job = job_template.create_unified_job(_eager_fields=dict(artifacts={'foooo': 'bar'}, status='successful', finished=now()))
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=job_template, job=job)
return wfj
def test_multiple_types(self, project, wfj_artifacts):
project_update = project.create_unified_job()
WorkflowJobNode.objects.create(workflow_job=wfj_artifacts, unified_job_template=project, job=project_update)
assert wfj_artifacts.get_effective_artifacts() == {'foooo': 'bar'}
def test_precedence_based_on_time(self, wfj_artifacts, job_template):
later_job = job_template.create_unified_job(
_eager_fields=dict(artifacts={'foooo': 'zoo'}, status='successful', finished=now()) # finished later, should win
)
WorkflowJobNode.objects.create(workflow_job=wfj_artifacts, unified_job_template=job_template, job=later_job)
assert wfj_artifacts.get_effective_artifacts() == {'foooo': 'zoo'}
def test_bad_data_with_artifacts(self, organization):
# This is toxic database data, this tests that it doesn't create an infinite loop
wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='child')
wfj = WorkflowJob.objects.create(workflow_job_template=wfjt, launch_type='workflow')
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj)
job = Job.objects.create(artifacts={'foo': 'bar'}, status='successful')
WorkflowJobNode.objects.create(workflow_job=wfj, job=job)
# mostly, we just care that this assertion finishes in finite time
assert wfj.get_effective_artifacts() == {'foo': 'bar'}

View File

@@ -4,9 +4,8 @@ import os
import tempfile import tempfile
import shutil import shutil
from awx.main.tasks.jobs import RunProjectUpdate, RunInventoryUpdate
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
from awx.main.models import ProjectUpdate, InventoryUpdate, InventorySource, Instance, Job from awx.main.models import Instance, Job
@pytest.fixture @pytest.fixture
@@ -27,63 +26,6 @@ def test_no_worker_info_on_AWX_nodes(node_type):
execution_node_health_check(hostname) execution_node_health_check(hostname)
@pytest.mark.django_db
class TestDependentInventoryUpdate:
def test_dependent_inventory_updates_is_called(self, scm_inventory_source, scm_revision_file, mock_me):
task = RunProjectUpdate()
task.revision_path = scm_revision_file
proj_update = scm_inventory_source.source_project.create_project_update()
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
with mock.patch.object(RunProjectUpdate, 'release_lock'):
task.post_run_hook(proj_update, 'successful')
inv_update_mck.assert_called_once_with(proj_update, mock.ANY)
def test_no_unwanted_dependent_inventory_updates(self, project, scm_revision_file, mock_me):
task = RunProjectUpdate()
task.revision_path = scm_revision_file
proj_update = project.create_project_update()
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
with mock.patch.object(RunProjectUpdate, 'release_lock'):
task.post_run_hook(proj_update, 'successful')
assert not inv_update_mck.called
def test_dependent_inventory_updates(self, scm_inventory_source, default_instance_group, mock_me):
task = RunProjectUpdate()
scm_inventory_source.scm_last_revision = ''
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
with mock.patch('awx.main.tasks.jobs.create_partition'):
task._update_dependent_inventories(proj_update, [scm_inventory_source])
assert InventoryUpdate.objects.count() == 1
inv_update = InventoryUpdate.objects.first()
iu_run_mock.assert_called_once_with(inv_update.id)
assert inv_update.source_project_update_id == proj_update.pk
def test_dependent_inventory_project_cancel(self, project, inventory, default_instance_group, mock_me):
"""
Test that dependent inventory updates exhibit good behavior on cancel
of the source project update
"""
task = RunProjectUpdate()
proj_update = ProjectUpdate.objects.create(project=project)
kwargs = dict(source_project=project, source='scm', source_path='inventory_file', update_on_project_update=True, inventory=inventory)
is1 = InventorySource.objects.create(name="test-scm-inv", **kwargs)
is2 = InventorySource.objects.create(name="test-scm-inv2", **kwargs)
def user_cancels_project(pk):
ProjectUpdate.objects.all().update(cancel_flag=True)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
with mock.patch('awx.main.tasks.jobs.create_partition'):
iu_run_mock.side_effect = user_cancels_project
task._update_dependent_inventories(proj_update, [is1, is2])
# Verify that it bails after 1st update, detecting a cancel
assert is2.inventory_updates.count() == 0
iu_run_mock.assert_called_once()
@pytest.fixture @pytest.fixture
def mock_job_folder(request): def mock_job_folder(request):
pdd_path = tempfile.mkdtemp(prefix='awx_123_') pdd_path = tempfile.mkdtemp(prefix='awx_123_')

View File

@@ -20,7 +20,7 @@ def test_activity_stream_related():
""" """
serializer_related = set( serializer_related = set(
ActivityStream._meta.get_field(field_name).related_model ActivityStream._meta.get_field(field_name).related_model
for field_name, stuff in ActivityStreamSerializer()._local_summarizable_fk_fields for field_name, stuff in ActivityStreamSerializer()._local_summarizable_fk_fields(None)
if hasattr(ActivityStream, field_name) if hasattr(ActivityStream, field_name)
) )

View File

@@ -79,6 +79,19 @@ def test_invalid_field():
assert 'is not an allowed field name. Must be ascii encodable.' in str(excinfo.value) assert 'is not an allowed field name. Must be ascii encodable.' in str(excinfo.value)
def test_valid_iexact():
field_lookup = FieldLookupBackend()
value, new_lookup, _ = field_lookup.value_to_python(JobTemplate, 'project__name__iexact', 'foo')
assert 'foo' in value
def test_invalid_iexact():
field_lookup = FieldLookupBackend()
with pytest.raises(ValueError) as excinfo:
field_lookup.value_to_python(Job, 'id__iexact', '1')
assert 'is not a text field and cannot be filtered by case-insensitive search' in str(excinfo.value)
@pytest.mark.parametrize('lookup_suffix', ['', 'contains', 'startswith', 'in']) @pytest.mark.parametrize('lookup_suffix', ['', 'contains', 'startswith', 'in'])
@pytest.mark.parametrize('password_field', Credential.PASSWORD_FIELDS) @pytest.mark.parametrize('password_field', Credential.PASSWORD_FIELDS)
def test_filter_on_password_field(password_field, lookup_suffix): def test_filter_on_password_field(password_field, lookup_suffix):

View File

@@ -69,21 +69,21 @@ class TestJobTemplateLabelList:
class TestInventoryInventorySourcesUpdate: class TestInventoryInventorySourcesUpdate:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"can_update, can_access, is_source, is_up_on_proj, expected", "can_update, can_access, is_source, expected",
[ [
(True, True, "ec2", False, [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]), (True, True, "ec2", [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
(False, True, "gce", False, [{'status': 'Could not start because `can_update` returned False', 'inventory_source': 1}]), (False, True, "gce", [{'status': 'Could not start because `can_update` returned False', 'inventory_source': 1}]),
(True, False, "scm", True, [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]), (True, False, "scm", [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
], ],
) )
def test_post(self, mocker, can_update, can_access, is_source, is_up_on_proj, expected): def test_post(self, mocker, can_update, can_access, is_source, expected):
class InventoryUpdate: class InventoryUpdate:
id = 1 id = 1
class Project: class Project:
name = 'project' name = 'project'
InventorySource = namedtuple('InventorySource', ['source', 'update_on_project_update', 'pk', 'can_update', 'update', 'source_project']) InventorySource = namedtuple('InventorySource', ['source', 'pk', 'can_update', 'update', 'source_project'])
class InventorySources(object): class InventorySources(object):
def all(self): def all(self):
@@ -92,7 +92,6 @@ class TestInventoryInventorySourcesUpdate:
pk=1, pk=1,
source=is_source, source=is_source,
source_project=Project, source_project=Project,
update_on_project_update=is_up_on_proj,
can_update=can_update, can_update=can_update,
update=lambda: InventoryUpdate, update=lambda: InventoryUpdate,
) )

View File

@@ -1,28 +1,13 @@
import pytest import pytest
from unittest import mock
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from awx.main.models import ( from awx.main.models import (
UnifiedJob,
InventoryUpdate, InventoryUpdate,
InventorySource, InventorySource,
) )
def test_cancel(mocker):
with mock.patch.object(UnifiedJob, 'cancel', return_value=True) as parent_cancel:
iu = InventoryUpdate()
iu.save = mocker.MagicMock()
build_job_explanation_mock = mocker.MagicMock()
iu._build_job_explanation = mocker.MagicMock(return_value=build_job_explanation_mock)
iu.cancel()
parent_cancel.assert_called_with(is_chain=False, job_explanation=None)
def test__build_job_explanation(): def test__build_job_explanation():
iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update') iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update')
@@ -53,9 +38,3 @@ class TestControlledBySCM:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
inv_src.clean_source_path() inv_src.clean_source_path()
def test_clean_update_on_launch_update_on_project_update(self):
inv_src = InventorySource(update_on_project_update=True, update_on_launch=True, source='scm')
with pytest.raises(ValidationError):
inv_src.clean_update_on_launch()

View File

@@ -1,7 +1,7 @@
from awx.main.tasks.callback import RunnerCallback from awx.main.tasks.callback import RunnerCallback
from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
def test_delay_update(mock_me): def test_delay_update(mock_me):

View File

@@ -0,0 +1,50 @@
import signal
from awx.main.tasks.signals import signal_state, signal_callback, with_signal_handling
def test_outer_inner_signal_handling():
"""
Even if the flag is set in the outer context, its value should persist in the inner context
"""
@with_signal_handling
def f2():
assert signal_callback()
@with_signal_handling
def f1():
assert signal_callback() is False
signal_state.set_flag()
assert signal_callback()
f2()
original_sigterm = signal.getsignal(signal.SIGTERM)
assert signal_callback() is False
f1()
assert signal_callback() is False
assert signal.getsignal(signal.SIGTERM) is original_sigterm
def test_inner_outer_signal_handling():
"""
Even if the flag is set in the inner context, its value should persist in the outer context
"""
@with_signal_handling
def f2():
assert signal_callback() is False
signal_state.set_flag()
assert signal_callback()
@with_signal_handling
def f1():
assert signal_callback() is False
f2()
assert signal_callback()
original_sigterm = signal.getsignal(signal.SIGTERM)
assert signal_callback() is False
f1()
assert signal_callback() is False
assert signal.getsignal(signal.SIGTERM) is original_sigterm

View File

@@ -922,7 +922,8 @@ class TestJobCredentials(TestJobExecution):
assert env['AWS_SECURITY_TOKEN'] == 'token' assert env['AWS_SECURITY_TOKEN'] == 'token'
assert safe_env['AWS_SECRET_ACCESS_KEY'] == HIDDEN_PASSWORD assert safe_env['AWS_SECRET_ACCESS_KEY'] == HIDDEN_PASSWORD
def test_gce_credentials(self, private_data_dir, job, mock_me): @pytest.mark.parametrize("cred_env_var", ['GCE_CREDENTIALS_FILE_PATH', 'GOOGLE_APPLICATION_CREDENTIALS'])
def test_gce_credentials(self, cred_env_var, private_data_dir, job, mock_me):
gce = CredentialType.defaults['gce']() gce = CredentialType.defaults['gce']()
credential = Credential(pk=1, credential_type=gce, inputs={'username': 'bob', 'project': 'some-project', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY}) credential = Credential(pk=1, credential_type=gce, inputs={'username': 'bob', 'project': 'some-project', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY})
credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data')
@@ -931,7 +932,7 @@ class TestJobCredentials(TestJobExecution):
env = {} env = {}
safe_env = {} safe_env = {}
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir) credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
runner_path = env['GCE_CREDENTIALS_FILE_PATH'] runner_path = env[cred_env_var]
local_path = to_host_path(runner_path, private_data_dir) local_path = to_host_path(runner_path, private_data_dir)
json_data = json.load(open(local_path, 'rb')) json_data = json.load(open(local_path, 'rb'))
assert json_data['type'] == 'service_account' assert json_data['type'] == 'service_account'
@@ -1316,6 +1317,7 @@ class TestJobCredentials(TestJobExecution):
assert env['AZURE_AD_USER'] == 'bob' assert env['AZURE_AD_USER'] == 'bob'
assert env['AZURE_PASSWORD'] == 'secret' assert env['AZURE_PASSWORD'] == 'secret'
# Because this is testing a mix of multiple cloud creds, we are not going to test the GOOGLE_APPLICATION_CREDENTIALS here
path = to_host_path(env['GCE_CREDENTIALS_FILE_PATH'], private_data_dir) path = to_host_path(env['GCE_CREDENTIALS_FILE_PATH'], private_data_dir)
json_data = json.load(open(path, 'rb')) json_data = json.load(open(path, 'rb'))
assert json_data['type'] == 'service_account' assert json_data['type'] == 'service_account'
@@ -1645,7 +1647,8 @@ class TestInventoryUpdateCredentials(TestJobExecution):
assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD
def test_gce_source(self, inventory_update, private_data_dir, mocker, mock_me): @pytest.mark.parametrize("cred_env_var", ['GCE_CREDENTIALS_FILE_PATH', 'GOOGLE_APPLICATION_CREDENTIALS'])
def test_gce_source(self, cred_env_var, inventory_update, private_data_dir, mocker, mock_me):
task = jobs.RunInventoryUpdate() task = jobs.RunInventoryUpdate()
task.instance = inventory_update task.instance = inventory_update
gce = CredentialType.defaults['gce']() gce = CredentialType.defaults['gce']()
@@ -1669,7 +1672,7 @@ class TestInventoryUpdateCredentials(TestJobExecution):
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir) credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
assert env['GCE_ZONE'] == expected_gce_zone assert env['GCE_ZONE'] == expected_gce_zone
json_data = json.load(open(env['GCE_CREDENTIALS_FILE_PATH'], 'rb')) json_data = json.load(open(env[cred_env_var], 'rb'))
assert json_data['type'] == 'service_account' assert json_data['type'] == 'service_account'
assert json_data['private_key'] == self.EXAMPLE_PRIVATE_KEY assert json_data['private_key'] == self.EXAMPLE_PRIVATE_KEY
assert json_data['client_email'] == 'bob' assert json_data['client_email'] == 'bob'

View File

@@ -3,6 +3,8 @@ from django.db import transaction, DatabaseError, InterfaceError
import logging import logging
import time import time
from awx.main.tasks.signals import signal_callback
logger = logging.getLogger('awx.main.tasks.utils') logger = logging.getLogger('awx.main.tasks.utils')
@@ -37,7 +39,10 @@ def update_model(model, pk, _attempt=0, _max_attempts=5, select_for_update=False
# Attempt to retry the update, assuming we haven't already # Attempt to retry the update, assuming we haven't already
# tried too many times. # tried too many times.
if _attempt < _max_attempts: if _attempt < _max_attempts:
time.sleep(5) for i in range(5):
time.sleep(1)
if signal_callback():
raise RuntimeError(f'Could not fetch {pk} because of receiving abort signal')
return update_model(model, pk, _attempt=_attempt + 1, _max_attempts=_max_attempts, **updates) return update_model(model, pk, _attempt=_attempt + 1, _max_attempts=_max_attempts, **updates)
else: else:
logger.error('Failed to update %s after %d retries.', model._meta.object_name, _attempt) logger.error('Failed to update %s after %d retries.', model._meta.object_name, _attempt)

View File

@@ -1537,9 +1537,11 @@ register(
('is_superuser_attr', 'saml_attr'), ('is_superuser_attr', 'saml_attr'),
('is_superuser_value', 'value'), ('is_superuser_value', 'value'),
('is_superuser_role', 'saml_role'), ('is_superuser_role', 'saml_role'),
('remove_superusers', True),
('is_system_auditor_attr', 'saml_attr'), ('is_system_auditor_attr', 'saml_attr'),
('is_system_auditor_value', 'value'), ('is_system_auditor_value', 'value'),
('is_system_auditor_role', 'saml_role'), ('is_system_auditor_role', 'saml_role'),
('remove_system_auditors', True),
], ],
) )

View File

@@ -743,8 +743,10 @@ class SAMLUserFlagsAttrField(HybridDictField):
is_superuser_attr = fields.CharField(required=False, allow_null=True) is_superuser_attr = fields.CharField(required=False, allow_null=True)
is_superuser_value = fields.CharField(required=False, allow_null=True) is_superuser_value = fields.CharField(required=False, allow_null=True)
is_superuser_role = fields.CharField(required=False, allow_null=True) is_superuser_role = fields.CharField(required=False, allow_null=True)
remove_superusers = fields.BooleanField(required=False, allow_null=True)
is_system_auditor_attr = fields.CharField(required=False, allow_null=True) is_system_auditor_attr = fields.CharField(required=False, allow_null=True)
is_system_auditor_value = fields.CharField(required=False, allow_null=True) is_system_auditor_value = fields.CharField(required=False, allow_null=True)
is_system_auditor_role = fields.CharField(required=False, allow_null=True) is_system_auditor_role = fields.CharField(required=False, allow_null=True)
remove_system_auditors = fields.BooleanField(required=False, allow_null=True)
child = _Forbidden() child = _Forbidden()

View File

@@ -77,6 +77,21 @@ def _update_m2m_from_expression(user, related, expr, remove=True):
related.remove(user) related.remove(user)
def get_or_create_with_default_galaxy_cred(**kwargs):
from awx.main.models import Organization, Credential
(org, org_created) = Organization.objects.get_or_create(**kwargs)
if org_created:
logger.debug("Created org {} (id {}) from {}".format(org.name, org.id, kwargs))
public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first()
if public_galaxy_credential is not None:
org.galaxy_credentials.add(public_galaxy_credential)
logger.debug("Added default Ansible Galaxy credential to org")
else:
logger.debug("Could not find default Ansible Galaxy credential to add to org")
return org
def _update_org_from_attr(user, related, attr, remove, remove_admins, remove_auditors, backend): def _update_org_from_attr(user, related, attr, remove, remove_admins, remove_auditors, backend):
from awx.main.models import Organization from awx.main.models import Organization
from django.conf import settings from django.conf import settings
@@ -94,8 +109,7 @@ def _update_org_from_attr(user, related, attr, remove, remove_admins, remove_aud
organization_name = org_name organization_name = org_name
except Exception: except Exception:
organization_name = org_name organization_name = org_name
org = Organization.objects.get_or_create(name=organization_name)[0] org = get_or_create_with_default_galaxy_cred(name=organization_name)
org.create_default_galaxy_credential()
else: else:
org = Organization.objects.get(name=org_name) org = Organization.objects.get(name=org_name)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@@ -121,7 +135,6 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs):
""" """
if not user: if not user:
return return
from awx.main.models import Organization
org_map = backend.setting('ORGANIZATION_MAP') or {} org_map = backend.setting('ORGANIZATION_MAP') or {}
for org_name, org_opts in org_map.items(): for org_name, org_opts in org_map.items():
@@ -130,8 +143,7 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs):
organization_name = organization_alias organization_name = organization_alias
else: else:
organization_name = org_name organization_name = org_name
org = Organization.objects.get_or_create(name=organization_name)[0] org = get_or_create_with_default_galaxy_cred(name=organization_name)
org.create_default_galaxy_credential()
# Update org admins from expression(s). # Update org admins from expression(s).
remove = bool(org_opts.get('remove', True)) remove = bool(org_opts.get('remove', True))
@@ -152,15 +164,14 @@ def update_user_teams(backend, details, user=None, *args, **kwargs):
""" """
if not user: if not user:
return return
from awx.main.models import Organization, Team from awx.main.models import Team
team_map = backend.setting('TEAM_MAP') or {} team_map = backend.setting('TEAM_MAP') or {}
for team_name, team_opts in team_map.items(): for team_name, team_opts in team_map.items():
# Get or create the org to update. # Get or create the org to update.
if 'organization' not in team_opts: if 'organization' not in team_opts:
continue continue
org = Organization.objects.get_or_create(name=team_opts['organization'])[0] org = get_or_create_with_default_galaxy_cred(name=team_opts['organization'])
org.create_default_galaxy_credential()
# Update team members from expression(s). # Update team members from expression(s).
team = Team.objects.get_or_create(name=team_name, organization=org)[0] team = Team.objects.get_or_create(name=team_name, organization=org)[0]
@@ -216,8 +227,7 @@ def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs)
try: try:
if settings.SAML_AUTO_CREATE_OBJECTS: if settings.SAML_AUTO_CREATE_OBJECTS:
org = Organization.objects.get_or_create(name=organization_name)[0] org = get_or_create_with_default_galaxy_cred(name=organization_name)
org.create_default_galaxy_credential()
else: else:
org = Organization.objects.get(name=organization_name) org = Organization.objects.get(name=organization_name)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@@ -245,6 +255,7 @@ def _check_flag(user, flag, attributes, user_flags_settings):
is_role_key = "is_%s_role" % (flag) is_role_key = "is_%s_role" % (flag)
is_attr_key = "is_%s_attr" % (flag) is_attr_key = "is_%s_attr" % (flag)
is_value_key = "is_%s_value" % (flag) is_value_key = "is_%s_value" % (flag)
remove_setting = "remove_%ss" % (flag)
# Check to see if we are respecting a role and, if so, does our user have that role? # Check to see if we are respecting a role and, if so, does our user have that role?
role_setting = user_flags_settings.get(is_role_key, None) role_setting = user_flags_settings.get(is_role_key, None)
@@ -276,7 +287,7 @@ def _check_flag(user, flag, attributes, user_flags_settings):
# if they don't match make sure that new_flag is false # if they don't match make sure that new_flag is false
else: else:
logger.debug( logger.debug(
"Refusing %s for %s because attr %s (%s) did not match value '%s'" "For %s on %s attr %s (%s) did not match expected value '%s'"
% (flag, user.username, attr_setting, attribute_value, user_flags_settings.get(is_value_key)) % (flag, user.username, attr_setting, attribute_value, user_flags_settings.get(is_value_key))
) )
new_flag = False new_flag = False
@@ -285,8 +296,16 @@ def _check_flag(user, flag, attributes, user_flags_settings):
logger.debug("Giving %s %s from attribute %s" % (user.username, flag, attr_setting)) logger.debug("Giving %s %s from attribute %s" % (user.username, flag, attr_setting))
new_flag = True new_flag = True
# If the user was flagged and we are going to make them not flagged make sure there is a message # Get the users old flag
old_value = getattr(user, "is_%s" % (flag)) old_value = getattr(user, "is_%s" % (flag))
# If we are not removing the flag and they were a system admin and now we don't want them to be just return
remove_flag = user_flags_settings.get(remove_setting, True)
if not remove_flag and (old_value and not new_flag):
logger.debug("Remove flag %s preventing removal of %s for %s" % (remove_flag, flag, user.username))
return old_value, False
# If the user was flagged and we are going to make them not flagged make sure there is a message
if old_value and not new_flag: if old_value and not new_flag:
logger.debug("Revoking %s from %s" % (flag, user.username)) logger.debug("Revoking %s from %s" % (flag, user.username))

View File

@@ -4,8 +4,8 @@ from unittest import mock
from django.utils.timezone import now from django.utils.timezone import now
from awx.conf.registry import settings_registry
from awx.sso.pipeline import update_user_orgs, update_user_teams, update_user_orgs_by_saml_attr, update_user_teams_by_saml_attr, _check_flag from awx.sso.pipeline import update_user_orgs, update_user_teams, update_user_orgs_by_saml_attr, update_user_teams_by_saml_attr, _check_flag
from awx.main.models import User, Team, Organization, Credential, CredentialType from awx.main.models import User, Team, Organization, Credential, CredentialType
@@ -92,8 +92,13 @@ class TestSAMLMap:
assert Organization.objects.get(name="Default_Alias") is not None assert Organization.objects.get(name="Default_Alias") is not None
for o in Organization.objects.all(): for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1 if o.name == 'Default':
assert o.galaxy_credentials.first().name == 'Ansible Galaxy' # The default org was already created and should not have a galaxy credential
assert o.galaxy_credentials.count() == 0
else:
# The Default_Alias was created by SAML and should get the galaxy credential
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams(self, backend, users, galaxy_credential): def test_update_user_teams(self, backend, users, galaxy_credential):
u1, u2, u3 = users u1, u2, u3 = users
@@ -203,7 +208,13 @@ class TestSAMLAttr:
], ],
} }
return MockSettings() mock_settings_obj = MockSettings()
for key in settings_registry.get_registered_settings(category_slug='logging'):
value = settings_registry.get_setting_field(key).get_default()
setattr(mock_settings_obj, key, value)
setattr(mock_settings_obj, 'DEBUG', True)
return mock_settings_obj
@pytest.fixture @pytest.fixture
def backend(self): def backend(self):
@@ -263,8 +274,13 @@ class TestSAMLAttr:
assert Organization.objects.get(name="o1_alias").member_role.members.count() == 1 assert Organization.objects.get(name="o1_alias").member_role.members.count() == 1
for o in Organization.objects.all(): for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1 if o.id in [o1.id, o2.id, o3.id]:
assert o.galaxy_credentials.first().name == 'Ansible Galaxy' # o[123] were created without a default galaxy cred
assert o.galaxy_credentials.count() == 0
else:
# anything else created should have a default galaxy cred
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings): def test_update_user_teams_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings): with mock.patch('django.conf.settings', mock_settings):
@@ -322,8 +338,13 @@ class TestSAMLAttr:
assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3 assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3
for o in Organization.objects.all(): for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1 if o.id in [o1.id, o2.id, o3.id]:
assert o.galaxy_credentials.first().name == 'Ansible Galaxy' # o[123] were created without a default galaxy cred
assert o.galaxy_credentials.count() == 0
else:
# anything else created should have a default galaxy cred
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams_alias_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings): def test_update_user_teams_alias_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings): with mock.patch('django.conf.settings', mock_settings):
@@ -396,73 +417,113 @@ class TestSAMLAttr:
assert o.galaxy_credentials.count() == 1 assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy' assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_galaxy_credential_no_auto_assign(self, users, kwargs, galaxy_credential, mock_settings):
# A Galaxy credential should not be added to an existing org
o = Organization.objects.create(name='Default1')
o = Organization.objects.create(name='Default2')
o = Organization.objects.create(name='Default3')
o = Organization.objects.create(name='Default4')
kwargs['response']['attributes']['memberOf'] = ['Default1']
kwargs['response']['attributes']['groups'] = ['Blue']
with mock.patch('django.conf.settings', mock_settings):
for u in users:
update_user_orgs_by_saml_attr(None, None, u, **kwargs)
update_user_teams_by_saml_attr(None, None, u, **kwargs)
assert Organization.objects.count() == 4
for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 0
@pytest.mark.django_db @pytest.mark.django_db
class TestSAMLUserFlags: class TestSAMLUserFlags:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_flags_settings, expected", "user_flags_settings, expected, is_superuser",
[ [
# In this case we will pass no user flags so new_flag should be false and changed will def be false # In this case we will pass no user flags so new_flag should be false and changed will def be false
( (
{}, {},
(False, False), (False, False),
False,
), ),
# In this case we will give the user a group to make them an admin # In this case we will give the user a group to make them an admin
( (
{'is_superuser_role': 'test-role-1'}, {'is_superuser_role': 'test-role-1'},
(True, True), (True, True),
False,
), ),
# In this case we will give the user a flag that will make then an admin # In this case we will give the user a flag that will make then an admin
( (
{'is_superuser_attr': 'is_superuser'}, {'is_superuser_attr': 'is_superuser'},
(True, True), (True, True),
False,
), ),
# In this case we will give the user a flag but the wrong value # In this case we will give the user a flag but the wrong value
( (
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'}, {'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
(False, False), (False, False),
False,
), ),
# In this case we will give the user a flag and the right value # In this case we will give the user a flag and the right value
( (
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'}, {'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
(True, True), (True, True),
False,
), ),
# In this case we will give the user a proper role and an is_superuser_attr role that they dont have, this should make them an admin # In this case we will give the user a proper role and an is_superuser_attr role that they dont have, this should make them an admin
( (
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'gibberish', 'is_superuser_value': 'true'}, {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'gibberish', 'is_superuser_value': 'true'},
(True, True), (True, True),
False,
), ),
# In this case we will give the user a proper role and an is_superuser_attr role that they have, this should make them an admin # In this case we will give the user a proper role and an is_superuser_attr role that they have, this should make them an admin
( (
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'test-role-1'}, {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'test-role-1'},
(True, True), (True, True),
False,
), ),
# In this case we will give the user a proper role and an is_superuser_attr role that they have but a bad value, this should make them an admin # In this case we will give the user a proper role and an is_superuser_attr role that they have but a bad value, this should make them an admin
( (
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'}, {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
(False, False), (False, False),
False,
), ),
# In this case we will give the user everything # In this case we will give the user everything
( (
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'}, {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
(True, True), (True, True),
False,
), ),
# In this test case we will validate that a single attribute (instead of a list) still works # In this test case we will validate that a single attribute (instead of a list) still works
( (
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'test_id'}, {'is_superuser_attr': 'name_id', 'is_superuser_value': 'test_id'},
(True, True), (True, True),
False,
), ),
# This will be a negative test for a single atrribute # This will be a negative test for a single atrribute
( (
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk'}, {'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk'},
(False, False), (False, False),
False,
),
# The user is already a superuser so we should remove them
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': True},
(False, True),
True,
),
# The user is already a superuser but we don't have a remove field
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': False},
(True, False),
True,
), ),
], ],
) )
def test__check_flag(self, user_flags_settings, expected): def test__check_flag(self, user_flags_settings, expected, is_superuser):
user = User() user = User()
user.username = 'John' user.username = 'John'
user.is_superuser = False user.is_superuser = is_superuser
attributes = { attributes = {
'email': ['noone@nowhere.com'], 'email': ['noone@nowhere.com'],

View File

@@ -123,9 +123,11 @@ class TestSAMLUserFlagsAttrField:
{'is_superuser_attr': 'something'}, {'is_superuser_attr': 'something'},
{'is_superuser_value': 'value'}, {'is_superuser_value': 'value'},
{'is_superuser_role': 'my_peeps'}, {'is_superuser_role': 'my_peeps'},
{'remove_superusers': False},
{'is_system_auditor_attr': 'something_else'}, {'is_system_auditor_attr': 'something_else'},
{'is_system_auditor_value': 'value2'}, {'is_system_auditor_value': 'value2'},
{'is_system_auditor_role': 'other_peeps'}, {'is_system_auditor_role': 'other_peeps'},
{'remove_system_auditors': False},
], ],
) )
def test_internal_value_valid(self, data): def test_internal_value_valid(self, data):
@@ -165,6 +167,17 @@ class TestSAMLUserFlagsAttrField:
'junk2': ['Invalid field.'], 'junk2': ['Invalid field.'],
}, },
), ),
# make sure we can't pass a string to the boolean fields
(
{
'remove_superusers': 'test',
'remove_system_auditors': 'test',
},
{
"remove_superusers": ["Must be a valid boolean."],
"remove_system_auditors": ["Must be a valid boolean."],
},
),
], ],
) )
def test_internal_value_invalid(self, data, expected): def test_internal_value_invalid(self, data, expected):

View File

@@ -2,26 +2,27 @@
## UX Considerations ## UX Considerations
Historically, the code that powers search in the AngularJS version of the AWX UI is very complex and prone to bugs. In order to reduce that complexity, we've made some UX decisions to help make the code easier to maintain. Historically, the code that powers search in the AngularJS version of the AWX UI is very complex and prone to bugs. In order to reduce that complexity, we've made some UX decisions to help make the code easier to maintain.
**ALL query params namespaced and in url bar** **ALL query params namespaced and in url bar**
This includes lists that aren't necessarily hyperlinked, like lookup lists. The reason behind this is so we can treat the url bar as the source of truth for queries always. Any params that have both a key AND value that is in the defaultParams section of the qs config are stripped out of the search string (see "Encoding for UI vs. API" for more info on this point) This includes lists that aren't necessarily hyperlinked, like lookup lists. The reason behind this is so we can treat the url bar as the source of truth for queries always. Any params that have both a key AND value that is in the defaultParams section of the qs config are stripped out of the search string (see "Encoding for UI vs. API" for more info on this point)
**Django fuzzy search (`?search=`) is not accessible outside of "advanced search"** **Django fuzzy search (`?search=`) is not accessible outside of "advanced search"**
In current smart search typing a term with no key utilizes `?search=` i.e. for "foo" tag, `?search=foo` is given. `?search=` looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well). Due to the fact a key will always be present on the left-hand of simple search, it doesn't make sense to use `?search=` as the default. In current smart search typing a term with no key utilizes `?search=` i.e. for "foo" tag, `?search=foo` is given. `?search=` looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well). Due to the fact a key will always be present on the left-hand of simple search, it doesn't make sense to use `?search=` as the default.
We may allow passing of `?search=` through our future advanced search interface. Some details that were gathered in planning phases about `?search=` that might be helpful in the future:
We may allow passing of `?search=` through our future advanced search interface. Some details that were gathered in planning phases about `?search=` that might be helpful in the future:
- `?search=` tags are OR'd together (union is returned). - `?search=` tags are OR'd together (union is returned).
- `?search=foo&name=bar` returns items that have a name field of bar (not case insensitive) AND some text field with foo on it - `?search=foo&name=bar` returns items that have a name field of bar (not case insensitive) AND some text field with foo on it
- `?search=foo&search=bar&name=baz` returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name) - `?search=foo&search=bar&name=baz` returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name)
- similarly `?related__search=` looks on the static list of "guesses" for models related to the endpoint. The specific fields are not "searched" for `?related__search=`. - similarly `?related__search=` looks on the static list of "guesses" for models related to the endpoint. The specific fields are not "searched" for `?related__search=`.
- `?related__search=` not currently used in awx ui - `?related__search=` not currently used in awx ui
**A note on clicking a tag to putting it back into the search bar** **A note on clicking a tag to putting it back into the search bar**
This was brought up as a nice to have when we were discussing our initial implementation of search in the new application. Since there isn't a way we would be able to know if the user created the tag from the simple or advanced search interface, we wouldn't know where to put it back. This breaks our idea of using the query params as the exclusive source of truth, so we've decided against implementing it for now. This was brought up as a nice to have when we were discussing our initial implementation of search in the new application. Since there isn't a way we would be able to know if the user created the tag from the simple or advanced search interface, we wouldn't know where to put it back. This breaks our idea of using the query params as the exclusive source of truth, so we've decided against implementing it for now.
## Tasklist ## Tasklist
@@ -50,171 +51,197 @@ This was brought up as a nice to have when we were discussing our initial implem
- DONE remove button for search tags of duplicate keys are broken, fix that - DONE remove button for search tags of duplicate keys are broken, fix that
### TODO pre-holiday break ### TODO pre-holiday break
- Update COLUMNS to SORT_COLUMNS and SEARCH_COLUMNS - Update COLUMNS to SORT_COLUMNS and SEARCH_COLUMNS
- Update to using new PF Toolbar component (currently an experimental component) - Update to using new PF Toolbar component (currently an experimental component)
- Change the right-hand input based on the type of key selected on the left-hand side. In addition to text input, for our MVP we will support: - Change the right-hand input based on the type of key selected on the left-hand side. In addition to text input, for our MVP we will support:
- number input - number input
- select input (multiple-choice configured from UI or Options) - select input (multiple-choice configured from UI or Options)
- Update the following lists to have the following keys: - Update the following lists to have the following keys:
**Jobs list** (signed off earlier in chat) **Jobs list** (signed off earlier in chat)
- Name (which is also the name of the job template) - search is ?name=jt
- Job ID - search is ?id=13 - Name (which is also the name of the job template) - search is ?name=jt
- Label name - search is ?labels__name=foo - Job ID - search is ?id=13
- Job type (dropdown on right with the different types) ?type = job - Label name - search is ?labels\_\_name=foo
- Created by (username) - search is ?created_by__username=admin - Job type (dropdown on right with the different types) ?type = job
- Status - search (dropdown on right with different statuses) is ?status=successful - Created by (username) - search is ?created_by\_\_username=admin
- Status - search (dropdown on right with different statuses) is ?status=successful
Instances of jobs list include: Instances of jobs list include:
- Jobs list
- Host completed jobs list - Jobs list
- JT completed jobs list - Host completed jobs list
- JT completed jobs list
**Organization list** **Organization list**
- Name - search is ?name=org
- ? Team name (of a team in the org) - search is ?teams__name=ansible - Name - search is ?name=org
- ? Username (of a user in the org) - search is ?users__username=johndoe - ? Team name (of a team in the org) - search is ?teams\_\_name=ansible
- ? Username (of a user in the org) - search is ?users\_\_username=johndoe
Instances of orgs list include: Instances of orgs list include:
- Orgs list
- User orgs list - Orgs list
- Lookup on Project - User orgs list
- Lookup on Credential - Lookup on Project
- Lookup on Inventory - Lookup on Credential
- User access add wizard list - Lookup on Inventory
- Team access add wizard list - User access add wizard list
- Team access add wizard list
**Instance Groups list** **Instance Groups list**
- Name - search is ?name=ig
- ? is_container_group boolean choice (doesn't work right now in API but will soon) - search is ?is_container_group=true - Name - search is ?name=ig
- ? credential name - search is ?credentials__name=kubey - ? is_container_group boolean choice (doesn't work right now in API but will soon) - search is ?is_container_group=true
- ? credential name - search is ?credentials\_\_name=kubey
Instance of instance groups list include: Instance of instance groups list include:
- Lookup on Org
- Lookup on JT - Lookup on Org
- Lookup on Inventory - Lookup on JT
- Lookup on Inventory
**Users list** **Users list**
- Username - search is ?username=johndoe
- First Name - search is ?first_name=John - Username - search is ?username=johndoe
- Last Name - search is ?last_name=Doe - First Name - search is ?first_name=John
- ? (if not superfluous, would not include on Team users list) Team Name - search is ?teams__name=team_of_john_does (note API issue: User has no field named "teams") - Last Name - search is ?last_name=Doe
- ? (only for access or permissions list) Role Name - search is ?roles__name=Admin (note API issue: Role has no field "name") - ? (if not superfluous, would not include on Team users list) Team Name - search is ?teams\_\_name=team_of_john_does (note API issue: User has no field named "teams")
- ? (if not superfluous, would not include on Organization users list) ORg Name - search is ?organizations__name=org_of_jhn_does - ? (only for access or permissions list) Role Name - search is ?roles\_\_name=Admin (note API issue: Role has no field "name")
- ? (if not superfluous, would not include on Organization users list) ORg Name - search is ?organizations\_\_name=org_of_jhn_does
Instance of user lists include: Instance of user lists include:
- User list
- Org user list - User list
- Access list for Org, JT, Project, Credential, Inventory, User and Team - Org user list
- Access list for JT - Access list for Org, JT, Project, Credential, Inventory, User and Team
- Access list Project - Access list for JT
- Access list for Credential - Access list Project
- Access list for Inventory - Access list for Credential
- Access list for User - Access list for Inventory
- Access list for Team - Access list for User
- Team add users list - Access list for Team
- Users list in access wizard (to add new roles for a particular list) for Org - Team add users list
- Users list in access wizard (to add new roles for a particular list) for JT - Users list in access wizard (to add new roles for a particular list) for Org
- Users list in access wizard (to add new roles for a particular list) for Project - Users list in access wizard (to add new roles for a particular list) for JT
- Users list in access wizard (to add new roles for a particular list) for Credential - Users list in access wizard (to add new roles for a particular list) for Project
- Users list in access wizard (to add new roles for a particular list) for Inventory - Users list in access wizard (to add new roles for a particular list) for Credential
- Users list in access wizard (to add new roles for a particular list) for Inventory
**Teams list** **Teams list**
- Name - search is ?name=teamname
- ? Username (of a user in the team) - search is ?users__username=johndoe - Name - search is ?name=teamname
- ? (if not superfluous, would not include on Organizations teams list) Org Name - search is ?organizations__name=org_of_john_does - ? Username (of a user in the team) - search is ?users\_\_username=johndoe
- ? (if not superfluous, would not include on Organizations teams list) Org Name - search is ?organizations\_\_name=org_of_john_does
Instance of team lists include: Instance of team lists include:
- Team list
- Org team list - Team list
- User team list - Org team list
- Team list in access wizard (to add new roles for a particular list) for Org - User team list
- Team list in access wizard (to add new roles for a particular list) for JT - Team list in access wizard (to add new roles for a particular list) for Org
- Team list in access wizard (to add new roles for a particular list) for Project - Team list in access wizard (to add new roles for a particular list) for JT
- Team list in access wizard (to add new roles for a particular list) for Credential - Team list in access wizard (to add new roles for a particular list) for Project
- Team list in access wizard (to add new roles for a particular list) for Inventory - Team list in access wizard (to add new roles for a particular list) for Credential
- Team list in access wizard (to add new roles for a particular list) for Inventory
**Credentials list** **Credentials list**
- Name
- ? Type (dropdown on right with different types) - Name
- ? Created by (username) - ? Type (dropdown on right with different types)
- ? Modified by (username) - ? Created by (username)
- ? Modified by (username)
Instance of credential lists include: Instance of credential lists include:
- Credential list
- Lookup for JT - Credential list
- Lookup for Project - Lookup for JT
- User access add wizard list - Lookup for Project
- Team access add wizard list - User access add wizard list
- Team access add wizard list
**Projects list** **Projects list**
- Name - search is ?name=proj
- ? Type (dropdown on right with different types) - search is scm_type=git - Name - search is ?name=proj
- ? SCM URL - search is ?scm_url=github.com/ansible/test-playbooks - ? Type (dropdown on right with different types) - search is scm_type=git
- ? Created by (username) - search is ?created_by__username=admin - ? SCM URL - search is ?scm_url=github.com/ansible/test-playbooks
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of project lists include: Instance of project lists include:
- Project list
- Lookup for JT - Project list
- User access add wizard list - Lookup for JT
- Team access add wizard list - User access add wizard list
- Team access add wizard list
**Templates list** **Templates list**
- Name - search is ?name=cleanup
- ? Type (dropdown on right with different types) - search is ?type=playbook_run - Name - search is ?name=cleanup
- ? Playbook name - search is ?job_template__playbook=debug.yml - ? Type (dropdown on right with different types) - search is ?type=playbook_run
- ? Created by (username) - search is ?created_by__username=admin - ? Playbook name - search is ?job_template\_\_playbook=debug.yml
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of template lists include: Instance of template lists include:
- Template list
- Project Templates list - Template list
- Project Templates list
**Inventories list** **Inventories list**
- Name - search is ?name=inv
- ? Created by (username) - search is ?created_by__username=admin - Name - search is ?name=inv
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of inventory lists include: Instance of inventory lists include:
- Inventory list
- Lookup for JT - Inventory list
- User access add wizard list - Lookup for JT
- Team access add wizard list - User access add wizard list
- Team access add wizard list
**Groups list** **Groups list**
- Name - search is ?name=group_name
- ? Created by (username) - search is ?created_by__username=admin - Name - search is ?name=group_name
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of group lists include: Instance of group lists include:
- Group list
- Group list
**Hosts list** **Hosts list**
- Name - search is ?name=hostname
- ? Created by (username) - search is ?created_by__username=admin - Name - search is ?name=hostname
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of host lists include: Instance of host lists include:
- Host list
- Host list
**Notifications list** **Notifications list**
- Name - search is ?name=notification_template_name
- ? Type (dropdown on right with different types) - search is ?type=slack - Name - search is ?name=notification_template_name
- ? Created by (username) - search is ?created_by__username=admin - ? Type (dropdown on right with different types) - search is ?type=slack
- ? Modified by (username) - search is ?modified_by__username=admin - ? Created by (username) - search is ?created_by\_\_username=admin
- ? Modified by (username) - search is ?modified_by\_\_username=admin
Instance of notification lists include: Instance of notification lists include:
- Org notification list
- JT notification list - Org notification list
- Project notification list - JT notification list
- Project notification list
### TODO backlog ### TODO backlog
- Change the right-hand input based on the type of key selected on the left-hand side. We will eventually want to support:
- Change the right-hand input based on the type of key selected on the left-hand side. We will eventually want to support:
- lookup input (selection of particular resources, based on API list endpoints) - lookup input (selection of particular resources, based on API list endpoints)
- date picker input - date picker input
- Update the following lists to have the following keys: - Update the following lists to have the following keys:
- Update all __name and __username related field search-based keys to be type-ahead lookup based searches - Update all **name and **username related field search-based keys to be type-ahead lookup based searches
## Code Details ## Code Details
@@ -230,13 +257,13 @@ The component looks like this:
/> />
``` ```
**qsConfig** is used to get namespace so that multiple lists can be on the page. When tags are modified they append namespace to query params. The qsConfig is also used to get "type" of fields in order to correctly parse values as int or date as it is translating. **qsConfig** is used to get namespace so that multiple lists can be on the page. When tags are modified they append namespace to query params. The qsConfig is also used to get "type" of fields in order to correctly parse values as int or date as it is translating.
**columns** are passed as an array, as defined in the screen where the list is located. You pass a bool `isDefault` to indicate that should be the key that shows up in the left-hand dropdown as default in the UI. If you don't pass any columns, a default of `isDefault=true` will be added to a name column, which is nearly universally shared throughout the models of awx. **columns** are passed as an array, as defined in the screen where the list is located. You pass a bool `isDefault` to indicate that should be the key that shows up in the left-hand dropdown as default in the UI. If you don't pass any columns, a default of `isDefault=true` will be added to a name column, which is nearly universally shared throughout the models of awx.
There is a type attribute that can be `'string'`, `'number'` or `'choice'` (and in the future, `'date'` and `'lookup'`), which will change the type of input on the right-hand side of the search bar. For a key that has a set number of choices, you will pass a choices attribute, which is an array in the format choices: [{label: 'Foo', value: 'foo'}] There is a type attribute that can be `'string'`, `'number'` or `'choice'` (and in the future, `'date'` and `'lookup'`), which will change the type of input on the right-hand side of the search bar. For a key that has a set number of choices, you will pass a choices attribute, which is an array in the format choices: [{label: 'Foo', value: 'foo'}]
**onSearch** calls the `mergeParams` qs util in order to add new tags to the queryset. mergeParams is used so that we can support duplicate keys (see mergeParams vs. replaceParams for more info). **onSearch** calls the `mergeParams` qs util in order to add new tags to the queryset. mergeParams is used so that we can support duplicate keys (see mergeParams vs. replaceParams for more info).
### ListHeader component ### ListHeader component
@@ -253,15 +280,16 @@ All of these functions act on the react-router history using the `pushHistorySta
**a note on sort_columns and search_columns** **a note on sort_columns and search_columns**
We have split out column configuration into separate search and sort column array props--these are passed to the search and sort columns. Both accept an isDefault prop for one of the items in the array to be the default option selected when going to the page. Sort column items can pass an isNumeric boolean in order to chnage the iconography of the sort UI element. Search column items can pass type and if applicable choices, in order to configure the right-hand side of the search bar. We have split out column configuration into separate search and sort column array props--these are passed to the search and sort columns. Both accept an isDefault prop for one of the items in the array to be the default option selected when going to the page. Sort column items can pass an isNumeric boolean in order to chnage the iconography of the sort UI element. Search column items can pass type and if applicable choices, in order to configure the right-hand side of the search bar.
### FilterTags component ### FilterTags component
Similar to the way the list grabs data based on changes to the react-router params, the `FilterTags` component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array). Both key and value are displayed for the tag. Similar to the way the list grabs data based on changes to the react-router params, the `FilterTags` component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array). Both key and value are displayed for the tag.
### qs utility ### qs utility
The qs (queryset) utility is used to make the search speak the language of the REST API. The main functions of the utilities are to: The qs (queryset) utility is used to make the search speak the language of the REST API. The main functions of the utilities are to:
- add, replace and remove filters - add, replace and remove filters
- translate filters as url params (for linking and maintaining state), in-memory representation (as JS objects), and params that Django REST Framework understands. - translate filters as url params (for linking and maintaining state), in-memory representation (as JS objects), and params that Django REST Framework understands.
@@ -269,7 +297,7 @@ More info in the below sections:
#### Encoding for UI vs. API #### Encoding for UI vs. API
For the UI url params, we want to only encode those params that aren't defaults, as the default behavior was defined through configuration and we don't need these in the url as a source of truth. For the API, we need to pass these params so that they are taken into account when the response is built. For the UI url params, we want to only encode those params that aren't defaults, as the default behavior was defined through configuration and we don't need these in the url as a source of truth. For the API, we need to pass these params so that they are taken into account when the response is built.
#### mergeParams vs. replaceParams #### mergeParams vs. replaceParams
@@ -283,13 +311,13 @@ From a UX perspective, we wanted to be able to support searching on the same key
} }
``` ```
Concatenating terms in this way gives you the intersection of both terms (i.e. foo must be "bar" and "baz"). This is helpful for the most-common type of searching, substring (`__icontains`) searches. This will increase filtering, allowing the user to drill-down into the list as terms are added. Concatenating terms in this way gives you the intersection of both terms (i.e. foo must be "bar" and "baz"). This is helpful for the most-common type of searching, substring (`__icontains`) searches. This will increase filtering, allowing the user to drill-down into the list as terms are added.
**replaceParams** is used to support sorting, setting page_size, etc. These params only allow one choice, and we need to replace a particular key's value if one is passed. **replaceParams** is used to support sorting, setting page_size, etc. These params only allow one choice, and we need to replace a particular key's value if one is passed.
#### Working with REST API #### Working with REST API
The REST API is coupled with the qs util through the `paramsSerializer`, due to the fact we need axios to support the array for duplicate key values in the object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys automatically. The REST API is coupled with the qs util through the `paramsSerializer`, due to the fact we need axios to support the array for duplicate key values in the object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys automatically.
# Advanced Search - this section is a mess, update eventually # Advanced Search - this section is a mess, update eventually
@@ -305,85 +333,84 @@ Current thinking is Advanced Search will be post-3.6, or at least late 3.6 after
That being said, we want to plan it out so we make sure the infrastructure of how we set up adding/removing tags, what shows up in the url bar, etc. all doesn't have to be redone. That being said, we want to plan it out so we make sure the infrastructure of how we set up adding/removing tags, what shows up in the url bar, etc. all doesn't have to be redone.
Users will get to advanced search with a button to the right of search bar. When selected type-ahead key thing opens, left dropdown of search bar goes away, and x is given to get back to regular search (this is in the mockups) Users will get to advanced search with a button to the right of search bar. When selected type-ahead key thing opens, left dropdown of search bar goes away, and x is given to get back to regular search (this is in the mockups)
It is okay to only make this typing representation available initially (i.e. they start doing stuff with the type-ahead and the phases, no more typing in to make a query that way). It is okay to only make this typing representation available initially (i.e. they start doing stuff with the type-ahead and the phases, no more typing in to make a query that way).
when you click through or type in the search bar for the various phases of crafting the query ("not", "related resource project", "related resource key name", "value foo") which might be represented in the top bar as a series of tags that can be added and removed before submitting the tag. when you click through or type in the search bar for the various phases of crafting the query ("not", "related resource project", "related resource key name", "value foo") which might be represented in the top bar as a series of tags that can be added and removed before submitting the tag.
We will try to form options data from a static file. Because options data is static, we may be able to generate and store as a static file of some sort (that we can use for managing smart search). Alan had ideas around this. If we do this it will mean we don't have to make a ton of requests as we craft smart search filters. It sounds like the cli may start using something similar. We will try to form options data from a static file. Because options data is static, we may be able to generate and store as a static file of some sort (that we can use for managing smart search). Alan had ideas around this. If we do this it will mean we don't have to make a ton of requests as we craft smart search filters. It sounds like the cli may start using something similar.
## Smart search flow ## Smart search flow
Smart search will be able to craft the tag through various states. Note that the phases don't necessarily need to be completed in sequential order. Smart search will be able to craft the tag through various states. Note that the phases don't necessarily need to be completed in sequential order.
PHASE 1: prefix operators PHASE 1: prefix operators
**TODO: Double check there's no reason we need to include or__ and chain__ and can just do not__** **TODO: Double check there's no reason we need to include or** and chain** and can just do not\_\_**
- not__ - not\_\_
- or__ - or\_\_
- chain__ - chain\_\_
how these work: how these work:
To exclude results matching certain criteria, prefix the field parameter with not__: To exclude results matching certain criteria, prefix the field parameter with not\_\_:
?not__field=value ?not**field=value
By default, all query string filters are AND'ed together, so only the results matching all filters will be returned. To combine results matching any one of multiple criteria, prefix each query string parameter with or__: By default, all query string filters are AND'ed together, so only the results matching all filters will be returned. To combine results matching any one of multiple criteria, prefix each query string parameter with or**:
?or__field=value&or__field=othervalue ?or**field=value&or**field=othervalue
?or__not__field=value&or__field=othervalue ?or**not**field=value&or**field=othervalue
(Added in Ansible Tower 1.4.5) The default AND filtering applies all filters simultaneously to each related object being filtered across database relationships. The chain filter instead applies filters separately for each related object. To use, prefix the query string parameter with chain__: (Added in Ansible Controller 1.4.5) The default AND filtering applies all filters simultaneously to each related object being filtered across database relationships. The chain filter instead applies filters separately for each related object. To use, prefix the query string parameter with chain**:
?chain__related__field=value&chain__related__field2=othervalue ?chain**related**field=value&chain**related**field2=othervalue
?chain__not__related__field=value&chain__related__field2=othervalue ?chain**not**related**field=value&chain**related**field2=othervalue
If the first query above were written as ?related__field=value&related__field2=othervalue, it would return only the primary objects where the same related object satisfied both conditions. As written using the chain filter, it would return the intersection of primary objects matching each condition. If the first query above were written as ?related**field=value&related\_\_field2=othervalue, it would return only the primary objects where the same related object satisfied both conditions. As written using the chain filter, it would return the intersection of primary objects matching each condition.
PHASE 2: related fields, given by array, where __search is appended to them, i.e. PHASE 2: related fields, given by array, where \_\_search is appended to them, i.e.
``` ```
"related_search_fields": [ "related_search_fields": [
"credentials__search", "credentials__search",
"labels__search", "labels__search",
"created_by__search", "created_by__search",
"modified_by__search", "modified_by__search",
"notification_templates__search", "notification_templates__search",
"custom_inventory_scripts__search", "custom_inventory_scripts__search",
"notification_templates_error__search", "notification_templates_error__search",
"notification_templates_success__search", "notification_templates_success__search",
"notification_templates_any__search", "notification_templates_any__search",
"teams__search", "teams__search",
"projects__search", "projects__search",
"inventories__search", "inventories__search",
"applications__search", "applications__search",
"workflows__search", "workflows__search",
"instance_groups__search" "instance_groups__search"
], ],
``` ```
PHASE 3: keys, give by object key names for data.actions.GET PHASE 3: keys, give by object key names for data.actions.GET - type is given for each key which we could use to help craft the value
- type is given for each key which we could use to help craft the value
PHASE 4: after key postfix operators can be PHASE 4: after key postfix operators can be
**TODO: will need to figure out which ones we support** **TODO: will need to figure out which ones we support**
- exact: Exact match (default lookup if not specified). - exact: Exact match (default lookup if not specified).
- iexact: Case-insensitive version of exact. - iexact: Case-insensitive version of exact.
- contains: Field contains value. - contains: Field contains value.
- icontains: Case-insensitive version of contains. - icontains: Case-insensitive version of contains.
- startswith: Field starts with value. - startswith: Field starts with value.
- istartswith: Case-insensitive version of startswith. - istartswith: Case-insensitive version of startswith.
- endswith: Field ends with value. - endswith: Field ends with value.
- iendswith: Case-insensitive version of endswith. - iendswith: Case-insensitive version of endswith.
- regex: Field matches the given regular expression. - regex: Field matches the given regular expression.
- iregex: Case-insensitive version of regex. - iregex: Case-insensitive version of regex.
- gt: Greater than comparison. - gt: Greater than comparison.
- gte: Greater than or equal to comparison. - gte: Greater than or equal to comparison.
- lt: Less than comparison. - lt: Less than comparison.
- lte: Less than or equal to comparison. - lte: Less than or equal to comparison.
- isnull: Check whether the given field or related object is null; expects a boolean value. - isnull: Check whether the given field or related object is null; expects a boolean value.
- in: Check whether the given field's value is present in the list provided; expects a list of items. - in: Check whether the given field's value is present in the list provided; expects a list of items.
PHASE 5: The value. Based on options, we can give hints or validation based on type of value (like number fields don't accept "foo" or whatever) PHASE 5: The value. Based on options, we can give hints or validation based on type of value (like number fields don't accept "foo" or whatever)

View File

@@ -2,15 +2,39 @@ This document is meant to provide some guidance into the functionality of Job Ou
## Overview of the feature/screen. Summary of what it does/is ## Overview of the feature/screen. Summary of what it does/is
1. Elapsed time / unfollow button Joboutput is a feature that allows users to see how their job is doing as it is being run.
2. Page up and page down buttons This feature displays data sent to the UI via websockets that are connected to several
3. Unique qualities of the different job types. different endpoints in the API.
- Some dont allow search by event data and thus Event is not an option in the drop down The job output has 2 different states that result in different functionality. One state
- Some dont have expand, collapse is when, the job is actively running. There is limited functionality because of how the
job events are processed when they reach the UI. While the job is running, and
output is coming into the UI, the following features turn off:
4. Differences in the output from when a job is running and when a job is complete. 1. [Search](#Search)- The ability to search the output of a job.
5. Which features are enabled when its running and which arent. 2. [Expand/Collapse](#Expand/Collapse)- The ability to expand and collapse job events, tasks, plays, or even the
job itself. The only part of the job ouput that is not collapsable is the playbook summary (only jobs that
are executed from a Job Template have Expand/Collapse functionality).
The following features are enabled:
1. Follow/unfollow - `Follow` indicates you are streaming the output on the screen
as it comes into the UI. If you see some output that you want to examine closer while the job is running
scroll to it, and click `Unfollow`, and the output will stop streaming onto the screen. This feature is only
enabled when the job is running and is not complete. If the user scrolls up in the output the UI will unfollow.
2. Page up and page down buttons- Use these buttons to navigate quickly up and down the output.
![Running job](images/JobOutput-running.png)
After the job is complete, the Follow/Unfollow button disabled, and Expand/Collapse and Search become enabled.
![Finished job](images/JobOutput-complete.png)
Not all job types are created equal. Some jobs have a concept of parent-child events. Job events can be inside a Task,
a Task can be inside a Play, and a Play inside a Playbook. Leveraging this concept to enable Expand/Collapse for these
job types, allows you to collapse and hide the children of a particular line of output. This parent-child event
relationship only exists on jobs executed from a job template. All other types of jobs do not
have this event concept, and therefore, do not have Expand/Collapse functionality. By default all job
events are expanded.
## How output works generally. ## How output works generally.
@@ -26,11 +50,13 @@ This document is meant to provide some guidance into the functionality of Job Ou
## Non-standard cases ## Non-standard cases
1. When an event comes into the output that has a parent, but the parent hasnt arrived yet. 1. When an event comes into the output that has a parent, but the parent hasnt arrived yet.
2. When an event that has children arrives in output, but the children are not present yet 2. When an event with children arrives in output, but the children are not yet present.
## Expand collapse a single event- how it works and how it changes the state object ## Expand/Collapse
## Expand collapse all- how it works and how it changes the state object ### Expand collapse a single event - how it works and how it changes the state object
### Expand collapse all - how it works and how it changes the state object
## Search ## Search

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

785
awx/ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,29 +6,29 @@
"node": ">=16.13.1" "node": ">=16.13.1"
}, },
"dependencies": { "dependencies": {
"@lingui/react": "3.13.3", "@lingui/react": "3.14.0",
"@patternfly/patternfly": "4.196.7", "@patternfly/patternfly": "4.202.1",
"@patternfly/react-core": "^4.201.0", "@patternfly/react-core": "^4.221.3",
"@patternfly/react-icons": "4.49.19", "@patternfly/react-icons": "4.75.1",
"@patternfly/react-table": "4.83.1", "@patternfly/react-table": "4.93.1",
"ace-builds": "^1.6.0", "ace-builds": "^1.6.0",
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",
"axios": "0.22.0", "axios": "0.27.2",
"codemirror": "^5.65.4", "codemirror": "^6.0.1",
"d3": "7.4.4", "d3": "7.4.4",
"dagre": "^0.8.4", "dagre": "^0.8.4",
"dompurify": "2.3.8", "dompurify": "2.3.8",
"formik": "2.2.9", "formik": "2.2.9",
"has-ansi": "5.0.1", "has-ansi": "5.0.1",
"html-entities": "2.3.2", "html-entities": "2.3.2",
"js-yaml": "^3.13.1", "js-yaml": "4.1.0",
"luxon": "^2.4.0", "luxon": "^2.4.0",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react": "17.0.2", "react": "17.0.2",
"react-ace": "^10.1.0", "react-ace": "^10.1.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.3.3",
"react-virtualized": "^9.21.1", "react-virtualized": "^9.21.1",
"rrule": "2.7.0", "rrule": "2.7.0",
"styled-components": "5.3.5" "styled-components": "5.3.5"

View File

@@ -24,7 +24,7 @@
</script> </script>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io https://d3js.org; img-src 'self' *.pendo.io data:; worker-src 'self' blob: ;" content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:; worker-src 'self' blob: ;"
/> />
<link rel="shortcut icon" href="{% static 'media/favicon.ico' %}" /> <link rel="shortcut icon" href="{% static 'media/favicon.ico' %}" />
<% } else { %> <% } else { %>

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-collection/ v1.0.7 Copyright 2018 Mike Bostock
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t(n.d3=n.d3||{})}(this,function(n){"use strict";function t(){}function e(n,e){var r=new t;if(n instanceof t)n.each(function(n,t){r.set(t,n)});else if(Array.isArray(n)){var i,u=-1,o=n.length;if(null==e)for(;++u<o;)r.set(u,n[u]);else for(;++u<o;)r.set(e(i=n[u],u,n),i)}else if(n)for(var s in n)r.set(s,n[s]);return r}function r(){return{}}function i(n,t,e){n[t]=e}function u(){return e()}function o(n,t,e){n.set(t,e)}function s(){}t.prototype=e.prototype={constructor:t,has:function(n){return"$"+n in this},get:function(n){return this["$"+n]},set:function(n,t){return this["$"+n]=t,this},remove:function(n){var t="$"+n;return t in this&&delete this[t]},clear:function(){for(var n in this)"$"===n[0]&&delete this[n]},keys:function(){var n=[];for(var t in this)"$"===t[0]&&n.push(t.slice(1));return n},values:function(){var n=[];for(var t in this)"$"===t[0]&&n.push(this[t]);return n},entries:function(){var n=[];for(var t in this)"$"===t[0]&&n.push({key:t.slice(1),value:this[t]});return n},size:function(){var n=0;for(var t in this)"$"===t[0]&&++n;return n},empty:function(){for(var n in this)if("$"===n[0])return!1;return!0},each:function(n){for(var t in this)"$"===t[0]&&n(this[t],t.slice(1),this)}};var f=e.prototype;function c(n,t){var e=new s;if(n instanceof s)n.each(function(n){e.add(n)});else if(n){var r=-1,i=n.length;if(null==t)for(;++r<i;)e.add(n[r]);else for(;++r<i;)e.add(t(n[r],r,n))}return e}s.prototype=c.prototype={constructor:s,has:f.has,add:function(n){return this["$"+(n+="")]=n,this},remove:f.remove,clear:f.clear,values:f.keys,size:f.size,empty:f.empty,each:f.each},n.nest=function(){var n,t,s,f=[],c=[];function a(r,i,u,o){if(i>=f.length)return null!=n&&r.sort(n),null!=t?t(r):r;for(var s,c,h,l=-1,v=r.length,p=f[i++],y=e(),d=u();++l<v;)(h=y.get(s=p(c=r[l])+""))?h.push(c):y.set(s,[c]);return y.each(function(n,t){o(d,t,a(n,i,u,o))}),d}return s={object:function(n){return a(n,0,r,i)},map:function(n){return a(n,0,u,o)},entries:function(n){return function n(e,r){if(++r>f.length)return e;var i,u=c[r-1];return null!=t&&r>=f.length?i=e.entries():(i=[],e.each(function(t,e){i.push({key:e,values:n(t,r)})})),null!=u?i.sort(function(n,t){return u(n.key,t.key)}):i}(a(n,0,u,o),0)},key:function(n){return f.push(n),s},sortKeys:function(n){return c[f.length-1]=n,s},sortValues:function(t){return n=t,s},rollup:function(n){return t=n,s}}},n.set=c,n.map=e,n.keys=function(n){var t=[];for(var e in n)t.push(e);return t},n.values=function(n){var t=[];for(var e in n)t.push(n[e]);return t},n.entries=function(n){var t=[];for(var e in n)t.push({key:e,value:n[e]});return t},Object.defineProperty(n,"__esModule",{value:!0})});

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-dispatch/ v1.0.6 Copyright 2019 Mike Bostock
!function(n,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((n=n||self).d3=n.d3||{})}(this,function(n){"use strict";var e={value:function(){}};function t(){for(var n,e=0,t=arguments.length,o={};e<t;++e){if(!(n=arguments[e]+"")||n in o||/[\s.]/.test(n))throw new Error("illegal type: "+n);o[n]=[]}return new r(o)}function r(n){this._=n}function o(n,e){return n.trim().split(/^|\s+/).map(function(n){var t="",r=n.indexOf(".");if(r>=0&&(t=n.slice(r+1),n=n.slice(0,r)),n&&!e.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:t}})}function i(n,e){for(var t,r=0,o=n.length;r<o;++r)if((t=n[r]).name===e)return t.value}function f(n,t,r){for(var o=0,i=n.length;o<i;++o)if(n[o].name===t){n[o]=e,n=n.slice(0,o).concat(n.slice(o+1));break}return null!=r&&n.push({name:t,value:r}),n}r.prototype=t.prototype={constructor:r,on:function(n,e){var t,r=this._,l=o(n+"",r),u=-1,a=l.length;if(!(arguments.length<2)){if(null!=e&&"function"!=typeof e)throw new Error("invalid callback: "+e);for(;++u<a;)if(t=(n=l[u]).type)r[t]=f(r[t],n.name,e);else if(null==e)for(t in r)r[t]=f(r[t],n.name,null);return this}for(;++u<a;)if((t=(n=l[u]).type)&&(t=i(r[t],n.name)))return t},copy:function(){var n={},e=this._;for(var t in e)n[t]=e[t].slice();return new r(n)},call:function(n,e){if((t=arguments.length-2)>0)for(var t,r,o=new Array(t),i=0;i<t;++i)o[i]=arguments[i+2];if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(i=0,t=(r=this._[n]).length;i<t;++i)r[i].value.apply(e,o)},apply:function(n,e,t){if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(var r=this._[n],o=0,i=r.length;o<i;++o)r[o].value.apply(e,t)}},n.dispatch=t,Object.defineProperty(n,"__esModule",{value:!0})});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-timer/ v1.0.10 Copyright 2019 Mike Bostock
!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t=t||self).d3=t.d3||{})}(this,function(t){"use strict";var n,e,o=0,i=0,r=0,u=1e3,l=0,c=0,f=0,a="object"==typeof performance&&performance.now?performance:Date,s="object"==typeof window&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};function _(){return c||(s(m),c=a.now()+f)}function m(){c=0}function p(){this._call=this._time=this._next=null}function w(t,n,e){var o=new p;return o.restart(t,n,e),o}function d(){_(),++o;for(var t,e=n;e;)(t=c-e._time)>=0&&e._call.call(null,t),e=e._next;--o}function h(){c=(l=a.now())+f,o=i=0;try{d()}finally{o=0,function(){var t,o,i=n,r=1/0;for(;i;)i._call?(r>i._time&&(r=i._time),t=i,i=i._next):(o=i._next,i._next=null,i=t?t._next=o:n=o);e=t,v(r)}(),c=0}}function y(){var t=a.now(),n=t-l;n>u&&(f-=n,l=t)}function v(t){o||(i&&(i=clearTimeout(i)),t-c>24?(t<1/0&&(i=setTimeout(h,t-a.now()-f)),r&&(r=clearInterval(r))):(r||(l=a.now(),r=setInterval(y,u)),o=1,s(h)))}p.prototype=w.prototype={constructor:p,restart:function(t,o,i){if("function"!=typeof t)throw new TypeError("callback is not a function");i=(null==i?_():+i)+(null==o?0:+o),this._next||e===this||(e?e._next=this:n=this,e=this),this._call=t,this._time=i,v()},stop:function(){this._call&&(this._call=null,this._time=1/0,v())}},t.interval=function(t,n,e){var o=new p,i=n;return null==n?(o.restart(t,n,e),o):(n=+n,e=null==e?_():+e,o.restart(function r(u){u+=i,o.restart(r,i+=n,e),t(u)},n,e),o)},t.now=_,t.timeout=function(t,n,e){var o=new p;return n=null==n?0:+n,o.restart(function(e){o.stop(),t(e+n)},n,e),o},t.timer=w,t.timerFlush=d,Object.defineProperty(t,"__esModule",{value:!0})});

View File

@@ -26,13 +26,6 @@ function AdHocCommands({
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const verbosityOptions = [
{ value: '0', key: '0', label: t`0 (Normal)` },
{ value: '1', key: '1', label: t`1 (Verbose)` },
{ value: '2', key: '2', label: t`2 (More Verbose)` },
{ value: '3', key: '3', label: t`3 (Debug)` },
{ value: '4', key: '4', label: t`4 (Connection Debug)` },
];
useEffect(() => { useEffect(() => {
if (isKebabified) { if (isKebabified) {
onKebabModalChange(isWizardOpen); onKebabModalChange(isWizardOpen);
@@ -159,7 +152,6 @@ function AdHocCommands({
adHocItems={adHocItems} adHocItems={adHocItems}
organizationId={organizationId} organizationId={organizationId}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId} credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)} onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit} onLaunch={handleSubmit}

View File

@@ -3,13 +3,13 @@ import { t } from '@lingui/macro';
import { withFormik, useFormikContext } from 'formik'; import { withFormik, useFormikContext } from 'formik';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { VERBOSITY } from 'components/VerbositySelectField';
import Wizard from '../Wizard'; import Wizard from '../Wizard';
import useAdHocLaunchSteps from './useAdHocLaunchSteps'; import useAdHocLaunchSteps from './useAdHocLaunchSteps';
function AdHocCommandsWizard({ function AdHocCommandsWizard({
onLaunch, onLaunch,
moduleOptions, moduleOptions,
verbosityOptions,
onCloseWizard, onCloseWizard,
credentialTypeId, credentialTypeId,
organizationId, organizationId,
@@ -18,7 +18,6 @@ function AdHocCommandsWizard({
const { steps, validateStep, visitStep, visitAllSteps } = useAdHocLaunchSteps( const { steps, validateStep, visitStep, visitAllSteps } = useAdHocLaunchSteps(
moduleOptions, moduleOptions,
verbosityOptions,
organizationId, organizationId,
credentialTypeId credentialTypeId
); );
@@ -57,13 +56,13 @@ function AdHocCommandsWizard({
} }
const FormikApp = withFormik({ const FormikApp = withFormik({
mapPropsToValues({ adHocItems, verbosityOptions }) { mapPropsToValues({ adHocItems }) {
const adHocItemStrings = adHocItems.map((item) => item.name).join(', '); const adHocItemStrings = adHocItems.map((item) => item.name).join(', ');
return { return {
limit: adHocItemStrings || 'all', limit: adHocItemStrings || 'all',
credentials: [], credentials: [],
module_args: '', module_args: '',
verbosity: verbosityOptions[0].value, verbosity: VERBOSITY()[0],
forks: 0, forks: 0,
diff_mode: false, diff_mode: false,
become_enabled: '', become_enabled: '',
@@ -79,7 +78,6 @@ const FormikApp = withFormik({
FormikApp.propTypes = { FormikApp.propTypes = {
onLaunch: PropTypes.func.isRequired, onLaunch: PropTypes.func.isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired, moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onCloseWizard: PropTypes.func.isRequired, onCloseWizard: PropTypes.func.isRequired,
credentialTypeId: PropTypes.number.isRequired, credentialTypeId: PropTypes.number.isRequired,
}; };

View File

@@ -13,13 +13,6 @@ jest.mock('../../api/models/Credentials');
jest.mock('../../api/models/ExecutionEnvironments'); jest.mock('../../api/models/ExecutionEnvironments');
jest.mock('../../api/models/Root'); jest.mock('../../api/models/Root');
const verbosityOptions = [
{ value: '0', key: '0', label: '0 (Normal)' },
{ value: '1', key: '1', label: '1 (Verbose)' },
{ value: '2', key: '2', label: '2 (More Verbose)' },
{ value: '3', key: '3', label: '3 (Debug)' },
{ value: '4', key: '4', label: '4 (Connection Debug)' },
];
const moduleOptions = [ const moduleOptions = [
['command', 'command'], ['command', 'command'],
['shell', 'shell'], ['shell', 'shell'],
@@ -44,7 +37,6 @@ describe('<AdHocCommandsWizard/>', () => {
adHocItems={adHocItems} adHocItems={adHocItems}
onLaunch={onLaunch} onLaunch={onLaunch}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
onCloseWizard={() => {}} onCloseWizard={() => {}}
credentialTypeId={1} credentialTypeId={1}
organizationId={1} organizationId={1}

View File

@@ -7,6 +7,7 @@ import { Form, FormGroup, Switch, Checkbox } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { required } from 'util/validators'; import { required } from 'util/validators';
import useBrandName from 'hooks/useBrandName'; import useBrandName from 'hooks/useBrandName';
import { VerbositySelectField } from 'components/VerbositySelectField';
import AnsibleSelect from '../AnsibleSelect'; import AnsibleSelect from '../AnsibleSelect';
import FormField from '../FormField'; import FormField from '../FormField';
import { VariablesField } from '../CodeEditor'; import { VariablesField } from '../CodeEditor';
@@ -21,7 +22,7 @@ const TooltipWrapper = styled.div`
text-align: left; text-align: left;
`; `;
function AdHocDetailsStep({ verbosityOptions, moduleOptions }) { function AdHocDetailsStep({ moduleOptions }) {
const brandName = useBrandName(); const brandName = useBrandName();
const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({ const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
name: 'module_name', name: 'module_name',
@@ -32,7 +33,7 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
const [diffModeField, , diffModeHelpers] = useField('diff_mode'); const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [becomeEnabledField, , becomeEnabledHelpers] = const [becomeEnabledField, , becomeEnabledHelpers] =
useField('become_enabled'); useField('become_enabled');
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({ const [, verbosityMeta] = useField({
name: 'verbosity', name: 'verbosity',
validate: required(null), validate: required(null),
}); });
@@ -122,33 +123,16 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
) )
} }
/> />
<FormGroup
<VerbositySelectField
fieldId="verbosity" fieldId="verbosity"
aria-label={t`select verbosity`} tooltip={t`These are the verbosity levels for standard out of the command run that are supported.`}
label={t`Verbosity`} isValid={
isRequired
validated={
!verbosityMeta.touched || !verbosityMeta.error !verbosityMeta.touched || !verbosityMeta.error
? 'default' ? 'default'
: 'error' : 'error'
} }
helperTextInvalid={verbosityMeta.error} />
labelIcon={
<Popover
content={t`These are the verbosity levels for standard out of the command run that are supported.`}
/>
}
>
<AnsibleSelect
{...verbosityField}
isValid={!verbosityMeta.touched || !verbosityMeta.error}
id="verbosity"
data={verbosityOptions || []}
onChange={(event, value) => {
verbosityHelpers.setValue(parseInt(value, 10));
}}
/>
</FormGroup>
<FormField <FormField
id="limit" id="limit"
name="limit" name="limit"
@@ -296,7 +280,6 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
AdHocDetailsStep.propTypes = { AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired, moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
}; };
export default AdHocDetailsStep; export default AdHocDetailsStep;

View File

@@ -3,6 +3,7 @@ import { t } from '@lingui/macro';
import { Tooltip } from '@patternfly/react-core'; import { Tooltip } from '@patternfly/react-core';
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons'; import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
import { VERBOSITY } from '../VerbositySelectField';
import { toTitleCase } from '../../util/strings'; import { toTitleCase } from '../../util/strings';
import { VariablesDetail } from '../CodeEditor'; import { VariablesDetail } from '../CodeEditor';
import { jsonToYaml } from '../../util/yaml'; import { jsonToYaml } from '../../util/yaml';
@@ -21,7 +22,7 @@ const ErrorMessageWrapper = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
`; `;
function AdHocPreviewStep({ hasErrors, values }) { function AdHocPreviewStep({ hasErrors, values }) {
const { credential, execution_environment, extra_vars } = values; const { credential, execution_environment, extra_vars, verbosity } = values;
const items = Object.entries(values); const items = Object.entries(values);
return ( return (
@@ -44,6 +45,7 @@ function AdHocPreviewStep({ hasErrors, values }) {
key !== 'extra_vars' && key !== 'extra_vars' &&
key !== 'execution_environment' && key !== 'execution_environment' &&
key !== 'credentials' && key !== 'credentials' &&
key !== 'verbosity' &&
!key.startsWith('credential_passwords') && ( !key.startsWith('credential_passwords') && (
<Detail key={key} label={toTitleCase(key)} value={value} /> <Detail key={key} label={toTitleCase(key)} value={value} />
) )
@@ -57,6 +59,9 @@ function AdHocPreviewStep({ hasErrors, values }) {
value={execution_environment[0]?.name} value={execution_environment[0]?.name}
/> />
)} )}
{verbosity && (
<Detail label={t`Verbosity`} value={VERBOSITY()[values.verbosity]} />
)}
{extra_vars && ( {extra_vars && (
<VariablesDetail <VariablesDetail
value={jsonToYaml(JSON.stringify(extra_vars))} value={jsonToYaml(JSON.stringify(extra_vars))}

View File

@@ -5,11 +5,7 @@ import StepName from '../LaunchPrompt/steps/StepName';
import AdHocDetailsStep from './AdHocDetailsStep'; import AdHocDetailsStep from './AdHocDetailsStep';
const STEP_ID = 'details'; const STEP_ID = 'details';
export default function useAdHocDetailsStep( export default function useAdHocDetailsStep(visited, moduleOptions) {
visited,
moduleOptions,
verbosityOptions
) {
const { values, touched, setFieldError } = useFormikContext(); const { values, touched, setFieldError } = useFormikContext();
const hasError = () => { const hasError = () => {
@@ -39,12 +35,7 @@ export default function useAdHocDetailsStep(
{t`Details`} {t`Details`}
</StepName> </StepName>
), ),
component: ( component: <AdHocDetailsStep moduleOptions={moduleOptions} />,
<AdHocDetailsStep
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
/>
),
enableNext: true, enableNext: true,
nextButtonText: t`Next`, nextButtonText: t`Next`,
}, },

View File

@@ -24,7 +24,6 @@ function showCredentialPasswordsStep(credential) {
export default function useAdHocLaunchSteps( export default function useAdHocLaunchSteps(
moduleOptions, moduleOptions,
verbosityOptions,
organizationId, organizationId,
credentialTypeId credentialTypeId
) { ) {
@@ -32,7 +31,7 @@ export default function useAdHocLaunchSteps(
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const steps = [ const steps = [
useAdHocDetailsStep(visited, moduleOptions, verbosityOptions), useAdHocDetailsStep(visited, moduleOptions),
useAdHocExecutionEnvironmentStep(organizationId), useAdHocExecutionEnvironmentStep(organizationId),
useAdHocCredentialStep(visited, credentialTypeId), useAdHocCredentialStep(visited, credentialTypeId),
useCredentialPasswordsStep( useCredentialPasswordsStep(

View File

@@ -46,7 +46,9 @@ function AnsibleSelect({
value={option.value} value={option.value}
label={option.label} label={option.label}
isDisabled={option.isDisabled} isDisabled={option.isDisabled}
/> >
{option.label}
</FormSelectOption>
))} ))}
</FormSelect> </FormSelect>
); );

View File

@@ -41,6 +41,7 @@ const Detail = ({
className, className,
dataCy, dataCy,
alwaysVisible, alwaysVisible,
isEmpty,
helpText, helpText,
isEncrypted, isEncrypted,
isNotConfigured, isNotConfigured,
@@ -49,6 +50,10 @@ const Detail = ({
return null; return null;
} }
if (isEmpty && !alwaysVisible) {
return null;
}
const labelCy = dataCy ? `${dataCy}-label` : null; const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null; const valueCy = dataCy ? `${dataCy}-value` : null;

View File

@@ -163,16 +163,16 @@ function JobListItem({
<Td colSpan={showTypeColumn ? 6 : 5}> <Td colSpan={showTypeColumn ? 6 : 5}>
<ExpandableRowContent> <ExpandableRowContent>
<DetailList> <DetailList>
{job.type === 'inventory_update' && {job.type === 'inventory_update' && (
inventorySourceLabels.length > 0 && ( <Detail
<Detail dataCy="job-inventory-source-type"
dataCy="job-inventory-source-type" label={t`Source`}
label={t`Source`} value={inventorySourceLabels?.map(([string, label]) =>
value={inventorySourceLabels.map(([string, label]) => string === job.source ? label : null
string === job.source ? label : null )}
)} isEmpty={inventorySourceLabels?.length === 0}
/> />
)} )}
<LaunchedByDetail job={job} /> <LaunchedByDetail job={job} />
{job.launch_type === 'scheduled' && {job.launch_type === 'scheduled' &&
(schedule ? ( (schedule ? (
@@ -254,7 +254,7 @@ function JobListItem({
dataCy={`execution-environment-detail-${job.id}`} dataCy={`execution-environment-detail-${job.id}`}
/> />
)} )}
{credentials && credentials.length > 0 && ( {credentials && (
<Detail <Detail
fullWidth fullWidth
label={t`Credentials`} label={t`Credentials`}
@@ -275,6 +275,7 @@ function JobListItem({
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={credentials.length === 0}
/> />
)} )}
{labels && labels.count > 0 && ( {labels && labels.count > 0 && (

View File

@@ -203,6 +203,49 @@ describe('<JobListItem />', () => {
wrapper.find('Detail[label="Execution Environment"] dd').text() wrapper.find('Detail[label="Execution Environment"] dd').text()
).toBe('Missing resource'); ).toBe('Missing resource');
}); });
test('should not load Source', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
inventorySourceLabels={[]}
job={{
...mockJob,
type: 'inventory_update',
summary_fields: {
user_capabilities: {},
},
}}
/>
</tbody>
</table>
);
const source_detail = wrapper.find(`Detail[label="Source"]`).at(0);
expect(source_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
job={{
...mockJob,
type: 'inventory_update',
summary_fields: {
credentials: [],
},
}}
/>
</tbody>
</table>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
}); });
describe('<JobListItem with failed job />', () => { describe('<JobListItem with failed job />', () => {

View File

@@ -113,48 +113,6 @@ describe('LaunchButton', () => {
expect(history.location.pathname).toEqual('/jobs/9000/output'); expect(history.location.pathname).toEqual('/jobs/9000/output');
}); });
test('should disable button to prevent duplicate clicks', async () => {
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
WorkflowJobTemplatesAPI.launch.mockImplementation(async () => {
// return asynchronously so isLaunching isn't set back to false in the
// same tick
await new Promise((resolve) => setTimeout(resolve, 10));
return {
data: {
id: 9000,
},
};
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job_template',
}}
>
{({ handleLaunch, isLaunching }) => (
<button type="submit" onClick={handleLaunch} disabled={isLaunching} />
)}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
await act(() => button.prop('onClick')());
wrapper.update();
expect(wrapper.find('button').prop('disabled')).toEqual(false);
});
test('should relaunch job correctly', async () => { test('should relaunch job correctly', async () => {
JobsAPI.readRelaunch.mockResolvedValue({ JobsAPI.readRelaunch.mockResolvedValue({
data: { data: {

View File

@@ -9,6 +9,7 @@ import { TagMultiSelect } from '../../MultiSelect';
import AnsibleSelect from '../../AnsibleSelect'; import AnsibleSelect from '../../AnsibleSelect';
import { VariablesField } from '../../CodeEditor'; import { VariablesField } from '../../CodeEditor';
import Popover from '../../Popover'; import Popover from '../../Popover';
import { VerbositySelectField } from '../../VerbositySelectField';
const FieldHeader = styled.div` const FieldHeader = styled.div`
display: flex; display: flex;
@@ -57,7 +58,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
aria-label={t`Job Tags`} aria-label={t`Job Tags`}
tooltip={t`Tags are useful when you have a large tooltip={t`Tags are useful when you have a large
playbook, and you want to run a specific part of a play or task. playbook, and you want to run a specific part of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower Use commas to separate multiple tags. Refer to Ansible Controller
documentation for details on the usage of tags.`} documentation for details on the usage of tags.`}
/> />
)} )}
@@ -69,7 +70,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
aria-label={t`Skip Tags`} aria-label={t`Skip Tags`}
tooltip={t`Skip tags are useful when you have a large tooltip={t`Skip tags are useful when you have a large
playbook, and you want to skip specific parts of a play or task. playbook, and you want to skip specific parts of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower Use commas to separate multiple tags. Refer to Ansible Controller
documentation for details on the usage of tags.`} documentation for details on the usage of tags.`}
/> />
)} )}
@@ -129,36 +130,16 @@ function JobTypeField() {
} }
function VerbosityField() { function VerbosityField() {
const [field, meta, helpers] = useField('verbosity'); const [, meta] = useField('verbosity');
const options = [
{ value: '0', key: '0', label: t`0 (Normal)` },
{ value: '1', key: '1', label: t`1 (Verbose)` },
{ value: '2', key: '2', label: t`2 (More Verbose)` },
{ value: '3', key: '3', label: t`3 (Debug)` },
{ value: '4', key: '4', label: t`4 (Connection Debug)` },
];
const isValid = !(meta.touched && meta.error); const isValid = !(meta.touched && meta.error);
return ( return (
<FormGroup <VerbositySelectField
fieldId="prompt-verbosity" fieldId="prompt-verbosity"
validated={isValid ? 'default' : 'error'} tooltip={t`Control the level of output ansible
label={t`Verbosity`}
labelIcon={
<Popover
content={t`Control the level of output ansible
will produce as the playbook executes.`} will produce as the playbook executes.`}
/> isValid={isValid ? 'default' : 'error'}
} />
>
<AnsibleSelect
id="prompt-verbosity"
data={options}
{...field}
onChange={(event, value) => helpers.setValue(value)}
/>
</FormGroup>
); );
} }

View File

@@ -85,7 +85,7 @@ describe('OtherPromptsStep', () => {
expect(wrapper.find('VerbosityField')).toHaveLength(1); expect(wrapper.find('VerbosityField')).toHaveLength(1);
expect( expect(
wrapper.find('VerbosityField AnsibleSelect').prop('data') wrapper.find('VerbosityField AnsibleSelect').prop('data')
).toHaveLength(5); ).toHaveLength(6);
}); });
test('should render show changes toggle', async () => { test('should render show changes toggle', async () => {

View File

@@ -40,13 +40,11 @@ function PreviewStep({ resource, launchConfig, surveyConfig, formErrors }) {
.filter((q) => q.type === 'password') .filter((q) => q.type === 'password')
.map((q) => q.variable); .map((q) => q.variable);
const masked = maskPasswords(surveyValues, passwordFields); const masked = maskPasswords(surveyValues, passwordFields);
overrides.extra_vars = yaml.safeDump( overrides.extra_vars = yaml.dump(
mergeExtraVars(initialExtraVars, masked) mergeExtraVars(initialExtraVars, masked)
); );
} else { } else {
overrides.extra_vars = yaml.safeDump( overrides.extra_vars = yaml.dump(mergeExtraVars(initialExtraVars, {}));
mergeExtraVars(initialExtraVars, {})
);
} }
} catch (e) { } catch (e) {
// //

View File

@@ -349,7 +349,7 @@ function HostFilterLookup({
content={t`Populate the hosts for this inventory by using a search content={t`Populate the hosts for this inventory by using a search
filter. Example: ansible_facts__ansible_distribution:"RedHat". filter. Example: ansible_facts__ansible_distribution:"RedHat".
Refer to the documentation for further syntax and Refer to the documentation for further syntax and
examples. Refer to the Ansible Tower documentation for further syntax and examples. Refer to the Ansible Controller documentation for further syntax and
examples.`} examples.`}
/> />
} }

View File

@@ -14,6 +14,7 @@ import PromptProjectDetail from './PromptProjectDetail';
import PromptInventorySourceDetail from './PromptInventorySourceDetail'; import PromptInventorySourceDetail from './PromptInventorySourceDetail';
import PromptJobTemplateDetail from './PromptJobTemplateDetail'; import PromptJobTemplateDetail from './PromptJobTemplateDetail';
import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail'; import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail';
import { VERBOSITY } from '../VerbositySelectField';
const PromptTitle = styled(Title)` const PromptTitle = styled(Title)`
margin-top: var(--pf-global--spacer--xl); margin-top: var(--pf-global--spacer--xl);
@@ -93,14 +94,6 @@ function PromptDetail({
overrides = {}, overrides = {},
workflowNode = false, workflowNode = false,
}) { }) {
const VERBOSITY = {
0: t`0 (Normal)`,
1: t`1 (Verbose)`,
2: t`2 (More Verbose)`,
3: t`3 (Debug)`,
4: t`4 (Connection Debug)`,
};
const details = omitOverrides(resource, overrides, launchConfig.defaults); const details = omitOverrides(resource, overrides, launchConfig.defaults);
details.type = overrides?.nodeType || details.type; details.type = overrides?.nodeType || details.type;
const hasOverrides = Object.keys(overrides).length > 0; const hasOverrides = Object.keys(overrides).length > 0;
@@ -226,7 +219,7 @@ function PromptDetail({
launchConfig.ask_verbosity_on_launch ? ( launchConfig.ask_verbosity_on_launch ? (
<Detail <Detail
label={t`Verbosity`} label={t`Verbosity`}
value={VERBOSITY[overrides.verbosity]} value={VERBOSITY()[overrides.verbosity]}
/> />
) : null} ) : null}
{launchConfig.ask_tags_on_launch && ( {launchConfig.ask_tags_on_launch && (

View File

@@ -13,6 +13,7 @@ import { VariablesDetail } from '../CodeEditor';
import CredentialChip from '../CredentialChip'; import CredentialChip from '../CredentialChip';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail';
import { VERBOSITY } from '../VerbositySelectField';
function PromptInventorySourceDetail({ resource }) { function PromptInventorySourceDetail({ resource }) {
const { const {
@@ -28,25 +29,11 @@ function PromptInventorySourceDetail({ resource }) {
summary_fields, summary_fields,
update_cache_timeout, update_cache_timeout,
update_on_launch, update_on_launch,
update_on_project_update,
verbosity, verbosity,
} = resource; } = resource;
const VERBOSITY = {
0: t`0 (Normal)`,
1: t`1 (Verbose)`,
2: t`2 (More Verbose)`,
3: t`3 (Debug)`,
4: t`4 (Connection Debug)`,
};
let optionsList = ''; let optionsList = '';
if ( if (overwrite || overwrite_vars || update_on_launch) {
overwrite ||
overwrite_vars ||
update_on_launch ||
update_on_project_update
) {
optionsList = ( optionsList = (
<TextList component={TextListVariants.ul}> <TextList component={TextListVariants.ul}>
{overwrite && ( {overwrite && (
@@ -64,11 +51,6 @@ function PromptInventorySourceDetail({ resource }) {
{t`Update on launch`} {t`Update on launch`}
</TextListItem> </TextListItem>
)} )}
{update_on_project_update && (
<TextListItem component={TextListItemVariants.li}>
{t`Update on project update`}
</TextListItem>
)}
</TextList> </TextList>
); );
} }
@@ -115,20 +97,19 @@ function PromptInventorySourceDetail({ resource }) {
executionEnvironment={summary_fields?.execution_environment} executionEnvironment={summary_fields?.execution_environment}
/> />
<Detail label={t`Inventory File`} value={source_path} /> <Detail label={t`Inventory File`} value={source_path} />
<Detail label={t`Verbosity`} value={VERBOSITY[verbosity]} /> <Detail label={t`Verbosity`} value={VERBOSITY()[verbosity]} />
<Detail <Detail
label={t`Cache Timeout`} label={t`Cache Timeout`}
value={`${update_cache_timeout} ${t`Seconds`}`} value={`${update_cache_timeout} ${t`Seconds`}`}
/> />
{summary_fields?.credentials?.length > 0 && ( <Detail
<Detail fullWidth
fullWidth label={t`Credential`}
label={t`Credential`} value={summary_fields?.credentials?.map((cred) => (
value={summary_fields.credentials.map((cred) => ( <CredentialChip key={cred?.id} credential={cred} isReadOnly />
<CredentialChip key={cred?.id} credential={cred} isReadOnly /> ))}
))} isEmpty={summary_fields?.credentials?.length === 0}
/> />
)}
{source_regions && ( {source_regions && (
<Detail <Detail
fullWidth fullWidth

View File

@@ -67,7 +67,6 @@ describe('PromptInventorySourceDetail', () => {
</li>, </li>,
<li>Overwrite local variables from remote inventory source</li>, <li>Overwrite local variables from remote inventory source</li>,
<li>Update on launch</li>, <li>Update on launch</li>,
<li>Update on project update</li>,
]) ])
).toEqual(true); ).toEqual(true);
}); });
@@ -79,4 +78,19 @@ describe('PromptInventorySourceDetail', () => {
); );
assertDetail(wrapper, 'Organization', 'Deleted'); assertDetail(wrapper, 'Organization', 'Deleted');
}); });
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<PromptInventorySourceDetail
resource={{
...mockInvSource,
summary_fields: {
credentials: [],
},
}}
/>
);
const credentials_detail = wrapper.find(`Detail[label="Credential"]`).at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
}); });

View File

@@ -15,6 +15,7 @@ import Sparkline from '../Sparkline';
import { Detail, DeletedDetail } from '../DetailList'; import { Detail, DeletedDetail } from '../DetailList';
import { VariablesDetail } from '../CodeEditor'; import { VariablesDetail } from '../CodeEditor';
import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail';
import { VERBOSITY } from '../VerbositySelectField';
function PromptJobTemplateDetail({ resource }) { function PromptJobTemplateDetail({ resource }) {
const { const {
@@ -25,7 +26,7 @@ function PromptJobTemplateDetail({ resource }) {
extra_vars, extra_vars,
forks, forks,
host_config_key, host_config_key,
instance_groups, instance_groups = [],
job_slice_count, job_slice_count,
job_tags, job_tags,
job_type, job_type,
@@ -42,14 +43,6 @@ function PromptJobTemplateDetail({ resource }) {
custom_virtualenv, custom_virtualenv,
} = resource; } = resource;
const VERBOSITY = {
0: t`0 (Normal)`,
1: t`1 (Verbose)`,
2: t`2 (More Verbose)`,
3: t`3 (Debug)`,
4: t`4 (Connection Debug)`,
};
let optionsList = ''; let optionsList = '';
if ( if (
become_enabled || become_enabled ||
@@ -101,9 +94,11 @@ function PromptJobTemplateDetail({ resource }) {
return ( return (
<> <>
{summary_fields.recent_jobs?.length > 0 && ( <Detail
<Detail value={<Sparkline jobs={recentJobs} />} label={t`Activity`} /> label={t`Activity`}
)} value={<Sparkline jobs={recentJobs} />}
isEmpty={summary_fields.recent_jobs?.length === 0}
/>
<Detail label={t`Job Type`} value={toTitleCase(job_type)} /> <Detail label={t`Job Type`} value={toTitleCase(job_type)} />
{summary_fields?.organization ? ( {summary_fields?.organization ? (
<Detail <Detail
@@ -153,7 +148,7 @@ function PromptJobTemplateDetail({ resource }) {
<Detail label={t`Playbook`} value={playbook} /> <Detail label={t`Playbook`} value={playbook} />
<Detail label={t`Forks`} value={forks || '0'} /> <Detail label={t`Forks`} value={forks || '0'} />
<Detail label={t`Limit`} value={limit} /> <Detail label={t`Limit`} value={limit} />
<Detail label={t`Verbosity`} value={VERBOSITY[verbosity]} /> <Detail label={t`Verbosity`} value={VERBOSITY()[verbosity]} />
{typeof diff_mode === 'boolean' && ( {typeof diff_mode === 'boolean' && (
<Detail label={t`Show Changes`} value={diff_mode ? t`On` : t`Off`} /> <Detail label={t`Show Changes`} value={diff_mode ? t`On` : t`Off`} />
)} )}
@@ -187,7 +182,7 @@ function PromptJobTemplateDetail({ resource }) {
/> />
)} )}
{optionsList && <Detail label={t`Enabled Options`} value={optionsList} />} {optionsList && <Detail label={t`Enabled Options`} value={optionsList} />}
{summary_fields?.credentials?.length > 0 && ( {summary_fields?.credentials && (
<Detail <Detail
fullWidth fullWidth
label={t`Credentials`} label={t`Credentials`}
@@ -202,9 +197,10 @@ function PromptJobTemplateDetail({ resource }) {
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={summary_fields?.credentials?.length === 0}
/> />
)} )}
{summary_fields?.labels?.results?.length > 0 && ( {summary_fields?.labels?.results && (
<Detail <Detail
fullWidth fullWidth
label={t`Labels`} label={t`Labels`}
@@ -221,28 +217,28 @@ function PromptJobTemplateDetail({ resource }) {
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={summary_fields?.labels?.results?.length === 0}
/> />
)} )}
{instance_groups?.length > 0 && ( <Detail
<Detail fullWidth
fullWidth label={t`Instance Groups`}
label={t`Instance Groups`} value={
value={ <ChipGroup
<ChipGroup numChips={5}
numChips={5} totalChips={instance_groups?.length}
totalChips={instance_groups.length} ouiaId="prompt-jt-instance-group-chips"
ouiaId="prompt-jt-instance-group-chips" >
> {instance_groups?.map((ig) => (
{instance_groups.map((ig) => ( <Chip key={ig.id} isReadOnly>
<Chip key={ig.id} isReadOnly> {ig.name}
{ig.name} </Chip>
</Chip> ))}
))} </ChipGroup>
</ChipGroup> }
} isEmpty={instance_groups?.length === 0}
/> />
)} {job_tags && (
{job_tags?.length > 0 && (
<Detail <Detail
fullWidth fullWidth
label={t`Job Tags`} label={t`Job Tags`}
@@ -259,9 +255,10 @@ function PromptJobTemplateDetail({ resource }) {
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={job_tags?.length === 0}
/> />
)} )}
{skip_tags?.length > 0 && ( {skip_tags && (
<Detail <Detail
fullWidth fullWidth
label={t`Skip Tags`} label={t`Skip Tags`}
@@ -278,6 +275,7 @@ function PromptJobTemplateDetail({ resource }) {
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={skip_tags?.length === 0}
/> />
)} )}
{extra_vars && ( {extra_vars && (

View File

@@ -125,4 +125,92 @@ describe('PromptJobTemplateDetail', () => {
assertDetail(wrapper, 'Organization', 'Deleted'); assertDetail(wrapper, 'Organization', 'Deleted');
assertDetail(wrapper, 'Project', 'Deleted'); assertDetail(wrapper, 'Project', 'Deleted');
}); });
test('should not load Activity', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
recent_jobs: [],
},
}}
/>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
credentials: [],
},
}}
/>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
labels: {
results: [],
},
},
}}
/>
);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Instance Groups', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
instance_groups: [],
}}
/>
);
const instance_groups_detail = wrapper
.find(`Detail[label="Instance Groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Job Tags', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
job_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
});
test('should not load Skip Tags', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
skip_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
});
}); });

View File

@@ -57,9 +57,11 @@ function PromptWFJobTemplateDetail({ resource }) {
return ( return (
<> <>
{summary_fields?.recent_jobs?.length > 0 && ( <Detail
<Detail value={<Sparkline jobs={recentJobs} />} label={t`Activity`} /> label={t`Activity`}
)} value={<Sparkline jobs={recentJobs} />}
isEmpty={summary_fields?.recent_jobs?.length === 0}
/>
{summary_fields?.organization && ( {summary_fields?.organization && (
<Detail <Detail
label={t`Organization`} label={t`Organization`}
@@ -108,7 +110,7 @@ function PromptWFJobTemplateDetail({ resource }) {
} }
/> />
)} )}
{summary_fields?.labels?.results?.length > 0 && ( {summary_fields?.labels?.results && (
<Detail <Detail
fullWidth fullWidth
label={t`Labels`} label={t`Labels`}
@@ -125,6 +127,7 @@ function PromptWFJobTemplateDetail({ resource }) {
))} ))}
</ChipGroup> </ChipGroup>
} }
isEmpty={summary_fields?.labels?.results?.length === 0}
/> />
)} )}
{extra_vars && ( {extra_vars && (

View File

@@ -62,4 +62,36 @@ describe('PromptWFJobTemplateDetail', () => {
'---\nmock: data' '---\nmock: data'
); );
}); });
test('should not load Activity', () => {
wrapper = mountWithContexts(
<PromptWFJobTemplateDetail
resource={{
...mockWF,
summary_fields: {
recent_jobs: [],
},
}}
/>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', () => {
wrapper = mountWithContexts(
<PromptWFJobTemplateDetail
resource={{
...mockWF,
summary_fields: {
labels: {
results: [],
},
},
}}
/>
);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
}); });

View File

@@ -112,7 +112,6 @@
"update_on_launch":true, "update_on_launch":true,
"update_cache_timeout":2, "update_cache_timeout":2,
"source_project":8, "source_project":8,
"update_on_project_update":true,
"last_update_failed": true, "last_update_failed": true,
"last_updated":null "last_updated":null
} }

View File

@@ -68,34 +68,32 @@ function ResourceAccessListItem({ accessRecord, onRoleDelete }) {
<Td dataLabel={t`Last name`}>{accessRecord.last_name}</Td> <Td dataLabel={t`Last name`}>{accessRecord.last_name}</Td>
<Td dataLabel={t`Roles`}> <Td dataLabel={t`Roles`}>
<DetailList stacked> <DetailList stacked>
{userRoles.length > 0 && ( <Detail
<Detail label={t`User Roles`}
label={t`User Roles`} value={
value={ <ChipGroup
<ChipGroup numChips={5}
numChips={5} totalChips={userRoles.length}
totalChips={userRoles.length} ouiaId="user-role-chips"
ouiaId="user-role-chips" >
> {userRoles.map(renderChip)}
{userRoles.map(renderChip)} </ChipGroup>
</ChipGroup> }
} isEmpty={userRoles.length === 0}
/> />
)} <Detail
{teamRoles.length > 0 && ( label={t`Team Roles`}
<Detail value={
label={t`Team Roles`} <ChipGroup
value={ numChips={5}
<ChipGroup totalChips={teamRoles.length}
numChips={5} ouiaId="team-role-chips"
totalChips={teamRoles.length} >
ouiaId="team-role-chips" {teamRoles.map(renderChip)}
> </ChipGroup>
{teamRoles.map(renderChip)} }
</ChipGroup> isEmpty={teamRoles.length === 0}
} />
/>
)}
</DetailList> </DetailList>
</Td> </Td>
</Tr> </Tr>

View File

@@ -53,5 +53,41 @@ describe('<ResourceAccessListItem />', () => {
expect(wrapper.find('Td[dataLabel="First name"]').text()).toBe('jane'); expect(wrapper.find('Td[dataLabel="First name"]').text()).toBe('jane');
expect(wrapper.find('Td[dataLabel="Last name"]').text()).toBe('brown'); expect(wrapper.find('Td[dataLabel="Last name"]').text()).toBe('brown');
const user_roles_detail = wrapper.find(`Detail[label="User Roles"]`).at(0);
expect(user_roles_detail.prop('isEmpty')).toEqual(true);
});
test('should not load team roles', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ResourceAccessListItem
accessRecord={{
...accessRecord,
summary_fields: {
direct_access: [
{
role: {
id: 3,
name: 'Member',
user_capabilities: { unattach: true },
},
},
],
indirect_access: [],
},
}}
onRoleDelete={() => {}}
/>
</tbody>
</table>
);
});
const team_roles_detail = wrapper.find(`Detail[label="Team Roles"]`).at(0);
expect(team_roles_detail.prop('isEmpty')).toEqual(true);
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More