Merge "Add image validation admin method"

This commit is contained in:
Zuul
2025-09-12 20:02:47 +00:00
committed by Gerrit Code Review
4 changed files with 179 additions and 6 deletions

View File

@@ -1743,6 +1743,85 @@ class TestWebProviders(LauncherBaseTestCase, WebMixin):
if 'build_artifacts' not in data[1]:
break
@simple_layout('layouts/nodepool-image-validate.yaml',
enable_nodepool=True)
@return_data(
'build-debian-local-image',
'refs/heads/master',
LauncherBaseTestCase.debian_return_data,
)
@mock.patch('zuul.driver.aws.awsendpoint.AwsImageUploadJob.run',
return_value="ami-785db401")
def test_web_upload_validate(self, mock_image_upload_run):
self.executor_server.hold_jobs_in_build = True
self.startWebServer()
self.waitUntilSettled()
self.executor_server.release('build-debian-local-image')
self.waitUntilSettled()
self.assertHistory([
dict(name='build-debian-local-image', result='SUCCESS'),
])
nodes = self.launcher.api.getProviderNodes()
self.assertEqual(len(nodes), 2)
for node in nodes:
self.assertEqual(node.create_state['image_external_id'],
mock_image_upload_run.return_value)
self.assertEqual(len(self.builds), 2)
for build in self.builds:
build.should_fail = True
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
self.waitUntilSettled()
self.assertHistory([
dict(name='build-debian-local-image', result='SUCCESS'),
dict(name='validate-debian-local-image', result='FAILURE'),
dict(name='validate-debian-local-image', result='FAILURE'),
])
resp = self.get_url('api/tenant/tenant-one/images')
data = resp.json()
self.assertEqual(1, len(data))
self.assertEqual(1, len(data[0]['build_artifacts']))
self.assertEqual(2, len(data[0]['build_artifacts'][0]['uploads']))
upload = data[0]['build_artifacts'][0]['uploads'][0]
self.assertFalse(upload['validated'])
# Now retrigger validation
url = f"api/tenant/tenant-one/image-upload/{upload['uuid']}/validate"
# Test that unauthenticated access fails
resp = self.post_url(url)
self.assertEqual(401, resp.status_code, resp.text)
# Do it again with auth
token = self._getToken(['tenant-one'])
resp = self.post_url(
url,
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(200, resp.status_code, resp.text)
self.waitUntilSettled("image validate")
self.assertHistory([
dict(name='build-debian-local-image', result='SUCCESS'),
dict(name='validate-debian-local-image', result='FAILURE'),
dict(name='validate-debian-local-image', result='FAILURE'),
dict(name='validate-debian-local-image', result='SUCCESS'),
])
resp = self.get_url('api/tenant/tenant-one/images')
data = resp.json()
self.assertEqual(1, len(data))
self.assertEqual(1, len(data[0]['build_artifacts']))
self.assertEqual(2, len(data[0]['build_artifacts'][0]['uploads']))
for new_upload in data[0]['build_artifacts'][0]['uploads']:
if new_upload['uuid'] == upload['uuid']:
break
else:
raise Exception("Upload not found")
self.assertTrue(new_upload['validated'])
@return_data(
'build-debian-local-image',
'refs/heads/master',

View File

@@ -262,6 +262,13 @@ function deleteImageUpload(apiPrefix, uploadId) {
)
}
function validateImageUpload(apiPrefix, uploadId) {
return makeRequest(
apiPrefix + 'image-upload/' + uploadId + '/validate',
'post'
)
}
function fetchFlavors(apiPrefix) {
return makeRequest(apiPrefix + 'flavors')
}
@@ -471,4 +478,5 @@ export {
promote,
setNodeState,
setTenantState,
validateImageUpload,
}

View File

@@ -34,7 +34,7 @@ import {
import { fetchImages } from '../../actions/images'
import { fetchProviders } from '../../actions/providers'
import { addNotification, addApiError } from '../../actions/notifications'
import { deleteImageUpload } from '../../api'
import { deleteImageUpload, validateImageUpload } from '../../api'
const STATE_STYLES = {
ready: {
@@ -54,7 +54,8 @@ const STATE_STYLES = {
function ImageUploadTable(props) {
const { build, uploads, fetching } = props
const [showDeleteUploadModal, setShowDeleteUploadModal] = useState(false)
const [pendingDeleteRow, setPendingDeleteRow] = useState(null)
const [showValidateUploadModal, setShowValidateUploadModal] = useState(false)
const [pendingActionRow, setPendingActionRow] = useState(null)
const tenant = useSelector((state) => state.tenant)
const user = useSelector((state) => state.user)
const dispatch = useDispatch()
@@ -94,7 +95,7 @@ function ImageUploadTable(props) {
const state_style = STATE_STYLES[upload.state] || {}
return {
_uuid: upload.uuid,
canDelete: build.build_tenant,
canModify: build.build_tenant,
cells: [
{
title: upload.uuid
@@ -141,17 +142,24 @@ function ImageUploadTable(props) {
}
const actionResolver = (rowData) => {
if (rowData.canDelete &&
if (rowData.canModify &&
user.isAdmin &&
user.scope.indexOf(tenant.name) !== -1) {
return [
{
title: 'Delete upload',
onClick: () => {
setPendingDeleteRow(rowData)
setPendingActionRow(rowData)
setShowDeleteUploadModal(true)
}
},
{
title: 'Validate upload',
onClick: () => {
setPendingActionRow(rowData)
setShowValidateUploadModal(true)
}
},
]
}
return []
@@ -170,7 +178,7 @@ function ImageUploadTable(props) {
onClick={() => {
setShowDeleteUploadModal(false)
deleteImageUpload(tenant.apiPrefix,
pendingDeleteRow._uuid
pendingActionRow._uuid
).then(() => {
dispatch(addNotification(
{
@@ -200,6 +208,49 @@ function ImageUploadTable(props) {
)
}
function renderValidateUploadModal() {
const title = 'Validate image upload'
return (
<Modal
variant={ModalVariant.small}
isOpen={showValidateUploadModal}
title={title}
onClose={() => { setShowValidateUploadModal(false) }}
actions={[
<Button key="confirm" variant="primary"
onClick={() => {
setShowValidateUploadModal(false)
validateImageUpload(tenant.apiPrefix,
pendingActionRow._uuid
).then(() => {
dispatch(addNotification(
{
text: 'Validation event submitted.',
type: 'success',
status: '',
url: '',
}))
dispatch(fetchProviders(tenant))
dispatch(fetchImages(tenant))
})
.catch(error => {
dispatch(addApiError(error))
})
}}>
Confirm
</Button>,
<Button key="cancel" variant="link"
onClick={() => {setShowValidateUploadModal(false) }}>
Cancel
</Button>,
]}>
<p>
Please confirm that you want to validate this image upload.
</p>
</Modal>
)
}
const haveUploads = uploads && uploads.length > 0
let rows = []
@@ -239,6 +290,7 @@ function ImageUploadTable(props) {
</EmptyState>
)}
{renderDeleteUploadModal()}
{renderValidateUploadModal()}
</>
)
}

View File

@@ -2407,6 +2407,34 @@ class ZuulWebAPI(object):
upload.state = model.STATE_DELETING
cherrypy.response.status = 204
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['POST'])
@cherrypy.tools.check_tenant_auth(require_admin=True)
def image_upload_validate(self, tenant_name, tenant, auth, upload_id):
upload = self.zuulweb.image_upload_registry.getItem(upload_id)
if upload:
iba = self.zuulweb.image_build_registry.getItem(
upload.artifact_uuid)
else:
iba = None
if not iba or tenant.name != iba.build_tenant_name:
raise cherrypy.HTTPError(
404, "Image upload not found in tenant")
self.log.info(f'User {auth.uid} requesting image-upload-validate on '
f'{upload_id}')
project_hostname, project_name = \
iba.project_canonical_name.split('/', 1)
driver = self.zuulweb.connections.drivers['zuul']
event = driver.getImageValidateEvent(
[iba.name], project_hostname, project_name, iba.project_branch,
upload.uuid)
self.zuulweb.trigger_events[tenant_name].put(
event.trigger_name, event)
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@@ -3246,6 +3274,12 @@ class ZuulWeb(object):
controller=api,
conditions=dict(method=['DELETE', 'OPTIONS']),
action='image_upload_delete')
route_map.connect('api',
'/api/tenant/{tenant_name}/'
'image-upload/{upload_id}/validate',
controller=api,
conditions=dict(method=['POST', 'OPTIONS']),
action='image_upload_validate')
route_map.connect('api', '/api/tenant/{tenant_name}/labels',
controller=api, action='labels')
route_map.connect('api', '/api/tenant/{tenant_name}/nodes',