mirror of
https://github.com/SAP/jenkins-library.git
synced 2024-12-12 10:55:20 +02:00
Add Docker deploy support to cloudFoundryDeploy (#977)
Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Co-authored-by: Daniel Kurzynski <daniel.kurzynski@sap.com>
This commit is contained in:
parent
c15f6a03d2
commit
d754a669b2
@ -1,33 +1,34 @@
|
||||
import com.sap.piper.JenkinsUtils
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
import org.junit.rules.RuleChain
|
||||
|
||||
import util.BasePiperTest
|
||||
import util.JenkinsCredentialsRule
|
||||
import util.JenkinsEnvironmentRule
|
||||
import util.JenkinsDockerExecuteRule
|
||||
import util.JenkinsEnvironmentRule
|
||||
import util.JenkinsFileExistsRule
|
||||
import util.JenkinsLoggingRule
|
||||
import util.JenkinsReadFileRule
|
||||
import util.JenkinsReadYamlRule
|
||||
import util.JenkinsShellCallRule
|
||||
import util.JenkinsStepRule
|
||||
import util.JenkinsWriteFileRule
|
||||
import util.JenkinsReadYamlRule
|
||||
import util.Rules
|
||||
|
||||
import static org.hamcrest.Matchers.stringContainsInOrder
|
||||
import static org.junit.Assert.*
|
||||
|
||||
import static org.hamcrest.Matchers.allOf
|
||||
import static org.hamcrest.Matchers.containsString
|
||||
import static org.hamcrest.Matchers.equalTo
|
||||
import static org.hamcrest.Matchers.hasEntry
|
||||
import static org.hamcrest.Matchers.hasItem
|
||||
import static org.hamcrest.Matchers.is
|
||||
import static org.hamcrest.Matchers.not
|
||||
import static org.hamcrest.Matchers.hasEntry
|
||||
import static org.hamcrest.Matchers.allOf
|
||||
import static org.hamcrest.Matchers.containsString
|
||||
import static org.hamcrest.Matchers.stringContainsInOrder
|
||||
import static org.junit.Assert.assertNotNull
|
||||
import static org.junit.Assert.assertThat
|
||||
import static org.junit.Assert.assertTrue
|
||||
|
||||
class CloudFoundryDeployTest extends BasePiperTest {
|
||||
|
||||
@ -42,6 +43,8 @@ class CloudFoundryDeployTest extends BasePiperTest {
|
||||
private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this)
|
||||
private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this)
|
||||
private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this, [])
|
||||
private JenkinsCredentialsRule credentialsRule = new JenkinsCredentialsRule(this)
|
||||
|
||||
|
||||
private writeInfluxMap = [:]
|
||||
|
||||
@ -63,13 +66,17 @@ class CloudFoundryDeployTest extends BasePiperTest {
|
||||
.around(fileExistsRule)
|
||||
.around(dockerExecuteRule)
|
||||
.around(environmentRule)
|
||||
.around(new JenkinsCredentialsRule(this).withCredentials('test_cfCredentialsId', 'test_cf', '********'))
|
||||
.around(credentialsRule)
|
||||
.around(stepRule) // needs to be activated after dockerExecuteRule, otherwise executeDocker is not mocked
|
||||
|
||||
@Before
|
||||
void init() {
|
||||
UUID.metaClass.static.randomUUID = { -> 1}
|
||||
helper.registerAllowedMethod('influxWriteData', [Map.class], {m ->
|
||||
// removing additional credentials tests might have added; adding default credentials
|
||||
credentialsRule.reset()
|
||||
.withCredentials('test_cfCredentialsId', 'test_cf', '********')
|
||||
|
||||
UUID.metaClass.static.randomUUID = { -> 1 }
|
||||
helper.registerAllowedMethod('influxWriteData', [Map.class], { m ->
|
||||
writeInfluxMap = m
|
||||
})
|
||||
}
|
||||
@ -140,6 +147,8 @@ class CloudFoundryDeployTest extends BasePiperTest {
|
||||
assertThat(loggingRule.log, containsString('[cloudFoundryDeploy] WARNING! Found unsupported deployTool. Skipping deployment.'))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testCfNativeWithAppName() {
|
||||
readYamlRule.registerYaml('test.yml', "applications: [{name: 'manifestAppName'}]")
|
||||
@ -219,6 +228,95 @@ class CloudFoundryDeployTest extends BasePiperTest {
|
||||
assertThat(shellRule.shell, hasItem(containsString("cf logout")))
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCfNativeWithDockerImage() {
|
||||
// adding additional credentials for Docker registry authorization
|
||||
credentialsRule.withCredentials('test_cfDockerCredentialsId', 'test_cf_docker', '********')
|
||||
readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]")
|
||||
helper.registerAllowedMethod('writeYaml', [Map], { Map parameters ->
|
||||
generatedFile = parameters.file
|
||||
data = parameters.data
|
||||
})
|
||||
stepRule.step.cloudFoundryDeploy([
|
||||
script: nullScript,
|
||||
juStabUtils: utils,
|
||||
jenkinsUtilsStub: new JenkinsUtilsMock(),
|
||||
deployTool: 'cf_native',
|
||||
deployDockerImage: 'repo/image:tag',
|
||||
cloudFoundry: [
|
||||
org: 'testOrg',
|
||||
space: 'testSpace',
|
||||
credentialsId: 'test_cfCredentialsId',
|
||||
appName: 'testAppName'
|
||||
]
|
||||
])
|
||||
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf push testAppName --docker-image repo/image:tag')))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf logout')))
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCfNativeWithDockerImageAndCredentials() {
|
||||
// adding additional credentials for Docker registry authorization
|
||||
credentialsRule.withCredentials('test_cfDockerCredentialsId', 'test_cf_docker', '********')
|
||||
readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]")
|
||||
helper.registerAllowedMethod('writeYaml', [Map], { Map parameters ->
|
||||
generatedFile = parameters.file
|
||||
data = parameters.data
|
||||
})
|
||||
stepRule.step.cloudFoundryDeploy([
|
||||
script: nullScript,
|
||||
juStabUtils: utils,
|
||||
jenkinsUtilsStub: new JenkinsUtilsMock(),
|
||||
deployTool: 'cf_native',
|
||||
deployDockerImage: 'repo/image:tag',
|
||||
dockerCredentialsId: 'test_cfDockerCredentialsId',
|
||||
cloudFoundry: [
|
||||
org: 'testOrg',
|
||||
space: 'testSpace',
|
||||
credentialsId: 'test_cfCredentialsId',
|
||||
appName: 'testAppName'
|
||||
]
|
||||
])
|
||||
assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry(equalTo('CF_DOCKER_PASSWORD'), equalTo("${'********'}")))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf push testAppName --docker-image repo/image:tag --docker-username test_cf_docker')))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf logout')))
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCfNativeWithManifestAndDockerCredentials() {
|
||||
// Docker image can be done via manifest.yml; if a private Docker registry is used, --docker-username and DOCKER_PASSWORD
|
||||
// must be set; this is checked by this test
|
||||
|
||||
// adding additional credentials for Docker registry authorization
|
||||
credentialsRule.withCredentials('test_cfDockerCredentialsId', 'test_cf_docker', '********')
|
||||
readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]")
|
||||
helper.registerAllowedMethod('writeYaml', [Map], { Map parameters ->
|
||||
generatedFile = parameters.file
|
||||
data = parameters.data
|
||||
})
|
||||
stepRule.step.cloudFoundryDeploy([
|
||||
script: nullScript,
|
||||
juStabUtils: utils,
|
||||
jenkinsUtilsStub: new JenkinsUtilsMock(),
|
||||
deployTool: 'cf_native',
|
||||
dockerCredentialsId: 'test_cfDockerCredentialsId',
|
||||
cloudFoundry: [
|
||||
org: 'testOrg',
|
||||
space: 'testSpace',
|
||||
credentialsId: 'test_cfCredentialsId',
|
||||
appName: 'testAppName',
|
||||
manifest: 'manifest.yml'
|
||||
]
|
||||
])
|
||||
assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry(equalTo('CF_DOCKER_PASSWORD'), equalTo("${'********'}")))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')))
|
||||
assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'manifest.yml' --docker-username test_cf_docker")))
|
||||
assertThat(shellRule.shell, hasItem(containsString('cf logout')))
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCfNativeAppNameFromManifest() {
|
||||
fileExistsRule.registerExistingFile('test.yml')
|
||||
|
@ -1,10 +1,10 @@
|
||||
package util
|
||||
|
||||
import com.lesfurets.jenkins.unit.BasePipelineTest
|
||||
import org.jenkinsci.plugins.credentialsbinding.impl.CredentialNotFoundException
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import org.jenkinsci.plugins.credentialsbinding.impl.CredentialNotFoundException
|
||||
|
||||
/**
|
||||
* By default a user "anonymous" with password "********"
|
||||
@ -32,6 +32,11 @@ class JenkinsCredentialsRule implements TestRule {
|
||||
return this
|
||||
}
|
||||
|
||||
JenkinsCredentialsRule reset(){
|
||||
credentials.clear()
|
||||
return this
|
||||
}
|
||||
|
||||
@Override
|
||||
Statement apply(Statement base, Description description) {
|
||||
return statement(base)
|
||||
@ -63,44 +68,49 @@ class JenkinsCredentialsRule implements TestRule {
|
||||
})
|
||||
|
||||
testInstance.helper.registerAllowedMethod('withCredentials', [List, Closure], { config, closure ->
|
||||
// there can be multiple credentials defined for the closure; collecting the necessary binding
|
||||
// preparations and destructions before executing closure
|
||||
def preparations = []
|
||||
def destructions = []
|
||||
config.each { cred ->
|
||||
def credsId = cred.credentialsId
|
||||
def credentialsBindingType = bindingTypes.get(credsId)
|
||||
def creds = credentials.get(credsId)
|
||||
|
||||
def credsId = config[0].credentialsId
|
||||
def credentialsBindingType = bindingTypes.get(credsId)
|
||||
def creds = credentials.get(credsId)
|
||||
|
||||
def tokenVariable, usernameVariable, passwordVariable, prepare, destruct
|
||||
if(credentialsBindingType == "usernamePassword") {
|
||||
passwordVariable = config[0].passwordVariable
|
||||
usernameVariable = config[0].usernameVariable
|
||||
prepare = {
|
||||
binding.setProperty(usernameVariable, creds?.user)
|
||||
binding.setProperty(passwordVariable, creds?.passwd)
|
||||
def tokenVariable, usernameVariable, passwordVariable, prepare, destruct
|
||||
if (credentialsBindingType == "usernamePassword") {
|
||||
passwordVariable = cred.passwordVariable
|
||||
usernameVariable = cred.usernameVariable
|
||||
preparations.add({
|
||||
binding.setProperty(usernameVariable, creds?.user)
|
||||
binding.setProperty(passwordVariable, creds?.passwd)
|
||||
})
|
||||
destructions.add({
|
||||
binding.setProperty(usernameVariable, null)
|
||||
binding.setProperty(passwordVariable, null)
|
||||
})
|
||||
} else if (credentialsBindingType == "string") {
|
||||
tokenVariable = cred.variable
|
||||
preparations.add({
|
||||
binding.setProperty(tokenVariable, creds?.token)
|
||||
})
|
||||
destructions.add({
|
||||
binding.setProperty(tokenVariable, null)
|
||||
})
|
||||
} else {
|
||||
throw new RuntimeException("Unknown binding type")
|
||||
}
|
||||
destruct = {
|
||||
binding.setProperty(usernameVariable, null)
|
||||
binding.setProperty(passwordVariable, null)
|
||||
}
|
||||
} else if(credentialsBindingType == "string") {
|
||||
tokenVariable = config[0].variable
|
||||
prepare = {
|
||||
binding.setProperty(tokenVariable, creds?.token)
|
||||
}
|
||||
destruct = {
|
||||
binding.setProperty(tokenVariable, null)
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("Unknown binding type")
|
||||
}
|
||||
|
||||
prepare()
|
||||
preparations.each { it() }
|
||||
try {
|
||||
closure()
|
||||
} finally {
|
||||
destruct()
|
||||
destructions.each { it() }
|
||||
}
|
||||
})
|
||||
|
||||
base.evaluate()
|
||||
base.evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import com.sap.piper.BashUtils
|
||||
import com.sap.piper.CfManifestUtils
|
||||
import com.sap.piper.ConfigurationHelper
|
||||
import com.sap.piper.GenerateDocumentation
|
||||
import com.sap.piper.JenkinsUtils
|
||||
import com.sap.piper.Utils
|
||||
import groovy.transform.Field
|
||||
|
||||
import static com.sap.piper.Prerequisites.checkScript
|
||||
|
||||
import com.sap.piper.GenerateDocumentation
|
||||
import com.sap.piper.Utils
|
||||
import com.sap.piper.ConfigurationHelper
|
||||
import com.sap.piper.CfManifestUtils
|
||||
import com.sap.piper.BashUtils
|
||||
|
||||
import groovy.transform.Field
|
||||
|
||||
@Field String STEP_NAME = getClass().getName()
|
||||
|
||||
@Field Set GENERAL_CONFIG_KEYS = STEP_CONFIG_KEYS
|
||||
@ -134,10 +132,24 @@ import groovy.transform.Field
|
||||
*/
|
||||
'smokeTestStatusCode',
|
||||
/**
|
||||
* Provides more output. May reveal sensitive information.
|
||||
* @possibleValues true, false
|
||||
*/
|
||||
* Provides more output. May reveal sensitive information.
|
||||
* @possibleValues true, false
|
||||
*/
|
||||
'verbose',
|
||||
/**
|
||||
* Docker image deployments are supported (via manifest file in general)[https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#docker].
|
||||
* If no manifest is used, this parameter defines the image to be deployed. The specified name of the image is
|
||||
* passed to the `--docker-image` parameter of the cf CLI and must adhere it's naming pattern (e.g. REPO/IMAGE:TAG).
|
||||
* See (cf CLI documentation)[https://docs.cloudfoundry.org/devguide/deploy-apps/push-docker.html] for details.
|
||||
*
|
||||
* Note: The used Docker registry must be visible for the targeted Cloud Foundry instance.
|
||||
*/
|
||||
'deployDockerImage',
|
||||
/**
|
||||
* If the specified image in `deployDockerImage` is contained in a Docker registry, which requires authorization
|
||||
* this defines the credentials to be used.
|
||||
*/
|
||||
'dockerCredentialsId',
|
||||
]
|
||||
|
||||
@Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', manifestVariablesFiles: 'cfManifestVariablesFiles', manifestVariables: 'cfManifestVariables', org: 'cfOrg', space: 'cfSpace']]
|
||||
@ -259,7 +271,7 @@ def findMtar(){
|
||||
error "[${STEP_NAME}] No *.mtar file found!"
|
||||
}
|
||||
|
||||
def deployMta (config) {
|
||||
def deployMta(config) {
|
||||
if (config.mtaExtensionDescriptor == null) config.mtaExtensionDescriptor = ''
|
||||
if (!config.mtaExtensionDescriptor.isEmpty() && !config.mtaExtensionDescriptor.startsWith('-e ')) config.mtaExtensionDescriptor = "-e ${config.mtaExtensionDescriptor}"
|
||||
|
||||
@ -301,7 +313,7 @@ private void handleCFNativeDeployment(Map config, script) {
|
||||
checkAndUpdateDeployTypeForNotSupportedManifest(config)
|
||||
|
||||
if (config.deployType == 'blue-green') {
|
||||
prepareBlueGreenCfNativeDeploy(config,script)
|
||||
prepareBlueGreenCfNativeDeploy(config, script)
|
||||
} else {
|
||||
prepareCfPushCfNativeDeploy(config)
|
||||
}
|
||||
@ -309,19 +321,38 @@ private void handleCFNativeDeployment(Map config, script) {
|
||||
echo "[${STEP_NAME}] CF native deployment (${config.deployType}) with:"
|
||||
echo "[${STEP_NAME}] - cfAppName=${config.cloudFoundry.appName}"
|
||||
echo "[${STEP_NAME}] - cfManifest=${config.cloudFoundry.manifest}"
|
||||
echo "[${STEP_NAME}] - cfManifestVariables=${config.cloudFoundry.manifestVariables?:'none specified'}"
|
||||
echo "[${STEP_NAME}] - cfManifestVariablesFiles=${config.cloudFoundry.manifestVariablesFiles?:'none specified'}"
|
||||
echo "[${STEP_NAME}] - cfManifestVariables=${config.cloudFoundry.manifestVariables ?: 'none specified'}"
|
||||
echo "[${STEP_NAME}] - cfManifestVariablesFiles=${config.cloudFoundry.manifestVariablesFiles ?: 'none specified'}"
|
||||
echo "[${STEP_NAME}] - cfdeployDockerImage=${config.deployDockerImage ?: 'none specified'}"
|
||||
echo "[${STEP_NAME}] - cfdockerCredentialsId=${config.dockerCredentialsId ?: 'none specified'}"
|
||||
echo "[${STEP_NAME}] - smokeTestScript=${config.smokeTestScript}"
|
||||
|
||||
checkIfAppNameIsAvailable(config)
|
||||
dockerExecute(
|
||||
script: script,
|
||||
dockerImage: config.dockerImage,
|
||||
dockerWorkspace: config.dockerWorkspace,
|
||||
stashContent: config.stashContent,
|
||||
dockerEnvVars: [CF_HOME: "${config.dockerWorkspace}", CF_PLUGIN_HOME: "${config.dockerWorkspace}", STATUS_CODE: "${config.smokeTestStatusCode}"]
|
||||
) {
|
||||
deployCfNative(config)
|
||||
|
||||
def dockerCredentials = []
|
||||
if (config.dockerCredentialsId != null && config.dockerCredentialsId != '') {
|
||||
dockerCredentials.add(usernamePassword(
|
||||
credentialsId: config.dockerCredentialsId,
|
||||
passwordVariable: 'dockerPassword',
|
||||
usernameVariable: 'dockerUsername'
|
||||
))
|
||||
}
|
||||
withCredentials(dockerCredentials) {
|
||||
dockerExecute(
|
||||
script: script,
|
||||
dockerImage: config.dockerImage,
|
||||
dockerWorkspace: config.dockerWorkspace,
|
||||
stashContent: config.stashContent,
|
||||
dockerEnvVars: [
|
||||
CF_HOME : "${config.dockerWorkspace}",
|
||||
CF_PLUGIN_HOME : "${config.dockerWorkspace}",
|
||||
// if the Docker registry requires authentication the DOCKER_PASSWORD env variable must be set
|
||||
CF_DOCKER_PASSWORD: "${binding.hasVariable("dockerPassword") ? dockerPassword : ''}",
|
||||
STATUS_CODE : "${config.smokeTestStatusCode}"
|
||||
]
|
||||
) {
|
||||
deployCfNative(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -420,21 +451,32 @@ private checkIfAppNameIsAvailable(config) {
|
||||
}
|
||||
}
|
||||
|
||||
def deployCfNative (config) {
|
||||
def deployStatement = "cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} ${config.cfNativeDeployParameters}"
|
||||
deploy(null, deployStatement, config, { c -> stopOldAppIfRunning(c) })
|
||||
def deployCfNative(config) {
|
||||
// the deployStatement is complex and has lot of options; using a list and findAll allows to put each option
|
||||
// as a single list element; if a option is not set (= null or '') this removed before every element is joined
|
||||
// via a single whitespace; results in a single line deploy statement
|
||||
def deployStatement = [
|
||||
'cf',
|
||||
config.deployCommand,
|
||||
config.cloudFoundry.appName,
|
||||
config.deployOptions,
|
||||
config.cloudFoundry.manifest ? "-f '${config.cloudFoundry.manifest}'" : null,
|
||||
config.deployDockerImage ? "--docker-image ${config.deployDockerImage}" : null,
|
||||
binding.hasVariable("dockerUsername") ? "--docker-username ${dockerUsername}}" : null,
|
||||
config.smokeTest,
|
||||
config.cfNativeDeployParameters
|
||||
].findAll { s -> s != null && s != '' }.join(" ")
|
||||
deploy(null, deployStatement, config, { c -> stopOldAppIfRunning(c) })
|
||||
}
|
||||
|
||||
private deploy(def cfApiStatement, def cfDeployStatement, def config, Closure postDeployAction) {
|
||||
private deploy(String cfApiStatement, String cfDeployStatement, config, Closure postDeployAction) {
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.cloudFoundry.credentialsId,
|
||||
passwordVariable: 'password',
|
||||
usernameVariable: 'username'
|
||||
)]) {
|
||||
|
||||
def cfTraceFile = 'cf.log'
|
||||
|
||||
def deployScript = """#!/bin/bash
|
||||
set +x
|
||||
set -e
|
||||
@ -446,7 +488,7 @@ private deploy(def cfApiStatement, def cfDeployStatement, def config, Closure po
|
||||
${cfDeployStatement}
|
||||
"""
|
||||
|
||||
if(config.verbose) {
|
||||
if (config.verbose) {
|
||||
// Password contained in output below is hidden by withCredentials
|
||||
echo "[INFO][${STEP_NAME}] Executing command: '${deployScript}'."
|
||||
}
|
||||
@ -458,11 +500,11 @@ private deploy(def cfApiStatement, def cfDeployStatement, def config, Closure po
|
||||
|
||||
error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details."
|
||||
}
|
||||
if(config.verbose) {
|
||||
if (config.verbose) {
|
||||
handleCfCliLog(cfTraceFile)
|
||||
}
|
||||
|
||||
if(postDeployAction) postDeployAction(config)
|
||||
if (postDeployAction) postDeployAction(config)
|
||||
|
||||
sh "cf logout"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user