clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles
Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la
premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines :
1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT
le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage
inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster.
2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve
desormais, message de l'API compris.
3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du
gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend
sans un mot. Le noeud se lit dans l'UPID lui-meme.
4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui
n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter.
Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un
retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du
gabarit au lieu de 40, sans que rien ne le dise.
Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage,
clone_complet) au lieu de l'heriter du tenant actif.
Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues.
42 preuves vertes, ansible-lint profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
dc92f01fe4
commit
6715f0d5c5
4 changed files with 161 additions and 9 deletions
51
CHANGELOG.md
51
CHANGELOG.md
|
|
@ -1,5 +1,56 @@
|
||||||
# CHANGELOG — Set-OPS
|
# CHANGELOG — Set-OPS
|
||||||
|
|
||||||
|
## 2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés
|
||||||
|
|
||||||
|
Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. `site-cache-01`
|
||||||
|
est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du
|
||||||
|
moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom.
|
||||||
|
|
||||||
|
**1. Le clonage visait le nœud de destination.** L'URL était
|
||||||
|
`nodes/{{ proxmox_clone_noeud }}/qemu/<gabarit>/clone`, alors que l'API veut le nœud qui
|
||||||
|
**détient** le modèle ; la destination se dit par `target`. Le gabarit vit sur asgard,
|
||||||
|
la VM allait sur gandalf → `500 Configuration file 'nodes/gandalf/qemu-server/99998.conf'
|
||||||
|
does not exist`. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster.
|
||||||
|
|
||||||
|
**2. Le refus de l'API était avalé.** `failed_when: false` + `no_log: true` protégeaient
|
||||||
|
l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève
|
||||||
|
maintenant, message de l'API compris — le masque reste, l'erreur sort.
|
||||||
|
|
||||||
|
**3. L'attente cherchait la tâche au mauvais endroit.** Elle interrogeait
|
||||||
|
`nodes/<destination>/tasks/<UPID>` ; un clonage inter-nœuds s'exécute sur le nœud du
|
||||||
|
gabarit. L'UPID n'existait pas là, l'API répondait en erreur, `until` n'était jamais
|
||||||
|
satisfait : **60 tentatives × 10 s pour un clonage terminé en 87 s**, puis la suite qui
|
||||||
|
reprend sans un mot. Un UPID s'écrit `UPID:<nœud>:<pid>:…` — le nœud y est déjà, on le lit
|
||||||
|
là plutôt que de le redemander à une variable.
|
||||||
|
|
||||||
|
**4. La garde d'après-attente ne pouvait pas échouer.** `exitstatus | default('OK')`
|
||||||
|
faisait passer pour un succès une attente qui n'avait **rien observé** : sans `json.data`,
|
||||||
|
pas d'`exitstatus`, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et
|
||||||
|
bien s'arrêter.
|
||||||
|
|
||||||
|
### La taille des disques porte toujours son unité
|
||||||
|
|
||||||
|
`disque: 40` produisait un `resize` à « 40 » que Proxmox lisait comme un **rétrécissement**
|
||||||
|
(`shrinking disks is not supported`). Le playbook tolère cette erreur — à raison, un disque
|
||||||
|
déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit
|
||||||
|
au lieu de 40, **sans que rien ne le dise**. Les plans des tenants écrivent `40G` depuis
|
||||||
|
toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité.
|
||||||
|
|
||||||
|
### Le site déclare ce qu'il matérialise
|
||||||
|
|
||||||
|
Le playbook prenait le gabarit et le stockage dans les group_vars du **tenant actif** :
|
||||||
|
`make site-creer` aurait cloné depuis un modèle différent selon le symlink `instance`,
|
||||||
|
sans rien dire. `materialisation:` (vmid du gabarit, stockage, `clone_complet`) vit
|
||||||
|
désormais dans l'underlay du site, et `site-creer` les passe explicitement.
|
||||||
|
|
||||||
|
### Ce que le chronomètre a dit
|
||||||
|
|
||||||
|
Le clonage réel : **59 s et 87 s** pour 3,3 Gio effectivement alloués (le gabarit en
|
||||||
|
déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes
|
||||||
|
d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (`rbd clone` depuis
|
||||||
|
`@__base__`, instantané), mais il exige `CephNVMe` en destination : `TrueNAS` est du LVM
|
||||||
|
épais, sans copy-on-write.
|
||||||
|
|
||||||
## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles
|
## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles
|
||||||
|
|
||||||
### Un mot manquait au vocabulaire des flux
|
### Un mot manquait au vocabulaire des flux
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -751,6 +751,7 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
|
||||||
if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
|
if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
|
||||||
for m in $$machines; do \
|
for m in $$machines; do \
|
||||||
printf '\n=== Machine du site: %s ===\n' "$$m"; \
|
printf '\n=== Machine du site: %s ===\n' "$$m"; \
|
||||||
|
t0=$$(date +%s); \
|
||||||
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
|
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
|
||||||
eval "$$params"; \
|
eval "$$params"; \
|
||||||
$(MAKE) cloner-vm \
|
$(MAKE) cloner-vm \
|
||||||
|
|
@ -766,8 +767,10 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
|
||||||
COEURS="$$SETOPS_COEURS" \
|
COEURS="$$SETOPS_COEURS" \
|
||||||
MEMOIRE="$$SETOPS_MEMOIRE" \
|
MEMOIRE="$$SETOPS_MEMOIRE" \
|
||||||
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
|
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
|
||||||
VMID_MODELE="$(VMID_MODELE)" \
|
VMID_MODELE="$$SETOPS_VMID_MODELE" \
|
||||||
|
CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \
|
||||||
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
|
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
|
||||||
|
printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \
|
||||||
done; \
|
done; \
|
||||||
printf '\nLes machines du site sont creees.\n'
|
printf '\nLes machines du site sont creees.\n'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,36 @@
|
||||||
| selectattr('vmid', 'equalto', proxmox_clone_vmid | int)
|
| selectattr('vmid', 'equalto', proxmox_clone_vmid | int)
|
||||||
| list | length > 0 }}
|
| list | length > 0 }}
|
||||||
|
|
||||||
|
# OU VIT LE GABARIT — et ce n'est pas forcement ou va la VM (2026-08-25).
|
||||||
|
#
|
||||||
|
# L'URL de clonage visait `nodes/{{ proxmox_clone_noeud }}`, c'est-a-dire le noeud de
|
||||||
|
# DESTINATION. Or l'API veut le noeud qui DETIENT le modele ; la destination se dit
|
||||||
|
# par `target`. Cloner vers gandalf un gabarit qui vit sur asgard rendait donc :
|
||||||
|
#
|
||||||
|
# 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist
|
||||||
|
#
|
||||||
|
# Ca n'etait jamais apparu parce que toutes les VM naissaient sur le noeud du gabarit
|
||||||
|
# et migraient ensuite. La premiere machine placee ailleurs l'a revele.
|
||||||
|
- name: Localiser le noeud qui detient le gabarit
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
proxmox_noeud_gabarit: >-
|
||||||
|
{{ ((proxmox_inventaire_vm.json.data | default([]))
|
||||||
|
| selectattr('vmid', 'defined')
|
||||||
|
| selectattr('vmid', 'equalto', proxmox_clone_vmid_modele | int)
|
||||||
|
| map(attribute='node') | list | first) | default('', true) }}
|
||||||
|
|
||||||
|
- name: "Un noeud de la grappe detient bien le gabarit {{ proxmox_clone_vmid_modele }}"
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: proxmox_noeud_gabarit | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
Aucun noeud de la grappe ne detient le gabarit
|
||||||
|
{{ proxmox_clone_vmid_modele }}. Sans lui, rien a cloner.
|
||||||
|
|
||||||
- name: Cloner la VM depuis le modele (API directe, cible par VMID)
|
- name: Cloner la VM depuis le modele (API directe, cible par VMID)
|
||||||
vars:
|
vars:
|
||||||
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_noeud_gabarit }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
||||||
method: POST
|
method: POST
|
||||||
headers:
|
headers:
|
||||||
Authorization: >-
|
Authorization: >-
|
||||||
|
|
@ -236,6 +261,9 @@
|
||||||
# A la CREATION seulement : l'API ne sait pas changer le pool d'une VM
|
# A la CREATION seulement : l'API ne sait pas changer le pool d'une VM
|
||||||
# existante par cet appel (il faut passer par le membre du pool).
|
# existante par cet appel (il faut passer par le membre du pool).
|
||||||
pool: "{{ proxmox_clone_pool | default(omit, true) }}"
|
pool: "{{ proxmox_clone_pool | default(omit, true) }}"
|
||||||
|
# LA DESTINATION SE DIT ICI, pas dans l'URL. Omise quand elle coincide avec le
|
||||||
|
# noeud du gabarit : l'API refuse un `target` inutile sur stockage non partage.
|
||||||
|
target: "{{ proxmox_clone_noeud if proxmox_clone_noeud != proxmox_noeud_gabarit else omit }}"
|
||||||
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
|
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
|
||||||
status_code: 200
|
status_code: 200
|
||||||
register: proxmox_clone_lance
|
register: proxmox_clone_lance
|
||||||
|
|
@ -246,6 +274,23 @@
|
||||||
no_log: true
|
no_log: true
|
||||||
when: not proxmox_clone_deja_la
|
when: not proxmox_clone_deja_la
|
||||||
|
|
||||||
|
# L'ECHEC DU POST ETAIT AVALE. `failed_when: false` protege du 500 « VMID deja pris »,
|
||||||
|
# mais il avalait AUSSI un refus reel — et la tache d'attente qui suit se bloquait
|
||||||
|
# alors sur un clonage qui n'avait jamais commence. Un blocage sans message, la panne
|
||||||
|
# la plus chere a diagnostiquer. On garde le masque pour l'en-tete, et on releve
|
||||||
|
# l'echec ici, message de l'API compris.
|
||||||
|
- name: "Le clonage a bien ete accepte par l'API"
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: (proxmox_clone_lance.status | default(0)) == 200
|
||||||
|
fail_msg: >-
|
||||||
|
L'API a refuse le clonage
|
||||||
|
({{ proxmox_clone_lance.status | default('aucune reponse') }}) :
|
||||||
|
{{ proxmox_clone_lance.json.message
|
||||||
|
| default(proxmox_clone_lance.msg | default('sans message')) }}
|
||||||
|
— gabarit {{ proxmox_clone_vmid_modele }} sur « {{ proxmox_noeud_gabarit }} »,
|
||||||
|
destination « {{ proxmox_clone_noeud }} ».
|
||||||
|
when: not proxmox_clone_deja_la
|
||||||
|
|
||||||
# ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande.
|
# ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande.
|
||||||
#
|
#
|
||||||
# `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en
|
# `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en
|
||||||
|
|
@ -262,8 +307,20 @@
|
||||||
vars:
|
vars:
|
||||||
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
# LE NOEUD VIENT DE L'UPID LUI-MEME, et de nulle part ailleurs (2026-08-25).
|
||||||
|
#
|
||||||
|
# Cette attente interrogeait `nodes/{{ proxmox_clone_noeud }}` — la DESTINATION.
|
||||||
|
# Or un clonage inter-noeuds s'execute sur le noeud du GABARIT : l'UPID n'existait
|
||||||
|
# pas la ou on le cherchait, l'API repondait en erreur, `failed_when: false`
|
||||||
|
# l'avalait, et `until` n'etait jamais satisfait. Resultat : soixante tentatives a
|
||||||
|
# dix secondes pour un clonage deja termine en 87 s — dix minutes d'attente
|
||||||
|
# immobile, puis la suite qui reprend sans un mot.
|
||||||
|
#
|
||||||
|
# Un UPID s'ecrit `UPID:<noeud>:<pid>:...`. Le noeud y est deja ; le redemander a
|
||||||
|
# une variable, c'est se donner une chance de se tromper.
|
||||||
url: >-
|
url: >-
|
||||||
{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud
|
{{ proxmox_api_racine }}/api2/json/nodes/{{
|
||||||
|
proxmox_clone_lance.json.data.split(':')[1]
|
||||||
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
|
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
|
||||||
method: GET
|
method: GET
|
||||||
headers:
|
headers:
|
||||||
|
|
@ -288,10 +345,18 @@
|
||||||
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
|
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
|
||||||
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
|
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
|
||||||
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
|
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
|
||||||
Regarder la tache sur {{ proxmox_clone_noeud }}.
|
Regarder la tache sur
|
||||||
|
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
|
||||||
when:
|
when:
|
||||||
- not proxmox_clone_deja_la
|
- not proxmox_clone_deja_la
|
||||||
- proxmox_clone_tache.json.data.exitstatus | default('OK') != 'OK'
|
# `exitstatus | default('OK')` faisait passer pour un SUCCES une attente qui
|
||||||
|
# n'avait jamais rien observe : quand l'API repondait en erreur — le cas du
|
||||||
|
# clonage inter-noeuds interroge sur le mauvais noeud — il n'y avait aucun
|
||||||
|
# `json.data`, donc aucun `exitstatus`, donc « OK » par defaut. On exige
|
||||||
|
# desormais d'avoir VU la tache s'arreter, et bien s'arreter.
|
||||||
|
- >-
|
||||||
|
(proxmox_clone_tache.json.data.status | default('')) != 'stopped'
|
||||||
|
or (proxmox_clone_tache.json.data.exitstatus | default('')) != 'OK'
|
||||||
|
|
||||||
- name: Dire pourquoi le clonage a echoue, s'il a echoue
|
- name: Dire pourquoi le clonage a echoue, s'il a echoue
|
||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,36 @@ import underlay as U # noqa: E402
|
||||||
|
|
||||||
# Ce que vaut une machine du site quand son `gabarit:` ne le dit pas. Volontairement
|
# Ce que vaut une machine du site quand son `gabarit:` ne le dit pas. Volontairement
|
||||||
# modeste : une machine de l'hebergeur sert UN role, elle n'heberge pas de charge.
|
# modeste : une machine de l'hebergeur sert UN role, elle n'heberge pas de charge.
|
||||||
GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": 40}
|
GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": "40G"}
|
||||||
|
|
||||||
|
|
||||||
|
def _taille(v) -> str:
|
||||||
|
"""Une taille de disque PORTE TOUJOURS SON UNITE.
|
||||||
|
|
||||||
|
`disque: 40` a produit un `resize` a « 40 » que Proxmox a lu comme un
|
||||||
|
RETRECISSEMENT : « shrinking disks is not supported ». Le playbook tolere cette
|
||||||
|
erreur — a raison, un disque deja assez grand n'est pas une panne — donc la machine
|
||||||
|
est nee avec les 16 Go du gabarit au lieu de 40, SANS QUE RIEN NE LE DISE.
|
||||||
|
Le plan des tenants ecrit `40G` depuis toujours ; le site ecrit pareil, et un entier
|
||||||
|
nu se voit rattacher son unite plutot que de repartir en silence.
|
||||||
|
"""
|
||||||
|
s = str(v).strip()
|
||||||
|
return f"{s}G" if s.isdigit() else s
|
||||||
|
|
||||||
|
|
||||||
|
def materialisation() -> dict:
|
||||||
|
"""Gabarit, stockage et mode de clonage — DECLARES par le site.
|
||||||
|
|
||||||
|
Sans ce bloc, le playbook de clonage prenait ces valeurs dans les group_vars du
|
||||||
|
TENANT ACTIF : `make site-creer` aurait clone depuis un gabarit different selon le
|
||||||
|
symlink `instance`, sans rien dire. Un accident silencieux vaut moins qu'un refus.
|
||||||
|
"""
|
||||||
|
m = (U.charger() or {}).get("materialisation") or {}
|
||||||
|
if not m.get("vmid_modele"):
|
||||||
|
raise SystemExit(
|
||||||
|
"underlay.yml : `materialisation.vmid_modele` est requis — sans lui, le site "
|
||||||
|
"clonerait le gabarit du tenant actif, ce qui depend du symlink `instance`.")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
def _machines() -> dict[str, dict]:
|
def _machines() -> dict[str, dict]:
|
||||||
|
|
@ -66,15 +95,19 @@ def parametres(nom: str) -> dict[str, str]:
|
||||||
"SETOPS_NOEUD": str(m["noeud"]),
|
"SETOPS_NOEUD": str(m["noeud"]),
|
||||||
"SETOPS_COEURS": str(g["vcpu"]),
|
"SETOPS_COEURS": str(g["vcpu"]),
|
||||||
"SETOPS_MEMOIRE": str(g["memoire"]),
|
"SETOPS_MEMOIRE": str(g["memoire"]),
|
||||||
"SETOPS_DISQUE": str(g["disque"]),
|
"SETOPS_DISQUE": _taille(g["disque"]),
|
||||||
}
|
}
|
||||||
# UN SEGMENT NON ETIQUETE N'A PAS DE TAG, et lui en donner un le couperait du fil.
|
# UN SEGMENT NON ETIQUETE N'A PAS DE TAG, et lui en donner un le couperait du fil.
|
||||||
# `segment_physique` le dit ; `vlan: 1` (le natif) ne s'etiquette pas davantage.
|
# `segment_physique` le dit ; `vlan: 1` (le natif) ne s'etiquette pas davantage.
|
||||||
vlan = r.get("vlan")
|
vlan = r.get("vlan")
|
||||||
if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1:
|
if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1:
|
||||||
p["SETOPS_VLAN"] = str(vlan)
|
p["SETOPS_VLAN"] = str(vlan)
|
||||||
if m.get("stockage"):
|
mat = materialisation()
|
||||||
p["SETOPS_STOCKAGE"] = str(m["stockage"])
|
p["SETOPS_VMID_MODELE"] = str(mat["vmid_modele"])
|
||||||
|
# L'explicite de la machine gagne sur le defaut du site : une machine peut demander
|
||||||
|
# un autre stockage sans que tout le site bascule.
|
||||||
|
p["SETOPS_STOCKAGE"] = str(m.get("stockage") or mat.get("stockage") or "")
|
||||||
|
p["SETOPS_CLONE_COMPLET"] = "true" if mat.get("clone_complet", True) else "false"
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue