Fix local upload from browser failing due to ssvm cert not trusted (#13204)

This commit is contained in:
Abhisar Sinha 2026-05-26 13:27:33 +05:30 committed by GitHub
parent c267ad39bc
commit ff9ee24d12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 208 additions and 57 deletions

View File

@ -1617,6 +1617,8 @@
"label.offeringid": "Offering ID",
"label.offeringtype": "Compute Offering type",
"label.ok": "OK",
"label.ssvm.open.cert.page": "Open Certificate Page",
"label.retry.upload": "Retry Upload",
"label.only.end.date.and.time": "Only end date and time",
"label.only.start.date.and.time": "Only start date and time",
"label.open.documentation": "Open documentation",
@ -3667,6 +3669,9 @@
"message.upload.iso.failed.description": "Failed to upload ISO.",
"message.upload.template.failed.description": "Failed to upload Template",
"message.upload.volume.failed": "Volume upload failed",
"message.ssvm.cert.untrusted": "Unable to reach the upload server.",
"message.ssvm.cert.trust.instructions": "The upload server may be using a self-signed or untrusted certificate. Click 'Open Certificate Page' to open the server in a new browser tab, accept the certificate warning, then return here and click 'Retry Upload'. If the server remains unreachable, contact your administrator.",
"message.ssvm.unreachable.retry": "The upload server is still unreachable. If it uses a self-signed certificate, please accept it in the opened tab and try again.",
"message.user.not.permitted.api": "User is not permitted to use the API",
"message.validate.equalto": "Please enter the same value again.",
"message.validate.max": "Please enter a value less than or equal to {0}.",

View File

@ -355,7 +355,7 @@ a {
text-align: right;
padding-top: 15px;
button {
button, a.ant-btn {
margin-right: 5px;
}
}

30
ui/src/utils/ssvmProbe.js Normal file
View File

@ -0,0 +1,30 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
const SSVM_PROBE_TIMEOUT_MS = 5000
export async function probeSsvmCert (origin) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SSVM_PROBE_TIMEOUT_MS)
try {
await fetch(origin, { method: 'HEAD', mode: 'no-cors', signal: controller.signal })
return true
} catch (e) {
return false
} finally {
clearTimeout(timeoutId)
}
}

View File

@ -19,11 +19,27 @@
<div
class="form-layout"
@keyup.ctrl.enter="handleSubmit">
<span v-if="uploadPercentage > 0">
<span v-if="uploading">
<loading-outlined />
{{ $t('message.upload.file.processing') }}
<a-progress :percent="uploadPercentage" />
</span>
<div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
<a-alert
type="warning"
show-icon
:message="$t('message.ssvm.cert.untrusted')"
:description="$t('message.ssvm.cert.trust.instructions')" />
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
<a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
{{ $t('label.ssvm.open.cert.page') }}
</a-button>
<a-button type="primary" :loading="loading" @click="retryUpload">
{{ $t('label.retry.upload') }}
</a-button>
</div>
</div>
<a-spin :spinning="loading" v-else>
<a-form
:ref="formRef"
@ -311,6 +327,7 @@ import { api } from '@/api'
import store from '@/store'
import { axios } from '../../utils/request'
import { mixinForm } from '@/utils/mixin'
import { probeSsvmCert } from '@/utils/ssvmProbe'
import ResourceIcon from '@/components/view/ResourceIcon'
import TooltipLabel from '@/components/widgets/TooltipLabel'
@ -343,9 +360,12 @@ export default {
userdatapolicy: null,
userdatapolicylist: {},
loading: false,
uploading: false,
allowed: false,
uploadParams: null,
uploadPercentage: 0,
ssvmCertUntrusted: false,
ssvmOrigin: '',
currentForm: ['plus-outlined', 'PlusOutlined'].includes(this.action.currentAction.icon) ? 'Create' : 'Upload',
domains: [],
accounts: [],
@ -489,6 +509,17 @@ export default {
this.form.file = file
return false
},
async retryUpload () {
this.loading = true
const reachable = await probeSsvmCert(this.ssvmOrigin)
this.loading = false
if (!reachable) {
this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
return
}
this.ssvmCertUntrusted = false
this.handleUpload()
},
handleUpload () {
const { fileList } = this
if (this.fileList.length > 1) {
@ -502,6 +533,7 @@ export default {
fileList.forEach(file => {
formData.append('files[]', file)
})
this.uploading = true
this.uploadPercentage = 0
axios.post(this.uploadParams.postURL,
formData,
@ -529,6 +561,8 @@ export default {
description: `${this.$t('message.upload.iso.failed.description')} - ${e}`,
duration: 0
})
}).finally(() => {
this.uploading = false
})
},
handleSubmit (e) {
@ -583,18 +617,18 @@ export default {
}
params.format = 'ISO'
this.loading = true
api('getUploadParamsForIso', params).then(json => {
api('getUploadParamsForIso', params).then(async json => {
this.uploadParams = (json.postuploadisoresponse && json.postuploadisoresponse.getuploadparams) ? json.postuploadisoresponse.getuploadparams : ''
const response = this.handleUpload()
if (this.userdataid !== null) {
this.linkUserdataToTemplate(this.userdataid, json.postuploadisoresponse.iso[0].id)
}
if (response === 'upload successful') {
this.$notification.success({
message: this.$t('message.success.upload'),
description: this.$t('message.success.upload.iso.description')
})
this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
const trusted = await probeSsvmCert(this.ssvmOrigin)
if (!trusted) {
this.ssvmCertUntrusted = true
return
}
this.handleUpload()
}).catch(error => {
this.$notifyError(error)
}).finally(() => {

View File

@ -19,11 +19,27 @@
<div
:class="'form-layout'"
@keyup.ctrl.enter="handleSubmit">
<span v-if="uploadPercentage > 0">
<span v-if="uploading">
<loading-outlined />
{{ $t('message.upload.file.processing') }}
<a-progress :percent="uploadPercentage" />
</span>
<div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
<a-alert
type="warning"
show-icon
:message="$t('message.ssvm.cert.untrusted')"
:description="$t('message.ssvm.cert.trust.instructions')" />
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
<a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
{{ $t('label.ssvm.open.cert.page') }}
</a-button>
<a-button type="primary" :loading="loading" @click="retryUpload">
{{ $t('label.retry.upload') }}
</a-button>
</div>
</div>
<a-spin :spinning="loading" v-else>
<a-form
:ref="formRef"
@ -472,6 +488,7 @@ import { api } from '@/api'
import store from '@/store'
import { axios } from '../../utils/request'
import { mixinForm } from '@/utils/mixin'
import { probeSsvmCert } from '@/utils/ssvmProbe'
import ResourceIcon from '@/components/view/ResourceIcon'
import TooltipLabel from '@/components/widgets/TooltipLabel'
@ -497,6 +514,8 @@ export default {
uploadPercentage: 0,
uploading: false,
fileList: [],
ssvmCertUntrusted: false,
ssvmOrigin: '',
zones: {},
defaultZone: '',
hyperVisor: {},
@ -610,12 +629,24 @@ export default {
this.form.file = file
return false
},
async retryUpload () {
this.loading = true
const reachable = await probeSsvmCert(this.ssvmOrigin)
this.loading = false
if (!reachable) {
this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
return
}
this.ssvmCertUntrusted = false
this.handleUpload()
},
handleUpload () {
const { fileList } = this
const formData = new FormData()
fileList.forEach(file => {
formData.append('files[]', file)
})
this.uploading = true
this.uploadPercentage = 0
axios.post(this.uploadParams.postURL,
formData,
@ -639,6 +670,8 @@ export default {
this.closeAction()
}).catch(e => {
this.$notifyError(e)
}).finally(() => {
this.uploading = false
})
},
fetchCustomHypervisorName () {
@ -1124,12 +1157,18 @@ export default {
duration: 0
})
}
api('getUploadParamsForTemplate', params).then(json => {
api('getUploadParamsForTemplate', params).then(async json => {
this.uploadParams = (json.postuploadtemplateresponse && json.postuploadtemplateresponse.getuploadparams) ? json.postuploadtemplateresponse.getuploadparams : ''
this.handleUpload()
if (this.userdataid !== null) {
this.linkUserdataToTemplate(this.userdataid, json.postuploadtemplateresponse.template[0].id)
}
this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
const trusted = await probeSsvmCert(this.ssvmOrigin)
if (!trusted) {
this.ssvmCertUntrusted = true
return
}
this.handleUpload()
}).catch(error => {
this.$notifyError(error)
}).finally(() => {

View File

@ -16,13 +16,29 @@
// under the License.
<template>
<div class="form-layout" v-ctrl-enter="handleSubmit">
<span v-if="uploadPercentage > 0">
<div class="form-layout">
<span v-if="uploading">
<loading-outlined />
{{ $t('message.upload.file.processing') }}
<a-progress :percent="uploadPercentage" />
</span>
<a-spin :spinning="loading" v-else>
<div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
<a-alert
type="warning"
show-icon
:message="$t('message.ssvm.cert.untrusted')"
:description="$t('message.ssvm.cert.trust.instructions')" />
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
<a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
{{ $t('label.ssvm.open.cert.page') }}
</a-button>
<a-button type="primary" :loading="loading" @click="retryUpload">
{{ $t('label.retry.upload') }}
</a-button>
</div>
</div>
<a-spin :spinning="loading" v-else v-ctrl-enter="handleSubmit">
<a-form
:ref="formRef"
:model="form"
@ -156,6 +172,7 @@ import { ref, reactive, toRaw } from 'vue'
import { api } from '@/api'
import { axios } from '../../utils/request'
import { mixinForm } from '@/utils/mixin'
import { probeSsvmCert } from '@/utils/ssvmProbe'
import ResourceIcon from '@/components/view/ResourceIcon'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import InfiniteScrollSelect from '@/components/widgets/InfiniteScrollSelect.vue'
@ -178,7 +195,10 @@ export default {
customDiskOffering: false,
isCustomizedDiskIOps: false,
loading: false,
uploadPercentage: 0
uploading: false,
uploadPercentage: 0,
ssvmCertUntrusted: false,
ssvmOrigin: ''
}
},
beforeCreate () {
@ -267,6 +287,63 @@ export default {
this.form.account = accountName
this.account = accountName
},
async retryUpload () {
this.loading = true
const reachable = await probeSsvmCert(this.ssvmOrigin)
this.loading = false
if (!reachable) {
this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
return
}
this.ssvmCertUntrusted = false
this.handleUpload()
},
handleUpload () {
if (this.fileList.length > 1) {
this.$notification.error({
message: this.$t('message.upload.volume.failed'),
description: this.$t('message.upload.file.limit'),
duration: 0
})
return
}
const { fileList } = this
const formData = new FormData()
fileList.forEach(file => {
formData.append('files[]', file)
})
this.uploading = true
this.uploadPercentage = 0
axios.post(this.uploadParams.postURL,
formData,
{
headers: {
'content-type': 'multipart/form-data',
'x-signature': this.uploadParams.signature,
'x-expires': this.uploadParams.expires,
'x-metadata': this.uploadParams.metadata
},
onUploadProgress: (progressEvent) => {
this.uploadPercentage = Number(parseFloat(100 * progressEvent.loaded / progressEvent.total).toFixed(1))
},
timeout: 86400000
}).then((json) => {
this.$notification.success({
message: this.$t('message.success.upload'),
description: this.$t('message.success.upload.volume.description')
})
this.closeAction()
}).catch(e => {
this.$notification.error({
message: this.$t('message.upload.failed'),
description: `${this.$t('message.upload.volume.failed')} - ${e}`,
duration: 0
})
}).finally(() => {
this.uploading = false
this.loading = false
})
},
handleSubmit (e) {
e.preventDefault()
if (this.loading) return
@ -286,49 +363,15 @@ export default {
}
params.domainId = this.domainId
this.loading = true
api('getUploadParamsForVolume', params).then(json => {
api('getUploadParamsForVolume', params).then(async json => {
this.uploadParams = json.postuploadvolumeresponse?.getuploadparams || ''
const { fileList } = this
if (this.fileList.length > 1) {
this.$notification.error({
message: this.$t('message.upload.volume.failed'),
description: this.$t('message.upload.file.limit'),
duration: 0
})
this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
const trusted = await probeSsvmCert(this.ssvmOrigin)
if (!trusted) {
this.ssvmCertUntrusted = true
return
}
const formData = new FormData()
fileList.forEach(file => {
formData.append('files[]', file)
})
this.uploadPercentage = 0
axios.post(this.uploadParams.postURL,
formData,
{
headers: {
'content-type': 'multipart/form-data',
'x-signature': this.uploadParams.signature,
'x-expires': this.uploadParams.expires,
'x-metadata': this.uploadParams.metadata
},
onUploadProgress: (progressEvent) => {
this.uploadPercentage = Number(parseFloat(100 * progressEvent.loaded / progressEvent.total).toFixed(1))
},
timeout: 86400000
}).then((json) => {
this.$notification.success({
message: this.$t('message.success.upload'),
description: this.$t('message.success.upload.volume.description')
})
this.closeAction()
}).catch(e => {
this.$notification.error({
message: this.$t('message.upload.failed'),
description: `${this.$t('message.upload.volume.failed')} - ${e}`,
duration: 0
})
}).finally(() => {
this.loading = false
})
this.handleUpload()
}).catch(e => {
this.$notification.error({
message: this.$t('message.upload.failed'),