mirror of https://github.com/rancher/dartboard.git
add basic tests around CRDs
This commit is contained in:
parent
3eb7d2508e
commit
c59396d19a
|
|
@ -0,0 +1,180 @@
|
|||
import { check, fail, sleep } from 'k6';
|
||||
import http from 'k6/http'
|
||||
import { retryUntilExpected } from "./rancher_utils.js";
|
||||
import * as YAML from './lib/js-yaml-4.1.0.mjs'
|
||||
|
||||
export const baseCRDPath = "v1/apiextensions.k8s.io.customresourcedefinitions"
|
||||
export const crdTag = { url: `/v1/apiextensions.k8s.io.customresourcedefinitions/<CRD ID>` }
|
||||
export const crdsTag = { url: `/v1/apiextensions.k8s.io.customresourcedefinitions` }
|
||||
export const putCRDTag = { url: `/v1/apiextensions.k8s.io.customresourcedefinitions/<CRD Name>` }
|
||||
|
||||
export const crdRefreshDelaySeconds = 2
|
||||
export const crdRefreshDelayMs = crdRefreshDelaySeconds * 1000
|
||||
export const backgroundRefreshSeconds = 10
|
||||
export const backgroundRefreshMs = backgroundRefreshSeconds * 1000
|
||||
|
||||
|
||||
export function cleanupMatchingCRDs(baseUrl, cookies, namePrefix) {
|
||||
let res = http.get(`${baseUrl}/${baseCRDPath}`, { cookies: cookies })
|
||||
check(res, {
|
||||
'/v1/apiextensions.k8s.io.customresourcedefinitions returns status 200': (r) => r.status === 200,
|
||||
})
|
||||
JSON.parse(res.body)["data"].filter(r => r["metadata"]["name"].startsWith(namePrefix)).forEach(r => {
|
||||
res = http.del(`${baseUrl}/${baseCRDPath}/${r["id"]}`, { cookies: cookies })
|
||||
check(res, {
|
||||
'DELETE /v1/apiextensions.k8s.io.customresourcedefinitions returns status 204': (r) => r.status === 204,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function getRandomArrayItems(arr, numItems) {
|
||||
var len = arr.length
|
||||
if (numItems > len)
|
||||
throw new RangeError("getRandomItems: more elements taken than available");
|
||||
var result = new Array(numItems),
|
||||
taken = new Array(len);
|
||||
while (numItems--) {
|
||||
var x = Math.floor(Math.random() * len);
|
||||
result[numItems] = arr[x in taken ? taken[x] : x];
|
||||
taken[x] = --len in taken ? taken[len] : len;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sizeOfHeaders(hdrs) {
|
||||
return Object.keys(hdrs).reduce((sum, key) => sum + key.length + hdrs[key].length, 0);
|
||||
}
|
||||
|
||||
export function trackDataMetricsPerURL(res, tags, headerDataRecv, epDataRecv) {
|
||||
// Add data points for received data
|
||||
headerDataRecv.add(sizeOfHeaders(res.headers));
|
||||
if (res.hasOwnProperty('body') && res.body) {
|
||||
epDataRecv.add(res.body.length, tags);
|
||||
} else {
|
||||
epDataRecv.add(0, tags)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCRD(baseUrl, cookies, id) {
|
||||
let res = http.get(`${baseUrl}/${baseCRDPath}/${id}`, { cookies: cookies, tag: crdTag })
|
||||
let criteria = []
|
||||
criteria[`GET /${baseCRDPath}/<CRD ID> returns status 200`] = (r) => r.status === 200
|
||||
check(res, criteria)
|
||||
// console.log(`GET CRD status: ${res.status}`)
|
||||
return res
|
||||
}
|
||||
|
||||
export function getCRDs(baseUrl, cookies) {
|
||||
let res = http.get(`${baseUrl}/${baseCRDPath}`, { cookies: cookies, tags: crdsTag })
|
||||
check(res, {
|
||||
'GET /v1/apiextensions.k8s.io.customresourcedefinitions returns status 200': (r) => r.status === 200,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export function verifyCRDs(baseUrl, cookies, namePrefix, expectedLength, timeoutMs) {
|
||||
const timeWas = new Date();
|
||||
let timeSpent = null
|
||||
let res = null
|
||||
let criteria = []
|
||||
let currentLength = -1
|
||||
// Poll customresourcedefinitions until receiving a 200
|
||||
while (new Date() - timeWas < timeoutMs) {
|
||||
res = retryUntilExpected(200, () => { return getCRDs(baseUrl, cookies) })
|
||||
timeSpent = new Date() - timeWas
|
||||
if (res.status === 200) {
|
||||
let data = JSON.parse(res.body)["data"]
|
||||
data = data.filter(r => r["metadata"]["name"].startsWith(namePrefix))
|
||||
currentLength = data.length
|
||||
if (currentLength == expectedLength) {
|
||||
console.log("Polling conditions met after ", timeSpent, "ms");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
console.log("Polling CRDs failed to receive 200 status")
|
||||
}
|
||||
}
|
||||
criteria[`detected the expected # of CRDs "${expectedLength}" matches the received # of CRDs "${currentLength}"`] = (r) => currentLength == expectedLength
|
||||
check(res, criteria)
|
||||
return { res: res, timeSpent: timeSpent }
|
||||
}
|
||||
|
||||
export function createCRD(baseUrl, cookies, suffix) {
|
||||
const namePattern = `-test-${suffix}`
|
||||
const res = http.post(
|
||||
`${baseUrl}/${baseCRDPath}`,
|
||||
`apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: crontabs${namePattern}.stable.example.com\nspec:\n group: stable.example.com\n versions:\n - name: v1\n served: true\n storage: false\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n properties:\n cronSpec:\n type: string\n image:\n type: string\n replicas:\n type: integer\n - name: v2\n served: true\n storage: true\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n properties:\n cronSpec:\n type: string\n newField:\n type: string\n image:\n type: string\n replicas:\n type: integer\n scope: Namespaced\n names:\n plural: crontabs${namePattern}\n singular: crontab${namePattern}\n kind: CronTab${namePattern}\n shortNames:\n - ct${namePattern}\n`,
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/yaml",
|
||||
},
|
||||
cookies: cookies,
|
||||
tags: crdsTag,
|
||||
}
|
||||
)
|
||||
check(res, {
|
||||
'CRD post returns 201 (created)': (r) => r.status === 201,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export function deleteCRD(baseUrl, cookies, id) {
|
||||
let res = http.del(`${baseUrl}/${baseCRDPath}/${id}`, null, { cookies: cookies, tags: crdTag })
|
||||
|
||||
check(res, {
|
||||
'DELETE /v1/apiextensions.k8s.io.customresourcedefinitions returns status 200': (r) => r.status === 200 || r.status === 204,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export function getCRDsMatchingName(baseUrl, cookies, namePrefix) {
|
||||
let res = getCRDs(baseUrl, cookies)
|
||||
if (!Object.hasOwn(res, 'body') || !Object.hasOwn(JSON.parse(res.body), 'data')) {
|
||||
console.log("Response doesn't have body");
|
||||
return { res: res, crdArray: [] };
|
||||
}
|
||||
let crdArray = []
|
||||
crdArray = JSON.parse(res.body)["data"]
|
||||
crdArray = crdArray.filter(r => r["metadata"]["name"].startsWith(namePrefix))
|
||||
return { res: res, crdArray: crdArray }
|
||||
}
|
||||
|
||||
export function getCRDsMatchingNameVersions(baseUrl, cookies, namePrefix, numVersions) {
|
||||
let res = getCRDs(baseUrl, cookies)
|
||||
if (!Object.hasOwn(res, 'body') || !Object.hasOwn(JSON.parse(res.body), 'data')) {
|
||||
console.log("Response doesn't have body");
|
||||
return { res: res, crdArray: [] };
|
||||
}
|
||||
let crdArray = JSON.parse(res.body)["data"]
|
||||
crdArray = crdArray.filter(r => r["metadata"]["name"].startsWith(namePrefix) && r["spec"]["versions"].length == numVersions)
|
||||
return crdArray
|
||||
}
|
||||
|
||||
export function selectCRDs(crdArray, numVersions) {
|
||||
let modifyCRDs = getRandomArrayItems(crdArray, numVersions)
|
||||
return modifyCRDs
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
cleanup(data.cookies)
|
||||
}
|
||||
|
||||
export function updateCRD(baseUrl, cookies, crd) {
|
||||
let body = YAML.dump(crd)
|
||||
let res = http.put(
|
||||
`${baseUrl}/${baseCRDPath}/${crd.metadata.name}`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/yaml',
|
||||
},
|
||||
cookies: cookies,
|
||||
tags: putCRDTag,
|
||||
}
|
||||
)
|
||||
check(res, {
|
||||
'PUT /v1/apiextensions.k8s.io.customresourcedefinitions returns status 200': (r) => r.status === 200,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { check, fail, sleep } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { Trend } from 'k6/metrics';
|
||||
import { getCookies, login } from "./rancher_utils.js";
|
||||
import exec from "k6/execution";
|
||||
import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||
import * as crdUtil from "./crd_utils.js";
|
||||
|
||||
|
||||
const vus = __ENV.K6_VUS || 20
|
||||
const crdCount = __ENV.CRD_COUNT || 500
|
||||
const iterations = __ENV.PER_VU_ITERATIONS || 30
|
||||
const baseUrl = __ENV.BASE_URL
|
||||
const username = __ENV.USERNAME
|
||||
const password = __ENV.PASSWORD
|
||||
const namePrefix = "crontabs-test-"
|
||||
|
||||
export const epDataRecv = new Trend('endpoint_data_recv');
|
||||
export const headerDataRecv = new Trend('header_data_recv');
|
||||
export const timePolled = new Trend('time_polled', true);
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
create: {
|
||||
executor: 'shared-iterations',
|
||||
exec: 'createCRDs',
|
||||
vus: vus,
|
||||
iterations: iterations,
|
||||
maxDuration: '24h',
|
||||
}
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<=0.01'], // http errors should be less than 1%
|
||||
http_req_duration: ['p(99)<=500'], // 95% of requests should be below 500ms
|
||||
checks: ['rate>0.99'], // the rate of successful checks should be higher than 99%
|
||||
header_data_recv: ['p(95) < 1024'],
|
||||
[`endpoint_data_recv{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['min > 2048'], // bytes in this case
|
||||
[`time_polled{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['p(99) < 5000', 'avg < 2500'],
|
||||
},
|
||||
setupTimeout: '15m',
|
||||
teardownTimeout: '15m'
|
||||
}
|
||||
|
||||
function cleanup(cookies, namePrefix) {
|
||||
let { _, crdArray } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
let deleteAllFailed = false
|
||||
crdArray.forEach(r => {
|
||||
let delRes = crdUtil.deleteCRD(baseUrl, cookies, r["id"])
|
||||
if (delRes.status !== 200 && delRes.status !== 204) deleteAllFailed = true
|
||||
sleep(0.5)
|
||||
})
|
||||
return deleteAllFailed
|
||||
}
|
||||
|
||||
// Test functions, in order of execution
|
||||
export function setup() {
|
||||
// log in
|
||||
if (!login(baseUrl, {}, username, password)) {
|
||||
fail(`could not login into cluster`)
|
||||
}
|
||||
const cookies = getCookies(baseUrl)
|
||||
|
||||
// delete leftovers, if any
|
||||
let deleteAllFailed = cleanup(cookies, namePrefix)
|
||||
if (deleteAllFailed) fail("Failed to delete all existing crontab CRDs during setup!")
|
||||
// return data that remains constant throughout the test
|
||||
return cookies
|
||||
}
|
||||
|
||||
export function createCRDs(cookies) {
|
||||
for (let i = 0; i < crdCount; i++) {
|
||||
let crdSuffix = `${exec.vu.idInTest}-${randomString(4)}`
|
||||
let res = crdUtil.createCRD(baseUrl, cookies, crdSuffix)
|
||||
crdUtil.trackDataMetricsPerURL(res, crdUtil.crdsTag, headerDataRecv, epDataRecv)
|
||||
sleep(0.5)
|
||||
}
|
||||
sleep(0.15)
|
||||
let { _, timeSpent } = crdUtil.verifyCRDs(baseUrl, cookies, namePrefix, 500, crdUtil.crdRefreshDelayMs * 5)
|
||||
timePolled.add(timeSpent, crdUtil.crdsTag)
|
||||
sleep(60)
|
||||
let deleteAllFailed = cleanup(cookies, namePrefix)
|
||||
if (deleteAllFailed) fail("Failed to delete all existing crontab CRDs!")
|
||||
// Give time for resource usage to cool down between iterations
|
||||
sleep(900)
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { check, fail, sleep } from 'k6';
|
||||
import http from 'k6/http'
|
||||
import { Trend } from 'k6/metrics';
|
||||
import { getCookies, login } from "./rancher_utils.js";
|
||||
import { vu as metaVU } from 'k6/execution'
|
||||
import * as crdUtil from "./crd_utils.js";
|
||||
|
||||
|
||||
const vus = __ENV.K6_VUS || 20
|
||||
const crdCount = __ENV.CRD_COUNT || 500
|
||||
const iterations = __ENV.PER_VU_ITERATIONS || 30
|
||||
const baseUrl = __ENV.BASE_URL
|
||||
const username = __ENV.USERNAME
|
||||
const password = __ENV.PASSWORD
|
||||
const namePrefix = "crontabs-test-"
|
||||
|
||||
export const epDataRecv = new Trend('endpoint_data_recv');
|
||||
export const headerDataRecv = new Trend('header_data_recv');
|
||||
export const timePolled = new Trend('time_polled', true);
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
delete: {
|
||||
executor: 'shared-iterations',
|
||||
exec: 'deleteCRDs',
|
||||
vus: vus,
|
||||
iterations: iterations,
|
||||
maxDuration: '24h',
|
||||
}
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<=0.01'], // http errors should be less than 1%
|
||||
http_req_duration: ['p(99)<=500'], // 95% of requests should be below 500ms
|
||||
checks: ['rate>0.99'], // the rate of successful checks should be higher than 99%
|
||||
header_data_recv: ['p(95) < 1024'],
|
||||
[`endpoint_data_recv{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['min > 2048'], // bytes in this case
|
||||
[`endpoint_data_recv{url:'/v1/apiextensions.k8s.io.customresourcedefinitions/<CRD ID>'}`]: ['max < 512'], // bytes in this case
|
||||
[`time_polled{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['p(99) < 5000', 'avg < 2500'],
|
||||
},
|
||||
setupTimeout: '30m',
|
||||
teardownTimeout: '30m'
|
||||
}
|
||||
|
||||
function cleanup(cookies) {
|
||||
let { res, crdArray } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
console.log("Got CRD Status in delete: ", res.status)
|
||||
let deleteAllFailed = false
|
||||
crdArray.forEach(r => {
|
||||
let delRes = crdUtil.deleteCRD(baseUrl, cookies, r["id"])
|
||||
if (delRes.status !== 200 && delRes.status !== 204) deleteAllFailed = true
|
||||
sleep(0.15)
|
||||
})
|
||||
return deleteAllFailed
|
||||
}
|
||||
|
||||
// Test functions, in order of execution
|
||||
export function setup() {
|
||||
// log in
|
||||
if (!login(baseUrl, {}, username, password)) {
|
||||
fail(`could not login into cluster`)
|
||||
}
|
||||
const cookies = getCookies(baseUrl)
|
||||
|
||||
let deleteAllFailed = cleanup(cookies, namePrefix)
|
||||
if (deleteAllFailed) fail("Failed to delete all existing crontab CRDs during setup!")
|
||||
let { _, crdArray } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
|
||||
// return data that remains constant throughout the test
|
||||
return { cookies: cookies, crdArray: checkAndBuildCRDArray(cookies, crdArray) }
|
||||
}
|
||||
|
||||
export function checkAndBuildCRDArray(cookies, crdArray) {
|
||||
let retries = 3
|
||||
let attempts = 0
|
||||
while (crdArray.length != crdCount && attempts < retries) {
|
||||
console.log("Creating needed CRDs")
|
||||
// delete leftovers, if any so that we create exactly crdCount
|
||||
if (crdArray.length == crdCount) {
|
||||
console.log("Finished setting up expected CRD count")
|
||||
break;
|
||||
}
|
||||
if (crdArray.length > 0) {
|
||||
let deleteAllFailed = cleanup(cookies,)
|
||||
if (deleteAllFailed && attempts == (retries - 1)) fail("Failed to delete all existing crontab CRDs during setup!")
|
||||
}
|
||||
for (let i = 0; i < crdCount; i++) {
|
||||
let crdSuffix = `${i}`
|
||||
let res = crdUtil.createCRD(baseUrl, cookies, crdSuffix)
|
||||
crdUtil.trackDataMetricsPerURL(res, crdUtil.crdsTag, headerDataRecv, epDataRecv)
|
||||
sleep(0.25)
|
||||
}
|
||||
let { res, crdArray: crds } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
if (Array.isArray(crds) && crds.length) crdArray = crds
|
||||
if (res.status != 200 && attempts == (retries - 1)) fail("Failed to retrieve expected CRDs during setup")
|
||||
attempts += 1
|
||||
}
|
||||
if (crdArray.length != crdCount) fail("Failed to create expected # of CRDs")
|
||||
console.log("Expected number of CRDs accounted for ", crdArray.length)
|
||||
sleep(300)
|
||||
return crdArray
|
||||
}
|
||||
|
||||
export function deleteCRDs(data) {
|
||||
data.crdArray.forEach(c => {
|
||||
let res = crdUtil.deleteCRD(baseUrl, data.cookies, c.id)
|
||||
crdUtil.trackDataMetricsPerURL(res, crdUtil.crdTag, headerDataRecv, epDataRecv)
|
||||
sleep(0.15)
|
||||
})
|
||||
let { _, timeSpent } = crdUtil.verifyCRDs(baseUrl, data.cookies, namePrefix, 0, crdUtil.crdRefreshDelayMs * 5)
|
||||
timePolled.add(timeSpent, crdUtil.crdsTag)
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import http from 'k6/http'
|
||||
import { check, fail, sleep } from 'k6';
|
||||
import exec from 'k6/execution';
|
||||
import { Trend } from 'k6/metrics';
|
||||
import { getCookies, login } from "./rancher_utils.js";
|
||||
import * as k8s from './k8s.js'
|
||||
import * as crdUtil from "./crd_utils.js";
|
||||
|
||||
|
||||
const vus = __ENV.K6_VUS || 20
|
||||
const crdCount = __ENV.CRD_COUNT || 500
|
||||
const baseUrl = __ENV.BASE_URL
|
||||
const username = __ENV.USERNAME
|
||||
const password = __ENV.PASSWORD
|
||||
|
||||
export const epDataRecv = new Trend('endpoint_data_recv');
|
||||
export const headerDataRecv = new Trend('header_data_recv');
|
||||
|
||||
// Option setting
|
||||
export const options = {
|
||||
setupTimeout: '8h',
|
||||
scenarios: {
|
||||
load: {
|
||||
executor: 'per-vu-iterations',
|
||||
exec: 'getCRDs',
|
||||
vus: vus,
|
||||
iterations: crdCount,
|
||||
maxDuration: '1h',
|
||||
}
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<=0.01'], // http errors should be less than 1%
|
||||
http_req_duration: ['p(99)<=500'], // 95% of requests should be below 500ms
|
||||
checks: ['rate>0.99'], // the rate of successful checks should be higher than 99%
|
||||
header_data_recv: ['p(95) < 1024'],
|
||||
[`endpoint_data_recv{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['min > 2048'], // bytes in this case
|
||||
[`time_polled{url:'/v1/apiextensions.k8s.io.customresourcedefinitions'}`]: ['p(99) < 5000', 'avg < 2500'],
|
||||
}
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
// log in
|
||||
if (!login(baseUrl, {}, username, password)) {
|
||||
fail(`could not login into cluster`)
|
||||
}
|
||||
const cookies = getCookies(baseUrl)
|
||||
|
||||
let { _, crdArray } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
|
||||
// return data that remains constant throughout the test
|
||||
return { cookies: cookies, crdArray: checkAndBuildCRDArray(cookies, crdArray) }
|
||||
}
|
||||
|
||||
export function checkAndBuildCRDArray(cookies, crdArray) {
|
||||
let retries = 3
|
||||
let attempts = 0
|
||||
while (crdArray.length != crdCount && attempts < retries) {
|
||||
console.log("Creating needed CRDs")
|
||||
// delete leftovers, if any so that we create exactly crdCount
|
||||
if (crdArray.length == crdCount) {
|
||||
console.log("Finished setting up expected CRD count")
|
||||
break;
|
||||
}
|
||||
if (crdArray.length > 0) {
|
||||
let deleteAllFailed = cleanup(cookies,)
|
||||
if (deleteAllFailed && attempts == (retries - 1)) fail("Failed to delete all existing crontab CRDs during setup!")
|
||||
}
|
||||
for (let i = 0; i < crdCount; i++) {
|
||||
let crdSuffix = `${i}`
|
||||
let res = crdUtil.createCRD(baseUrl, cookies, crdSuffix)
|
||||
crdUtil.trackDataMetricsPerURL(res, crdUtil.crdsTag, headerDataRecv, epDataRecv)
|
||||
sleep(0.25)
|
||||
}
|
||||
let { res, crdArray } = crdUtil.getCRDsMatchingName(baseUrl, cookies, namePrefix)
|
||||
if (res.status != 200 && attempts == (retries - 1)) fail("Failed to retrieve expected CRDs during setup")
|
||||
attempts += 1
|
||||
}
|
||||
if (crdArray.length != crdCount) fail("Failed to create expected # of CRDs")
|
||||
console.log("Expected number of CRDs accounted for ", crdArray.length)
|
||||
return crdArray
|
||||
}
|
||||
|
||||
export function getCRDs(data) {
|
||||
let res = crdUtil.getCRDs(baseUrl, data.cookies)
|
||||
crdUtil.trackDataMetricsPerURL(res, crdUtil.crdsTag, headerDataRecv, epDataRecv)
|
||||
return res
|
||||
}
|
||||
Loading…
Reference in New Issue