Merge pull request #2667 from GNS3/release/v2.2.58

Release v2.2.58
This commit is contained in:
Jeremy Grossmann 2026-04-11 17:42:38 +08:00 committed by GitHub
commit adeec10188
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 592 additions and 256 deletions

View File

@ -1,5 +1,11 @@
# Change Log
## 2.2.58 10/04/2026
* Bundle web-ui v2.2.58
* Sync appliances
* Snapshot refactoring
## 2.2.57 23/03/2026
* feat(telnet_server): improve error handling and connection management

View File

@ -28,6 +28,13 @@
"options": "-cpu host"
},
"images": [
{
"filename": "vEOS64-lab-4.35.3F.qcow2",
"version": "4.35.3F",
"md5sum": "00d11e33dc4288f509441a4ab6319ca0",
"filesize": 638910464,
"download_url": "https://www.arista.com/en/support/software-download"
},
{
"filename": "vEOS64-lab-4.33.2F.qcow2",
"version": "4.33.2F",
@ -58,6 +65,13 @@
}
],
"versions": [
{
"name": "4.35.3F",
"images": {
"hda_disk_image": "Aboot-veos-serial-8.0.2.iso",
"hdb_disk_image": "vEOS64-lab-4.35.3F.qcow2"
}
},
{
"name": "4.33.2F",
"images": {

View File

@ -0,0 +1,43 @@
{
"appliance_id": "924a2ef5-7b91-45ae-a1e4-7220338b76b0",
"name": "Cisco Alpine Linux TRex",
"category": "guest",
"description": "Alpine Linux VM image configured to run Cisco's Trex traffic generator in stateless mode",
"vendor_name": "Cisco",
"vendor_url": "http://www.cisco.com/",
"documentation_url": "https://trex-tgn.cisco.com/",
"product_name": "Alpine Linux Virt",
"product_url": "https://developer.cisco.com/modeling-labs/",
"registry_version": 4,
"status": "beta",
"maintainer": "GNS3 Team",
"maintainer_email": "developers@gns3.net",
"usage": "By default, a \"cisco\" account with password \"cisco\" is created.",
"port_name_format": "eth{0}",
"qemu": {
"adapter_type": "virtio-net-pci",
"adapters": 3,
"ram": 512,
"hda_disk_interface": "virtio",
"arch": "x86_64",
"console_type": "vnc",
"kvm": "allow"
},
"images": [
{
"filename": "alpine-trex-3-21-3.qcow2",
"version": "2.87",
"md5sum": "54cf267bbc2ab7a1c71dc53bced6e128",
"filesize": 465174528,
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [
{
"name": "2.87",
"images": {
"hda_disk_image": "alpine-trex-3-21-3.qcow2"
}
}
]
}

View File

@ -31,21 +31,21 @@
"version": "9.22.1.1 CML",
"md5sum": "250a924cdc2370208eaac9d1dc8dc9e3",
"filesize": 379518976,
"download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "asav9-18-2.qcow2",
"version": "9.18.2 CML",
"md5sum": "6f10fe106edfad9163625770a47a6b73",
"filesize": 340262912,
"download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "asav9-16-2.qcow2",
"version": "9.16.2 CML",
"md5sum": "1f8db97063a7f738fddc81ac880a906c",
"filesize": 262078976,
"download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "asav9-16-2.qcow2",

View File

@ -31,14 +31,14 @@
"version": "17.12(1)",
"md5sum": "e587e92186f42bdf69d7fa27f34425f7",
"filesize": 2739404800,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "cat9kv-prd-17.10.01prd7.qcow2",
"version": "17.10(1)",
"md5sum": "ffdbace33d31deae33e2a920a96b79ef",
"filesize": 2155806720,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -134,21 +134,21 @@
"version": "16.5.1b",
"md5sum": "ac11d33041b8ff6dc3553e324d02cccb",
"filesize": 1209543680,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "csr1000v-universalk9.03.17.00.S.156-1.S-ext.qcow2",
"version": "3.17",
"md5sum": "06cbfcd11f3557391db64fe2a6015a6e",
"filesize": 1346305024,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "csr1000v-universalk9.16.3.1-build2.qcow2",
"version": "16.3.1-build2",
"md5sum": "a770e96de928265515304c9c9d6b46b9",
"filesize": 1280835584,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "csr1000v-universalk9.16.03.01.qcow2",
@ -162,21 +162,21 @@
"version": "16.3.2",
"md5sum": "2e5803d23cd52cba5d55fa8306be5f13",
"filesize": 1167720448,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "csr1000v-universalk9.16.4.1.qcow2",
"version": "16.4.1",
"md5sum": "3428e0dcf5132a1b11ab7696d8c61b2e",
"filesize": 1261961216,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "csr1000v-universalk9.16.6.1.qcow2",
"version": "16.6.1-VIRL",
"md5sum": "996eb6dbf54781f6393bd689571a818c",
"filesize": 1316301824,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -30,77 +30,77 @@
"version": "15.9(3)M9",
"md5sum": "01b707a2e33185d6d33e0255ced45d23",
"filesize": 57323008,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.159-3.m8.qcow2",
"version": "15.9(3)M8",
"md5sum": "8d93a185c2fa778178a933f20b02150a",
"filesize": 57319424,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.159-3.m6.qcow2",
"version": "15.9(3)M6",
"md5sum": "49a6977977263b2774bebc56e4e678ff",
"filesize": 57309696,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.159-3.m4.qcow2",
"version": "15.9(3)M4",
"md5sum": "355b13ab821e64e2939fd7008d6304d7",
"filesize": 57297920,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.159-3.m3.qcow2",
"version": "15.9(3)M3",
"md5sum": "12893843af18e4c62f13d07266755653",
"filesize": 57296384,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.159-3.m2.qcow2",
"version": "15.9(3)M2",
"md5sum": "a19e998bc3086825c751d125af722329",
"filesize": 57308672,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.spa.158-3.m2.qcow2",
"version": "15.8(3)M2",
"md5sum": "40e3d25b5b0cb13d639fcd2cf18e9965",
"filesize": 57129984,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.vmdk.SPA.157-3.M3",
"version": "15.7(3)M3",
"md5sum": "37c148ffa14a82f418a6e9c2b049fafe",
"filesize": 134021120,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.vmdk.SPA.156-2.T",
"version": "15.6(2)T",
"md5sum": "83707e3cc93646da58ee6563a68002b5",
"filesize": 128450560,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.vmdk.SPA.156-1.T",
"version": "15.6(1)T",
"md5sum": "e7cb1bbd0c59280dd946feefa68fa270",
"filesize": 128122880,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios-adventerprisek9-m.vmdk.SPA.155-3.M",
"version": "15.5(3)M",
"md5sum": "79f613ac3b179d5a64520730925130b2",
"filesize": 127926272,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "IOSv_startup_config.img",

View File

@ -30,35 +30,35 @@
"version": "15.2(20200924:215240)",
"md5sum": "99ecab32de12410c533e6abd4e9710aa",
"filesize": 90409984,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios_l2-adventerprisek9-m.ssa.high_iron_20190423.qcow2",
"version": "15.2(6.0.81)E",
"md5sum": "71cacb678f98a106f99e889b97b34686",
"filesize": 44950016,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios_l2-adventerprisek9-m.SSA.high_iron_20180619.qcow2",
"version": "15.2.1",
"md5sum": "14b981002e40b660f2d7400401e04c14",
"filesize": 44938752,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios_l2-adventerprisek9-m.03.2017.qcow2",
"version": "15.2(20170321:233949)",
"md5sum": "8f14b50083a14688dec2fc791706bb3e",
"filesize": 41157632,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "vios_l2-adventerprisek9-m.vmdk.SSA.152-4.0.55.E",
"version": "15.2(4.0.55)E",
"md5sum": "1a3a21f5697cae64bb930895b986d71e",
"filesize": 96862208,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -30,14 +30,14 @@
"version": "6.1.3",
"md5sum": "1693b5d22a398587dd0fed2877d8dfac",
"filesize": 428588544,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "iosxrv-k9-demo-6.0.1.qcow2",
"version": "6.0.1",
"md5sum": "0831ecf43628eccb752ebb275de9a62a",
"filesize": 908132352,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -31,21 +31,21 @@
"version": "24.3.1",
"md5sum": "bd047c457e29952f265583f299bab845",
"filesize": 1704658432,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "xrv9k-fullk9-x-7.7.1.qcow2",
"version": "7.7.1",
"md5sum": "682fff40d2ff373d8da3342906553b54",
"filesize": 1643905024,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "xrv9k-fullk9-x-7.1.1.qcow2",
"version": "7.1.1",
"md5sum": "dcf241e3f8df0151fec2c7bfac9d96ac",
"filesize": 1443758080,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -30,14 +30,14 @@
"version": "7.3.0",
"md5sum": "b4cd6edf15ab4c6bce53c3f6c1e3a742",
"filesize": 214368256,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
},
{
"filename": "titanium-d1.7.2.0.D1.1.vmdk",
"version": "7.2.0",
"md5sum": "0ee38c7d717840cb4ca822f4870671d0",
"filesize": 361103360,
"download_url": "https://learningnetworkstore.cisco.com/myaccount"
"download_url": "https://u.cisco.com/my-learning/my-account"
}
],
"versions": [

View File

@ -7,7 +7,7 @@
"vendor_url": "https://www.extremenetworks.com",
"documentation_url": "https://www.extremenetworks.com/support/documentation",
"product_name": "EXOS VM",
"registry_version": 4,
"registry_version": 5,
"status": "stable",
"maintainer": "Extreme Networks",
"maintainer_email": "GitHubscripting@extremenetworks.com",
@ -18,18 +18,25 @@
"qemu": {
"adapter_type": "rtl8139",
"adapters": 13,
"ram": 512,
"ram": 1024,
"hda_disk_interface": "ide",
"arch": "x86_64",
"console_type": "telnet",
"boot_priority": "dc",
"kvm": "allow",
"options": "-cpu core2duo"
"options": "-nographic -cpu host -enable-kvm"
},
"images": [
{
{
"filename": "EXOS-VM_33.6.1.14.qcow2",
"version": "33.6.1.14",
"md5sum": "01ea4bea3f321f91910565bb223a2540",
"filesize": 278647296,
"direct_download_url": "https://akamai-ep.extremenetworks.com/Extreme_P/github-en/Virtual_EXOS/EXOS-VM_33.6.1.14.qcow2"
},
{
"filename": "EXOS-VM_32.7.2.19.qcow2",
"version": "32.7.2.19",
"md5sum": "eba580a2e18d2a9cc972c9ece8917ea8",
@ -48,6 +55,12 @@
"versions": [
{
"name": "33.6.1.14",
"images": {
"hda_disk_image": "EXOS-VM_33.6.1.14.qcow2"
}
},
{
"name": "32.7.2.19",
"images": {

View File

@ -125,9 +125,23 @@
"md5sum": "0c4da508cfcb702c066f47227644b65f",
"version": "26.02.1",
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.02.1/infix-x86_64-v26.02.1.qcow2"
},
{
"filename": "infix-x86_64-v26.03.0.qcow2",
"filesize": 320339968,
"md5sum": "24cd1006734993dab338e5c75f80b875",
"version": "26.03.0",
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.03.0/infix-x86_64-v26.03.0.qcow2"
}
],
"versions": [
{
"name": "26.03.0",
"images": {
"bios_image": "OVMF-edk2-stable202305.fd",
"hda_disk_image": "infix-x86_64-v26.03.0.qcow2"
}
},
{
"name": "26.02.1",
"images": {

View File

@ -18,7 +18,7 @@
"port_name_format": "ge-0/0/{port0}",
"qemu": {
"adapter_type": "virtio-net-pci",
"adapters": 10,
"adapters": 11,
"ram": 5120,
"cpus": 4,
"hda_disk_interface": "virtio",

View File

@ -27,6 +27,24 @@
"options": "-nographic"
},
"images": [
{
"filename": "chr-7.22.1.img",
"version": "7.22.1",
"md5sum": "a67290634f489071eae2fd99dcadba86",
"filesize": 134217728,
"download_url": "http://www.mikrotik.com/download",
"direct_download_url": "https://download.mikrotik.com/routeros/7.22.1/chr-7.22.1.img.zip",
"compression": "zip"
},
{
"filename": "chr-7.20.8.img",
"version": "7.20.8",
"md5sum": "a432cf89f405115163952eefd82934e4",
"filesize": 134217728,
"download_url": "http://www.mikrotik.com/download",
"direct_download_url": "https://download.mikrotik.com/routeros/7.20.8/chr-7.20.8.img.zip",
"compression": "zip"
},
{
"filename": "chr-7.16.img",
"version": "7.16",
@ -74,6 +92,18 @@
}
],
"versions": [
{
"name": "7.22.1",
"images": {
"hda_disk_image": "chr-7.22.1.img"
}
},
{
"name": "7.20.8",
"images": {
"hda_disk_image": "chr-7.20.8.img"
}
},
{
"name": "7.16",
"images": {

View File

@ -9,27 +9,54 @@
"documentation_url": "https://docs.vyos.io/",
"product_name": "VyOS Universal Router",
"product_url": "https://vyos.io/vyos-universal-router",
"registry_version": 4,
"registry_version": 8,
"status": "stable",
"availability": "service-contract",
"maintainer": "VyOS Inc.",
"maintainer_email": "support@vyos.io",
"usage": "\nDefault credentials:\nUser: vyos\nPassword: vyos",
"installation_instructions": "To install the appliance, you must:\n\n1. Have an active VyOS subscription (for details, visit https://vyos.io/).\n2. Download the QEMU/KVM image for your target version from the support portal: https://support.vyos.io/.\n3. Import the downloaded image for installation in the next step.\n\nAlternatively, you can:\n- Build your own image, or\n- Use any compatible qcow2 images (including rolling or stream releases) by selecting \"Create a new version\" and enabling the \"Allow custom files\" feature.",
"usage": "No extra installation steps are required - simply boot the router.\nFor more details, please refer to the documentation: https://docs.vyos.io/",
"default_username": "vyos",
"default_password": "vyos",
"symbol": "vyos.svg",
"port_name_format": "eth{0}",
"qemu": {
"adapter_type": "virtio-net-pci",
"adapters": 10,
"ram": 2048,
"cpus": 4,
"hda_disk_interface": "virtio",
"arch": "x86_64",
"console_type": "telnet",
"boot_priority": "c",
"kvm": "require",
"on_close": "shutdown_signal"
},
"settings": [
{
"name": "default x86_64",
"default": true,
"template_type": "qemu",
"template_properties": {
"adapter_type": "virtio-net-pci",
"adapters": 10,
"port_name_format": "eth{0}",
"ram": 2048,
"cpus": 4,
"hda_disk_interface": "virtio",
"platform": "x86_64",
"console_type": "telnet",
"boot_priority": "c",
"options": "-cpu host",
"uefi": false,
"on_close": "shutdown_signal"
}
},
{
"name": "1.5 x86_64",
"inherit_default_properties": true,
"template_type": "qemu",
"template_properties": {
"ram": 8192,
"cpus": 4
}
}
],
"images": [
{
"filename": "vyos-1.5.0-kvm-amd64.qcow2",
"version": "1.5.0",
"md5sum": "f84587bd6c45b139563a1d1d2fe0b2e8",
"filesize": 618528768,
"download_url": "https://support.vyos.io/"
},
{
"filename": "vyos-1.4.4-kvm-amd64.qcow2",
"version": "1.4.4",
@ -132,11 +159,18 @@
"filename": "vyos-1.2.5-amd64.qcow2",
"version": "1.2.5",
"md5sum": "110c22309ec480600446fd2fb4f27a0d",
"filesize": 411500544 ,
"filesize": 411500544,
"download_url": "https://support.vyos.io/"
}
],
"versions": [
{
"name": "1.5.0",
"settings": "1.5 x86_64",
"images": {
"hda_disk_image": "vyos-1.5.0-kvm-amd64.qcow2"
}
},
{
"name": "1.4.4",
"images": {

View File

@ -217,7 +217,7 @@ class Controller:
try:
os.makedirs(os.path.dirname(self._config_file), exist_ok=True)
with open(self._config_file, 'w+') as f:
json.dump(controller_settings, f, indent=4)
json.dump(controller_settings, f, indent=4, sort_keys=True)
except OSError as e:
log.error("Cannot write controller configuration file '{}': {}".format(self._config_file, e))

View File

@ -32,7 +32,16 @@ log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 8 # 8KB
async def export_project(zstream, project, temporary_dir, include_images=False, include_snapshots=False, keep_compute_ids=False, allow_all_nodes=False, reset_mac_addresses=False):
async def export_project(
zstream,
project,
temporary_dir,
include_images=False,
include_snapshots=False,
keep_compute_ids=False,
allow_all_nodes=False,
reset_mac_addresses=False
):
"""
Export a project to a zip file.
@ -46,7 +55,7 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
:param include_snapshots: save snapshots to the zip file
:param keep_compute_ids: If false replace all compute IDs y local (standard behavior for .gns3project to make it portable)
:param allow_all_nodes: Allow all nodes type to be included in the zip even if not portable
:param reset_mac_addresses: Reset MAC addresses for each node.
:param reset_mac_addresses: Reset MAC addresses for each node
"""
# To avoid issue with data not saved we disallow the export of a running project
@ -62,7 +71,16 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
# First we process the .gns3 in order to be sure we don't have an error
for file in os.listdir(project._path):
if file.endswith(".gns3"):
await _patch_project_file(project, os.path.join(project._path, file), zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses)
await _patch_project_file(
project,
os.path.join(project._path, file),
zstream,
include_images,
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
)
# Export the local files
for root, dirs, files in os.walk(project._path, topdown=True, followlinks=False):
@ -87,6 +105,10 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
# save empty directories
for directory in dirs:
path = os.path.join(root, directory)
if path == temporary_dir:
continue
if include_snapshots is False and path.endswith("snapshots"):
continue
if not os.listdir(path):
zstream.write(path, os.path.relpath(path, project._path))
except FileNotFoundError as e:
@ -142,7 +164,7 @@ def _is_exportable(path, include_snapshots=False):
"""
# do not export snapshots by default
if include_snapshots is False and path.endswith("snapshots"):
if include_snapshots is False and os.path.dirname(path).endswith("snapshots"):
return False
# do not export directories of snapshots
@ -167,7 +189,16 @@ def _is_exportable(path, include_snapshots=False):
return True
async def _patch_project_file(project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses):
async def _patch_project_file(
project,
path,
zstream,
include_images,
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
):
"""
Patch a project file (.gns3) to export a project.
The .gns3 file is renamed to project.gns3
@ -240,7 +271,7 @@ async def _patch_project_file(project, path, zstream, include_images, keep_compu
for compute_id, image_type, image in remote_images:
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir)
zstream.writestr("project.gns3", json.dumps(topology).encode())
zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode())
return images

View File

@ -39,8 +39,18 @@ Handle the import of project from a .gns3project
"""
async def import_project(controller, project_id, stream, location=None, name=None, keep_compute_ids=False,
auto_start=False, auto_open=False, auto_close=True):
async def import_project(
controller,
project_id,
stream,
location=None,
name=None,
reset_mac_addresses=False,
keep_compute_ids=False,
auto_start=False,
auto_open=False,
auto_close=True,
):
"""
Import a project contain in a zip file
@ -51,7 +61,9 @@ async def import_project(controller, project_id, stream, location=None, name=Non
:param stream: A io.BytesIO of the zipfile
:param location: Directory for the project if None put in the default directory
:param name: Wanted project name, generate one from the .gns3 if None
:param reset_mac_addresses: Reset MAC addresses for each node
:param keep_compute_ids: keep compute IDs unchanged
:param project_name: Original project name when restoring a snapshot
:returns: Project
"""
@ -72,12 +84,14 @@ async def import_project(controller, project_id, stream, location=None, name=Non
# We import the project on top of an existing project (snapshots)
if topology["project_id"] == project_id:
project_name = topology["name"]
restoring_snapshot = True
else:
# If the project name is already used we generate a new one
if name:
project_name = controller.get_free_project_name(name)
else:
project_name = controller.get_free_project_name(topology["name"])
restoring_snapshot = False
except (ValueError, KeyError):
raise aiohttp.web.HTTPConflict(text="Cannot import project, the project.gns3 file is corrupted")
@ -105,27 +119,11 @@ async def import_project(controller, project_id, stream, location=None, name=Non
topology["auto_open"] = auto_open
topology["auto_close"] = auto_close
# Generate a new node id
node_old_to_new = {}
for node in topology["topology"]["nodes"]:
if "node_id" in node:
node_old_to_new[node["node_id"]] = str(uuid.uuid4())
_move_node_file(path, node["node_id"], node_old_to_new[node["node_id"]])
node["node_id"] = node_old_to_new[node["node_id"]]
else:
node["node_id"] = str(uuid.uuid4())
if not restoring_snapshot:
# Do not re-generate IDs if we are restoring a snapshot because they should be the same in a project
regenerate_topology_ids(topology, path, reset_mac_addresses=reset_mac_addresses)
# Update link to use new id
for link in topology["topology"]["links"]:
link["link_id"] = str(uuid.uuid4())
for node in link["nodes"]:
node["node_id"] = node_old_to_new[node["node_id"]]
# Generate new drawings id
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
# Modify the compute id of the node depending of compute capacity
# Modify the compute id of the node depending on compute capacity
if not keep_compute_ids:
# For some VM type we move them to the GNS3 VM if possible
# unless it's a linux host without GNS3 VM
@ -135,14 +133,6 @@ async def import_project(controller, project_id, stream, location=None, name=Non
node["compute_id"] = "vm"
else:
# Round-robin through available compute resources.
# computes = []
# for compute_id in controller.computes:
# compute = controller.get_compute(compute_id)
# # only use the local compute or any connected compute
# if compute_id == "local" or compute.connected:
# computes.append(compute_id)
# else:
# log.warning(compute.name, "is not connected!")
compute_nodes = itertools.cycle(controller.computes)
for node in topology["topology"]["nodes"]:
node["compute_id"] = next(compute_nodes)
@ -162,7 +152,7 @@ async def import_project(controller, project_id, stream, location=None, name=Non
# We change the project_id to avoid erasing the project
topology["project_id"] = project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4)
json.dump(topology, f, indent=4, sort_keys=True)
os.remove(os.path.join(path, "project.gns3"))
images_path = os.path.join(path, "images")
@ -170,8 +160,8 @@ async def import_project(controller, project_id, stream, location=None, name=Non
await _import_images(controller, images_path)
snapshots_path = os.path.join(path, "snapshots")
if os.path.exists(snapshots_path):
await _import_snapshots(snapshots_path, project_name, project_id)
if not restoring_snapshot and os.path.exists(snapshots_path):
await update_snapshots(snapshots_path, path, project_name, project_id, reset_mac_addresses=reset_mac_addresses)
project = await controller.load_project(dot_gns3_path, load=False)
return project
@ -195,6 +185,41 @@ def _create_symbolic_links(zip_file, path):
except OSError as e:
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
"""
Regenerate IDs in the topology and move the files of the nodes to match the new IDs.
This is necessary because IDs must be unique across projects.
:param topology: topology content
:param new_project_path: new project path
:param reset_mac_addresses: reset MAC addresses
"""
# Generate new node IDs
node_old_to_new = {}
for node in topology["topology"]["nodes"]:
new_node_id = str(uuid.uuid4())
if "node_id" in node:
node_old_to_new[node["node_id"]] = new_node_id
_move_node_file(new_project_path, node["node_id"], new_node_id)
node["node_id"] = new_node_id
if reset_mac_addresses:
if "properties" in node:
for prop, value in node["properties"].items():
# reset the MAC address
if prop in ("mac_addr", "mac_address"):
node["properties"][prop] = None
# Generate new link IDs
for link in topology["topology"]["links"]:
link["link_id"] = str(uuid.uuid4())
for node in link["nodes"]:
node["node_id"] = node_old_to_new[node["node_id"]]
# Generate new drawings IDs
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
def _move_node_file(path, old_id, new_id):
"""
Move a file from a node when changing its id
@ -261,16 +286,17 @@ async def _import_images(controller, images_path):
await wait_run_in_executor(shutil.move, path, dst)
async def _import_snapshots(snapshots_path, project_name, project_id):
async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True):
"""
Import the snapshots and update their project name and ID to be the same as the main project.
Load the snapshots and update their project name and project ID to be the same as the main project.
Regenerate all the node, link and drawing IDs
"""
for snapshot in os.listdir(snapshots_path):
if not snapshot.endswith(".gns3project"):
for snapshot in os.listdir(snapshots_dir):
if not (snapshot.endswith(".gns3snapshot") or snapshot.endswith(".gns3project")):
continue
snapshot_path = os.path.join(snapshots_path, snapshot)
with tempfile.TemporaryDirectory(dir=snapshots_path) as tmpdir:
snapshot_path = os.path.join(snapshots_dir, snapshot)
with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir:
# extract everything to a temporary directory
try:
@ -288,9 +314,9 @@ async def _import_snapshots(snapshots_path, project_name, project_id):
topology_file_path = os.path.join(tmpdir, "project.gns3")
with open(topology_file_path, encoding="utf-8") as f:
topology = json.load(f)
topology["name"] = project_name
topology["project_id"] = project_id
regenerate_topology_ids(topology, project_path, reset_mac_addresses)
with open(topology_file_path, "w+", encoding="utf-8") as f:
json.dump(topology, f, indent=4, sort_keys=True)
except OSError as e:
@ -308,5 +334,6 @@ async def _import_snapshots(snapshots_path, project_name, project_id):
async with aiofiles.open(snapshot_path, 'wb+') as f:
async for chunk in zstream:
await f.write(chunk)
log.info("Project '{}': updated and repacked snapshot file '{}'".format(project_name, snapshot))
except OSError as e:
raise aiohttp.web.HTTPConflict(text="Cannot update snapshot '{}': the snapshot cannot be recreated: {}".format(os.path.basename(snapshot), e))

View File

@ -91,7 +91,7 @@ class Node:
self._first_port_name = None
self._console_auto_start = False
# This properties will be recompute
# This properties will be recomputed
ignore_properties = ("width", "height", "hover_symbol")
self.properties = kwargs.pop('properties', {})
@ -382,6 +382,7 @@ class Node:
await self.parse_node_response(response.json)
return True
trial += 1
return False
async def update(self, **kwargs):
"""

View File

@ -15,7 +15,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import re
import os
import json
@ -46,7 +45,7 @@ from ..utils.asyncio import locking
from ..utils.asyncio import aiozipstream
from ..utils.asyncio import wait_run_in_executor
from .export_project import export_project
from .import_project import import_project, _move_node_file
from .import_project import import_project, update_snapshots, regenerate_topology_ids
import logging
log = logging.getLogger(__name__)
@ -96,6 +95,7 @@ class Project:
self._show_interface_labels = show_interface_labels
self._variables = variables
self._supplier = supplier
self._snapshots_config_file = "snapshots.conf"
self._loading = False
self._closing = False
@ -189,19 +189,7 @@ class Project:
self._drawings = {}
self._snapshots = {}
self._computes = []
# List the available snapshots
snapshot_dir = os.path.join(self.path, "snapshots")
if os.path.exists(snapshot_dir):
for snap in os.listdir(snapshot_dir):
if snap.endswith(".gns3project"):
try:
snapshot = Snapshot(self, filename=snap)
except ValueError:
log.error("Invalid snapshot file: {}".format(snap))
continue
self._snapshots[snapshot.id] = snapshot
self._load_snapshot_config()
# Create the project on demand on the compute node
self._project_created_on_compute = set()
@ -782,6 +770,60 @@ class Project:
except KeyError:
raise aiohttp.web.HTTPNotFound(text="Snapshot ID {} doesn't exist".format(snapshot_id))
def _load_snapshot_config(self):
snapshot_dir = os.path.join(self.path, "snapshots")
self._snapshot_conf_path = os.path.join(snapshot_dir, self._snapshots_config_file)
self._snapshot_conf = []
if os.path.isfile(self._snapshot_conf_path):
try:
with open(self._snapshot_conf_path, encoding="utf-8") as f:
self._snapshot_conf = json.load(f)
except (OSError, UnicodeDecodeError, ValueError):
log.exception("Could not read snapshot config %s", self._snapshot_conf_path)
raise aiohttp.web.HTTPConflict(text="Could not read snapshot configuration file")
# Load all legacy snapshots (.gns3project files) to create an initial snapshot config if it doesn't exist
if os.path.exists(snapshot_dir) and not self._snapshot_conf:
for snap in os.listdir(snapshot_dir):
if snap.endswith(".gns3project"):
try:
snapshot = Snapshot(self, filename=snap)
except ValueError:
log.error("Invalid snapshot file: {}".format(snap))
continue
self._snapshots[snapshot.id] = snapshot
else:
# Create the Snapshot instances from the snapshot config file
for snapshot_entry in self._snapshot_conf:
try:
path = os.path.join(snapshot_dir, snapshot_entry["filename"])
if not os.path.isfile(path):
log.warning("Snapshot file '{}' does not exist".format(path))
continue
snapshot_entry.pop("project_id")
snapshot = Snapshot(self, **snapshot_entry)
self._snapshots[snapshot.id] = snapshot
except KeyError:
log.error("Invalid entry in snapshot config file: {}".format(snapshot_entry))
continue
self._save_snapshot_config()
def _save_snapshot_config(self):
if not self._snapshots:
return
self._snapshot_conf = []
for snapshot in self._snapshots.values():
self._snapshot_conf.append(snapshot.__json__())
try:
with open(self._snapshot_conf_path, 'w+') as f:
json.dump(self._snapshot_conf, f, indent=4)
except OSError as e:
log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e))
@open_required
async def snapshot(self, name):
"""
@ -795,12 +837,14 @@ class Project:
snapshot = Snapshot(self, name=name)
await snapshot.create()
self._snapshots[snapshot.id] = snapshot
self._save_snapshot_config()
return snapshot
@open_required
async def delete_snapshot(self, snapshot_id):
snapshot = self.get_snapshot(snapshot_id)
del self._snapshots[snapshot.id]
self._save_snapshot_config()
os.remove(snapshot.path)
@locking
@ -891,7 +935,7 @@ class Project:
def _get_default_project_directory(cls):
"""
Return the default location for the project directory
depending of the operating system
depending on the operating system
"""
server_config = Config.instance().get_section_config("Server")
@ -912,7 +956,7 @@ class Project:
Load topology elements
"""
if self._closing is True:
if self._closing:
raise aiohttp.web.HTTPConflict(text="Project is closing, please try again in a few seconds...")
if self._status == "opened":
@ -1083,7 +1127,14 @@ class Project:
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
# Do not compress the exported project when duplicating
with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream:
await export_project(zstream, self, tmpdir, keep_compute_ids=True, allow_all_nodes=True, reset_mac_addresses=reset_mac_addresses)
await export_project(
zstream,
self,
tmpdir,
keep_compute_ids=True,
include_snapshots=True,
allow_all_nodes=True
)
# export the project to a temporary location
project_path = os.path.join(tmpdir, "project.gns3p")
@ -1092,11 +1143,20 @@ class Project:
async for chunk in zstream:
await f.write(chunk)
# import the temporary project
new_project_id = str(uuid.uuid4())
# import the duplicated project
with open(project_path, "rb") as f:
project = await import_project(self._controller, str(uuid.uuid4()), f, location=location, name=name, keep_compute_ids=True)
project = await import_project(
self._controller,
new_project_id,
f,
location=location,
name=name,
reset_mac_addresses=reset_mac_addresses,
keep_compute_ids=True
)
log.info("Project '{}' duplicated in {:.4f} seconds".format(project.name, time.time() - begin))
log.info("Project '{}': duplicated in {:.4f} seconds".format(project.name, time.time() - begin))
except (ValueError, OSError, UnicodeEncodeError) as e:
raise aiohttp.web.HTTPConflict(text="Cannot duplicate project: {}".format(str(e)))
@ -1105,6 +1165,62 @@ class Project:
return project
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
"""
Fast duplication of a project.
Copy the project files directly rather than in an import-export fashion.
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the duplicated project
"""
# remote replication is not supported with remote computes
for compute in self.computes:
if compute.id != "local":
log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id))
return None
# work dir
p_work = pathlib.Path(location or self.path).parent.absolute()
t0 = time.time()
new_project_id = str(uuid.uuid4())
if location:
new_project_path = p_work.joinpath(location)
else:
new_project_path = p_work.joinpath(new_project_id)
# copy dir
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes())
project_name = name or topology["name"]
# If the project name is already used we generate a new one
project_name = self.controller.get_free_project_name(project_name)
topology["name"] = project_name
# To avoid unexpected behavior (project start without manual operations just after import)
topology["auto_start"] = False
topology["auto_open"] = False
topology["auto_close"] = False
# regenerate IDs for the duplicated project
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
# dump the updated .gns3 project file
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
topology["project_id"] = new_project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4, sort_keys=True)
# update the snapshots with new IDs
snapshots_dir = os.path.join(new_project_path, "snapshots")
if os.path.isdir(snapshots_dir):
await update_snapshots(snapshots_dir, new_project_path, project_name, new_project_id)
os.remove(new_project_path.joinpath('{}.gns3'.format(self.name)))
project = await self.controller.load_project(dot_gns3_path, load=False)
log.info("Project '{}': fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0))
return project
def is_running(self):
"""
If a node is started or paused return True
@ -1203,11 +1319,13 @@ class Project:
data['z'] = z
data['locked'] = False # duplicated node must not be locked
new_node_uuid = str(uuid.uuid4())
new_node = await self.add_node(node.compute,
node.name,
new_node_uuid,
node_type=node_type,
**data)
new_node = await self.add_node(
node.compute,
node.name,
new_node_uuid,
node_type=node_type,
**data
)
try:
await node.post("/duplicate", timeout=None, data={
"destination_node_id": new_node_uuid
@ -1255,72 +1373,4 @@ class Project:
def __repr__(self):
return "<gns3server.controller.Project {} {}>".format(self._name, self._id)
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
"""
Fast duplication of a project.
Copy the project files directly rather than in an import-export fashion.
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the new project
"""
# remote replication is not supported with remote computes
for compute in self.computes:
if compute.id != "local":
log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id))
return None
# work dir
p_work = pathlib.Path(location or self.path).parent.absolute()
t0 = time.time()
new_project_id = str(uuid.uuid4())
if location:
new_project_path = p_work.joinpath(location)
else:
new_project_path = p_work.joinpath(new_project_id)
# copy dir
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes())
project_name = name or topology["name"]
# If the project name is already used we generate a new one
project_name = self.controller.get_free_project_name(project_name)
topology["name"] = project_name
# To avoid unexpected behavior (project start without manual operations just after import)
topology["auto_start"] = False
topology["auto_open"] = False
topology["auto_close"] = False
# change node ID
node_old_to_new = {}
for node in topology["topology"]["nodes"]:
new_node_id = str(uuid.uuid4())
if "node_id" in node:
node_old_to_new[node["node_id"]] = new_node_id
_move_node_file(new_project_path, node["node_id"], new_node_id)
node["node_id"] = new_node_id
if reset_mac_addresses:
if "properties" in node:
for prop, value in node["properties"].items():
# reset the MAC address
if prop in ("mac_addr", "mac_address"):
node["properties"][prop] = None
# change link ID
for link in topology["topology"]["links"]:
link["link_id"] = str(uuid.uuid4())
for node in link["nodes"]:
node["node_id"] = node_old_to_new[node["node_id"]]
# Generate new drawings id
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
# And we dump the updated.gns3
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
topology["project_id"] = new_project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4)
os.remove(new_project_path.joinpath('{}.gns3'.format(self.name)))
project = await self.controller.load_project(dot_gns3_path, load=False)
log.info("Project '{}' fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0))
return project

View File

@ -35,30 +35,47 @@ import logging
log = logging.getLogger(__name__)
# The string use to extract the date from the filename
FILENAME_TIME_FORMAT = "%d%m%y_%H%M%S"
# Used to extract the date and time from the filename
FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S"
# Used to create a description of the snapshot with a human-readable date and time
DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S"
class Snapshot:
"""
A snapshot object
"""
def __init__(self, project, name=None, filename=None):
def __init__(self, project, snapshot_id=None, name=None, filename=None, created_at=None, description=None):
assert filename or name, "You need to pass a name or a filename"
self._id = str(uuid.uuid4()) # We don't need to keep id between project loading because they are use only as key for operation like delete, update.. but have no impact on disk
if snapshot_id:
self._id = snapshot_id
else:
self._id = str(uuid.uuid4())
self._project = project
if name:
self._name = name
self._created_at = datetime.now(timezone.utc).timestamp()
filename = self._name + "_" + datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(FILENAME_TIME_FORMAT) + ".gns3project"
if created_at:
self._created_at = created_at
else:
self._created_at = int(datetime.now(timezone.utc).timestamp())
if not filename:
filename = self._name + ".gns3snapshot"
else:
self._name = filename.rsplit("_", 2)[0]
datestring = filename.replace(self._name + "_", "").split(".")[0]
self._created_at = datetime.strptime(datestring, FILENAME_TIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()
self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp())
if not description:
date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT)
description = "Snapshot '{}' taken on {}".format(self._name, date)
self._description = description
self._filename = filename
self._path = os.path.join(project.path, "snapshots", filename)
@property
@ -69,13 +86,17 @@ class Snapshot:
def name(self):
return self._name
@property
def description(self):
return self._description
@property
def path(self):
return self._path
@property
def created_at(self):
return int(self._created_at)
return self._created_at
async def create(self):
"""
@ -114,14 +135,22 @@ class Snapshot:
await self._project.close(ignore_notification=True)
try:
begin = time.time()
# delete the current project files
project_files_path = os.path.join(self._project.path, "project-files")
if os.path.exists(project_files_path):
await wait_run_in_executor(shutil.rmtree, project_files_path)
with open(self._path, "rb") as f:
project = await import_project(self._project.controller, self._project.id, f, location=self._project.path,
auto_start=self._project.auto_start, auto_open=self._project.auto_open,
auto_close=self._project.auto_close)
project = await import_project(
self._project.controller,
self._project.id,
f,
location=self._project.path,
auto_start=self._project.auto_start,
auto_open=self._project.auto_open,
auto_close=self._project.auto_close
)
log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin))
except (OSError, PermissionError) as e:
raise aiohttp.web.HTTPConflict(text=str(e))
await project.open()
@ -132,6 +161,8 @@ class Snapshot:
return {
"snapshot_id": self._id,
"name": self._name,
"created_at": int(self._created_at),
"created_at": self._created_at,
"description": self._description,
"filename": self._filename,
"project_id": self._project.id
}

View File

@ -57,7 +57,7 @@ class CrashReport:
Report crash to a third party service
"""
DSN = "https://b3291ad085cbcc85059160e74ce83ab6@o19455.ingest.us.sentry.io/38482"
DSN = "https://74851857a3f05aecd909415e2a4cbd60@o19455.ingest.us.sentry.io/38482"
_instance = None
def __init__(self):

View File

@ -25,6 +25,10 @@ SNAPSHOT_CREATE_SCHEMA = {
"description": "Snapshot name",
"minLength": 1
},
"description": {
"description": "Snapshot description",
"minLength": 1
},
},
"additionalProperties": False,
"required": ["name"]
@ -50,7 +54,17 @@ SNAPSHOT_OBJECT_SCHEMA = {
"pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"
},
"name": {
"description": "Project name",
"description": "Snapshot name",
"type": "string",
"minLength": 1
},
"filename": {
"description": "Snapshot filename",
"type": "string",
"minLength": 1
},
"description": {
"description": "Snapshot description",
"type": "string",
"minLength": 1
},
@ -60,5 +74,5 @@ SNAPSHOT_OBJECT_SCHEMA = {
}
},
"additionalProperties": False,
"required": ["snapshot_id", "name", "created_at", "project_id"]
"required": ["snapshot_id", "name", "filename", "created_at", "description", "project_id"]
}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
!function(){"use strict";var e,v={},g={};function n(e){var a=g[e];if(void 0!==a)return a.exports;var t=g[e]={id:e,loaded:!1,exports:{}};return v[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}n.m=v,e=[],n.O=function(a,t,u,o){if(!t){var r=1/0;for(i=0;i<e.length;i++){t=e[i][0],u=e[i][1],o=e[i][2];for(var l=!0,f=0;f<t.length;f++)(!1&o||r>=o)&&Object.keys(n.O).every(function(b){return n.O[b](t[f])})?t.splice(f--,1):(l=!1,o<r&&(r=o));if(l){e.splice(i--,1);var s=u();void 0!==s&&(a=s)}}return a}o=o||0;for(var i=e.length;i>0&&e[i-1][2]>o;i--)e[i]=e[i-1];e[i]=[t,u,o]},n.n=function(e){var a=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(a,{a:a}),a},n.d=function(e,a){for(var t in a)n.o(a,t)&&!n.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:a[t]})},n.f={},n.e=function(e){return Promise.all(Object.keys(n.f).reduce(function(a,t){return n.f[t](e,a),a},[]))},n.u=function(e){return e+".77d4bfd104f37c42e028.js"},n.miniCssF=function(e){return"styles.f8555f2eecf8cf87f666.css"},n.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},n.o=function(e,a){return Object.prototype.hasOwnProperty.call(e,a)},function(){var e={},a="gns3-web-ui:";n.l=function(t,u,o,i){if(e[t])e[t].push(u);else{var r,l;if(void 0!==o)for(var f=document.getElementsByTagName("script"),s=0;s<f.length;s++){var c=f[s];if(c.getAttribute("src")==t||c.getAttribute("data-webpack")==a+o){r=c;break}}r||(l=!0,(r=document.createElement("script")).charset="utf-8",r.timeout=120,n.nc&&r.setAttribute("nonce",n.nc),r.setAttribute("data-webpack",a+o),r.src=n.tu(t)),e[t]=[u];var d=function(h,b){r.onerror=r.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],r.parentNode&&r.parentNode.removeChild(r),_&&_.forEach(function(m){return m(b)}),h)return h(b)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:r}),12e4);r.onerror=d.bind(null,r.onerror),r.onload=d.bind(null,r.onload),l&&document.head.appendChild(r)}}}(),n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e;n.tu=function(a){return void 0===e&&(e={createScriptURL:function(t){return t}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(a)}}(),n.p="",function(){var e={666:0};n.f.j=function(u,o){var i=n.o(e,u)?e[u]:void 0;if(0!==i)if(i)o.push(i[2]);else if(666!=u){var r=new Promise(function(c,d){i=e[u]=[c,d]});o.push(i[2]=r);var l=n.p+n.u(u),f=new Error;n.l(l,function(c){if(n.o(e,u)&&(0!==(i=e[u])&&(e[u]=void 0),i)){var d=c&&("load"===c.type?"missing":c.type),p=c&&c.target&&c.target.src;f.message="Loading chunk "+u+" failed.\n("+d+": "+p+")",f.name="ChunkLoadError",f.type=d,f.request=p,i[1](f)}},"chunk-"+u,u)}else e[u]=0},n.O.j=function(u){return 0===e[u]};var a=function(u,o){var f,s,i=o[0],r=o[1],l=o[2],c=0;for(f in r)n.o(r,f)&&(n.m[f]=r[f]);if(l)var d=l(n);for(u&&u(o);c<i.length;c++)n.o(e,s=i[c])&&e[s]&&e[s][0](),e[i[c]]=0;return n.O(d)},t=self.webpackChunkgns3_web_ui=self.webpackChunkgns3_web_ui||[];t.forEach(a.bind(null,0)),t.push=a.bind(null,t.push.bind(t))}()}();

View File

@ -0,0 +1 @@
!function(){"use strict";var e,v={},g={};function n(e){var u=g[e];if(void 0!==u)return u.exports;var t=g[e]={id:e,loaded:!1,exports:{}};return v[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}n.m=v,e=[],n.O=function(u,t,a,o){if(!t){var r=1/0;for(i=0;i<e.length;i++){t=e[i][0],a=e[i][1],o=e[i][2];for(var l=!0,f=0;f<t.length;f++)(!1&o||r>=o)&&Object.keys(n.O).every(function(b){return n.O[b](t[f])})?t.splice(f--,1):(l=!1,o<r&&(r=o));if(l){e.splice(i--,1);var s=a();void 0!==s&&(u=s)}}return u}o=o||0;for(var i=e.length;i>0&&e[i-1][2]>o;i--)e[i]=e[i-1];e[i]=[t,a,o]},n.n=function(e){var u=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(u,{a:u}),u},n.d=function(e,u){for(var t in u)n.o(u,t)&&!n.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:u[t]})},n.f={},n.e=function(e){return Promise.all(Object.keys(n.f).reduce(function(u,t){return n.f[t](e,u),u},[]))},n.u=function(e){return e+".34a83d9cee7db39cd0de.js"},n.miniCssF=function(e){return"styles.b001e5acc6978e8e4067.css"},n.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},n.o=function(e,u){return Object.prototype.hasOwnProperty.call(e,u)},function(){var e={},u="gns3-web-ui:";n.l=function(t,a,o,i){if(e[t])e[t].push(a);else{var r,l;if(void 0!==o)for(var f=document.getElementsByTagName("script"),s=0;s<f.length;s++){var c=f[s];if(c.getAttribute("src")==t||c.getAttribute("data-webpack")==u+o){r=c;break}}r||(l=!0,(r=document.createElement("script")).charset="utf-8",r.timeout=120,n.nc&&r.setAttribute("nonce",n.nc),r.setAttribute("data-webpack",u+o),r.src=n.tu(t)),e[t]=[a];var d=function(h,b){r.onerror=r.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],r.parentNode&&r.parentNode.removeChild(r),_&&_.forEach(function(m){return m(b)}),h)return h(b)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:r}),12e4);r.onerror=d.bind(null,r.onerror),r.onload=d.bind(null,r.onload),l&&document.head.appendChild(r)}}}(),n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e;n.tu=function(u){return void 0===e&&(e={createScriptURL:function(t){return t}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(u)}}(),n.p="",function(){var e={666:0};n.f.j=function(a,o){var i=n.o(e,a)?e[a]:void 0;if(0!==i)if(i)o.push(i[2]);else if(666!=a){var r=new Promise(function(c,d){i=e[a]=[c,d]});o.push(i[2]=r);var l=n.p+n.u(a),f=new Error;n.l(l,function(c){if(n.o(e,a)&&(0!==(i=e[a])&&(e[a]=void 0),i)){var d=c&&("load"===c.type?"missing":c.type),p=c&&c.target&&c.target.src;f.message="Loading chunk "+a+" failed.\n("+d+": "+p+")",f.name="ChunkLoadError",f.type=d,f.request=p,i[1](f)}},"chunk-"+a,a)}else e[a]=0},n.O.j=function(a){return 0===e[a]};var u=function(a,o){var f,s,i=o[0],r=o[1],l=o[2],c=0;for(f in r)n.o(r,f)&&(n.m[f]=r[f]);if(l)var d=l(n);for(a&&a(o);c<i.length;c++)n.o(e,s=i[c])&&e[s]&&e[s][0](),e[i[c]]=0;return n.O(d)},t=self.webpackChunkgns3_web_ui=self.webpackChunkgns3_web_ui||[];t.forEach(u.bind(null,0)),t.push=u.bind(null,t.push.bind(t))}()}();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,8 +23,8 @@
# or negative for a release candidate or beta (after the base version
# number has been incremented)
__version__ = "2.2.57"
__version_info__ = (2, 2, 57, 0)
__version__ = "2.2.58"
__version_info__ = (2, 2, 58, 0)
if "dev" in __version__:
try:

View File

@ -126,7 +126,7 @@ class Route(object):
response.set_status(401)
response.headers["WWW-Authenticate"] = 'Basic realm="GNS3 server"'
# Force close the keep alive. Work around a Qt issue where Qt timeout instead of handling the 401
# this happen only for the first query send by the client.
# this happens only for the first query send by the client.
response.force_close()
return response

View File

@ -68,7 +68,6 @@ def test_exportable_files():
assert not _is_exportable("project-files/tmp")
assert not _is_exportable("project-files/test_log.txt")
assert not _is_exportable("project-files/test.log")
assert not _is_exportable("test/snapshots")
assert not _is_exportable("test/project-files/snapshots")
assert not _is_exportable("test/project-files/snapshots/test.gns3p")

View File

@ -16,22 +16,16 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import pytest
from unittest.mock import patch, MagicMock
from uuid import uuid4
from gns3server.controller.project import Project
import pytest
from unittest import mock
from unittest.mock import patch, MagicMock
from gns3server.controller.snapshot import Snapshot
from tests.utils import AsyncioMagicMock
# @pytest.fixture
# def project(controller):
# project = Project(controller=controller, name="Test")
# controller._projects[project.id] = project
# return project
def test_snapshot_name(project):
"""
Test create a snapshot object with a name
@ -40,12 +34,7 @@ def test_snapshot_name(project):
snapshot = Snapshot(project, name="test1")
assert snapshot.name == "test1"
assert snapshot._created_at > 0
assert snapshot.path.startswith(os.path.join(project.path, "snapshots", "test1_"))
assert snapshot.path.endswith(".gns3project")
# Check if UTC conversion doesn't corrupt the path
snap2 = Snapshot(project, filename=os.path.basename(snapshot.path))
assert snap2.path == snapshot.path
assert snapshot.path == os.path.join(project.path, "snapshots", "test1.gns3snapshot")
def test_snapshot_filename(project):
@ -53,22 +42,44 @@ def test_snapshot_filename(project):
Test create a snapshot object with a filename
"""
# legacy snapshot
snapshot = Snapshot(project, filename="test1_260716_100439.gns3project")
assert snapshot.name == "test1"
assert snapshot._created_at == 1469527479.0
assert snapshot._created_at == 1469527479
assert snapshot.path == os.path.join(project.path, "snapshots", "test1_260716_100439.gns3project")
# new style snapshot
snapshot_id = str(uuid4())
snapshot = Snapshot(project, snapshot_id=snapshot_id, name="test2", created_at=1469527479)
assert snapshot.id == snapshot_id
assert snapshot.name == "test2"
assert snapshot.path == os.path.join(project.path, "snapshots", "test2.gns3snapshot")
assert snapshot._created_at == 1469527479
def test_json(project):
# legacy snapshot
snapshot = Snapshot(project, filename="snapshot_test_260716_100439.gns3project")
assert snapshot.__json__() == {
"snapshot_id": snapshot._id,
"name": "snapshot_test",
"project_id": project.id,
"description": "Snapshot 'snapshot_test' taken on 2016-07-26 at 10:04:39",
"filename": "snapshot_test_260716_100439.gns3project",
"created_at": 1469527479
}
# new style snapshot
snapshot = Snapshot(project, name="snapshot_test2")
assert snapshot.__json__() == {
"snapshot_id": snapshot._id,
"name": "snapshot_test2",
"project_id": project.id,
"filename": "snapshot_test2.gns3snapshot",
"description": mock.ANY,
"created_at": mock.ANY
}
def test_invalid_snapshot_filename(project):
@ -85,7 +96,8 @@ async def test_restore(project, controller):
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
await project.add_node(compute, "test1", None, node_type="vpcs", properties={"startup_config": "test.cfg"})
node1_id = str(uuid4())
await project.add_node(compute, "test1", node1_id, node_type="vpcs", properties={"startup_config": "test.cfg"})
snapshot = await project.snapshot(name="test")
# We add a node after the snapshots
@ -103,8 +115,11 @@ async def test_restore(project, controller):
with patch("gns3server.config.Config.get_section_config", return_value={"local": True}):
await snapshot.restore()
# make sure the original node IDs are restored
assert list(project.nodes.keys())[0] == node1_id
assert "snapshot.restored" in [c[0][0] for c in controller.notification.project_emit.call_args_list]
# project.closed notification should not be send when restoring snapshots
# project.closed notification should not be sent when restoring snapshots
assert "project.closed" not in [c[0][0] for c in controller.notification.project_emit.call_args_list]
project = controller.get_project(project.id)

View File

@ -23,6 +23,7 @@ import json
from unittest.mock import patch, MagicMock
from tests.utils import asyncio_patch
from gns3server.controller.node import Node
@pytest.fixture
@ -34,6 +35,14 @@ async def project(controller_api, controller):
return controller.get_project(u)
@pytest.fixture
def node(project, compute):
node = Node(project, compute, "test", node_type="vpcs")
project._nodes[node.id] = node
return node
async def test_create_project_with_path(controller_api, tmpdir):
response = await controller_api.post("/projects", {"name": "test", "path": str(tmpdir), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"})
@ -295,7 +304,7 @@ async def test_export_without_images(controller_api, tmpdir, project):
with myzip.open("a") as myfile:
content = myfile.read()
assert content == b"hello"
# Image should not exported
# Images should not be exported
with pytest.raises(KeyError):
myzip.getinfo("images/IOS/test.image")
@ -356,8 +365,14 @@ async def test_import(controller_api, tmpdir, controller):
assert content == "hello"
async def test_duplicate(controller_api, project):
async def test_duplicate(controller_api, controller, project, node):
response = await controller_api.post("/projects/{project_id}/duplicate".format(project_id=project.id), {"name": "hello"})
response = await controller_api.post("/projects/{project_id}/duplicate".format(project_id=project.id), {"name": "duplicated_project"})
assert response.status == 201
assert response.json["name"] == "hello"
assert response.json["name"] == "duplicated_project"
duplicated_project_id = response.json["project_id"]
assert duplicated_project_id != project.id # a new project_id should have been generated
duplicated_project = controller.get_project(duplicated_project_id)
with open(os.path.join(duplicated_project.path, "duplicated_project.gns3")) as f:
duplicated_topology = json.load(f)
assert duplicated_topology["topology"]["nodes"][0]["node_id"] != node.id # a new node_id should have been generated

View File

@ -57,10 +57,11 @@ async def test_restore_snapshot(controller_api, project, snapshot):
response = await controller_api.post("/projects/{}/snapshots/{}/restore".format(project.id, snapshot.id))
assert response.status == 201
assert response.json["name"] == project.name
assert response.json["project_id"] == project.id
async def test_create_snapshot(controller_api, project):
response = await controller_api.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"})
assert response.status == 201
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 1
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2