Merge branch 'ansible:devel' into devel

This commit is contained in:
Tarun Chawdhury
2023-01-25 07:18:09 -08:00
committed by GitHub
63 changed files with 456 additions and 117 deletions

View File

@@ -106,6 +106,13 @@ The Ansible Community is looking at building an EE that corresponds to all of th
### Oracle AWX ### Oracle AWX
We'd be happy to help if you can reproduce this with AWX since we do not have Oracle's Linux Automation Manager. If you need help with this specific version of Oracles Linux Automation Manager you will need to contact your Oracle for support. We'd be happy to help if you can reproduce this with AWX since we do not have Oracle's Linux Automation Manager. If you need help with this specific version of Oracles Linux Automation Manager you will need to contact your Oracle for support.
### Community Resolved
Hi,
We are happy to see that it appears a fix has been provided for your issue, so we will go ahead and close this ticket. Please feel free to reopen if any other problems arise.
<name of community member who helped> thanks so much for taking the time to write a thoughtful and helpful response to this issue!
### AWX Release ### AWX Release
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb

View File

@@ -38,9 +38,13 @@ jobs:
- name: Build collection and publish to galaxy - name: Build collection and publish to galaxy
run: | run: |
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
ansible-galaxy collection publish \ if [ "$(curl --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
--token=${{ secrets.GALAXY_TOKEN }} \ echo "Galaxy release already done"; \
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz else \
ansible-galaxy collection publish \
--token=${{ secrets.GALAXY_TOKEN }} \
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz; \
fi
- name: Set official pypi info - name: Set official pypi info
run: echo pypi_repo=pypi >> $GITHUB_ENV run: echo pypi_repo=pypi >> $GITHUB_ENV
@@ -52,6 +56,7 @@ jobs:
- name: Build awxkit and upload to pypi - name: Build awxkit and upload to pypi
run: | run: |
git reset --hard
cd awxkit && python3 setup.py bdist_wheel cd awxkit && python3 setup.py bdist_wheel
twine upload \ twine upload \
-r ${{ env.pypi_repo }} \ -r ${{ env.pypi_repo }} \
@@ -74,4 +79,6 @@ jobs:
docker tag ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} quay.io/${{ github.repository }}:latest docker tag ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} quay.io/${{ github.repository }}:latest
docker push quay.io/${{ github.repository }}:${{ github.event.release.tag_name }} docker push quay.io/${{ github.repository }}:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository }}:latest docker push quay.io/${{ github.repository }}:latest
docker pull ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker tag ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }} quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}

View File

@@ -84,6 +84,20 @@ jobs:
-e push=yes \ -e push=yes \
-e awx_official=yes -e awx_official=yes
- name: Log in to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Log in to Quay
run: |
echo ${{ secrets.QUAY_TOKEN }} | docker login quay.io -u ${{ secrets.QUAY_USER }} --password-stdin
- name: tag awx-ee:latest with version input
run: |
docker pull quay.io/ansible/awx-ee:latest
docker tag quay.io/ansible/awx-ee:latest ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
docker push ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Build and stage awx-operator - name: Build and stage awx-operator
working-directory: awx-operator working-directory: awx-operator
run: | run: |
@@ -103,6 +117,7 @@ jobs:
env: env:
AWX_TEST_IMAGE: ${{ github.repository }} AWX_TEST_IMAGE: ${{ github.repository }}
AWX_TEST_VERSION: ${{ github.event.inputs.version }} AWX_TEST_VERSION: ${{ github.event.inputs.version }}
AWX_EE_TEST_IMAGE: ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Create draft release for AWX - name: Create draft release for AWX
working-directory: awx working-directory: awx

View File

@@ -96,6 +96,15 @@ register(
category=_('Authentication'), category=_('Authentication'),
category_slug='authentication', category_slug='authentication',
) )
register(
'ALLOW_METRICS_FOR_ANONYMOUS_USERS',
field_class=fields.BooleanField,
default=False,
label=_('Allow anonymous users to poll metrics'),
help_text=_('If true, anonymous users are allowed to poll metrics.'),
category=_('Authentication'),
category_slug='authentication',
)
def authentication_validate(serializer, attrs): def authentication_validate(serializer, attrs):

View File

@@ -5,9 +5,11 @@
import logging import logging
# Django # Django
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Django REST Framework # Django REST Framework
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -31,9 +33,14 @@ class MetricsView(APIView):
renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer] renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer]
def initialize_request(self, request, *args, **kwargs):
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS:
self.permission_classes = (AllowAny,)
return super(APIView, self).initialize_request(request, *args, **kwargs)
def get(self, request): def get(self, request):
'''Show Metrics Details''' '''Show Metrics Details'''
if request.user.is_superuser or request.user.is_system_auditor: if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS or request.user.is_superuser or request.user.is_system_auditor:
metrics_to_show = '' metrics_to_show = ''
if not request.query_params.get('subsystemonly', "0") == "1": if not request.query_params.get('subsystemonly', "0") == "1":
metrics_to_show += metrics().decode('UTF-8') metrics_to_show += metrics().decode('UTF-8')

View File

@@ -1,6 +1,7 @@
import copy import copy
import os import os
import pathlib import pathlib
import time
from urllib.parse import urljoin from urllib.parse import urljoin
from .plugin import CredentialPlugin, CertFiles, raise_for_status from .plugin import CredentialPlugin, CertFiles, raise_for_status
@@ -247,7 +248,15 @@ def kv_backend(**kwargs):
request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/') request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/')
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert request_kwargs['verify'] = cert
response = sess.get(request_url, **request_kwargs) request_retries = 0
while request_retries < 5:
response = sess.get(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if response.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
raise_for_status(response) raise_for_status(response)
json = response.json() json = response.json()
@@ -289,8 +298,15 @@ def ssh_backend(**kwargs):
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert request_kwargs['verify'] = cert
resp = sess.post(request_url, **request_kwargs) request_retries = 0
while request_retries < 5:
resp = sess.post(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if resp.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
raise_for_status(resp) raise_for_status(resp)
return resp.json()['data']['signed_key'] return resp.json()['data']['signed_key']

View File

@@ -3,14 +3,12 @@ import logging
import os import os
import signal import signal
import time import time
import traceback
import datetime import datetime
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now as tz_now from django.utils.timezone import now as tz_now
from django.db import DatabaseError, OperationalError, transaction, connection as django_connection from django.db import transaction, connection as django_connection
from django.db.utils import InterfaceError, InternalError
from django_guid import set_guid from django_guid import set_guid
import psutil import psutil
@@ -64,6 +62,7 @@ class CallbackBrokerWorker(BaseWorker):
""" """
MAX_RETRIES = 2 MAX_RETRIES = 2
INDIVIDUAL_EVENT_RETRIES = 3
last_stats = time.time() last_stats = time.time()
last_flush = time.time() last_flush = time.time()
total = 0 total = 0
@@ -164,38 +163,48 @@ class CallbackBrokerWorker(BaseWorker):
else: # only calculate the seconds if the created time already has been set else: # only calculate the seconds if the created time already has been set
metrics_total_job_event_processing_seconds += e.modified - e.created metrics_total_job_event_processing_seconds += e.modified - e.created
metrics_duration_to_save = time.perf_counter() metrics_duration_to_save = time.perf_counter()
saved_events = []
try: try:
cls.objects.bulk_create(events) cls.objects.bulk_create(events)
metrics_bulk_events_saved += len(events) metrics_bulk_events_saved += len(events)
saved_events = events
self.buff[cls] = []
except Exception as exc: except Exception as exc:
logger.warning(f'Error in events bulk_create, will try indiviually up to 5 errors, error {str(exc)}') # If the database is flaking, let ensure_connection throw a general exception
# will be caught by the outer loop, which goes into a proper sleep and retry loop
django_connection.ensure_connection()
logger.warning(f'Error in events bulk_create, will try indiviually, error: {str(exc)}')
# if an exception occurs, we should re-attempt to save the # if an exception occurs, we should re-attempt to save the
# events one-by-one, because something in the list is # events one-by-one, because something in the list is
# broken/stale # broken/stale
consecutive_errors = 0
events_saved = 0
metrics_events_batch_save_errors += 1 metrics_events_batch_save_errors += 1
for e in events: for e in events.copy():
try: try:
e.save() e.save()
events_saved += 1 metrics_singular_events_saved += 1
consecutive_errors = 0 events.remove(e)
saved_events.append(e) # Importantly, remove successfully saved events from the buffer
except Exception as exc_indv: except Exception as exc_indv:
consecutive_errors += 1 retry_count = getattr(e, '_retry_count', 0) + 1
logger.info(f'Database Error Saving individual Job Event, error {str(exc_indv)}') e._retry_count = retry_count
if consecutive_errors >= 5:
raise # special sanitization logic for postgres treatment of NUL 0x00 char
metrics_singular_events_saved += events_saved if (retry_count == 1) and isinstance(exc_indv, ValueError) and ("\x00" in e.stdout):
if events_saved == 0: e.stdout = e.stdout.replace("\x00", "")
raise
if retry_count >= self.INDIVIDUAL_EVENT_RETRIES:
logger.error(f'Hit max retries ({retry_count}) saving individual Event error: {str(exc_indv)}\ndata:\n{e.__dict__}')
events.remove(e)
else:
logger.info(f'Database Error Saving individual Event uuid={e.uuid} try={retry_count}, error: {str(exc_indv)}')
metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save
for e in events: for e in saved_events:
if not getattr(e, '_skip_websocket_message', False): if not getattr(e, '_skip_websocket_message', False):
metrics_events_broadcast += 1 metrics_events_broadcast += 1
emit_event_detail(e) emit_event_detail(e)
if getattr(e, '_notification_trigger_event', False): if getattr(e, '_notification_trigger_event', False):
job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e) job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e)
self.buff = {}
self.last_flush = time.time() self.last_flush = time.time()
# only update metrics if we saved events # only update metrics if we saved events
if (metrics_bulk_events_saved + metrics_singular_events_saved) > 0: if (metrics_bulk_events_saved + metrics_singular_events_saved) > 0:
@@ -267,20 +276,16 @@ class CallbackBrokerWorker(BaseWorker):
try: try:
self.flush(force=flush) self.flush(force=flush)
break break
except (OperationalError, InterfaceError, InternalError) as exc: except Exception as exc:
# Aside form bugs, exceptions here are assumed to be due to database flake
if retries >= self.MAX_RETRIES: if retries >= self.MAX_RETRIES:
logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.') logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.')
self.buff = {}
return return
delay = 60 * retries delay = 60 * retries
logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}') logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}')
django_connection.close() django_connection.close()
time.sleep(delay) time.sleep(delay)
retries += 1 retries += 1
except DatabaseError: except Exception:
logger.exception('Database Error Flushing Job Events') logger.exception(f'Callback Task Processor Raised Unexpected Exception processing event data:\n{body}')
django_connection.close()
break
except Exception as exc:
tb = traceback.format_exc()
logger.error('Callback Task Processor Raised Exception: %r', exc)
logger.error('Detail: {}'.format(tb))

View File

@@ -1,7 +1,15 @@
import pytest import pytest
import time
from unittest import mock
from uuid import uuid4
from django.test import TransactionTestCase
from awx.main.dispatch.worker.callback import job_stats_wrapup, CallbackBrokerWorker
from awx.main.dispatch.worker.callback import job_stats_wrapup
from awx.main.models.jobs import Job from awx.main.models.jobs import Job
from awx.main.models.inventory import InventoryUpdate, InventorySource
from awx.main.models.events import InventoryUpdateEvent
@pytest.mark.django_db @pytest.mark.django_db
@@ -24,3 +32,108 @@ def test_wrapup_does_send_notifications(mocker):
job.refresh_from_db() job.refresh_from_db()
assert job.host_status_counts == {} assert job.host_status_counts == {}
mock.assert_called_once_with('succeeded') mock.assert_called_once_with('succeeded')
class FakeRedis:
def keys(self, *args, **kwargs):
return []
def set(self):
pass
def get(self):
return None
@classmethod
def from_url(cls, *args, **kwargs):
return cls()
def pipeline(self):
return self
class TestCallbackBrokerWorker(TransactionTestCase):
@pytest.fixture(autouse=True)
def turn_off_websockets(self):
with mock.patch('awx.main.dispatch.worker.callback.emit_event_detail', lambda *a, **kw: None):
yield
def get_worker(self):
with mock.patch('redis.Redis', new=FakeRedis): # turn off redis stuff
return CallbackBrokerWorker()
def event_create_kwargs(self):
inventory_update = InventoryUpdate.objects.create(source='file', inventory_source=InventorySource.objects.create(source='file'))
return dict(inventory_update=inventory_update, created=inventory_update.created)
def test_flush_with_valid_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events}
worker.flush()
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
def test_flush_with_invalid_event(self):
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good1', **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='bad', counter=-2, **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good2', **kwargs),
]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert InventoryUpdateEvent.objects.filter(uuid=events[1].uuid).count() == 0
assert InventoryUpdateEvent.objects.filter(uuid=events[2].uuid).count() == 1
assert worker.buff == {InventoryUpdateEvent: [events[1]]}
def test_duplicate_key_not_saved_twice(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
# put current saved event in buffer (error case)
worker.buff = {InventoryUpdateEvent: [InventoryUpdateEvent.objects.get(uuid=events[0].uuid)]}
worker.last_flush = time.time() - 2.0
# here, the bulk_create will fail with UNIQUE constraint violation, but individual saves should resolve it
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert worker.buff.get(InventoryUpdateEvent, []) == []
def test_give_up_on_bad_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), counter=-2, **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
for i in range(5):
worker.last_flush = time.time() - 2.0
worker.flush()
# Could not save, should be logged, and buffer should be cleared
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 0 # sanity
def test_postgres_invalid_NUL_char(self):
# In postgres, text fields reject NUL character, 0x00
# tests use sqlite3 which will not raise an error
# but we can still test that it is sanitized before saving
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [InventoryUpdateEvent(uuid=str(uuid4()), stdout="\x00", **kwargs)]
assert "\x00" in events[0].stdout # sanity
worker.buff = {InventoryUpdateEvent: events.copy()}
with mock.patch.object(InventoryUpdateEvent.objects, 'bulk_create', side_effect=ValueError):
with mock.patch.object(events[0], 'save', side_effect=ValueError):
worker.flush()
assert "\x00" not in events[0].stdout
worker.last_flush = time.time() - 2.0
worker.flush()
event = InventoryUpdateEvent.objects.get(uuid=events[0].uuid)
assert "\x00" not in event.stdout

View File

@@ -418,6 +418,9 @@ AUTH_BASIC_ENABLED = True
# when trying to access a UI page that requries authentication. # when trying to access a UI page that requries authentication.
LOGIN_REDIRECT_OVERRIDE = '' LOGIN_REDIRECT_OVERRIDE = ''
# Note: This setting may be overridden by database settings.
ALLOW_METRICS_FOR_ANONYMOUS_USERS = False
DEVSERVER_DEFAULT_ADDR = '0.0.0.0' DEVSERVER_DEFAULT_ADDR = '0.0.0.0'
DEVSERVER_DEFAULT_PORT = '8013' DEVSERVER_DEFAULT_PORT = '8013'

View File

@@ -34,8 +34,14 @@ const QS_CONFIG = getQSConfig('template', {
order_by: 'name', order_by: 'name',
}); });
function RelatedTemplateList({ searchParams, projectName = null }) { const resources = {
const { id: projectId } = useParams(); projects: 'project',
inventories: 'inventory',
credentials: 'credentials',
};
function RelatedTemplateList({ searchParams, resourceName = null }) {
const { id } = useParams();
const location = useLocation(); const location = useLocation();
const { addToast, Toast, toastProps } = useToast(); const { addToast, Toast, toastProps } = useToast();
@@ -129,12 +135,19 @@ function RelatedTemplateList({ searchParams, projectName = null }) {
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
let linkTo = ''; let linkTo = '';
if (resourceName) {
if (projectName) { const queryString = {
const qs = encodeQueryString({ resource_id: id,
project_id: projectId, resource_name: resourceName,
project_name: projectName, resource_type: resources[location.pathname.split('/')[1]],
}); resource_kind: null,
};
if (Array.isArray(resourceName)) {
const [name, kind] = resourceName;
queryString.resource_name = name;
queryString.resource_kind = kind;
}
const qs = encodeQueryString(queryString);
linkTo = `/templates/job_template/add/?${qs}`; linkTo = `/templates/job_template/add/?${qs}`;
} else { } else {
linkTo = '/templates/job_template/add'; linkTo = '/templates/job_template/add';

View File

@@ -0,0 +1 @@
/* eslint-disable import/prefer-default-export */

View File

@@ -22,6 +22,16 @@ import { CredentialsAPI } from 'api';
import CredentialDetail from './CredentialDetail'; import CredentialDetail from './CredentialDetail';
import CredentialEdit from './CredentialEdit'; import CredentialEdit from './CredentialEdit';
const jobTemplateCredentialTypes = [
'machine',
'cloud',
'net',
'ssh',
'vault',
'kubernetes',
'cryptography',
];
function Credential({ setBreadcrumb }) { function Credential({ setBreadcrumb }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -75,13 +85,14 @@ function Credential({ setBreadcrumb }) {
link: `/credentials/${id}/access`, link: `/credentials/${id}/access`,
id: 1, id: 1,
}, },
{ ];
if (jobTemplateCredentialTypes.includes(credential?.kind)) {
tabsArray.push({
name: t`Job Templates`, name: t`Job Templates`,
link: `/credentials/${id}/job_templates`, link: `/credentials/${id}/job_templates`,
id: 2, id: 2,
}, });
]; }
let showCardHeader = true; let showCardHeader = true;
if (pathname.endsWith('edit') || pathname.endsWith('add')) { if (pathname.endsWith('edit') || pathname.endsWith('add')) {
@@ -133,6 +144,7 @@ function Credential({ setBreadcrumb }) {
<Route key="job_templates" path="/credentials/:id/job_templates"> <Route key="job_templates" path="/credentials/:id/job_templates">
<RelatedTemplateList <RelatedTemplateList
searchParams={{ credentials__id: credential.id }} searchParams={{ credentials__id: credential.id }}
resourceName={[credential.name, credential.kind]}
/> />
</Route>, </Route>,
<Route key="not-found" path="*"> <Route key="not-found" path="*">

View File

@@ -6,7 +6,8 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../testUtils/enzymeHelpers'; } from '../../../testUtils/enzymeHelpers';
import mockCredential from './shared/data.scmCredential.json'; import mockMachineCredential from './shared/data.machineCredential.json';
import mockSCMCredential from './shared/data.scmCredential.json';
import Credential from './Credential'; import Credential from './Credential';
jest.mock('../../api'); jest.mock('../../api');
@@ -21,13 +22,10 @@ jest.mock('react-router-dom', () => ({
describe('<Credential />', () => { describe('<Credential />', () => {
let wrapper; let wrapper;
beforeEach(() => { test('initially renders user-based machine credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({ CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockCredential, data: mockMachineCredential,
}); });
});
test('initially renders user-based credential successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />); wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
}); });
@@ -36,6 +34,18 @@ describe('<Credential />', () => {
expect(wrapper.find('RoutedTabs li').length).toBe(4); expect(wrapper.find('RoutedTabs li').length).toBe(4);
}); });
test('initially renders user-based SCM credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockSCMCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(3);
});
test('should render expected tabs', async () => { test('should render expected tabs', async () => {
const expectedTabs = [ const expectedTabs = [
'Back to Credentials', 'Back to Credentials',

View File

@@ -181,6 +181,7 @@ function Inventory({ setBreadcrumb }) {
> >
<RelatedTemplateList <RelatedTemplateList
searchParams={{ inventory__id: inventory.id }} searchParams={{ inventory__id: inventory.id }}
resourceName={inventory.name}
/> />
</Route>, </Route>,
<Route path="*" key="not-found"> <Route path="*" key="not-found">

View File

@@ -179,7 +179,7 @@ function Project({ setBreadcrumb }) {
searchParams={{ searchParams={{
project__id: project.id, project__id: project.id,
}} }}
projectName={project.name} resourceName={project.name}
/> />
</Route> </Route>
{project?.scm_type && project.scm_type !== '' && ( {project?.scm_type && project.scm_type !== '' && (

View File

@@ -9,29 +9,31 @@ function JobTemplateAdd() {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();
const projectParams = { const resourceParams = {
project_id: null, resource_id: null,
project_name: null, resource_name: null,
resource_type: null,
resource_kind: null,
}; };
history.location.search history.location.search
.replace(/^\?/, '') .replace(/^\?/, '')
.split('&') .split('&')
.map((s) => s.split('=')) .map((s) => s.split('='))
.forEach(([key, val]) => { .forEach(([key, val]) => {
if (!(key in projectParams)) { if (!(key in resourceParams)) {
return; return;
} }
projectParams[key] = decodeURIComponent(val); resourceParams[key] = decodeURIComponent(val);
}); });
let projectValues = null; let resourceValues = null;
if ( if (history.location.search.includes('resource_id' && 'resource_name')) {
Object.values(projectParams).filter((item) => item !== null).length === 2 resourceValues = {
) { id: resourceParams.resource_id,
projectValues = { name: resourceParams.resource_name,
id: projectParams.project_id, type: resourceParams.resource_type,
name: projectParams.project_name, kind: resourceParams.resource_kind, // refers to credential kind
}; };
} }
@@ -122,7 +124,7 @@ function JobTemplateAdd() {
handleCancel={handleCancel} handleCancel={handleCancel}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
submitError={formSubmitError} submitError={formSubmitError}
projectValues={projectValues} resourceValues={resourceValues}
isOverrideDisabledLookup isOverrideDisabledLookup
/> />
</CardBody> </CardBody>

View File

@@ -274,9 +274,14 @@ describe('<JobTemplateAdd />', () => {
test('should parse and pre-fill project field from query params', async () => { test('should parse and pre-fill project field from query params', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: [ initialEntries: [
'/templates/job_template/add/add?project_id=6&project_name=Demo%20Project', '/templates/job_template/add?resource_id=6&resource_name=Demo%20Project&resource_type=project',
], ],
}); });
ProjectsAPI.read.mockResolvedValueOnce({
count: 1,
results: [{ name: 'foo', id: 1, allow_override: true, organization: 1 }],
});
ProjectsAPI.readOptions.mockResolvedValueOnce({});
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<JobTemplateAdd />, { wrapper = mountWithContexts(<JobTemplateAdd />, {
@@ -284,8 +289,9 @@ describe('<JobTemplateAdd />', () => {
}); });
}); });
await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0);
expect(wrapper.find('input#project').prop('value')).toEqual('Demo Project'); expect(wrapper.find('input#project').prop('value')).toEqual('Demo Project');
expect(ProjectsAPI.readPlaybooks).toBeCalledWith('6'); expect(ProjectsAPI.readPlaybooks).toBeCalledWith(6);
}); });
test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => { test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => {

View File

@@ -690,7 +690,7 @@ JobTemplateForm.defaultProps = {
}; };
const FormikApp = withFormik({ const FormikApp = withFormik({
mapPropsToValues({ projectValues = {}, template = {} }) { mapPropsToValues({ resourceValues = null, template = {} }) {
const { const {
summary_fields = { summary_fields = {
labels: { results: [] }, labels: { results: [] },
@@ -698,7 +698,7 @@ const FormikApp = withFormik({
}, },
} = template; } = template;
return { const initialValues = {
allow_callbacks: template.allow_callbacks || false, allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false, allow_simultaneous: template.allow_simultaneous || false,
ask_credential_on_launch: template.ask_credential_on_launch || false, ask_credential_on_launch: template.ask_credential_on_launch || false,
@@ -739,7 +739,7 @@ const FormikApp = withFormik({
playbook: template.playbook || '', playbook: template.playbook || '',
prevent_instance_group_fallback: prevent_instance_group_fallback:
template.prevent_instance_group_fallback || false, template.prevent_instance_group_fallback || false,
project: summary_fields?.project || projectValues || null, project: summary_fields?.project || null,
scm_branch: template.scm_branch || '', scm_branch: template.scm_branch || '',
skip_tags: template.skip_tags || '', skip_tags: template.skip_tags || '',
timeout: template.timeout || 0, timeout: template.timeout || 0,
@@ -756,6 +756,24 @@ const FormikApp = withFormik({
execution_environment: execution_environment:
template.summary_fields?.execution_environment || null, template.summary_fields?.execution_environment || null,
}; };
if (resourceValues !== null) {
if (resourceValues.type === 'credentials') {
initialValues[resourceValues.type] = [
{
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
kind: resourceValues.kind,
},
];
} else {
initialValues[resourceValues.type] = {
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
};
}
}
return initialValues;
}, },
handleSubmit: async (values, { props, setErrors }) => { handleSubmit: async (values, { props, setErrors }) => {
try { try {

View File

@@ -46,90 +46,216 @@ action_groups:
plugin_routing: plugin_routing:
inventory: inventory:
tower: tower:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller instead.
redirect: awx.awx.controller redirect: awx.awx.controller
lookup: lookup:
tower_api: tower_api:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller_api instead.
redirect: awx.awx.controller_api redirect: awx.awx.controller_api
tower_schedule_rrule: tower_schedule_rrule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.schedule_rrule instead.
redirect: awx.awx.schedule_rrule redirect: awx.awx.schedule_rrule
modules: modules:
tower_ad_hoc_command_cancel: tower_ad_hoc_command_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_cancel instead.
redirect: awx.awx.ad_hoc_command_cancel redirect: awx.awx.ad_hoc_command_cancel
tower_ad_hoc_command_wait: tower_ad_hoc_command_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_wait instead.
redirect: awx.awx.ad_hoc_command_wait redirect: awx.awx.ad_hoc_command_wait
tower_ad_hoc_command: tower_ad_hoc_command:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command instead.
redirect: awx.awx.ad_hoc_command redirect: awx.awx.ad_hoc_command
tower_application: tower_application:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.application instead.
redirect: awx.awx.application redirect: awx.awx.application
tower_meta: tower_meta:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.controller_meta instead.
redirect: awx.awx.controller_meta redirect: awx.awx.controller_meta
tower_credential_input_source: tower_credential_input_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_input_source instead.
redirect: awx.awx.credential_input_source redirect: awx.awx.credential_input_source
tower_credential_type: tower_credential_type:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_type instead.
redirect: awx.awx.credential_type redirect: awx.awx.credential_type
tower_credential: tower_credential:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential instead.
redirect: awx.awx.credential redirect: awx.awx.credential
tower_execution_environment: tower_execution_environment:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.execution_environment instead.
redirect: awx.awx.execution_environment redirect: awx.awx.execution_environment
tower_export: tower_export:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.export instead.
redirect: awx.awx.export redirect: awx.awx.export
tower_group: tower_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.group instead.
redirect: awx.awx.group redirect: awx.awx.group
tower_host: tower_host:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.host instead.
redirect: awx.awx.host redirect: awx.awx.host
tower_import: tower_import:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.import instead.
redirect: awx.awx.import redirect: awx.awx.import
tower_instance_group: tower_instance_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.instance_group instead.
redirect: awx.awx.instance_group redirect: awx.awx.instance_group
tower_inventory_source_update: tower_inventory_source_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source_update instead.
redirect: awx.awx.inventory_source_update redirect: awx.awx.inventory_source_update
tower_inventory_source: tower_inventory_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source instead.
redirect: awx.awx.inventory_source redirect: awx.awx.inventory_source
tower_inventory: tower_inventory:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory instead.
redirect: awx.awx.inventory redirect: awx.awx.inventory
tower_job_cancel: tower_job_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_cancel instead.
redirect: awx.awx.job_cancel redirect: awx.awx.job_cancel
tower_job_launch: tower_job_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_launch instead.
redirect: awx.awx.job_launch redirect: awx.awx.job_launch
tower_job_list: tower_job_list:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_list instead.
redirect: awx.awx.job_list redirect: awx.awx.job_list
tower_job_template: tower_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_template instead.
redirect: awx.awx.job_template redirect: awx.awx.job_template
tower_job_wait: tower_job_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_wait instead.
redirect: awx.awx.job_wait redirect: awx.awx.job_wait
tower_label: tower_label:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.label instead.
redirect: awx.awx.label redirect: awx.awx.label
tower_license: tower_license:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.license instead.
redirect: awx.awx.license redirect: awx.awx.license
tower_notification_template: tower_notification_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.notification_template instead.
redirect: awx.awx.notification_template redirect: awx.awx.notification_template
tower_notification: tower_notification:
redirect: awx.awx.notification_template redirect: awx.awx.notification_template
tower_organization: tower_organization:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.organization instead.
redirect: awx.awx.organization redirect: awx.awx.organization
tower_project_update: tower_project_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project_update instead.
redirect: awx.awx.project_update redirect: awx.awx.project_update
tower_project: tower_project:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project instead.
redirect: awx.awx.project redirect: awx.awx.project
tower_role: tower_role:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.role instead.
redirect: awx.awx.role redirect: awx.awx.role
tower_schedule: tower_schedule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.schedule instead.
redirect: awx.awx.schedule redirect: awx.awx.schedule
tower_settings: tower_settings:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.settings instead.
redirect: awx.awx.settings redirect: awx.awx.settings
tower_team: tower_team:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.team instead.
redirect: awx.awx.team redirect: awx.awx.team
tower_token: tower_token:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.token instead.
redirect: awx.awx.token redirect: awx.awx.token
tower_user: tower_user:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.user instead.
redirect: awx.awx.user redirect: awx.awx.user
tower_workflow_approval: tower_workflow_approval:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_approval instead.
redirect: awx.awx.workflow_approval redirect: awx.awx.workflow_approval
tower_workflow_job_template_node: tower_workflow_job_template_node:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template_node instead.
redirect: awx.awx.workflow_job_template_node redirect: awx.awx.workflow_job_template_node
tower_workflow_job_template: tower_workflow_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template instead.
redirect: awx.awx.workflow_job_template redirect: awx.awx.workflow_job_template
tower_workflow_launch: tower_workflow_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_launch instead.
redirect: awx.awx.workflow_launch redirect: awx.awx.workflow_launch
tower_workflow_node_wait: tower_workflow_node_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_node_wait instead.
redirect: awx.awx.workflow_node_wait redirect: awx.awx.workflow_node_wait

View File

@@ -128,7 +128,7 @@ def main():
description = module.params.get('description') description = module.params.get('description')
state = module.params.pop('state') state = module.params.pop('state')
preserve_existing_hosts = module.params.get('preserve_existing_hosts') preserve_existing_hosts = module.params.get('preserve_existing_hosts')
preserve_existing_children = module.params.get('preserve_existing_groups') preserve_existing_children = module.params.get('preserve_existing_children')
variables = module.params.get('variables') variables = module.params.get('variables')
# Attempt to look up the related items the user specified (these will fail the module if not found) # Attempt to look up the related items the user specified (these will fail the module if not found)

View File

@@ -1 +0,0 @@
ad_hoc_command.py

View File

@@ -1 +0,0 @@
ad_hoc_command_cancel.py

View File

@@ -1 +0,0 @@
ad_hoc_command_wait.py

View File

@@ -1 +0,0 @@
application.py

View File

@@ -1 +0,0 @@
controller_meta.py

View File

@@ -1 +0,0 @@
credential.py

View File

@@ -1 +0,0 @@
credential_input_source.py

View File

@@ -1 +0,0 @@
credential_type.py

View File

@@ -1 +0,0 @@
execution_environment.py

View File

@@ -1 +0,0 @@
export.py

View File

@@ -1 +0,0 @@
group.py

View File

@@ -1 +0,0 @@
host.py

View File

@@ -1 +0,0 @@
import.py

View File

@@ -1 +0,0 @@
instance_group.py

View File

@@ -1 +0,0 @@
inventory.py

View File

@@ -1 +0,0 @@
inventory_source.py

View File

@@ -1 +0,0 @@
inventory_source_update.py

View File

@@ -1 +0,0 @@
job_cancel.py

View File

@@ -1 +0,0 @@
job_launch.py

View File

@@ -1 +0,0 @@
job_list.py

View File

@@ -1 +0,0 @@
job_template.py

View File

@@ -1 +0,0 @@
job_wait.py

View File

@@ -1 +0,0 @@
label.py

View File

@@ -1 +0,0 @@
license.py

View File

@@ -1 +0,0 @@
notification_template.py

View File

@@ -1 +0,0 @@
organization.py

View File

@@ -1 +0,0 @@
project.py

View File

@@ -1 +0,0 @@
project_update.py

View File

@@ -1 +0,0 @@
role.py

View File

@@ -1 +0,0 @@
schedule.py

View File

@@ -1 +0,0 @@
settings.py

View File

@@ -1 +0,0 @@
team.py

View File

@@ -1 +0,0 @@
token.py

View File

@@ -1 +0,0 @@
user.py

View File

@@ -1 +0,0 @@
workflow_approval.py

View File

@@ -1 +0,0 @@
workflow_job_template.py

View File

@@ -1 +0,0 @@
workflow_job_template_node.py

View File

@@ -1 +0,0 @@
workflow_launch.py

View File

@@ -1 +0,0 @@
workflow_node_wait.py

View File

@@ -19,7 +19,6 @@ author: "John Westcott IV (@john-westcott-iv)"
short_description: create, update, or destroy Automation Platform Controller workflow job templates. short_description: create, update, or destroy Automation Platform Controller workflow job templates.
description: description:
- Create, update, or destroy Automation Platform Controller workflow job templates. - Create, update, or destroy Automation Platform Controller workflow job templates.
- Replaces the deprecated tower_workflow_template module.
- Use workflow_job_template_node after this, or use the workflow_nodes parameter to build the workflow's graph - Use workflow_job_template_node after this, or use the workflow_nodes parameter to build the workflow's graph
options: options:
name: name:
@@ -614,6 +613,10 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
if workflow_node['unified_job_template']['type'] != 'workflow_approval': if workflow_node['unified_job_template']['type'] != 'workflow_approval':
module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields)) module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields))
inventory = workflow_node.get('inventory')
if inventory:
workflow_node_fields['inventory'] = module.resolve_name_to_id('inventories', inventory)
# Lookup Values for other fields # Lookup Values for other fields
for field_name in ( for field_name in (

View File

@@ -20,7 +20,6 @@ short_description: create, update, or destroy Automation Platform Controller wor
description: description:
- Create, update, or destroy Automation Platform Controller workflow job template nodes. - Create, update, or destroy Automation Platform Controller workflow job template nodes.
- Use this to build a graph for a workflow, which dictates what the workflow runs. - Use this to build a graph for a workflow, which dictates what the workflow runs.
- Replaces the deprecated tower_workflow_template module schema command.
- You can create nodes first, and link them afterwards, and not worry about ordering. - You can create nodes first, and link them afterwards, and not worry about ordering.
For failsafe referencing of a node, specify identifier, WFJT, and organization. For failsafe referencing of a node, specify identifier, WFJT, and organization.
With those specified, you can choose to modify or not modify any other parameter. With those specified, you can choose to modify or not modify any other parameter.

View File

@@ -74,6 +74,7 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co
- 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection. - 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection.
- 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/). - 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/).
- 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names - 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names
- 21.11.0 "tower" modules deprecated and symlinks removed.
- 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable. - 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable.
{% else %} {% else %}
- 3.7.0 initial release - 3.7.0 initial release

View File

@@ -1,6 +1,10 @@
awxkit awxkit
====== ======
Python library that backs the provided `awx` command line client. A Python library that backs the provided `awx` command line client.
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs). It can be installed by running `pip install awxkit`.
The PyPI respository can be found [here](https://pypi.org/project/awxkit/).
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs).