diff --git a/.gitignore b/.gitignore index dcbbc5b..527e338 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,23 @@ Module.symvers Mkfile.old dkms.conf +# Python .DS_Store .idea -__pycache__ \ No newline at end of file +__pycache__ +build/ +dist/ +*.egg-info/ + +# NodeJS +node_modules/ +tsconfig.app.tsbuildinfo + +# VSCode +.vscode/ + +starlink-*/ +web/backend/data/ +exp/ +test/ +plans/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..02b5c38 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +## Project Overview + +StarryNet is a novel experimentation emulator that enables researchers to conveniently build credible and flexible experimental network environments (ENE) mimicking satellite dynamics and network behaviors of large-scale ISTNs. + +For more detailed information, refer to README.md + +### Building and Running + +Install python package `starrynet` and executables `sn` and `sn-worker`: +```bash +python3 setup.py install +``` + +Run local worker: +```bash +sudo sn-worker --workdir test --machine-id 0 --username abc --password 123456 +``` + +Run experiment: +```bash +python3 example.py +``` + +## Development + +### Repository Structure + +Focus on the following files and directories: +- `config.json`: sample topology and worker configuration +- `example.py`: Python API example +- `bin/`: entrypoints for CLI `sn` and worker `sn-worker` +- `starrynet/`: library code +- `web/` (DEVELOPING): web interface code + +### Rules + +- Planning before coding is highly recommended, especially for complex features. All plans should be documented in the `plans/` directory as markdown files to be reviewed by other developers and agents. Each plan should include the motivation, design, and implementation details. +- No compatibility consideration for now, feel free to refactor and break any APIs and data formats. +- Make the codebase and git history clean and organized. No agents/AI related signatures in commit messages. + +### Commit Message Format +``` +: + + + + + + + +``` \ No newline at end of file diff --git a/README.md b/README.md index 2af5ab0..d3288ce 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,272 @@ # StarryNet -StarryNet for the emulation of satellite Internet constellations. +StarryNet is a satellite-network emulator for building constellation topologies, creating links between satellites and ground stations, and scheduling runtime events such as link/routing changes, ping, iperf, link damage, and recovery. -## What is StarryNet? +## Current Runtime Model -StarryNet helps you to emulate your customized constellation and ground stations with run-time routing for a given duration. With StarryNet, you can test availability/bandwidth/loss within nodes, check routing states of a node and even damage certain links. +StarryNet no longer depends on Docker. It uses: -## What are the components? +- Python code for topology generation and control +- a small CPython C extension for namespace/container orchestration +- remote StarryNet worker daemons described in `config.json` -1. A configuration file (`config.json`). -2. An API Library (`starrynet`). -3. An example leveraging APIs to run your trials (`example.py`). -4. A `setup.py` and `./bin/sn`. +If you are migrating from older documentation, ignore Docker-specific setup steps. -## Preparation +## Repository Layout -CentOS 7.9.2009 or above, Docker 20.10.17 or above and Python 3.6 or above. +- `config.json`: sample topology and worker configuration +- `example.py`: Python API example +- `bin/sn`: interactive CLI entrypoint +- `bird.conf`: sample BIRD routing config +- `starrynet/`: library code -1. Support for CentOS 7.9.2009 and Python 3 or Python 2. Also support Ubuntu 20.04 LTS (and 18.04). -2. Install Docker on the machine for emulation. +## Requirements -## Installation - -Then run `bash ./install.sh` to install CLI sn, which will also install packets like `python3 -m pip install setuptools xlrd copy argparse time numpy random requests math skyfield sgp4 datetime paramiko`. - -## How to use it? - -1. Prepare a data directory: - -Finish *remote_machine_IP, remote_machine_username and remote_machine_password* in config.json to specify the remote machine for running the emulation. - -Put `config.json`, `starrynet`, and `example.py` at the same working directory. - -3. Start emulation: - -To speficy your own constellation, copy `config.json` and fill in the fields in it according to your satellite emulation environment, including the constellation name, orbit number, satellite number per orbit, ground station number, ground user number connected to each ground station and so on. You are only allowed to change `Name`, `Altitude (km)`, `Cycle (s)`, `Inclination`, `Phase shift`, `# of orbit`, `# of satellites`, `Duration(s)`, `update_time (s)`, `satellite link bandwidth ("X" Gbps)`, `sat-ground bandwidth ("X" Gbps)`, `satellite link loss ( 'X'% )`, `sat-ground loss ( 'X'% )`, `GS number`, `multi-machine('0' for no, '1' for yes)`, `antenna number`, `antenna_inclination_angle`, `remote_machine_IP`, `remote_machine_username`, `remote_machine_password` in `config.json`. - -Then use the APIs in `example.py` to start your trails. Remember to change the configuration_path of your `config.json`. - -4. OSPF is the only intra-routing protocol. In `example.py` you need to set he hello-interval. (example in example.py): - -> HelloInterval = 1 - -5. In `example.py`, you need to specify the latitude and longitude of ground stations in sequence. Their node indexes will be named right after the satellite nodes. - -> GS_lat_long=[[50.110924,8.682127],[46.635700,14.311817]] # frankfurt and Austria - -6. `ConfigurationFilePath` is where you put your config.json file, specified in `example.py`. - > ConfigurationFilePath = "./config.json" - -## What are the APIs? - -> sn.create_nodes() - -This API creates nodes for emulation, including satellite and GS nodes. - -> sn.create_links() +The exact system packages depend on your Linux distribution, but in practice you need: -This API creates initial network links for emulation. +- Python 3 +- `pip` +- (Optional) [BIRD](https://bird.network.cz/) +- reachable worker machines matching the `Machines` section in `config.json` (`127.0.0.1` recommended for initial trials) -> sn.run_routing_deamon() +Python packages are listed in `tools/requirements.txt`. -This API initiates the OSPF routing for the network, otherwise the network has no routing protocol running. +If you install StarryNet from this source tree with `python3 setup.py install`, you also need: -> sn.get_distance(node_index1, node_index2, time_index) +- a C compiler such as `gcc` +- Python development headers for compiling the `pyctr` and `pynetlink` extensions -This API returns distance between nodes at a certain time. +Those build dependencies are needed at install time, not for normal use after a successful install. -> sn.get_neighbors(node_index1, time_index) - -This API returns neighbor node indexes of a node at a certain time. - -> sn.get_GSes(node_index1, time_index) - -This API returns GSes connected to the node at a certain time. - -> sn.get_position(node_index1, time_index) - -This API returns the LLA of a node at a certain time. - -> sn.get_utility(time_index) - -This API returns the current CPU utility and memory utility. - -> sn.get_IP(node_index1) - -This API returns a list of IPs of a node at a certain time. - -> sn.set_damage(ratio, time_index) - -This API sets a random damage for the network links of a given ratio at a certain time. - -> sn.set_recovery(time_index) - -This API will recover all the damaged links at a certain time. - -> sn.check_routing_table(node_index1, time_index) - -This API returns a routing table file of a node at a certain time. The output file could be found at the working directory. - -> sn.set_next_hop(sat, des, next_hop_sat, time_index) - -This API sets the next hop at a certain time. Sat, Des and NextHopSat are indexes and Sat and NextHopSat are neighbors. +## Installation -> sn.set_ping(node_index1, node_index2, time_index) +### Quick install: -This API will starts pinging msg of two nodes at a certain time. The output file could be found at the working directory. +```bash +bash ./install.sh +``` -> sn.set_perf(node_index1, node_index2, time_index) +### Manual install (recommended for explicit control): -This API will starts perfing msg of two nodes at a certain time. The output file could be found at the working directory. +1. Install system dependencies (Ubuntu example): -> sn.start_emulation() +```bash +sudo apt update +sudo apt install python3 python3-pip python3-dev gcc bird +``` -This API starts the entire emulation of the duration. +2. (Optional) Create a Python virtual environment: -> sn.stop_emulation() +```bash +sudo apt install python3-venv +python3 -m venv sn-env +source sn-env/bin/activate +``` -This API stops the eimulation and clears the environment. +3. Install Python dependencies: -## Example one: use APIs in python +```bash +sudo python3 -m pip install -r tools/requirements.txt +``` -Run example.py to emulate the network. +4. Install the package and CLI: -In this example, 5\*5 satellites from Starlink in 550km with an inclination of 53 degree and two ground stations in Frankfurt and Austria are emulated. The node index sequence is: 25 sattelites, 2 ground stations. 25 satellites and 2 ground stations are in one AS, where OSPF is running within it. Hello_interval(s) in OSPF is set as one second. AS specified in `config.json`, each GS has one antenna with an 25 degree inclination angle to connect the nearest satellite. Loss and throuput are alse set in `config.json`. Link delay updating granularity is one second. +```bash +sudo python3 setup.py install +``` -The emulation duration is set as 100 seconds in `config.json`. A 30 percent damage ratio is set in #5 second and the network will be recovered in #10 second. In #15 second, we'd like to see the routing table of node 27, which will be found in the working directory. In #20 second, we set the next hop of node 1 to node 2 in order to get to node 27. And we will have a ping information from node 26 to node 27 from #30 second to #80 second. After running, a new directory will be made in the current path, where the output information will be found. -Other APIs help show the distance in km between node #1 and node #2 at #2 second, neighbor indexes of node #1 at the time, connected GS of node #7 at #2 second, LLA information of the node at the same time and all the IP of the node. Besides, get_utility will download a memory and CPU utility information at the time. +## Quick Start -## Example two: use CLI in shell +### 1. Start a worker daemon -Finish *remote_machine_IP, remote_machine_username and remote_machine_password* in config.json to specify the remote machine for running the emulation. Other fields should also be filled as described above. +Before running `example.py` or `sn`, start at least one worker daemon that matches the machine entry in `config.json`. -> sn +Example: -In the same path of `config.json`, run `sn` in shell, you will see the starrynet CLI. `sn` automatically starts a 5*5(satellites)+2(GS) scale network as described above if you only finish *remote_machine_IP, remote_machine_username and remote_machine_password* in config.json without changing other fields. See an example below. +```bash +sudo sn-worker \ + --workdir test \ + --machine-id 0 \ + --ssh-username abc \ + --ssh-password 123456 +``` -> sn -h +Important details: -> sn +- `--ssh-username` and `--ssh-password` must match the `username` and `password` fields in `config.json` +- `--ssh-port` defaults to `18888`, which should match the `port` field in `config.json` +- `--workdir` is where the daemon writes logs, SSH host keys, and runtime artifacts +- for the sample `config.json`, `127.0.0.1:18888` is the expected local worker endpoint -*This starts the CLI with the default 5*5+2 scale. You may also specify your customized scale in a config.json and run `sn -p "./config.json" -i 1 -n 27 -g 50.110924/8.682127/46.635700/14.311817` to start your own emulation. Here `-p` infers to the customized config.json path, `-i` infers to your customized hello packet intervall (10 by default), `-n` infers to the total node number and `-g` infers to the latitude and longitude of the GSes.* +You can inspect all daemon options with: -> starrynet> help +```bash +sn-worker --help +``` -> starrynet> create_nodes +### 2. Update `config.json` -> starrynet> create_links +At minimum, review the `Machines` section: -> starrynet> run_routing_deamon +```json +"Machines": [ + { + "IP": "127.0.0.1", + "port": 18888, + "username": "abc", + "password": "123456" + } +] +``` -> starrynet> get_distance 1 2 10 +Other commonly changed fields: -*It means getting the distance of two node (#1 and #2) at #10 second.* +- `Shells` +- `Duration (s)` +- `step (s)` +- `satellite link bandwidth ("X" Gbps)` +- `sat-ground bandwidth ("X" Gbps)` +- `satellite link loss ("X"% )` +- `sat-ground loss ("X"% )` +- `antenna number` +- `antenna elevation angle` -> starrynet> get_neighbors 5 16 +### 3. Run the Python example -*It means getting the neighbor node indexes of node #5 at #16 second.* +```bash +python3 example.py +``` -> starrynet> get_GSes 7 20 +The example: -*It means getting the connected GS node indexes of node #6 at #20 second.* +- loads `./config.json` +- creates nodes and links +- queries topology state +- schedules ping, iperf, damage, recovery, and route dump events +- starts the emulation -> starrynet> get_position 7 23 +The example uses node names such as `SH1O1S1` and `GS0`. This is the naming style used by the current API and CLI. -*It means getting the LLA position of node #7 at #23 second.* +### 4. Run the interactive CLI -> starrynet> get_IP 8 +```bash +sn +``` -*It means getting the IP addresses of node #8. "create_nodes" and "create_links" must be runned before this.* +Useful options: -> starrynet> get_utility 27 +```bash +sn --help +sn --path ./config.json +sn --gs 50.110924/8.682127/46.635700/14.311817 +sn --bird-conf ./bird.conf +sn --clean +``` -*It means getting the memory and CPU utility information at #27 second. The output file will be generated at the working directory once the emulation starts.* +## CLI Workflow -> starrynet> set_damage 0.3 30 +Start with: -*It means setting a random damage of a given ratio of 0.3 at #30 second, which will be processed during emulation.* +```text +starrynet> create_nodes +starrynet> create_links +starrynet> run_routing_daemon +starrynet> start_emulation +``` -> starrynet> set_recovery 50 +Useful inspection commands: -*It means setting a recovery of the damages at #50 second, which will be processed during emulation.* +```text +starrynet> status +starrynet> nodes +starrynet> nodes SH1O1 +starrynet> path +starrynet> get_distance SH1O1S1 SH1O1S2 2 +starrynet> get_neighbors SH1O1S1 2 +starrynet> get_GSes SH1O1S1 2 +starrynet> get_position SH1O1S1 2 +starrynet> get_IP SH1O1S1 +``` -> starrynet> check_routing_table 26 40 +Dynamic commands: -*It means listing the routing table of node #26 at #40 second. The output file will be written at the working directory.* +```text +starrynet> check_utility 2 +starrynet> check_routing_table SH1O1S1 3 +starrynet> set_static_route SH1O1S1 SH1O1S2 SH1O1S2 4 +starrynet> set_ping SH1O1S1 SH1O1S2 5 +starrynet> set_iperf SH1O1S1 SH1O1S2 6 +starrynet> set_damage 0.3 7 +starrynet> set_recovery 8 +starrynet> events +starrynet> start_emulation +starrynet> tasks +starrynet> task w0-t1 +starrynet> task_output w0-t1 +``` + +Notes: + +- `run_routing_daemon` uses `./bird.conf` by default. +- You may pass a different BIRD config path: + +```text +starrynet> run_routing_daemon ./bird.conf +``` + +- You may restrict routing startup to selected nodes: -> starrynet> set_next_hop 1 26 2 45 +```text +starrynet> run_routing_daemon ./bird.conf GS0 SH1O1S1 SH1O1S2 +``` -*It means setting the next hop to node #2 for node #1 for the destination of node #26 at #45 second. Sat, Des and NextHopSat are indexes and Sat and NextHopSat are neighbors, which will be processed during emulation.* +- Event commands are queued first and executed when `start_emulation` advances to the target time. + +## Python API Example -> starrynet> set_ping 1 26 46 +The current Python API looks like this: -*It means pinging from node #1 to node #26 at #46 second. The output file will be written at the working directory.* +```python +from starrynet.sn_synchronizer import StarryNet -> starrynet> set_perf 1 26 46 +gs_lat_long = [[50.110924, 8.682127], [46.635700, 14.311817]] +sn = StarryNet("./config.json", gs_lat_long) -*It means perfing from node #1 to node #26 at #46 second. The perfing output file will be written at the working directory.* +sn.create_nodes() +sn.create_links() +sn.run_routing_daemon("./bird.conf") -> starrynet> start_emulation +print(sn.get_distance("SH1O1S1", "SH1O1S2", 2)) +print(sn.get_neighbors("SH1O1S1", 2)) +print(sn.get_GSes("SH1O1S1", 2)) +print(sn.get_position("SH1O1S1", 2)) +print(sn.get_IP("SH1O1S1")) -*"create_nodes", "create_links" and "run_routing_deamon" must be runned before this. +sn.set_ping("SH1O1S1", "SH1O1S2", 4) +sn.set_iperf("SH1O1S1", "SH1O1S2", 5) +sn.set_damage(0.3, 6) +sn.set_recovery(7) +sn.start_emulation() +sn.clean() +``` + +## Output -> starrynet> stop_emulation +StarryNet writes generated files under a directory derived from the config location and experiment name, for example: + +```text +./starlink-Grid-LeastDelay/ +``` + +You can print the active output path in the CLI with: -> starrynet> exit +```text +starrynet> path +``` -*After running the commands above, you will find a working directory at the Starrynet/starrynet directory, containing the output files.* \ No newline at end of file +## Known Gaps + +- The project still contains some legacy files and names from older interfaces. +- The CLI is node-name based; older numeric examples are obsolete. +- Worker connectivity and host namespace permissions are not auto-validated yet. diff --git a/bin/sn b/bin/sn old mode 100644 new mode 100755 index 5a5de5e..5f0155d --- a/bin/sn +++ b/bin/sn @@ -1,137 +1,84 @@ -#!/usr/bin/env python -""" -Starrynet runner -author: Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) +#!/usr/bin/env python3 +"""StarryNet CLI runner.""" -To see options: - sudo sn -h - -""" - -from optparse import OptionParser +import argparse import os -import sys -import time - -if 'PYTHONPATH' in os.environ: - sys.path = os.environ['PYTHONPATH'].split(':') + sys.path -from starrynet.sn_observer import * -from starrynet.sn_orchestrater import * -from starrynet.sn_synchronizer import * -from starrynet.clean import cleanup -import starrynet.cli -from starrynet.log import lg, LEVELS, info, debug, warn, error, output - - -class StarrynetRunner(object): - "Build, setup, and run Starrynet." - - def __init__(self): - "Init." - self.options = None - self.args = None - self.validate = None - self.AS = [] - self.GS_lat_long = [] - self.configuration_file_path = "./config.xls" - self.hello_interval = None # hello_interval(s) in OSPF. 1-200 are supported. - self.parseArgs() - self.begin() - - def parseArgs(self): - """Parse command-line args and return options object. - returns: opts parse options dict""" - - desc = ("The %prog utility creates Starrynet network from the\n" - "command line. It can create parametrized topologies,\n" - "invoke the Starrynet CLI, and run tests.") - - usage = ('%prog [options]\n' - '(type %prog -h for details)') - - opts = OptionParser(description=desc, usage=usage) - opts.add_option( - '--path', - '-p', - type='string', - default="./config.xls", - help=('path of the configuration file, e.g. ./config.xls')) - opts.add_option( - '--hello_interval', - '-i', - type='int', - default=10, - help=('interval to send hello packet, larger than 1, defaul 10')) - opts.add_option( - '--node_number', - '-n', - type='int', - default=27, - help=('number of network nodes, including satellites and GSes')) - opts.add_option( - '--GS', - '-g', - type='string', - default="50.110924/8.682127/46.635700/14.311817", - help= - ('latitude and longitude of GSes sequentially, split by "/" like "la1/lo1/la2/lo2", e.g. 50.110924/8.682127/46.635700/14.311817' - )) - opts.add_option('--clean', - '-c', - action='store_true', - default=False, - help='clean and exit') +import traceback - self.options, self.args = opts.parse_args() +from starrynet.cli import CLI +from starrynet.log import debug, error, info +from starrynet.sn_synchronizer import StarryNet - # Extra arguments might be added in the future. - if self.args: - opts.print_help() - exit() - def begin(self): - "Create and run starrynet." +def parse_gs(value): + parts = value.split("/") + if len(parts) % 2 != 0: + raise argparse.ArgumentTypeError( + "GS coordinates must be la1/lo1/la2/lo2") - CLI = starrynet.cli.CLI - - opts = self.options - self.AS.append([1, opts.node_number]) - self.configuration_file_path = opts.path - self.hello_interval = opts.hello_interval - lat_long = opts.GS.split("/") - for i in range(int(len(lat_long) / 2)): - self.GS_lat_long.append( - [float(lat_long[2 * i]), - float(lat_long[2 * i + 1])]) - - if opts.clean: - cleanup() - exit() - start = time.time() - - sn = StarryNet(self.configuration_file_path, self.GS_lat_long, - self.hello_interval, self.AS) - CLI(sn) - cleanup() - - emulation_time = float(time.time() - start) - info('completed in %0.3f seconds\n' % emulation_time) + coordinates = [] + try: + for idx in range(0, len(parts), 2): + coordinates.append([float(parts[idx]), float(parts[idx + 1])]) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + return coordinates + + +def build_parser(): + parser = argparse.ArgumentParser( + description="Start the interactive StarryNet CLI.") + parser.add_argument( + "-p", "--path", + default="./config.json", + help="path to the JSON configuration file (default: ./config.json)") + parser.add_argument( + "-g", "--gs", + default="50.110924/8.682127/46.635700/14.311817", + type=parse_gs, + help="ground-station coordinates as la1/lo1/la2/lo2") + parser.add_argument( + "-b", "--bird-conf", + default="./bird.conf", + help="default BIRD configuration used by run_routing_daemon") + parser.add_argument( + "--clean", + action="store_true", + help="connect to configured workers, clean the environment, then exit") + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + if not os.path.exists(args.path): + parser.error(f"configuration file not found: {args.path}") + + sn = None + cleaned = False + try: + sn = StarryNet(args.path, args.gs) + if args.clean: + sn.clean() + cleaned = True + return 0 + + CLI(sn, default_bird_conf=args.bird_conf) + return 0 + except KeyboardInterrupt: + info("\nKeyboard Interrupt. Shutting down...\n") + return 130 + except Exception as exc: + error(f"Caught exception: {type(exc).__name__}: {exc}\n") + debug(traceback.format_exc() + "\n") + return 1 + finally: + if sn is not None and not cleaned: + try: + sn.clean() + except Exception as exc: + error(f"Cleanup failed: {exc}\n") if __name__ == "__main__": - try: - StarrynetRunner() - except KeyboardInterrupt: - info("\n\nKeyboard Interrupt. Shutting down and cleaning up...\n\n") - cleanup() - except Exception: - # Print exception - type_, val_, trace_ = sys.exc_info() - errorMsg = ("-" * 80 + "\n" + "Caught exception. Cleaning up...\n\n" + - "%s: %s\n" % (type_.__name__, val_) + "-" * 80 + "\n") - error(errorMsg) - # Print stack trace to debug log - import traceback - stackTrace = traceback.format_exc() - debug(stackTrace + "\n") - cleanup() + raise SystemExit(main()) diff --git a/bin/sn-worker b/bin/sn-worker new file mode 100755 index 0000000..c072ba3 --- /dev/null +++ b/bin/sn-worker @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""StarryNet worker starter.""" + +import argparse +import logging + +from starrynet.sn_orchestrator_daemon import DEFAULT_PORT, OrchestraterDaemon + + +def build_parser(): + parser = argparse.ArgumentParser( + description="StarryNet Orchestrater Daemon with SSH Server") + parser.add_argument("--workdir", type=str, help="Working directory") + parser.add_argument("--machine-id", type=int, help="Machine ID", default=0) + parser.add_argument("--daemon", action="store_true", help="Run as daemon") + parser.add_argument("--log-level", type=str, default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Log level") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, + help="server port") + parser.add_argument("--username", type=str, + help="username for authentication") + parser.add_argument("--password", type=str, + help="password for authentication") + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + + daemon = OrchestraterDaemon( + workdir=args.workdir, + machine_id=args.machine_id, + log_level=getattr(logging, args.log_level.upper()), + port=args.port, + username=args.username, + password=args.password, + ) + + if args.daemon: + import daemon + with daemon.DaemonContext(): + daemon.run() + return 0 + + daemon.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bird.conf b/bird.conf new file mode 100644 index 0000000..db18b86 --- /dev/null +++ b/bird.conf @@ -0,0 +1,43 @@ +log "/bird.log" { warning, error, auth, fatal, bug }; +protocol device { +} +protocol direct { + disabled; # Disable by default + ipv4; # Connect to default IPv4 table + ipv6; # ... and to default IPv6 table +} +protocol kernel { + ipv4 { # Connect protocol to IPv4 table by channel + export all; # Export to protocol. default is export none + }; +} +# protocol static { +# ipv4; # Again, IPv6 channel with default options +# } +protocol ospf{ + ipv4 { + import all; + }; + area 0 { + interface "SH*O*S*" { + type broadcast; # Detected by default + cost 10; + hello 5; + }; + interface "GS*" { + type broadcast; # Detected by default + cost 10; + hello 5; + }; + interface "POP" { + type broadcast; # Detected by default + cost 10; + hello 5; + }; + interface "eth*" { + type broadcast; # Detected by default + cost 10; + hello 5; + }; + }; +} diff --git a/config.json b/config.json index 43374a6..c23acb6 100644 --- a/config.json +++ b/config.json @@ -1,28 +1,39 @@ { "Name": "starlink", - "Altitude (km)": 550, - "Cycle (s)": 5731, - "Inclination": 53, - "Phase shift": 1, - "# of orbit": 5, - "# of satellites": 5, - "Duration (s)": 100, - "update_time (s)": 10, + "Shells": [ + { + "Altitude (km)": 550, + "Inclination": 53, + "Orbits": 10, + "Satellites per orbit": 10, + "Phase shift": 1 + }, + { + "Altitude (km)": 540, + "Inclination": 53.2, + "Orbits": 5, + "Satellites per orbit": 5, + "Phase shift": 0 + } + ], + "Duration (s)": 10, + "step (s)": 1, "satellite link bandwidth (\"X\" Gbps)": 5, "sat-ground bandwidth (\"X\" Gbps)": 5, - "satellite link loss (\"X\"% )": 1, + "satellite link loss (\"X\"% )": 0, "sat-ground loss (\"X\"% )": 1, - "GS number": 2, "antenna number": 1, - "antenna_inclination_angle": 25, - "remote_machine_IP": "101.6.21.2", - "remote_machine_username": "root", - "remote_machine_password": "123456", - "Satellite link": "grid", + "antenna elevation angle": 25, + "Satellite link": "Grid", "IP version": "IPv4", - "Intra-AS routing": "OSPF", - "Inter-AS routing": "BGP", "Link policy": "LeastDelay", "Handover policy": "instant handover", - "multi-machine (\"0\" for no, \"1\" for yes)": 0 -} \ No newline at end of file + "Machines": [ + { + "IP": "127.0.0.1", + "port": 18888, + "username": "abc", + "password": "123456" + } + ] +} diff --git a/example.py b/example.py index ef41ccb..f0fb079 100644 --- a/example.py +++ b/example.py @@ -6,7 +6,6 @@ """ from starrynet.sn_observer import * -from starrynet.sn_orchestrater import * from starrynet.sn_synchronizer import * if __name__ == "__main__": @@ -14,82 +13,64 @@ # The node index sequence is: 25 sattelites, 2 ground stations. # In this example, 25 satellites and 2 ground stations are one AS. - AS = [[1, 27]] # Node #1 to Node #27 are within the same AS. GS_lat_long = [[50.110924, 8.682127], [46.635700, 14.311817] ] # latitude and longitude of frankfurt and Austria configuration_file_path = "./config.json" - hello_interval = 1 # hello_interval(s) in OSPF. 1-200 are supported. print('Start StarryNet.') - sn = StarryNet(configuration_file_path, GS_lat_long, hello_interval, AS) + sn = StarryNet(configuration_file_path, GS_lat_long) + sn.create_nodes() sn.create_links() - sn.run_routing_deamon() - - node_index1 = 1 - node_index2 = 2 - time_index = 2 + + # LLA of a node at a certain time + LLA = sn.get_position(node='SH1O1S1', t=2) + print(f'\nLatitude, Longitude, Altitude of SH1O1S1: {LLA}') # distance between nodes at a certain time - node_distance = sn.get_distance(node_index1, node_index2, time_index) - print("node_distance (km): " + str(node_distance)) + node_distance = sn.get_distance(node1='SH1O1S1', node2='SH1O1S2', t=2) + print(f'\nSH1O1S1 - SH1O1S2 distance(km): {node_distance}') - # neighbor node indexes of node at a certain time - neighbors_index = sn.get_neighbors(node_index1, time_index) - print("neighbors_index: " + str(neighbors_index)) + # neighbor nodes at a certain time + neighbors = sn.get_neighbors(node='SH1O1S1', t=2) + print(f'\nSH1O1S1 neighbors: {neighbors}') # GS connected to the node at a certain time - node_index1 = 7 - GSes = sn.get_GSes(node_index1, time_index) - print("GSes are: " + str(GSes)) + GSes = sn.get_GSes(node='SH1O1S1', t=2) + print(f"\nSH1O1S1 GSes: {GSes}") - # LLA of a node at a certain time - LLA = sn.get_position(node_index1, time_index) - print("LLA: " + str(LLA)) + # CPU and memory useage + sn.check_utility(t=2) + + # IP addresses of a node + IPs = sn.get_IP(node='SH1O1S1') + print(f'\nSH1O1S1 IP addresses: {IPs}') + + bird_conf_path = "./bird.conf" + # run OSPF daemon on all nodes + # sn.run_routing_daemon(bird_conf_path=bird_conf_path) + # run OSPF daemon on selected nodes + # sn.run_routing_daemon(bird_conf_path=bird_conf_path, node_lst=['GS0', 'SH1O2S2', 'SH1O2S3', 'SH1O3S3', 'GS1']) - sn.get_utility(time_index) # CPU and memory useage + # set the next hop at a certain time. + sn.set_static_route(src='SH1O1S1', dst='SH1O1S2', next_hop='SH1O1S2', t=2) - # IPList of a node - IP_list = sn.get_IP(node_index1) - print("IP: " + str(IP_list)) + # routing table of a node at a certain time. + print(sn.check_routing_table(node='SH1O1S1', t=3)) + + # ping msg of two nodes at a certain time. + sn.set_ping(src='SH1O1S1', dst='SH1O1S2', t=4) + + # perf msg of two nodes at a certain time. + sn.set_iperf(src='SH1O1S1', dst='SH1O1S2', t=5) - ratio = 0.3 - time_index = 5 # random damage of a given ratio at a certain time - sn.set_damage(ratio, time_index) - - time_index = 10 - sn.set_recovery(time_index) # recover the damages at a certain time - - node_index1 = 27 - time_index = 15 - # routing table of a node at a certain time. The output file will be written at the working directory. - sn.check_routing_table(node_index1, time_index) - - sat = 1 - des = 27 - next_hop_sat = 2 - time_index = 20 - # set the next hop at a certain time. Sat, Des and NextHopSat are indexes and Sat and NextHopSat are neighbors. - sn.set_next_hop(sat, des, next_hop_sat, time_index) - - node_index1 = 13 - node_index2 = 14 - time_index = 3 - # ping msg of two nodes at a certain time. The output file will be written at the working directory. - sn.set_ping(node_index1, node_index2, time_index) - for i in range(35, 80): - node_index1 = 26 - node_index2 = 27 - time_index = i - # ping msg of two nodes at a certain time. The output file will be written at the working directory. - sn.set_ping(node_index1, node_index2, time_index) - - node_index1 = 13 - node_index2 = 14 - time_index = 4 - # perf msg of two nodes at a certain time. The output file will be written at the working directory. - sn.set_perf(node_index1, node_index2, time_index) + sn.set_damage(damaging_ratio=0.3, t=6) + + # recover the damages at a certain time + sn.set_recovery(t=7) sn.start_emulation() - sn.stop_emulation() + + if input('clear environment?[y/n]').strip().lower()[:1] == 'y': + sn.clean() diff --git a/install.sh b/install.sh index 6010304..6d1dfc4 100644 --- a/install.sh +++ b/install.sh @@ -37,8 +37,14 @@ if [ "$DIST" = "Fedora" -o "$DIST" = "RedHatEnterpriseServer" ] || [ "$DIST" = fi echo "Installing dependencies" -$install python3 python-setuptools python3-pip -$install docker-ce docker-ce-cli containerd.io +if [ "$DIST" = "Ubuntu" ] || [ "$DIST" = "Debian" ]; then + $install python3 python3-dev python3-pip python3-setuptools gcc bird2 +elif [ "$DIST" = "Fedora" -o "$DIST" = "RedHatEnterpriseServer" ] || [ "$DIST" = "CentOS" ]; then + $install python3 python3-devel python3-pip python3-setuptools gcc bird +else + echo "Unsupported distribution for automatic package installation." + echo "Install Python 3, pip, Python development headers, gcc and bird manually." +fi sudo python3 -m pip install --upgrade pip sudo python3 -m pip install -r tools/requirements.txt -sudo python3 setup.py install \ No newline at end of file +sudo python3 setup.py install diff --git a/manual.json b/manual.json new file mode 100644 index 0000000..f383d38 --- /dev/null +++ b/manual.json @@ -0,0 +1,83 @@ +{ + "Name": "starlink", + "Satellite link": "Arbitrary", + "Shells": [ + { + "timeslots": [ + { + "position": [ + { + "latitude": 56.5, + "longitude": -20.1, + "altitude": 550 + }, + { + "latitude": 57.5, + "longitude": -23.1, + "altitude": 545 + } + ], + "links":[ + { + "sat1":0, + "sat2":1 + } + ] + }, + { + "position": [ + { + "latitude": 56.5, + "longitude": -20.1, + "altitude": 550 + }, + { + "latitude": 57.5, + "longitude": -23.1, + "altitude": 545 + } + ], + "links":[] + }, + { + "position": [ + { + "latitude": 56.5, + "longitude": -20.1, + "altitude": 550 + }, + { + "latitude": 57.5, + "longitude": -23.1, + "altitude": 545 + } + ], + "links":[ + { + "sat1":0, + "sat2":1 + } + ] + } + ] + } + ], + "step (s)": 1, + "satellite link bandwidth (\"X\" Gbps)": 5, + "sat-ground bandwidth (\"X\" Gbps)": 5, + "satellite link loss (\"X\"% )": 0, + "sat-ground loss (\"X\"% )": 1, + "antenna number": 1, + "antenna elevation angle": 25, + "IP version": "IPv4", + "Link policy": "LeastDelay", + "Handover policy": "instant handover", + "Machines": [ + { + "IP": "127.0.0.1", + "port": 22, + "username": "root", + "password": "123456" + } + ] +} \ No newline at end of file diff --git a/setup.py b/setup.py index 77e031e..199352d 100644 --- a/setup.py +++ b/setup.py @@ -1,38 +1,60 @@ -#!/usr/bin/env python -"Setuptools params" +#!/usr/bin/env python3 +"""Build and installation metadata for StarryNet.""" -from setuptools import setup, find_packages -from os.path import join - -# Get version number from source tree import sys +from pathlib import Path + +from setuptools import Extension, find_packages, setup + + +ROOT = Path(__file__).resolve().parent +README = ROOT / "README.md" +REQUIREMENTS = ROOT / "tools" / "requirements.txt" -sys.path.append('.') -scripts = [join('bin', filename) for filename in ['sn']] +def read_requirements(): + requirements = [] + for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines(): + requirement = line.strip() + if not requirement or requirement.startswith("#"): + continue + requirements.append(requirement) + return requirements -modname = distname = 'starrynet' +if sys.platform != "linux": + raise RuntimeError( + f"This package only supports Linux. " + f"Detected platform: {sys.platform}" + ) setup( - name=distname, + name="starrynet", version="1.0.0", - description= - 'StarryNet for the emulation of satellite Internet constellations.', - author=' Yangtao Deng', - author_email='dengyt21@mails.tsinghua.edu.cn', - packages=['starrynet'], - long_description=""" - StarryNet is a network emulator for satellite Internet constellations. - """, + description="StarryNet satellite-network emulator", + long_description=README.read_text(encoding="utf-8"), + long_description_content_type="text/markdown", + author="Yangtao Deng", + author_email="dengyt21@mails.tsinghua.edu.cn", + maintainer="Xin Xie", + maintainer_email="xiex24@mails.tsinghua.edu.cn", + url="https://github.com/SpaceNetLab/StarryNet", + license="BSD", + packages=find_packages(include=["starrynet", "starrynet.*"]), + python_requires=">=3.7", + install_requires=read_requirements(), + scripts=["bin/sn", "bin/sn-worker"], + ext_modules=[ + Extension("pyctr", [str(ROOT / "starrynet" / "pyctr.c")]), + Extension("pynetlink", [str(ROOT / "starrynet" / "pynetlink.c")]), + ], + platforms=["Linux"], classifiers=[ - "License :: OSI Approved :: BSD License", - "Programming Language :: Python", - "Development Status :: 1 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Topic :: System :: Emulators", ], - keywords='satellite Internet constellations emulator protocol', - license='BSD', - install_requires=['setuptools'], - scripts=scripts, + keywords="satellite network emulator constellation protocol", ) diff --git a/starrynet/clean.py b/starrynet/clean.py deleted file mode 100644 index d20d772..0000000 --- a/starrynet/clean.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/python -# -*- coding: UTF-8 -*- -""" -Starrynet Cleanup -author: Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) -""" -import os - - -def cleanup(): - print("Deleting all native bridges and containers...") - #os.system("FOR /f \"tokens=*\" %i IN ('docker ps -q') DO docker stop %i") - os.system("docker service rm constellation-test") - with os.popen( - "docker rm -f $(docker ps | grep \"lwsen/starlab_node:1.0\" | awk '{ print $1 }')" - ) as f: - f.readlines() - with os.popen("docker network ls") as f: - all_br_info = f.readlines() - for line in all_br_info: - if "La" in line or "Le" or "GS" in line: - network_name = line.split()[1] - print('docker network rm ' + network_name) - os.system('docker network rm ' + network_name) - - -if __name__ == "__main__": - cleanup() \ No newline at end of file diff --git a/starrynet/cli.py b/starrynet/cli.py index 138817a..053f053 100644 --- a/starrynet/cli.py +++ b/starrynet/cli.py @@ -1,252 +1,485 @@ -""" -A simple command-line interface for Starrynet. +"""Interactive CLI for StarryNet.""" -The Starrynet CLI provides a simple control console which -makes it easy to control the network and have access to nodes. For example, the command - -starrynet> create_nodes - -simply starts the network nodes. - -starrynet> get_neighbors 1, 5 - -gets the neighbors of node#1 at 5 second. - -starrynet> set_ping 26 27 30 - -should work correctly and allow node#26 to ping node#26 at 30 second. - -starrynet> set_perf 26 27 30 - -should work correctly and allow node#26 to perf node#26 at 30 second. - -author: Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) -""" - -from subprocess import call from cmd import Cmd -from os import isatty -from select import poll, POLLIN +import shlex import sys +import time -from starrynet.log import info, output, error -# from starrynet.term import makeTerms, runX11 -# from starrynet.util import ( quietRun, dumpNodeConnections, -# dumpPorts ) +from .log import error, info, output class CLI(Cmd): - "Simple command-line interface to talk to nodes." + """Simple command-line interface for StarryNet.""" - prompt = 'starrynet> ' + prompt = "starrynet> " - def __init__(self, starrynet, stdin=sys.stdin, *args, **kwargs): - """Start and run interactive or batch mode CLI - starrynet: Starrynet network object - stdin: standard input for CLI - script: script to run in batch mode""" + def __init__(self, starrynet, default_bird_conf="./bird.conf", + stdin=sys.stdin, *args, **kwargs): self.sn = starrynet - # Local variable bindings for py command - self.locals = {'net': starrynet} - # Attempt to handle input - self.inPoller = poll() - self.inPoller.register(stdin) - Cmd.__init__(self, *args, stdin=stdin, **kwargs) - info('*** Starting CLI:\n') - + self.default_bird_conf = default_bird_conf + super().__init__(*args, stdin=stdin, **kwargs) + info("*** Starting CLI. Type 'help' for commands.\n") self.run() def run(self): - "Run our cmdloop(), catching KeyboardInterrupt" while True: try: self.cmdloop() break except KeyboardInterrupt: - # Output a message - unless it's also interrupted - # pylint: disable=broad-except - try: - output('\nInterrupt\n') - except Exception: - pass - # pylint: enable=broad-except + output("\nInterrupt\n") def emptyline(self): - "Don't repeat last command when you hit return." pass - def getLocals(self): - "Local variable bindings for py command" - self.locals.update(self.mn) - return self.locals - - helpStr = ( - 'Supported commands are as follows:\n' - ' starrynet> help\n' - ' starrynet> create_nodes\n' - ' starrynet> create_links\n' - ' starrynet> run_routing_deamon\n' - ' starrynet> get_distance 1 2 10\n' - ' // It means getting the distance of two node (#1 and #2) at #10 second.\n' - ' starrynet> get_neighbors 5 16\n' - ' // It means getting the neighbor node indexes of node #5 at #16 second.\n' - ' starrynet> get_GSes 7 20\n' - ' // It means getting the connected GS node indexes of node #6 at #20 second.\n' - ' starrynet> get_position 7 23 \n' - ' // It means getting the LLA position of node #7 at #23 second.\n' - ' starrynet> get_IP 8 \n' - ' // It means getting the IP addresses of node #8. "create_nodes" and "create_links" must be runned before this.\n' - ' starrynet> get_utility 27\n' - ' // It means getting the memory and CPU utility information at #27 second. The output file will be generated at the working directory once the emulation starts.\n' - ' starrynet> set_damage 0.3 30\n' - ' // It means setting a random damage of a given ratio of 0.3 at #30 second, which will be processed during emulation.\n' - ' starrynet> set_recovery 50\n' - ' // It means setting a recovery of the damages at #50 second, which will be processed during emulation.\n' - ' starrynet> check_routing_table 26 40\n' - ' // It means listing the routing table of node #26 at #40 second. The output file will be written at the working directory.\n' - ' starrynet> set_next_hop 1 26 2 45\n' - ' // It means setting the next hop to node #2 for node #1 for the destination of node #26 at #45 second. Sat, Des and NextHopSat are indexes and Sat and NextHopSat are neighbors, which will be processed during emulation.\n' - ' starrynet> set_ping 1 26 46\n' - ' // It means pinging msg of from node #1 to node #26 at #46 second. The output file will be written at the working directory.\n' - ' starrynet> set_perf 1 26 46\n' - ' // It means perfing from node #1 to node #26 at #46 second. The perfing output file will be written at the working directory.\n' - ' starrynet> start_emulation\n' - ' // "create_nodes", "create_links" and "run_routing_deamon" must be runned before this.' - ' starrynet> stop_emulation\n' - ' starrynet> exit\n' - ' starrynet> quit\n' - ' starrynet> EOF\n' - ' You may use the commands multiple times.\n') + def _parse_args(self, line): + return shlex.split(line) + + def _require_args(self, line, expected, usage): + args = self._parse_args(line) + if len(args) != expected: + error(f"usage: {usage}\n") + return None + return args + + def _require_at_least_args(self, line, minimum, usage): + args = self._parse_args(line) + if len(args) < minimum: + error(f"usage: {usage}\n") + return None + return args + + def _parse_int(self, value, name): + try: + return int(value) + except ValueError: + error(f"{name} must be an integer: {value}\n") + return None + + def _parse_float(self, value, name): + try: + return float(value) + except ValueError: + error(f"{name} must be a number: {value}\n") + return None + + def _check_node(self, node): + if node not in self.sn.nodes: + error(f"unknown node: {node}\n") + return False + return True + + def _stringify_cell(self, value): + if value is None: + return "-" + if isinstance(value, (list, tuple)): + return ", ".join(str(item) for item in value) if value else "-" + if isinstance(value, dict): + if not value: + return "-" + return ", ".join(f"{key}={val}" for key, val in value.items()) + return str(value) + + def _format_wall_time(self, value): + if value is None: + return "-" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(value)) + except (TypeError, ValueError, OSError): + return str(value) + + def _render_table(self, headers, rows): + if not rows: + return "" + + rendered_rows = [ + [self._stringify_cell(cell) for cell in row] + for row in rows + ] + widths = [ + max(len(str(header)), *(len(row[idx]) for row in rendered_rows)) + for idx, header in enumerate(headers) + ] + + def build_row(values): + return "| " + " | ".join( + value.ljust(widths[idx]) for idx, value in enumerate(values) + ) + " |" + + separator = "+-" + "-+-".join("-" * width for width in widths) + "-+" + lines = [separator, build_row(headers), separator] + lines.extend(build_row(row) for row in rendered_rows) + lines.append(separator) + return "\n".join(lines) + + def _render_kv_table(self, title, mapping): + rows = [ + (key, value) + for key, value in mapping.items() + if value not in (None, "", [], {}, ()) + ] + table = self._render_table(["Field", "Value"], rows) + if not table: + return f"{title}: " + return f"{title}:\n{table}" def do_help(self, line): - "Describe available CLI commands." - Cmd.do_help(self, line) - if line == '': - output(self.helpStr) + if line: + return super().do_help(line) + output( + "Common workflow:\n" + " create_nodes\n" + " create_links\n" + " run_routing_daemon [bird.conf] [node1 node2 ...]\n" + " start_emulation\n\n" + "Useful commands:\n" + " status\n" + " nodes [prefix]\n" + " path\n" + " get_distance NODE1 NODE2 TIME\n" + " get_neighbors NODE TIME\n" + " get_GSes NODE TIME\n" + " get_position NODE TIME\n" + " get_IP NODE\n" + " check_utility TIME\n" + " check_routing_table NODE TIME\n" + " set_damage RATIO TIME\n" + " set_recovery TIME\n" + " set_static_route SRC DST NEXT_HOP TIME\n" + " set_ping SRC DST TIME\n" + " set_iperf SRC DST TIME\n" + " events\n" + " event EVENT_ID\n" + " event_result EVENT_ID\n" + " tasks [NODE]\n" + " task TASK_ID\n" + " task_output TASK_ID [NODE]\n" + " clean\n" + " exit\n\n" + "Notes:\n" + " NODE values use names such as SH1O1S1 or GS0.\n" + " Scheduled commands run when start_emulation is executed.\n" + ) + + def do_status(self, _line): + output( + f"experiment: {self.sn.experiment_name}\n" + f"config dir: {self.sn.configuration_dir}\n" + f"duration: {self.sn.duration}s\n" + f"step: {self.sn.step}s\n" + f"nodes: {len(self.sn.nodes)}\n" + f"workers: {len(self.sn.worker_lst)}\n" + f"events: {len(self.sn.list_events())}\n" + ) + + def do_nodes(self, line): + args = self._parse_args(line) + prefix = args[0] if args else "" + nodes = sorted(name for name in self.sn.nodes if name.startswith(prefix)) + if not nodes: + output("No nodes matched.\n") + return + output("\n".join(nodes) + "\n") def do_create_nodes(self, _line): - "initialize the entire network nodes" self.sn.create_nodes() def do_create_links(self, _line): - "initialize the entire network links" self.sn.create_links() - def do_run_routing_deamon(self, _line): - "run routing deamon for each node" - self.sn.run_routing_deamon() + def do_run_routing_daemon(self, line): + args = self._parse_args(line) + bird_conf_path = args[0] if args else self.default_bird_conf + node_lst = args[1:] if len(args) > 1 else "all" + self.sn.run_routing_daemon(bird_conf_path=bird_conf_path, + node_lst=node_lst) + + def do_run_routing_deamon(self, line): + self.do_run_routing_daemon(line) def do_get_distance(self, line): - "calculate the distance of two node at a certain time" - arg, args, line = self.parseline(line) - rest = line.split(' ') - node_distance = self.sn.get_distance(int(rest[0]), int(rest[1]), - int(rest[2])) - output("The distance between node#%d and node#%d is %.2fkm.\n" % - (int(rest[0]), int(rest[1]), node_distance)) + args = self._require_args( + line, 3, "get_distance NODE1 NODE2 TIME") + if args is None: + return + node1, node2 = args[0], args[1] + if not self._check_node(node1) or not self._check_node(node2): + return + t = self._parse_int(args[2], "TIME") + if t is None: + return + distance = self.sn.get_distance(node1=node1, node2=node2, t=t) + output(f"{node1} <-> {node2} at {t} s: {distance:.2f} km\n") def do_get_neighbors(self, line): - "list the neighbor node indexes of node at a certain time" - arg, args, line = self.parseline(line) - rest = line.split(' ') - neighbors_index = self.sn.get_neighbors(int(rest[0]), int(rest[1])) - output("The neighbors are: " + str(neighbors_index) + ".\n") + args = self._require_args(line, 2, "get_neighbors NODE TIME") + if args is None or not self._check_node(args[0]): + return + t = self._parse_int(args[1], "TIME") + if t is None: + return + neighbors = self.sn.get_neighbors(node=args[0], t=t) + output(f"neighbors of {args[0]} at {t} s: {neighbors}\n") def do_get_GSes(self, line): - "list the GS connected to the node at a certain time" - arg, args, line = self.parseline(line) - rest = line.split(' ') - GSes = self.sn.get_GSes(int(rest[0]), int(rest[1])) - output("The connected GS(es) is(are): " + str(GSes) + ".\n") + args = self._require_args(line, 2, "get_GSes NODE TIME") + if args is None or not self._check_node(args[0]): + return + t = self._parse_int(args[1], "TIME") + if t is None: + return + gses = self.sn.get_GSes(node=args[0], t=t) + output(f"connected ground stations of {args[0]} at {t} s: {gses}\n") def do_get_position(self, line): - "list the LLA of a node at a certain time" - arg, args, line = self.parseline(line) - rest = line.split(' ') - LLA = self.sn.get_position(int(rest[0]), int(rest[1])) - output("The LLA is: " + str(LLA)) + args = self._require_args(line, 2, "get_position NODE TIME") + if args is None or not self._check_node(args[0]): + return + t = self._parse_int(args[1], "TIME") + if t is None: + return + position = self.sn.get_position(node=args[0], t=t) + output(f"position of {args[0]} at {t} s: {position}\n") def do_get_IP(self, line): - "list the IP of a node" - arg, args, line = self.parseline(line) - IP = self.sn.get_IP(int(arg)) - output("The IP list of the node is(are): " + str(IP) + ".\n") - - def do_get_utility(self, line): - "list the CPU and memory useage at a certain time" - "The output file will be generated once the emulation starts" - arg, args, line = self.parseline(line) - self.sn.get_utility(int(arg)) + args = self._require_args(line, 1, "get_IP NODE") + if args is None or not self._check_node(args[0]): + return + ip_list = self.sn.get_IP(node=args[0]) + output(f"IPs of {args[0]}: {ip_list}\n") + + def do_check_utility(self, line): + args = self._require_args(line, 1, "check_utility TIME") + if args is None: + return + t = self._parse_int(args[0], "TIME") + if t is None: + return + event_id = self.sn.check_utility(t=t) + output(f"utility check scheduled: {event_id}\n") def do_set_damage(self, line): - "set a random damage of a given ratio at a certain time, which will be processed during emulation" - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.set_damage(float(rest[0]), int(rest[1])) + args = self._require_args(line, 2, "set_damage RATIO TIME") + if args is None: + return + ratio = self._parse_float(args[0], "RATIO") + t = self._parse_int(args[1], "TIME") + if ratio is None or t is None: + return + event_id = self.sn.set_damage(damaging_ratio=ratio, t=t) + output(f"damage event scheduled: {event_id}\n") def do_set_recovery(self, line): - "set a recovery of the damages at a certain time, which will be processed during emulation" - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.set_recovery(int(rest[0])) + args = self._require_args(line, 1, "set_recovery TIME") + if args is None: + return + t = self._parse_int(args[0], "TIME") + if t is None: + return + event_id = self.sn.set_recovery(t=t) + output(f"recovery event scheduled: {event_id}\n") def do_check_routing_table(self, line): - "list the routing table of a node at a certain time." - "The output file will be written at the working directory." - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.check_routing_table(int(rest[0]), int(rest[1])) + args = self._require_args(line, 2, "check_routing_table NODE TIME") + if args is None or not self._check_node(args[0]): + return + t = self._parse_int(args[1], "TIME") + if t is None: + return + event_id = self.sn.check_routing_table(node=args[0], t=t) + output(f"routing table dump scheduled: {event_id}\n") + + def do_set_static_route(self, line): + args = self._require_args( + line, 4, "set_static_route SRC DST NEXT_HOP TIME") + if args is None: + return + src, dst, next_hop = args[:3] + if not all(self._check_node(node) for node in [src, dst, next_hop]): + return + t = self._parse_int(args[3], "TIME") + if t is None: + return + event_id = self.sn.set_static_route(src=src, dst=dst, next_hop=next_hop, t=t) + output(f"static route scheduled: {event_id}\n") def do_set_next_hop(self, line): - "set the nhelpext hop at a certain time" - "Sat, Des and NextHopSat are indexes and Sat and NextHopSat are neighbors, which will be processed during emulation" - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.set_next_hop(int(rest[0]), int(rest[1]), int(rest[2]), - int(rest[3])) - - def do_path(self, line): - "get the working directory" - output(self.sn.configuration_file_path + "\n") + self.do_set_static_route(line) def do_set_ping(self, line): - "ping msg of two nodes at a certain time" - "The output file will be written at the working directory" - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.set_ping(int(rest[0]), int(rest[1]), int(rest[2])) + args = self._require_args(line, 3, "set_ping SRC DST TIME") + if args is None: + return + src, dst = args[:2] + if not self._check_node(src) or not self._check_node(dst): + return + t = self._parse_int(args[2], "TIME") + if t is None: + return + event_id = self.sn.set_ping(src=src, dst=dst, t=t) + output(f"ping scheduled: {event_id}\n") + + def do_set_iperf(self, line): + args = self._require_args(line, 3, "set_iperf SRC DST TIME") + if args is None: + return + src, dst = args[:2] + if not self._check_node(src) or not self._check_node(dst): + return + t = self._parse_int(args[2], "TIME") + if t is None: + return + event_id = self.sn.set_iperf(src=src, dst=dst, t=t) + output(f"iperf scheduled: {event_id}\n") def do_set_perf(self, line): - "perf msg of two nodes at a certain time" - "The output file will be written at the working directory" - arg, args, line = self.parseline(line) - rest = line.split(' ') - self.sn.set_perf(int(rest[0]), int(rest[1]), int(rest[2])) + self.do_set_iperf(line) + + def do_events(self, _line): + events = self.sn.list_events() + if not events: + output("No events.\n") + return + rows = [ + ( + item.get("event_id"), + item.get("time"), + item.get("type"), + item.get("status"), + item.get("result_mode"), + item.get("params"), + ) + for item in events + ] + output(self._render_table(["Event ID", "Time", "Type", "Status", "Result", "Params"], rows) + "\n") + + def do_event(self, line): + args = self._require_args(line, 1, "event EVENT_ID") + if args is None: + return + event = self.sn.get_event(args[0]) + if not event: + output("Event not found.\n") + return + event_fields = { + "event_id": event.get("event_id"), + "time": event.get("time"), + "type": event.get("type"), + "status": event.get("status"), + "result_mode": event.get("result_mode"), + "params": event.get("params"), + "created_at": self._format_wall_time(event.get("created_at")), + "triggered_at": self._format_wall_time(event.get("triggered_at")), + "finished_at": self._format_wall_time(event.get("finished_at")), + "error": event.get("error"), + "task_refs": event.get("task_refs"), + } + output(self._render_kv_table("Event", event_fields) + "\n") + + def do_event_result(self, line): + args = self._require_args(line, 1, "event_result EVENT_ID") + if args is None: + return + event = self.sn.get_event(args[0]) + if not event: + output("Event not found.\n") + return + + if event.get("error"): + output(f"{event.get('error')}\n") + else: + output(f"{event.get('result', 'No result available.')}\n") + + def do_tasks(self, line): + args = self._parse_args(line) + node = args[0] if args else None + if node is not None and not self._check_node(node): + return + tasks = self.sn.list_tasks(node=node) + if not tasks: + output("No tasks running.\n") + return + rows = [ + ( + task.get("task_id"), + task.get("task_type"), + task.get("node"), + task.get("status"), + task.get("output_file"), + self._format_wall_time(task.get("scheduled_at")), + self._format_wall_time(task.get("started_at")), + self._format_wall_time(task.get("finished_at")), + ) + for task in tasks + ] + output( + self._render_table( + ["Task ID", "Type", "Node", "Status", "Output", "Scheduled", "Started", "Finished"], + rows, + ) + "\n" + ) + + def do_task(self, line): + args = self._require_args(line, 1, "task TASK_ID") + if args is None: + return + task_id = args[0] + task = self.sn.get_task(task_id) + if not task: + output("Task not found.\n") + return + task_fields = { + "task_id": task.get("task_id"), + "task_type": task.get("task_type"), + "node": task.get("node"), + "status": task.get("status"), + "cmd": task.get("cmd"), + "output_file": task.get("output_file"), + "created_at": self._format_wall_time(task.get("created_at")), + "scheduled_at": self._format_wall_time(task.get("scheduled_at")), + "started_at": self._format_wall_time(task.get("started_at")), + "finished_at": self._format_wall_time(task.get("finished_at")), + "returncode": task.get("returncode"), + "metadata": task.get("metadata"), + } + output(self._render_kv_table("Task", task_fields) + "\n") + + def do_task_output(self, line): + args = self._require_at_least_args(line, 1, "task_output TASK_ID [NODE]") + if args is None: + return + task_id = args[0] + node = args[1] if len(args) > 1 else None + if node is not None and not self._check_node(node): + return + result = self.sn.get_task_output(task_id, node=node) + if not result: + output("Task not found.\n") + return + output(result.get('output', '') + "\n") def do_start_emulation(self, _line): - "start the emulation" self.sn.start_emulation() + def do_clean(self, _line): + self.sn.clean() + + def do_path(self, _line): + output(self.sn.local_dir + "\n") + def do_stop_emulation(self, _line): - "stop the emulation" - self.sn.stop_emulation() - return 'exited by user command' + self.sn.clean() + return "exited by user command" def do_exit(self, _line): - "stop the emulation" - self.sn.stop_emulation() - return 'exited by user command' + return "exited by user command" def do_quit(self, line): - "Exit" return self.do_exit(line) def do_EOF(self, line): - "Exit" - output('\n') + output("\n") return self.do_exit(line) def default(self, line): - "Exit" - error('*** Unknown command: %s\n' % line) - return + error(f"*** Unknown command: {line}\n") diff --git a/starrynet/pyctr.c b/starrynet/pyctr.c new file mode 100644 index 0000000..375a899 --- /dev/null +++ b/starrynet/pyctr.c @@ -0,0 +1,323 @@ +#include + +#ifndef _GNU_SOURCE +# define _GNU_SOURCE +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +const int NS = CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|CLONE_NEWIPC|CLONE_NEWUTS; + +static int child_err(const char *prefix, int write_fd) { + int err = errno; + const char *error_msg = strerror(err); + write(write_fd, prefix, strlen(prefix)); + write(write_fd, error_msg, strlen(error_msg)); + close(write_fd); + return err; +} + +// in child process with new namespace +static int container_init( + const char* newroot, + const char* overlay_opt, + const char* hostname, + int err_fd + ) { + close(STDIN_FILENO); + close(STDOUT_FILENO); + close(STDERR_FILENO); + int flags = fcntl(err_fd, F_GETFD); + flags |= FD_CLOEXEC; + fcntl(err_fd, F_SETFD, flags); + + if(mount("none", "/", NULL, MS_PRIVATE|MS_REC, NULL) != 0) { + return child_err("mount rprivate / failed: ", err_fd); + } + // mount overlay + if(mount("overlay", newroot, "overlay", 0, overlay_opt) != 0) { + return child_err("mount overlay failed: ", err_fd); + } + if(mount("none", newroot, NULL, MS_PRIVATE|MS_REC, NULL) != 0) { + return child_err("mount rprivate newroot failed: ", err_fd); + } + + if(chdir(newroot) != 0) { + return child_err("chdir failed: ", err_fd); + } + // pivot root + // https://unix.stackexchange.com/questions/456620/how-to-perform-chroot-with-linux-namespaces + if(syscall(SYS_pivot_root, ".", ".") != 0) { + return child_err("pivot_root failed: ", err_fd); + } + if(chroot(".") != 0) { + return child_err("chroot failed: ", err_fd); + } + if(umount2 (".", MNT_DETACH) != 0) { + return child_err("umount2 failed: ", err_fd); + } + // mount proc + if(mount("proc", "/proc", "proc", MS_NOSUID|MS_NOEXEC|MS_NODEV, NULL) != 0) { + return child_err("mount /proc failed: ", err_fd); + } + // new session, detach to become a daemon process + if(setsid() < 0) { + return child_err("setsid failed: ", err_fd); + } + + // other miscellaneous configuration, maybe warning is better choice + if(signal(SIGCLD, SIG_IGN) < 0) { + return child_err("ignore SIGCLD failed: ", err_fd); + } + if(sethostname(hostname, strlen(hostname))) { + return child_err("sethostname failed: ", err_fd); + } + if(clearenv() != 0) { + return child_err("clearenv failed: ", err_fd); + } + if(putenv("HOME=/root") + || putenv("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")) { + return child_err("putenv failed: ", err_fd); + } + // sleep infinity, need a process with low resource requirement + execlp("sleep", "sleep", "inf", NULL); + // should not be executed here + return child_err("execlp failed: ", err_fd); +} + +// in child process +int container_enter(pid_t ctr_pid, char *const* argv, int err_fd) { + int flags = fcntl(err_fd, F_GETFD); + if(flags < 0) + return child_err("failed to fcntl F_GETFD: ", err_fd); + flags |= FD_CLOEXEC; + if(fcntl(err_fd, F_SETFD, flags) < 0) + return child_err("failed to fcntl F_SETFD: ", err_fd); + + int pid_fd = syscall(SYS_pidfd_open, ctr_pid, 0); + if(pid_fd < 0) + return child_err("failed to pidfd_open: ", err_fd); + + int ret = setns(pid_fd, NS); + close(pid_fd); + if(ret != 0) + return child_err("failed to setns: ", err_fd); + + execvp(argv[0], &argv[0]); + return child_err("failed to execvp: ", err_fd); +} + +// in parent process +// on success, ret > 0 means child pid. +// ret < 0 for parent err, ret == 0 for child err +static int container_run_inner( + const char *base_dir, const char *hostname, char *chd_err, size_t max_len) { + // 0755 + const mode_t MODE = S_IRWXU | (S_IRGRP|S_IXGRP) | (S_IROTH|S_IXOTH); + const char* UPPER_DIR = "upper"; + const char* WORK_DIR = "work"; + const char* NEWROOT = "rootfs"; + + if(access(base_dir, F_OK) && mkdir(base_dir, MODE)) return -1; + + int dir_fd = open(base_dir, O_RDONLY); + if(dir_fd < 0) return -1; + if((faccessat(dir_fd, UPPER_DIR, F_OK, 0) && mkdirat(dir_fd, UPPER_DIR, MODE)) + || (faccessat(dir_fd, WORK_DIR, F_OK, 0) && mkdirat(dir_fd, WORK_DIR, MODE)) + || (faccessat(dir_fd, NEWROOT, F_OK, 0) && mkdirat(dir_fd, NEWROOT, MODE))) { + close(dir_fd); + return -1; + } + if(close(dir_fd) != 0) return -1; + + char overlay_opt[PATH_MAX * 3]; + char new_root[PATH_MAX]; + snprintf(overlay_opt, sizeof(overlay_opt), + "lowerdir=/,upperdir=%s/%s,workdir=%s/%s", + base_dir, UPPER_DIR, base_dir, WORK_DIR); + snprintf(new_root, sizeof(new_root), "%s/%s", base_dir, NEWROOT); + + int err_fds[2], event_fd; + if(pipe(err_fds) != 0 || (event_fd = eventfd(0, 0)) < 0) return -1; + + pid_t pid = fork(); + if(pid < 0) { + close(err_fds[0]), close(err_fds[1]), close(event_fd); + return -1; + } else if(pid == 0) { + close(err_fds[0]); + if(unshare(NS) != 0) { + close(event_fd); + exit(child_err("unshare failed: ", err_fds[1])); + } + pid = fork(); + if(pid < 0) { + close(event_fd); + exit(child_err("second fork failed: ", err_fds[1])); + } else if(pid == 0) { + close(event_fd); + exit(container_init(new_root, overlay_opt, hostname, err_fds[1])); + // should not execute here + } + close(err_fds[1]); + uint64_t pid_u64 = pid; + write(event_fd, &pid_u64, sizeof(pid_u64)); + close(event_fd); + exit(0); + // should not execute here + } + + close(err_fds[1]); + ssize_t len = read(err_fds[0], chd_err, max_len); + // anyway, child should exit immediately + waitpid(pid, NULL, 0); + if(len > 0) { + chd_err[len] = '\0'; + close(err_fds[0]), close(event_fd); + return 0; + } + + // receive grandchild pid from child + uint64_t pid_u64; + if(read(event_fd, &pid_u64, sizeof(pid_u64)) == sizeof(pid_u64)) + pid = pid_u64; + else + pid = -1; + + close(err_fds[0]), close(event_fd); + return pid; +} + +// in parent process +// on success, ret > 0 means child pid, need to be waited and recycled +// ret < 0 for parent err, ret == 0 for child err +static int container_exec_inner( + pid_t ctr_pid, char *const* argv, char *chd_err, size_t max_len) { + int err_fds[2]; + pid_t ret; + ssize_t err_len; + + if(pipe(err_fds) != 0) return -1; + + ret = fork(); + if(ret < 0) { + close(err_fds[0]), close(err_fds[1]); + return -1; + } else if(ret == 0) { + close(err_fds[0]); + exit(container_enter(ctr_pid, argv, err_fds[1])); + // should not be executed + } + close(err_fds[1]); + + err_len = read(err_fds[0], chd_err, max_len); + if(err_len > 0) { + chd_err[err_len] = '\0'; + waitpid(ret, NULL, 0); + ret = 0; + } + close(err_fds[0]); + return ret; +} + +// ========================Python wrapper======================== + +static PyObject *container_run(PyObject *self, PyObject *args) { + const char *base_dir = NULL; + const char *hostname = NULL; + char chd_err[256]; + int pid; + + if (!PyArg_ParseTuple(args, + "ss:container_run(base_dir, hostname)", &base_dir, &hostname)) + return NULL; + + pid = container_run_inner(base_dir, hostname, chd_err, sizeof(chd_err) - 1); + if(pid < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } else if (pid == 0) { + PyErr_SetString(PyExc_ChildProcessError, chd_err); + return NULL; + } else { // normal case + return PyLong_FromLong(pid); + } +} + +static PyObject *container_exec(PyObject *self, PyObject *args) { + int pid; + PyObject *cmdline; + Py_ssize_t argc; + char **argv; + char chd_err[256]; + int ret; + + if(!PyArg_ParseTuple(args, + "iO:container_exec(container_pid, cmdline)", &pid, &cmdline)) + return NULL; + if(!PySequence_Check(cmdline) || (argc = PySequence_Size(cmdline)) <= 0) { + PyErr_SetString(PyExc_TypeError, + "argument 2 \"cmdline\" must be sequence with length >= 1"); + return NULL; + } + + argv = malloc((argc + 1) * sizeof(*argv)); + for(Py_ssize_t i = 0; i < argc; i++) { + argv[i] = PyBytes_AsString(PySequence_ITEM(cmdline, i)); + if(argv[i] == NULL) { + free(argv); + return NULL; + } + } + argv[argc] = NULL; + + ret = container_exec_inner(pid, argv, chd_err, sizeof(chd_err) - 1); + if(ret < 0) { + PyErr_SetFromErrno(PyExc_OSError); + } else if (ret == 0) { + PyErr_SetString(PyExc_ChildProcessError, chd_err); + } else { + int status; + waitpid(ret, &status, 0); + if(!WIFEXITED(status)) { + PyErr_SetString(PyExc_ChildProcessError, "child did not exit normally"); + } else { + free(argv); + return PyLong_FromLong(WEXITSTATUS(status)); + } + } + free(argv); + return NULL; +} + +static PyMethodDef methods[] = { + {"container_run", container_run, METH_VARARGS, "run a simplified container"}, + {"container_exec", container_exec, METH_VARARGS, "exec command in container"}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, + "pyctr", /* name of module */ + NULL, /* module documentation, may be NULL */ + -1, /* size of per-interpreter state of the module, + or -1 if the module keeps state in global variables. */ + methods +}; + +PyMODINIT_FUNC PyInit_pyctr(void) { + return PyModule_Create(&module); +} diff --git a/starrynet/pynetlink.c b/starrynet/pynetlink.c new file mode 100644 index 0000000..29ee161 --- /dev/null +++ b/starrynet/pynetlink.c @@ -0,0 +1,1036 @@ +#define PY_SSIZE_T_CLEAN +#include +// POSIX and Linux +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// std C +#include +#include +#include +#include +#include +#include + +#define NETNS_DIR "/var/run/netns" +#define VXLAN_PORT 4789 + +static int init_rtnetlink_sock_() { + struct sockaddr_nl sa_nl = { + .nl_family = AF_NETLINK, + .nl_pid = 0, + .nl_groups = 0 + }; + int sock_fd; + + sock_fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); + if (sock_fd < 0) { + return -1; + } + + if (bind(sock_fd, (struct sockaddr*)&sa_nl, sizeof(sa_nl)) < 0) { + close(sock_fd); + return -1; + } + + return sock_fd; +} + +static int rtnetlink_request(int sock_fd, struct nlmsghdr* nl_hdr, size_t buf_len, + char *err_str, size_t err_len) { + struct sockaddr_nl sa_nl = { + .nl_family = AF_NETLINK, + .nl_pid = 0, + .nl_groups = 0 + }; + int ret; + + if(sendto(sock_fd, nl_hdr, nl_hdr->nlmsg_len, 0, (struct sockaddr*)&sa_nl, sizeof(sa_nl)) < 0) { + snprintf(err_str, err_len, "Failed to send netlink message: %s", strerror(errno)); + return -1; + } + + ret = recvfrom(sock_fd, nl_hdr, buf_len, 0, NULL, NULL); + if (ret < 0) { + snprintf(err_str, err_len, "Failed to receive netlink response: %s", strerror(errno)); + return -1; + } + + // Check for errors + if (nl_hdr->nlmsg_type == NLMSG_ERROR) { + struct nlmsgerr* err = (struct nlmsgerr*)NLMSG_DATA(nl_hdr); + if (err->error) { + snprintf(err_str, err_len, "Netlink error: %s (%d)", strerror(-err->error), -err->error); + return -1; + } + } + + return 0; +} + +static int netem_update_or_create_( + int sock_fd, const char *if_name, uint32_t delay, uint32_t loss, + uint64_t rate_Bps, char *err_str, size_t max_len) { + unsigned int if_idx; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct tcmsg* tc_msg; + struct rtattr* rta; + struct tc_netem_qopt* qopt; + uint8_t buf[1024]; + + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*tc_msg)); + nl_hdr->nlmsg_type = RTM_NEWQDISC; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE; + nl_hdr->nlmsg_pid = 0; + + tc_msg = NLMSG_DATA(nl_hdr); + tc_msg->tcm_family = AF_UNSPEC; + tc_msg->tcm__pad1 = tc_msg->tcm__pad2 = 0; + tc_msg->tcm_ifindex = if_idx; + tc_msg->tcm_handle = 0; + tc_msg->tcm_parent = TC_H_ROOT; // root + tc_msg->tcm_info = 0; + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = TCA_KIND; + rta->rta_len = RTA_LENGTH(strlen("netem") + 1); + memcpy(RTA_DATA(rta), "netem", strlen("netem") + 1); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = TCA_OPTIONS; + rta->rta_len = RTA_LENGTH(sizeof(*qopt)); + qopt = (struct tc_netem_qopt*)RTA_DATA(rta); + memset(qopt, 0, sizeof(*qopt)); + qopt->limit = 1000; + qopt->latency = delay; + qopt->loss = loss; + // If we have a rate specified, add rate information + if (rate_Bps > 0) { + struct rtattr* rate_attr; + struct tc_netem_rate* rate; + + rate_attr = (struct rtattr*)((char*)rta + RTA_ALIGN(rta->rta_len)); + rate_attr->rta_type = TCA_NETEM_RATE; + rate_attr->rta_len = RTA_LENGTH(sizeof(struct tc_netem_rate)); + rate = (struct tc_netem_rate*)RTA_DATA(rate_attr); + memset(rate, 0, sizeof(*rate)); + + rta->rta_len += RTA_ALIGN(rate_attr->rta_len); + + if (rate_Bps >= (1ULL << 32)) { + rate->rate = ~0U; + struct rtattr* rate64 = (struct rtattr*)((char*)rate_attr + RTA_ALIGN(rate_attr->rta_len)); + rate64->rta_type = TCA_NETEM_RATE64; + rate64->rta_len = RTA_LENGTH(sizeof(uint64_t)); + *(uint64_t*)RTA_DATA(rate64) = rate_Bps; + + rta->rta_len += RTA_ALIGN(rate64->rta_len); + } else { + rate->rate = rate_Bps; + } + } + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + return rtnetlink_request(sock_fd, nl_hdr, sizeof(buf), err_str, max_len); +} + +static int modify_addr4_(int sock_fd, uint16_t nlmsg_type, const char *if_name, + const struct in_addr *addr4, unsigned prefix_len, char *err_str, size_t max_len) { + unsigned int if_idx; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct ifaddrmsg* addr_msg; + struct rtattr* rta; + uint8_t buf[512]; + + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*addr_msg)); + nl_hdr->nlmsg_type = RTM_NEWADDR; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK | NLM_F_EXCL; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + addr_msg = NLMSG_DATA(nl_hdr); + memset(addr_msg, 0, sizeof(*addr_msg)); + addr_msg->ifa_family = AF_INET; + addr_msg->ifa_prefixlen = prefix_len; + addr_msg->ifa_flags = IFA_F_PERMANENT; + addr_msg->ifa_scope = RT_SCOPE_UNIVERSE; + addr_msg->ifa_index = if_idx; + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFA_LOCAL; + rta->rta_len = RTA_LENGTH(sizeof(*addr4)); + memcpy(RTA_DATA(rta), addr4, sizeof(*addr4)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + // Add address attribute (same as local for IPv4) + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFA_ADDRESS; + rta->rta_len = RTA_LENGTH(sizeof(*addr4)); + memcpy(RTA_DATA(rta), addr4, sizeof(*addr4)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + return rtnetlink_request(sock_fd, nl_hdr, sizeof(buf), err_str, max_len); +} + +static int modify_addr6_(int sock_fd, uint16_t nlmsg_type, const char *if_name, + const struct in6_addr *addr6, unsigned prefix_len, char *err_str, size_t max_len) { + unsigned int if_idx; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct ifaddrmsg* addr_msg; + struct rtattr* rta; + uint8_t buf[512]; + + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*addr_msg)); + nl_hdr->nlmsg_type = nlmsg_type; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK | NLM_F_EXCL; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + addr_msg = NLMSG_DATA(nl_hdr); + memset(addr_msg, 0, sizeof(*addr_msg)); + addr_msg->ifa_family = AF_INET6; + addr_msg->ifa_prefixlen = prefix_len; + addr_msg->ifa_flags = IFA_F_PERMANENT; + addr_msg->ifa_scope = RT_SCOPE_UNIVERSE; + addr_msg->ifa_index = if_idx; + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFA_LOCAL; + rta->rta_len = RTA_LENGTH(sizeof(*addr6)); + memcpy(RTA_DATA(rta), addr6, sizeof(*addr6)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + // Add address attribute (same as local for IPv6) + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFA_ADDRESS; + rta->rta_len = RTA_LENGTH(sizeof(*addr6)); + memcpy(RTA_DATA(rta), addr6, sizeof(*addr6)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + return rtnetlink_request(sock_fd, nl_hdr, sizeof(buf), err_str, max_len); +} + +static int modify_link_(int sock_fd, const char *if_name, uint16_t nlmsg_type, + unsigned ifi_flags, unsigned ifi_change, char *err_str, size_t max_len) { + unsigned int if_idx; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct ifinfomsg* if_msg; + uint8_t buf[512]; + + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*if_msg)); + nl_hdr->nlmsg_type = nlmsg_type; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + // Interface info message + if_msg = NLMSG_DATA(nl_hdr); + memset(if_msg, 0, sizeof(*if_msg)); + if_msg->ifi_family = AF_UNSPEC; + if_msg->ifi_index = if_idx; + if_msg->ifi_change = ifi_flags; + if_msg->ifi_flags = ifi_change; + + return rtnetlink_request(sock_fd, nl_hdr, sizeof(buf), err_str, max_len); +} + +static int add_link_veth_(int sock_fd, pid_t ns_pid, unsigned ifidx1, const char *if_name, + pid_t peer_ns_pid, unsigned ifidx2, const char *peer_name, char *err_str, size_t max_len) { + struct timespec ts; + uint32_t seq; + struct nlmsghdr *nl_hdr; + struct ifinfomsg *if_msg, *peer_ifi; + struct rtattr *rta, *nest_linkinfo, *nest_infodata, *nest_infopeer; + uint8_t buf[1024]; + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*if_msg)); + nl_hdr->nlmsg_type = RTM_NEWLINK; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + if_msg = NLMSG_DATA(nl_hdr); + memset(if_msg, 0, sizeof(*if_msg)); + if_msg->ifi_index = ifidx1; + if_msg->ifi_family = AF_UNSPEC; + + // netns + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_NET_NS_PID; + rta->rta_len = RTA_LENGTH(sizeof(ns_pid)); + memcpy(RTA_DATA(rta), &ns_pid, sizeof(ns_pid)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + // if name + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_IFNAME; + rta->rta_len = RTA_LENGTH(strlen(if_name)+1); + memcpy(RTA_DATA(rta), if_name, strlen(if_name)+1); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_LINKINFO; + rta->rta_len = RTA_LENGTH(0); + { + // kind: veth + nest_linkinfo = (struct rtattr*)RTA_DATA(rta); + nest_linkinfo->rta_type = IFLA_INFO_KIND; + nest_linkinfo->rta_len = RTA_LENGTH(strlen("veth")); + memcpy(RTA_DATA(nest_linkinfo), "veth", strlen("veth")); + rta->rta_len += RTA_ALIGN(nest_linkinfo->rta_len); + + nest_linkinfo = (struct rtattr*)((char*)nest_linkinfo + RTA_ALIGN(nest_linkinfo->rta_len)); + nest_linkinfo->rta_type = IFLA_INFO_DATA; + nest_linkinfo->rta_len = RTA_LENGTH(0); + { + // peer ifi + nest_infodata = (struct rtattr*)RTA_DATA(nest_linkinfo); + nest_infodata->rta_type = VETH_INFO_PEER; + nest_infodata->rta_len = RTA_LENGTH(sizeof(*peer_ifi)); + peer_ifi = (struct ifinfomsg*)RTA_DATA(nest_infodata); + memset(peer_ifi, 0, sizeof(*peer_ifi)); + peer_ifi->ifi_index = ifidx2; + peer_ifi->ifi_family = AF_UNSPEC; + { + // peer netns + nest_infopeer = (struct rtattr*)((char*)nest_infodata + RTA_ALIGN(nest_infodata->rta_len)); + nest_infopeer->rta_type = IFLA_NET_NS_PID; + nest_infopeer->rta_len = RTA_LENGTH(sizeof(peer_ns_pid)); + memcpy(RTA_DATA(nest_infopeer), &peer_ns_pid, sizeof(peer_ns_pid)); + nest_infodata->rta_len += RTA_ALIGN(nest_infopeer->rta_len); + // peer if name + nest_infopeer = (struct rtattr*)((char*)nest_infopeer + RTA_ALIGN(nest_infopeer->rta_len)); + nest_infopeer->rta_type = IFLA_IFNAME; + nest_infopeer->rta_len = RTA_LENGTH(strlen(peer_name)+1); + memcpy(RTA_DATA(nest_infopeer), peer_name, strlen(peer_name)+1); + nest_infodata->rta_len += RTA_ALIGN(nest_infopeer->rta_len); + } + nest_linkinfo->rta_len += RTA_ALIGN(nest_infodata->rta_len); + } + rta->rta_len += RTA_ALIGN(nest_linkinfo->rta_len); + } + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + return rtnetlink_request(sock_fd, nl_hdr, nl_hdr->nlmsg_len, err_str, max_len); +} + +static int add_link_vxlan_(int sock_fd, pid_t ns_pid, const char *if_name, + int vxlan_id, void *remote_addr, size_t addr_len, char *err_str, size_t max_len) { + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct ifinfomsg* if_msg; + struct rtattr *rta, *nest, *vxlan_nest; + uint16_t dstport; + uint8_t buf[1024]; + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(*if_msg)); + nl_hdr->nlmsg_type = RTM_NEWLINK; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + if_msg = NLMSG_DATA(nl_hdr); + memset(if_msg, 0, sizeof(*if_msg)); + if_msg->ifi_family = AF_UNSPEC; + + // netns + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_NET_NS_PID; + rta->rta_len = RTA_LENGTH(sizeof(ns_pid)); + memcpy(RTA_DATA(rta), &ns_pid, sizeof(ns_pid)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + // if name + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_IFNAME; + rta->rta_len = RTA_LENGTH(strlen(if_name)); + memcpy(RTA_DATA(rta), if_name, strlen(if_name)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = IFLA_LINKINFO; + rta->rta_len = RTA_LENGTH(0); + { + // kind: vxlan + nest = (struct rtattr*)RTA_DATA(rta); + nest->rta_type = IFLA_INFO_KIND; + nest->rta_len = RTA_LENGTH(strlen("vxlan")); + memcpy(RTA_DATA(nest), "vxlan", strlen("vxlan")); + rta->rta_len += RTA_ALIGN(nest->rta_len); + + nest = (struct rtattr*)((char*)rta + RTA_ALIGN(rta->rta_len)); + nest->rta_type = IFLA_INFO_DATA; + nest->rta_len = RTA_LENGTH(0); + { + // vxlan id + vxlan_nest = (struct rtattr*)RTA_DATA(nest); + vxlan_nest->rta_type = IFLA_VXLAN_ID; + vxlan_nest->rta_len = RTA_LENGTH(sizeof(vxlan_id)); + memcpy(RTA_DATA(vxlan_nest), &vxlan_id, sizeof(vxlan_id)); + nest->rta_len += RTA_ALIGN(vxlan_nest->rta_len); + + // Add remote IP + vxlan_nest = (struct rtattr*)((char*)vxlan_nest + RTA_ALIGN(vxlan_nest->rta_len)); + vxlan_nest->rta_type = IFLA_VXLAN_GROUP; + vxlan_nest->rta_len = RTA_LENGTH(addr_len); + memcpy(RTA_DATA(vxlan_nest), remote_addr, addr_len); + nest->rta_len += RTA_ALIGN(vxlan_nest->rta_len); + + // Add destination port + dstport = htons(VXLAN_PORT); + vxlan_nest = (struct rtattr*)((char*)vxlan_nest + RTA_ALIGN(vxlan_nest->rta_len)); + vxlan_nest->rta_type = IFLA_VXLAN_PORT; + vxlan_nest->rta_len = RTA_LENGTH(sizeof(dstport)); + memcpy(RTA_DATA(vxlan_nest), &dstport, sizeof(dstport)); + nest->rta_len += RTA_ALIGN(vxlan_nest->rta_len); + } + rta->rta_len += RTA_ALIGN(nest->rta_len); + } + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + + return rtnetlink_request(sock_fd, nl_hdr, nl_hdr->nlmsg_len, err_str, max_len); +} + +static int modify_route4(int sock_fd, uint16_t op, const struct in_addr *dst4, unsigned dst_prefix, + const struct in_addr *gw4, const char *if_name, int metric, char *err_str, size_t max_len) { + unsigned int if_idx = 0; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct rtmsg* rt_msg; + struct rtattr* rta; + uint8_t buf[512]; + + if(if_name) { + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + nl_hdr->nlmsg_type = op; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + rt_msg = NLMSG_DATA(nl_hdr); + memset(rt_msg, 0, sizeof(struct rtmsg)); + rt_msg->rtm_family = AF_INET; + rt_msg->rtm_dst_len = dst_prefix; + rt_msg->rtm_src_len = 0; + rt_msg->rtm_tos = 0; + rt_msg->rtm_table = RT_TABLE_MAIN; + rt_msg->rtm_protocol = RTPROT_STATIC; + rt_msg->rtm_scope = RT_SCOPE_UNIVERSE; + rt_msg->rtm_type = RTN_UNICAST; + rt_msg->rtm_flags = 0; + + if (dst4) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_DST; + rta->rta_len = RTA_LENGTH(sizeof(*dst4)); + memcpy(RTA_DATA(rta), dst4, sizeof(*dst4)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (gw4) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_GATEWAY; + rta->rta_len = RTA_LENGTH(sizeof(*gw4)); + memcpy(RTA_DATA(rta), gw4, sizeof(*gw4)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (if_idx > 0) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_OIF; + rta->rta_len = RTA_LENGTH(sizeof(if_idx)); + memcpy(RTA_DATA(rta), &if_idx, sizeof(if_idx)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (metric > 0) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_PRIORITY; + rta->rta_len = RTA_LENGTH(sizeof(metric)); + memcpy(RTA_DATA(rta), &metric, sizeof(metric)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + return rtnetlink_request(sock_fd, nl_hdr, nl_hdr->nlmsg_len, err_str, max_len); +} + +static int modify_route6(int sock_fd, uint16_t op, const struct in6_addr *dst6, unsigned dst_prefix, + const struct in6_addr *gw6, const char *if_name, int metric, char *err_str, size_t max_len) { + unsigned int if_idx = 0; + struct timespec ts; + uint32_t seq; + struct nlmsghdr* nl_hdr; + struct rtmsg* rt_msg; + struct rtattr* rta; + uint8_t buf[512]; + + if(if_name) { + if_idx = if_nametoindex(if_name); + if (if_idx == 0) { + snprintf(err_str, max_len, "Interface not found: %s", if_name); + return -1; + } + } + + clock_gettime(CLOCK_MONOTONIC, &ts); + seq = ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + + nl_hdr = (struct nlmsghdr*)buf; + nl_hdr->nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + nl_hdr->nlmsg_type = op; + nl_hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE; + nl_hdr->nlmsg_seq = seq; + nl_hdr->nlmsg_pid = 0; + + rt_msg = NLMSG_DATA(nl_hdr); + memset(rt_msg, 0, sizeof(struct rtmsg)); + rt_msg->rtm_family = AF_INET6; + rt_msg->rtm_dst_len = dst_prefix; + rt_msg->rtm_src_len = 0; + rt_msg->rtm_tos = 0; + rt_msg->rtm_table = RT_TABLE_MAIN; + rt_msg->rtm_protocol = RTPROT_STATIC; + rt_msg->rtm_scope = RT_SCOPE_UNIVERSE; + rt_msg->rtm_type = RTN_UNICAST; + rt_msg->rtm_flags = 0; + + if (dst6) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_DST; + rta->rta_len = RTA_LENGTH(sizeof(*dst6)); + memcpy(RTA_DATA(rta), dst6, sizeof(*dst6)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (gw6) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_GATEWAY; + rta->rta_len = RTA_LENGTH(sizeof(*gw6)); + memcpy(RTA_DATA(rta), gw6, sizeof(*gw6)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (if_idx > 0) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_OIF; + rta->rta_len = RTA_LENGTH(sizeof(if_idx)); + memcpy(RTA_DATA(rta), &if_idx, sizeof(if_idx)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + if (metric > 0) { + rta = (struct rtattr*)((char*)nl_hdr + NLMSG_ALIGN(nl_hdr->nlmsg_len)); + rta->rta_type = RTA_PRIORITY; + rta->rta_len = RTA_LENGTH(sizeof(metric)); + memcpy(RTA_DATA(rta), &metric, sizeof(metric)); + nl_hdr->nlmsg_len = NLMSG_ALIGN(nl_hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len); + } + + return rtnetlink_request(sock_fd, nl_hdr, nl_hdr->nlmsg_len, err_str, max_len); +} + +static PyObject* pynetlink_init_socket(PyObject* self, PyObject* args) { + int sock_fd = init_rtnetlink_sock_(); + if (sock_fd < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + + return PyLong_FromLong(sock_fd); +} + +static PyObject* pynetlink_close_socket(PyObject* self, PyObject* args) { + int sock_fd; + if (!PyArg_ParseTuple(args, "i", &sock_fd)) { + return NULL; + } + + close(sock_fd); + Py_RETURN_NONE; +} + +static PyObject* pynetlink_modify_addr(PyObject* self, PyObject* args) { + int add; + const char *if_name; + void *addr; + Py_ssize_t addr_len; + unsigned prefix_len; + int sock_fd = -1, temp_sock = -1, result; + char err[256]; + + if (!PyArg_ParseTuple(args, "psy#I|i", &add, &if_name, &addr, &addr_len, &prefix_len, &sock_fd)) { + return NULL; + } + + if (addr_len != sizeof(struct in_addr) && addr_len != sizeof(struct in6_addr)) { + PyErr_SetString(PyExc_ValueError, "Invalid IPv4 or IPv6 address length"); + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + if(addr_len == sizeof(struct in_addr)) { + result = modify_addr4_(sock_fd, add ? RTM_NEWADDR : RTM_DELADDR, + if_name, (struct in_addr*)addr, prefix_len, err, sizeof(err)); + } else if(addr_len == sizeof(struct in6_addr)) { + result = modify_addr6_(sock_fd, add ? RTM_NEWADDR : RTM_DELADDR, + if_name, (struct in6_addr*)addr, prefix_len, err, sizeof(err)); + } else { + result = -1; + snprintf(err, sizeof(err), "Unknown type of address, length: %ld", addr_len); + } + + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_traffic_control(PyObject* self, PyObject* args) { + const char *if_name; + const char *delay_str; + const char *rate_str; + const char *loss_str; + int sock_fd = -1, temp_sock = -1, result; + uint32_t delay, loss; + uint64_t rate_Bps = 0; + double rate_value = 0.0; + char rate_unit[16], err[256]; + + if (!PyArg_ParseTuple(args, "ssss|i", &if_name, &delay_str, &rate_str, &loss_str, &sock_fd)) { + return NULL; + } + + delay = (uint32_t)(atof(delay_str) * 15625); + loss = (uint32_t)(atof(loss_str) * (~0U/100U)); + + if (sscanf(rate_str, "%lf%15s", &rate_value, rate_unit) == 2) { + if (strcmp(rate_unit, "Gbit") == 0) { + rate_Bps = (uint64_t)(rate_value * (1000000000 / 8)); + } else if (strcmp(rate_unit, "Mbit") == 0) { + rate_Bps = (uint64_t)(rate_value * (1000000 / 8)); + } else if (strcmp(rate_unit, "Kbit") == 0) { + rate_Bps = (uint64_t)(rate_value * (1000 / 8)); + } else { + rate_Bps = (uint64_t)rate_value; + } + } else { + if (sscanf(rate_str, "%lf", &rate_value) == 1) { + rate_Bps = (uint64_t)rate_value * (1000000000 / 8); + } + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = netem_update_or_create_(sock_fd, if_name, delay, loss, rate_Bps, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_if_up(PyObject* self, PyObject* args) { + const char *if_name; + int sock_fd = -1, temp_sock = -1, result; + char err[256]; + + if (!PyArg_ParseTuple(args, "s|i", &if_name, &sock_fd)) { + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = modify_link_(sock_fd, if_name, RTM_NEWLINK, IFF_UP, IFF_UP, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_if_down(PyObject* self, PyObject* args) { + const char *if_name; + int sock_fd = -1, temp_sock = -1, result; + char err[256]; + + if (!PyArg_ParseTuple(args, "s|i", &if_name, &sock_fd)) { + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = modify_link_(sock_fd, if_name, RTM_NEWLINK, 0, IFF_UP, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_del_link(PyObject* self, PyObject* args) { + const char *if_name; + int sock_fd = -1, temp_sock = -1, result; + char err[256]; + + if (!PyArg_ParseTuple(args, "s|i", &if_name, &sock_fd)) { + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = modify_link_(sock_fd, if_name, RTM_DELLINK, 0, 0, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_modify_routes(PyObject* self, PyObject* args) { + int sock_fd = -1; + PyObject* route_list = NULL; + Py_ssize_t count; + char err[256]; + + if(!PyArg_ParseTuple(args, "Oi", &route_list, &sock_fd)) { + return NULL; + } + + if(sock_fd < 0) { + PyErr_SetString(PyExc_ValueError, "Not a valid socket fd"); + return NULL; + } + + if(!PyList_Check(route_list)) { + PyErr_SetString(PyExc_TypeError, "Need a sequence of routes"); + return NULL; + } + + count = PyList_Size(route_list); + + for (Py_ssize_t i = 0; i < count; i++) { + void *dst, *gw = NULL; + Py_ssize_t dst_len, gw_len; + const char *if_name = NULL; + unsigned dst_prefix; + int add, metric = 0, result; + PyObject* route_item; + + route_item = PyList_GetItem(route_list, i); + if (!PyTuple_Check(route_item)) { + PyErr_SetString(PyExc_TypeError, "Each route must be a tuple"); + return NULL; + } + + if (!PyArg_ParseTuple(route_item, "py#I|sy#i", + &add, &dst, &dst_len, &dst_prefix, &if_name, &gw, &gw_len, &metric)) { + PyErr_SetString(PyExc_ValueError, "Route format not correct"); + return NULL; + } + if (dst_len != sizeof(struct in_addr) && dst_len != sizeof(struct in6_addr)) { + PyErr_SetString(PyExc_ValueError, "Invalid IPv4 or IPv6 address length"); + return NULL; + } + if (gw && gw_len != dst_len) { + PyErr_SetString(PyExc_ValueError, "Gateway address not match destination"); + return NULL; + } + + if (dst_len == sizeof(struct in_addr)) { + result = modify_route4( + sock_fd, add ? RTM_NEWROUTE : RTM_DELROUTE, (struct in_addr*)dst, dst_prefix, + (struct in_addr*)gw, if_name, metric, err, sizeof(err) + ); + } else { + result = modify_route6( + sock_fd, add ? RTM_NEWROUTE : RTM_DELROUTE, (struct in6_addr*)dst, dst_prefix, + (struct in6_addr*)gw, if_name, metric, err, sizeof(err) + ); + } + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_add_link_veth(PyObject* self, PyObject* args) { + const char *if_name, *peer_name; + int sock_fd = -1, temp_sock = -1, result; + unsigned ifidx1 = 0, ifidx2 = 0; + pid_t netns = 0, netns_peer = 0; + char err[256]; + + if (!PyArg_ParseTuple(args, "iIsiIs|i", &netns, &ifidx1, &if_name, &netns_peer, &ifidx2, &peer_name, &sock_fd)) { + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = add_link_veth_( + sock_fd, netns, ifidx1, if_name, netns_peer, ifidx2 + , peer_name, err, sizeof(err) + ); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_add_link_vxlan(PyObject* self, PyObject* args) { + const char *if_name; + pid_t netns_pid; + int vxlan_id, sock_fd = -1, temp_sock = -1, result; + void *remote_addr; + Py_ssize_t remote_addr_len; + char err[256]; + + if (!PyArg_ParseTuple(args, "isiy#|i", + &netns_pid, &if_name, &vxlan_id, &remote_addr, &remote_addr_len, &sock_fd)) { + return NULL; + } + + if (remote_addr_len != sizeof(struct in_addr) && remote_addr_len != sizeof(struct in6_addr)) { + PyErr_SetString(PyExc_ValueError, "Invalid IPv4 or IPv6 address length"); + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = add_link_vxlan_(sock_fd, netns_pid, if_name, + vxlan_id, remote_addr, remote_addr_len, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* pynetlink_netlink_request(PyObject* self, PyObject* args) { + void *nlmsg_data; + Py_ssize_t nlmsg_len; + int sock_fd = -1, temp_sock = -1, result; + char err[256]; + + if (!PyArg_ParseTuple(args, "y#|i", &nlmsg_data, &nlmsg_len, &sock_fd)) { + return NULL; + } + + // temporary socket for legacy version + if(sock_fd < 0) { + temp_sock = init_rtnetlink_sock_(); + if(temp_sock < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + sock_fd = temp_sock; + } + + result = rtnetlink_request(sock_fd, nlmsg_data, nlmsg_len, err, sizeof(err)); + if(temp_sock >= 0) + close(temp_sock); + + if (result < 0) { + PyErr_SetString(PyExc_RuntimeError, err); + return NULL; + } + + Py_RETURN_NONE; +} + +// Define module methods +static PyMethodDef PyNetlinkMethods[] = { + {"init_socket", pynetlink_init_socket, METH_VARARGS, "Initialize netlink socket and return descriptor"}, + {"close_socket", pynetlink_close_socket, METH_VARARGS, "Close netlink socket"}, + {"modify_addr", pynetlink_modify_addr, METH_VARARGS, "Add or delete an IP address (v4/v6) on an interface"}, + {"traffic_control", pynetlink_traffic_control, METH_VARARGS, "Configure traffic control parameters on an interface"}, + {"if_up", pynetlink_if_up, METH_VARARGS, "Bring a network interface up"}, + {"if_down", pynetlink_if_down, METH_VARARGS, "Bring a network interface down"}, + {"modify_routes", pynetlink_modify_routes, METH_VARARGS, "Modify a list of routes"}, + {"del_link", pynetlink_del_link, METH_VARARGS, "Delete a network interface using netlink"}, + {"add_link_veth", pynetlink_add_link_veth, METH_VARARGS, "Create veth pair between two network namespaces"}, + {"add_link_vxlan", pynetlink_add_link_vxlan, METH_VARARGS, "Create vxlan interface"}, + {"netlink_request", pynetlink_netlink_request, METH_VARARGS, "Send a netlink message and check response"}, + {NULL, NULL, 0, NULL} +}; + +// Define module +static struct PyModuleDef pynetlink_module = { + PyModuleDef_HEAD_INIT, + "pynetlink", + "Python extension for efficient network interface updates using netlink", + -1, + PyNetlinkMethods +}; + +// Initialize module +PyMODINIT_FUNC PyInit_pynetlink(void) { + return PyModule_Create(&pynetlink_module); +} + diff --git a/starrynet/sn_daemon_client.py b/starrynet/sn_daemon_client.py new file mode 100644 index 0000000..602875b --- /dev/null +++ b/starrynet/sn_daemon_client.py @@ -0,0 +1,341 @@ +#!/usr/bin/python3 +import socket +import json +import time +import logging +import paramiko +import struct +from typing import Dict, Any, Optional, List + +""" +StarryNet Daemon Client +用于与orchestrater守护进程通信的客户端库 +""" + +CHUNK_SIZE = 4096 # 4KB + +MSG_MAX_SIZE = 10 * 1024 * 1024 # 10MB + +class SSHDaemonClient: + """SSH client for communicating with orchestrater daemon""" + + def __init__(self, host: str, port: int, username: str = None, + password: str = None, timeout: int = 30, + run_id: str = "default"): + """ + Initialize SSH client + + Args: + host: Remote host where daemon is running + port: SSH port of daemon + username: SSH username + password: SSH password + timeout: Connection timeout + """ + self.host = host + self.port = port + self.username = username + self.password = password + self.timeout = timeout + self.run_id = run_id + self.logger = logging.getLogger(__name__) + self._client = None + self._channel = None + self._connected = False + + def _command(self, command_type: str, params: Optional[Dict[str, Any]] = None): + return { + 'c': command_type, + 't': time.time(), + 'rid': self.run_id, + 'p': params or {}, + } + + def connect(self): + try: + self._client = paramiko.SSHClient() + self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self._client.connect( + hostname=self.host, + port=self.port, + username=self.username, + password=self.password, + timeout=self.timeout, + allow_agent=False, + look_for_keys=False + ) + + # Open interactive session + self._channel = self._client.invoke_shell() + self._connected = True + self.logger.info(f"SSH connected to daemon at {self.host}:{self.port}") + + except Exception as e: + raise Exception(f"SSH connection failed: {e}") + + def disconnect(self): + try: + if self._channel: + self._channel.close() + if self._client: + self._client.close() + except: + pass + finally: + self._channel = None + self._client = None + self._connected = False + self.logger.info("SSH connection closed") + + def _ensure_connected(self): + if not self._connected or not self._client or not self._channel: + self.connect() + + def _send_command_via_ssh(self, command: Dict[str, Any]): + self._ensure_connected() + + try: + # Send command as JSON string + command_json = json.dumps(command) + + # Log large commands for debugging + if len(command_json) > 4096: + self.logger.info(f"Sending large command ({len(command_json)} chars): {command_json[:200]}...") + + # Clear any pending data + while self._channel.recv_ready(): + self._channel.recv(4096) + + # Send command with length prefix + self._send_message_with_length_ssh(command_json) + + # Wait for response with length prefix + response_data = self._receive_message_with_length_ssh() + if response_data is None: + raise Exception("No response received") + + # Parse response + try: + response = json.loads(response_data.decode('utf-8')) + return response + except json.JSONDecodeError: + # If response is not JSON, wrap it + return { + "status": "error", + "message": f"Invalid JSON response: {response_data.decode('utf-8')}" + } + + except Exception as e: + self.logger.error(f"SSH command failed: {e}") + self._connected = False + + def _send_message_with_length_ssh(self, data: str): + try: + # Convert data to bytes + data_bytes = data.encode('utf-8') + + # Pack length as 4-byte integer (big-endian) + length_prefix = struct.pack('!I', len(data_bytes)) + + # Send length prefix followed by data + self._channel.sendall(length_prefix + data_bytes) + except Exception as e: + self.logger.error(f"Error sending message with length via SSH: {e}") + raise + + def _receive_message_with_length_ssh(self) -> bytes: + try: + # Read 4-byte length prefix + length_data = self._recv_exact_ssh(4) + if not length_data: + return None + + # Unpack length + message_length = struct.unpack('!I', length_data)[0] + + # Validate message length (prevent excessive memory usage) + if message_length > MSG_MAX_SIZE: + raise Exception(f"Message too large: {message_length} bytes") + + # Read the actual message data + message_data = self._recv_exact_ssh(message_length) + if not message_data: + return None + + return message_data + except Exception as e: + self.logger.error(f"Error receiving message with length via SSH: {e}") + raise + + def _recv_exact_ssh(self, length: int) -> bytes: + data = bytearray() + while len(data) < length: + chunk = self._channel.recv(length - len(data)) + if not chunk: + return None + data.extend(chunk) + return bytes(data) + + def send_config(self, shell_num: int, node_mid_dict: dict, ip_lst: list): + command = self._command('config', { + 'shell_num': shell_num, + 'node_mid_dict': node_mid_dict, + 'ip_lst': ip_lst + }) + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to send config: {response.get('message')}") + + def init_nodes(self): + command = self._command('nodes') + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to init nodes: {response.get('message')}") + return response.get('result', []) + + def get_nodes(self): + command = self._command('list') + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to get nodes: {response.get('message')}") + + nodes = [] + for node_info in response.get('result', {}).get('nodes', []): + nodes.append(node_info.get('name', '')) + + return nodes + + def update_network(self, link_updates: dict): + """Update network with link changes sent directly + + Args: + link_updates: Dictionary containing link update information + Structure: { + "del": [("GS1", "SH1O1S1"), ...], + "update": [("SH1O1S1", "SH1O1S2", "delay"), ...], + "add": [ + ("GS1", "SH1O1S2", "delay", src_ifi, "src_in4", "src_in6", dst_ifi, ...), + ... + ], + "isl_bw": "bandwidth", + "isl_loss": "loss", + "gsl_bw": "bandwidth", + "gsl_loss": "loss" + } + + Returns: + Response from daemon + """ + command = self._command('update_network_batch', link_updates) + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to update network: {response.get('message')}") + + def check_utility(self): + command = self._command('utility') + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to check utility: {response.get('message')}") + return response.get('result', {}) + + def check_routing_table(self, node: str): + command = self._command('rtable', { + 'node': node + }) + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to check routing table: {response.get('message')}") + return response.get('result', {}) + + def damage_nodes(self, nodes: list): + command = self._command('damage', { + 'nodes': nodes + }) + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to damage nodes: {response.get('message')}") + + def recover_nodes(self): + command = self._command('recovery') + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to recover nodes: {response.get('message')}") + + def init_routing(self, nodes: str | List, conf_text: str): + command = self._command('routed', { + 'nodes': nodes, + 'conf': conf_text, + }) + response = self._send_command_via_ssh(command) + if response.get('status') != 'success': + raise Exception(f"Failed to init routing: {response.get('message')}") + + def ping_batch(self, ping_cmds): + command = self._command('ping', { + 'batch': ping_cmds, + }) + return self._send_command_via_ssh(command) + + def iperf_batch(self, iperf_cmds): + command = self._command('iperf', { + 'batch': iperf_cmds, + }) + return self._send_command_via_ssh(command) + + def static_route_batch(self, rt_cmds): + command = self._command('sr', { + 'batch': rt_cmds, + }) + return self._send_command_via_ssh(command) + + def netlink_batch(self, nl_cmds): + command = self._command('netlink', { + 'batch': nl_cmds, + }) + return self._send_command_via_ssh(command) + + def exec_batch(self, exec_cmds): + command = self._command('exec', { + 'batch': exec_cmds, + }) + return self._send_command_via_ssh(command) + + def list_tasks(self, node: str = None, status: str = None, task_type: str = None): + params = {} + if node is not None: + params['node'] = node + if status is not None: + params['status'] = status + if task_type is not None: + params['type'] = task_type + command = self._command('tasks', params) + response = self._send_command_via_ssh(command) + return response.get('result', []) + + def get_task(self, task_id: str): + command = self._command('task', { + 'task_id': task_id, + }) + response = self._send_command_via_ssh(command) + return response.get('result', {}) + + def get_task_output(self, task_id: str): + command = self._command('task_output', { + 'task_id': task_id, + }) + response = self._send_command_via_ssh(command) + return response.get('result', {}) + + def clean(self): + """Clean up all resources""" + command = self._command('clean') + return self._send_command_via_ssh(command) + + def __enter__(self): + """Context manager entry""" + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.disconnect() diff --git a/starrynet/sn_observer.py b/starrynet/sn_observer.py index 3209114..c3c5306 100644 --- a/starrynet/sn_observer.py +++ b/starrynet/sn_observer.py @@ -1,265 +1,139 @@ #encoding: utf-8 -import math +import os +import datetime +import glob +import numpy as np from sgp4.api import Satrec, WGS84 from skyfield.api import load, wgs84, EarthSatellite -from datetime import datetime -import numpy as np -import os - -from starrynet.sn_utils import * - -_ = inf = 999999 # inf -# To calculate the connection between satellites and GSes in time_in -# fac_num: number of GSes +def _bound_gsl(antenna_elevation, altitude): + a = 6371 * np.cos(np.radians(90 + antenna_elevation)) + return a + np.sqrt(np.square(a) + np.square(altitude) + 2 * altitude * 6371) -class Observer(): +def _gsl_least_delay(topo_t_shell, gs_cbf, antenna_num, antenna_elevation, shell_lst): + gsls_t_shell = [] # [[[ [gsl] for every gs] for every ts] for every shell] + for shell_id, (name_lst, sat_cbf_t, sat_lla_t, isls_t) in enumerate(topo_t_shell): + altitude = shell_lst[shell_id]['altitude'] + bound_dist = _bound_gsl(antenna_elevation, altitude) + gsls_t = [] + for sat_cbf in sat_cbf_t: + # (gs_num) op (sat_num) -> (gs_num, sat_num) + dx = np.subtract.outer(gs_cbf[..., 0], sat_cbf[..., 0]) + dy = np.subtract.outer(gs_cbf[..., 1], sat_cbf[..., 1]) + dz = np.subtract.outer(gs_cbf[..., 2], sat_cbf[..., 2]) + dist = np.sqrt(np.square(dx) + np.square(dy) + np.square(dz)) + gsls = [] + for gs_dist in dist: + gs_dist = gs_dist.flatten() + bound_mask = gs_dist < bound_dist + sat_indices = np.arange(len(gs_dist))[bound_mask] + gs_dist = gs_dist[bound_mask] + sorted_sat = gs_dist.argsort() + gsls.append([ + # (sat_id, delay in ms) + (name_lst[sat_indices[sat]], + gs_dist[sat] / (17.31 / 29.5 * 299792.458) * 1000) + for sat in sorted_sat[:antenna_num] + ]) + gsls_t.append(gsls) + gsls_t_shell.append(gsls_t) + + # merge different shell + # [[gsls for every shell] for every gs] for every t] + gsls_t = [ + [list() for gid in range(len(gs_cbf))] for t in range(len(gsls_t_shell[0])) + ] + for t, gsls in enumerate(gsls_t): + for gid, gsl_lst in enumerate(gsls): + for shell_id in range(len(gsls_t_shell)): + for sat_name, delay in gsls_t_shell[shell_id][t][gid]: + if len(gsl_lst) >= antenna_num: + break + gsl_lst.append((sat_name, delay)) + return gsls_t - def __init__(self, file_path, configuration_file_path, inclination, - satellite_altitude, orbit_number, sat_number, duration, - antenna_number, GS_lat_long, antenna_inclination, - intra_routing, hello_interval, AS): - self.file_path = file_path - self.configuration_file_path = configuration_file_path - self.inclination = inclination - self.satellite_altitude = satellite_altitude - self.orbit_number = orbit_number - self.sat_number = sat_number - self.duration = duration - self.antenna_number = antenna_number - self.GS_lat_long = GS_lat_long - self.antenna_inclination = antenna_inclination - self.intra_routing = intra_routing - self.hello_interval = hello_interval - self.AS = AS - - def access_P_L_shortest(self, sat_cbf, fac_cbf, fac_num, sat_num, - num_orbits, num_sats_per_orbit, duration, fac_ll, - sat_lla, bound_dis, alpha, antenna_num, path): - delay_matrix = np.zeros((fac_num + sat_num, fac_num + sat_num)) - for cur_time in range(duration): - for i in range(0, fac_num): - access_list = {} - fac_lat = float(fac_ll[i][0]) # latitude - up_lat = fac_lat + alpha # bound - down_lat = fac_lat - alpha - x2 = fac_cbf[i][0] - y2 = fac_cbf[i][1] - z2 = fac_cbf[i][2] - for j in range(0, sat_num): - if sat_lla[cur_time][j][0] >= down_lat and sat_lla[ - cur_time][j][0] <= up_lat: - x1 = sat_cbf[cur_time][j][0] # in km - y1 = sat_cbf[cur_time][j][1] - z1 = sat_cbf[cur_time][j][2] - dist = math.sqrt( - np.square(x1 - x2) + np.square(y1 - y2) + - np.square(z1 - z2)) - if dist < bound_dis: - # [satellite index,distance] - access_list.update({j: dist}) - if len(access_list) > antenna_num: - sorted_access_list = dict( - sorted(access_list.items(), key=lambda item: item[1])) - cnt = 0 - for key, value in sorted_access_list.items(): - cnt = cnt + 1 - if cnt > antenna_num: - break - delay_time = value / (17.31 / 29.5 * - 299792.458) * 1000 # ms - delay_matrix[sat_num + i][key] = delay_time - delay_matrix[key][sat_num + i] = delay_time - elif len(access_list) != 0: - for key, value in access_list.items(): - delay_time = value / (17.31 / 29.5 * - 299792.458) * 1000 # ms - delay_matrix[sat_num + i][key] = delay_time - delay_matrix[key][sat_num + i] = delay_time - for i in range(num_orbits): - for j in range(num_sats_per_orbit): - num_sat1 = i * num_sats_per_orbit + j - x1 = sat_cbf[cur_time][num_sat1][0] # km - y1 = sat_cbf[cur_time][num_sat1][1] - z1 = sat_cbf[cur_time][num_sat1][2] - num_sat2 = i * num_sats_per_orbit + ( - j + 1) % num_sats_per_orbit - x2 = sat_cbf[cur_time][num_sat2][0] # km - y2 = sat_cbf[cur_time][num_sat2][1] - z2 = sat_cbf[cur_time][num_sat2][2] - num_sat3 = ((i + 1) % num_orbits) * num_sats_per_orbit + j - x3 = sat_cbf[cur_time][num_sat3][0] # km - y3 = sat_cbf[cur_time][num_sat3][1] - z3 = sat_cbf[cur_time][num_sat3][2] - delay1 = math.sqrt( - np.square(x1 - x2) + np.square(y1 - y2) + - np.square(z1 - z2)) / (17.31 / 29.5 * - 299792.458) * 1000 # ms - delay2 = math.sqrt( - np.square(x1 - x3) + np.square(y1 - y3) + - np.square(z1 - z3)) / (17.31 / 29.5 * - 299792.458) * 1000 # ms - delay_matrix[num_sat1][num_sat2] = delay1 - delay_matrix[num_sat2][num_sat1] = delay1 - delay_matrix[num_sat1][num_sat3] = delay2 - delay_matrix[num_sat3][num_sat1] = delay2 - np.savetxt(path + "/delay/" + str(cur_time + 1) + ".txt", - delay_matrix, - fmt='%.2f', - delimiter=',') - for i in range(len(delay_matrix)): - delay_matrix[i, ...] = 0 - - def to_cbf(self, lat_long, - length): # the xyz coordinate system. length: number of nodes - cbf = [] - radius = 6371 - for num in range(0, length): - cbf_in = [] - z = radius * math.sin(math.radians(float(lat_long[num][0]))) - x = radius * math.cos(math.radians(float( - lat_long[num][0]))) * math.cos( - math.radians(float(lat_long[num][1]))) - y = radius * math.cos(math.radians(float( - lat_long[num][0]))) * math.sin( - math.radians(float(lat_long[num][1]))) - cbf_in.append(x) - cbf_in.append(y) - cbf_in.append(z) - cbf.append(cbf_in) - return cbf # xyz coordinates of all the satellites +def to_cbf(lat_long):# the xyz coordinate system. + lat_long = np.array(lat_long) + radius = 6371 + if lat_long.shape[-1] > 2: + radius += lat_long[..., 2] + theta_mat = np.radians(lat_long[..., 0]) + phi_mat = np.radians(lat_long[..., 1]) + z_mat = radius * np.sin(theta_mat) + rho_mat = radius * np.cos(theta_mat) + x_mat = rho_mat * np.cos(phi_mat) + y_mat = rho_mat * np.sin(phi_mat) + return np.stack((x_mat, y_mat, z_mat), -1) - def calculate_bound(self, inclination_angle, height): - bound_distance = 6371 * math.cos( - (90 + inclination_angle) / 180 * math.pi) + math.sqrt( - math.pow( - 6371 * math.cos( - (90 + inclination_angle) / 180 * math.pi), 2) + - math.pow(height, 2) + 2 * height * 6371) - return bound_distance +def _sat_name(shell_id, orbit_id, sat_id): + return f'SH{shell_id+1}O{orbit_id+1}S{sat_id+1}' - def matrix_to_change(self, duration, orbit_number, sat_number, path, - GS_lat_long): - no_fac = len(GS_lat_long) - no_geo = 0 - duration = duration - 1 - no_leo = orbit_number * sat_number +def _gs_name(gid): + return f'GS{gid}' - topo_duration = [[[0 for i in range(no_leo + no_geo + no_fac)] - for i in range(no_leo + no_geo + no_fac)] - for k in range(duration)] - for time in range(1, duration + 1): - topo_path = path + '/delay/' + str(time) + ".txt" - adjacency_matrix = sn_get_param(topo_path) - for i in range(len(adjacency_matrix)): - for j in range(len(adjacency_matrix[i])): - if float(adjacency_matrix[i][j]) > 0: - adjacency_matrix[i][j] = 1 - else: - adjacency_matrix[i][j] = 0 - topo_duration[time - 1] = adjacency_matrix +def _isl_grid(sat_cbf_t, shell_id, orbit_num, sat_num): + # [[ [isl] for every satellite] for every t] + isls_lst_t = [] - changetime = [] - Duration = [] - for i in range(duration - 1): - l1 = topo_duration[i] - l2 = topo_duration[i + 1] - if l1 == l2: - continue - else: - changetime.append(i) - pretime = 0 - for item in changetime: - Duration.append(item - pretime) - pretime = item - Duration.append(60) + sat_cbf_t = sat_cbf_t.reshape(-1, orbit_num, sat_num, 3) + down_cbf_t = np.roll(sat_cbf_t, -1, 2) + right_cbf_t = np.roll(sat_cbf_t, -1, 1) + delay_down_t = np.sqrt(np.sum(np.square(sat_cbf_t - down_cbf_t), -1)) / ( + 17.31 / 29.5 * 299792.458) * 1000 # ms + delay_right_t = np.sqrt(np.sum(np.square(sat_cbf_t - right_cbf_t), -1)) / ( + 17.31 / 29.5 * 299792.458) * 1000 # ms + for delay_down, delay_right in zip(delay_down_t, delay_right_t): + isls_lst = [] + for oid in range(orbit_num): + for sid in range(sat_num): + # down isl + down_oid = oid + down_sid = sid + 1 if sid + 1 < sat_num else 0 + # right isl + right_oid = oid + 1 if oid + 1 < orbit_num else 0 + right_sid = sid - topo_leo_change_path = path + "/Topo_leo_change.txt" - f = open(topo_leo_change_path, "w") - cnt = 1 - for i in range(duration - 1): - pre_lines = topo_duration[i] - now_lines = topo_duration[i + 1] - if pre_lines == now_lines: - continue - else: - f.write("time " + str(i + 2) + ":\n") # time started from 1 - f.write('duration ' + str(Duration[cnt]) + ":\n") - cnt += 1 - f.write("add:\n") - for j in range(no_fac): - prelines = pre_lines[no_geo + no_leo + j] - nowlines = now_lines[no_geo + no_leo + j] - for k in range(no_geo + no_leo + no_fac): - if prelines[k] == 0 and nowlines[k] == 1: - f.write( - str(k + 1) + "-" + str(no_leo + j + 1) + - "\n") # index - f.write("del:\n") - for j in range(no_fac): - prelines = pre_lines[no_geo + no_leo + j] - nowlines = now_lines[no_geo + no_leo + j] - for k in range(no_geo + no_leo + no_fac): - if prelines[k] == 1 and nowlines[k] == 0: - f.write( - str(k + 1) + "-" + str(no_leo + j + 1) + - "\n") # index - f.write("time " + str(self.duration) + ":\n") # - f.write("end of the emulation! \n") # - f.close() - cnt = 1 + isls = [] + # to avoid duplication at small scale + if sat_num > 2 and down_sid > 0: + # (sat_name, delay in ms) + isls.append((_sat_name(shell_id, down_oid, down_sid), delay_down[oid, sid])) + if orbit_num > 2 and right_oid > 0: + isls.append((_sat_name(shell_id, right_oid, right_sid), delay_right[oid, sid])) + isls_lst.append(isls) + isls_lst_t.append(isls_lst) + return isls_lst_t - def calculate_delay(self): - path = self.configuration_file_path + "/" + self.file_path - sat_cbf = [ - ] # first dimension: time. second dimension: node. third dimension: xyz - sat_lla = [ - ] # first dimension: time. second dimension: node. third dimension: lla - fac_cbf = [] # first dimension: node. second dimension: xyz - - if os.path.exists(path + '/delay') == True: - osstr = "rm -f " + path + "/delay/*" - os.system(osstr) - else: - os.system("mkdir " + path) - os.system("mkdir " + path + "/delay") - if os.path.exists(path + '/position') == True: - osstr = "rm -f " + path + "/position/*" - os.system(osstr) - else: - os.system("mkdir " + path + "/position") - - ts = load.timescale() - since = datetime(1949, 12, 31, 0, 0, 0) - start = datetime(2020, 1, 1, 0, 0, 0) - epoch = (start - since).days - inclination = self.inclination * 2 * np.pi / 360 - GM = 3.9860044e14 - R = 6371393 - altitude = self.satellite_altitude * 1000 +def _topo_walker_delta(dir, duration, step, shell_lst): + ts_total = int(duration / step) + topo_t_shell = [] + ts = load.timescale() + since = datetime.datetime(1949, 12, 31, 0, 0, 0) + start = datetime.datetime(2020, 1, 1, 0, 0, 0) + epoch = (start - since).days + GM = 3.9860044e14 + R = 6371393 + F = 18 + ts_lst = [i * step for i in range(ts_total)] + for i, shell in enumerate(shell_lst): + inclination = shell['inclination'] * 2 * np.pi / 360 + altitude = shell['altitude'] * 1000 mean_motion = np.sqrt(GM / (R + altitude)**3) * 60 - num_of_orbit = self.orbit_number - sat_per_orbit = self.sat_number - num_of_sat = num_of_orbit * sat_per_orbit - F = 18 - bound_dis = self.calculate_bound( - self.antenna_inclination, self.satellite_altitude) * 29.5 / 17.31 - - duration = self.duration # second - result = [[] for i in range(duration)] # LLA result - lla_per_sec = [[] for i in range(duration)] # LLA result + orbit_number, sat_number = shell['orbit'], shell['sat'] + num_of_sat = orbit_number * sat_number - for i in range(num_of_orbit): # range(num_of_orbit) - raan = i / num_of_orbit * 2 * np.pi - for j in range(sat_per_orbit): # range(sat_per_orbit) - mean_anomaly = (j * 360 / sat_per_orbit + i * 360 * F / + sat_lla_t = np.zeros((ts_total, orbit_number * sat_number, 3)) + for oid in range(orbit_number): + raan = oid / orbit_number * 2 * np.pi + for sid in range(sat_number): + mean_anomaly = (sid * 360 / sat_number + oid * 360 * F / num_of_sat) % 360 * 2 * np.pi / 360 satrec = Satrec() satrec.sgp4init( WGS84, # gravity model 'i', # 'a' = old AFSPC mode, 'i' = improved mode - i * sat_per_orbit + j, # satnum: Satellite number + oid * sat_number + sid, # satnum: Satellite number epoch, # epoch: days since 1949 December 31 00:00 UT 2.8098e-05, # bstar: drag coefficient (/earth radii) 6.969196665e-13, # ndot: ballistic coefficient (revs/day) @@ -272,251 +146,149 @@ def calculate_delay(self): raan, # nodeo: right ascension of ascending node (radians) ) sat = EarthSatellite.from_satrec(satrec, ts) - cur = datetime(2022, 1, 1, 1, 0, 0) - t_ts = ts.utc(*cur.timetuple()[:5], - range(duration)) # [:4]:minute,[:5]:second + cur = datetime.datetime(2022, 1, 1, 1, 0, 0) + t_ts = ts.utc(*cur.timetuple()[:5], ts_lst) # [:4]:minute,[:5]:second geocentric = sat.at(t_ts) subpoint = wgs84.subpoint(geocentric) # list: [subpoint.latitude.degrees] [subpoint.longitude.degrees] [subpoint.elevation.km] - for t in range(duration): - lla = '%f,%f,%f\n' % (subpoint.latitude.degrees[t], - subpoint.longitude.degrees[t], - subpoint.elevation.km[t]) - result[t].append(lla) - lla = [] - lla.append(subpoint.latitude.degrees[t]) - lla.append(subpoint.longitude.degrees[t]) - lla.append(subpoint.elevation.km[t]) - lla_per_sec[t].append(lla) + for t in range(ts_total): + sat_lla_t[t, oid * sat_number + sid] = (subpoint.latitude.degrees[t], + subpoint.longitude.degrees[t], + subpoint.elevation.km[t]) + + name_lst = [_sat_name(i, oid, sid) + for oid in range(orbit_number) for sid in range(sat_number)] + pos_dir = os.path.join(dir, f'shell{i}', 'position') + os.makedirs(pos_dir, exist_ok=True) + for t, lla_lst in enumerate(sat_lla_t): + f = open(os.path.join(pos_dir, '%d.txt' % (t + 1)), 'w') + for name, lla in zip(name_lst, lla_lst): + f.write('%s:%f,%f,%f\n' % (name, lla[0], lla[1], lla[2])) + f.close() + sat_cbf_t = to_cbf(sat_lla_t) + isls_t = _isl_grid(sat_cbf_t, i, orbit_number, sat_number) - for t in range(duration): - file = path + '/position/' + '%d.txt' % t - with open(file, 'w') as fw: - fw.writelines(result[t]) - cbf_per_sec = self.to_cbf(lla_per_sec[t], num_of_sat) - sat_cbf.append(cbf_per_sec) - sat_lla.append(lla_per_sec[t]) + topo_t_shell.append((name_lst, sat_cbf_t, sat_lla_t, isls_t)) + return topo_t_shell - if len(self.GS_lat_long) != 0: - fac_cbf = self.to_cbf(self.GS_lat_long, len(self.GS_lat_long)) +def _topo_arbitrary(dir, duration, step, shell_lst): + topo_t_shell = [] + for i, shell in enumerate(shell_lst): + name_lst = [] + sat_cbf_t = [] + isls_t = [] + for t, slot in enumerate(shell['timeslots']): + sat_lla = [] + sat_names = [] + pos_dir = os.path.join(dir, shell['name'], 'position') + os.makedirs(pos_dir, exist_ok=True) + f = open(os.path.join(pos_dir, '%d.txt' % (t + 1)), 'w') + for sid, node in enumerate(slot['position']): + print(node) + lla = (node['latitude'], node['longitude'], node['altitude']) + sat_lla.append(lla) + name = f'SH{i+1}SAT{sid+1}' + f.write(name + (':%f,%f,%f\n' % lla)) + sat_names.append(name) + f.close() + sat_cbf = to_cbf(sat_lla) # np array, sat_num * 3 + sat_cbf_t.append(sat_cbf) - alpha = np.degrees( - np.arccos(6371 / (6371 + self.satellite_altitude) * - np.cos(np.radians(inclination)))) - inclination - self.access_P_L_shortest(sat_cbf, fac_cbf, len(self.GS_lat_long), - self.sat_number * self.orbit_number, - self.orbit_number, self.sat_number, - self.duration, self.GS_lat_long, sat_lla, - bound_dis, alpha, self.antenna_number, path) - self.matrix_to_change(self.duration, self.orbit_number, - self.sat_number, path, self.GS_lat_long) + if len(name_lst) == 0: + name_lst = sat_names + elif len(name_lst) != len(sat_names): + raise RuntimeError("satellites change between slots!") - def compute_conf(self, sat_node_number, interval, num1, num2, ID, Q, - num_backbone, matrix): - Q.append( - "log \"/var/log/bird.log\" { debug, trace, info, remote, warning, error, auth, fatal, bug };" - ) - Q.append("debug protocols all;") - Q.append("protocol device {") - Q.append("}") - Q.append(" protocol direct {") - Q.append(" disabled; # Disable by default") - Q.append(" ipv4; # Connect to default IPv4 table") - Q.append(" ipv6; # ... and to default IPv6 table") - Q.append("}") - Q.append("protocol kernel {") - Q.append(" ipv4 { # Connect protocol to IPv4 table by channel") - Q.append( - " export all; # Export to protocol. default is export none") - Q.append(" };") - Q.append("}") - # Q.append("protocol kernel {") - # Q.append(" ipv6 { export all; ") - # Q.append(" };") - # Q.append("}") - Q.append("protocol static {") - Q.append(" ipv4; # Again, IPv6 channel with default options") - Q.append("}") - Q.append("protocol ospf{") - Q.append(" ipv4 {") - Q.append(" import all;") - Q.append(" };") - Q.append(" area 0 {") - Q.append(" interface \"B%d-eth0\" {" % ID) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - Q.append(" interface \"inter_machine\" {") - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - if num1 <= sat_node_number and num2 <= num_backbone and ID <= sat_node_number: # satellite - for peer in range(num1, num2 + 1): - if (peer == ID) or (int(float(matrix[ID - 1][peer - 1])) == 0): - continue - Q.append(" interface \"B%d-eth%d\" {" % (ID, peer)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - if num2 > sat_node_number: - for i in range(sat_node_number + 1, - num_backbone + 1): # each ground station - Q.append(" interface \"B%d-eth%d\" {" % (ID, i)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - elif num1 <= sat_node_number and num2 <= num_backbone and ID > sat_node_number: # ground station - for peer in range(1, - 1 + sat_node_number): # fac and each satellite - Q.append(" interface \"B%d-eth%d\" {" % (ID, peer)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - Q.append(" interface \"B%d-default\" {" % (ID)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - elif num1 > num_backbone and num2 > num_backbone: # ground users - if ID != num1 and ID != num2: - Q.append(" interface \"B%d-eth%d\" {" % (ID, ID - 1)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - Q.append(" interface \"B%d-eth%d\" {" % (ID, ID + 1)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - elif ID == num1: - Q.append(" interface \"B%d-eth%d\" {" % (ID, ID + 1)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - elif ID == num2: - Q.append(" interface \"B%d-eth%d\" {" % (ID, ID - 1)) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - else: - return False - Q.append(" };") - Q.append(" }") - return True + isls = [list() for _ in range(len(name_lst))] + for link in slot['links']: + sid1, sid2 = link['sat1'], link['sat2'] + if sid1 > sid2: + sid1, sid2 = sid2, sid1 + delay = np.sqrt(np.sum(np.square(sat_cbf[sid1] - sat_cbf[sid2]))) + isls[sid1].append((name_lst[sid2], delay)) + isls_t.append(isls) + topo_t_shell.append((shell['name'], name_lst, sat_cbf_t, isls_t)) + return topo_t_shell + +def load_pos(path): + f = open(path, 'r') + lla_dict = {} + for line in f: + toks = line.strip().split(':') + lla = tuple(map(float, toks[1].split(','))) + lla_dict[toks[0]] = lla + f.close() + return lla_dict + +def load_links_dict(path): + f = open(path, 'r') + links_dict = {} + for line in f: + toks = line.strip().split(':') + link_lst = [] + for isl in toks[1].split(): + link_lst.append(isl.split(',')) + links_dict[toks[0]] = link_lst + f.close() + return links_dict - def print_conf(self, sat_node_number, fac_node_number, ID, Q, remote_ftp): - filename = self.file_path + "/conf/bird-" + \ - str(sat_node_number) + "-" + str(fac_node_number) + "/B%d.conf" % ID - fout = open(self.configuration_file_path + "/" + filename, 'w+') - for item in Q: - fout.write(str(item) + "\n") - fout.close() - remote_ftp.put(self.configuration_file_path + "/" + filename, filename) +#TODO: More ISL styles +topo_styles = { + 'Grid': _topo_walker_delta, + 'Arbitrary': _topo_arbitrary, +} - def generate_conf(self, remote_ssh, remote_ftp): - if self.intra_routing != "OSPF" and self.intra_routing != "ospf": - return False - if os.path.exists(self.configuration_file_path + "/" + self.file_path + - "/conf/bird-" + - str(self.orbit_number * self.sat_number) + "-" + - str(len(self.GS_lat_long))) == True: - osstr = "rm -f " + self.configuration_file_path+"/"+self.file_path+"/conf/bird-" + \ - str(self.orbit_number*self.sat_number) + "-" + str(len(self.GS_lat_long)) + "/*" - os.system(osstr) - sn_remote_cmd(remote_ssh, "mkdir ~/" + self.file_path + "/conf") - sn_remote_cmd( - remote_ssh, "mkdir ~/" + self.file_path + "/conf/bird-" + - str(self.orbit_number * self.sat_number) + "-" + - str(len(self.GS_lat_long))) +class Observer: + """Observer class for managing topology computation and updates""" + + def __init__(self, configuration_file_path, gs_lat_long, antenna_number, antenna_elevation): + """Initialize Observer with configuration + + Args: + configuration_file_path: Path to configuration file + gs_lat_long: Ground station coordinates + antenna_number: Number of antennas + antenna_elevation: Antenna elevation angle + """ + import os + from .sn_utils import sn_load_file + + sn_args = sn_load_file(configuration_file_path) + self.shell_lst = sn_args.shell_lst + self.link_style = sn_args.link_style + self.link_policy = sn_args.link_policy + self.step = sn_args.step + self.duration = sn_args.duration + self.gs_lat_long = gs_lat_long + self.antenna_number = antenna_number + self.antenna_elevation = antenna_elevation + self.configuration_dir = os.path.dirname(os.path.abspath(configuration_file_path)) + self.experiment_name = sn_args.cons_name + '-' + sn_args.link_style + '-' + sn_args.link_policy + self.data_dir = os.path.join(self.configuration_dir, self.experiment_name) + + + def compute_topology(self, gs_links): + """Compute topology and generate updates + + Returns: + dict: Topology data and updates + """ + sat_t_shell = topo_styles[self.link_style](self.data_dir, self.duration, self.step, self.shell_lst) + if gs_links: + gsls_t = gs_links else: - os.makedirs(self.configuration_file_path + "/" + self.file_path + - "/conf/bird-" + - str(self.orbit_number * self.sat_number) + "-" + - str(len(self.GS_lat_long))) - sn_remote_cmd(remote_ssh, "mkdir ~/" + self.file_path + "/conf") - sn_remote_cmd( - remote_ssh, "mkdir ~/" + self.file_path + "/conf/bird-" + - str(self.orbit_number * self.sat_number) + "-" + - str(len(self.GS_lat_long))) - path = self.configuration_file_path + "/" + self.file_path + "/delay/1.txt" - matrix = sn_get_param(path) - num_backbone = self.orbit_number * self.sat_number + len( - self.GS_lat_long) - error = True - for i in range(len(self.AS)): - if len(self.AS[i]) != 1: - for ID in range(self.AS[i][0], self.AS[i][1] + 1): - Q = [] - error = self.compute_conf( - self.orbit_number * self.sat_number, - self.hello_interval, self.AS[i][0], self.AS[i][1], ID, - Q, num_backbone, matrix) - self.print_conf(self.orbit_number * self.sat_number, - len(self.GS_lat_long), ID, Q, remote_ftp) - else: # one node in one AS - ID = self.AS[i][0] - Q = [] - Q.append( - "log \"/var/log/bird.log\" { debug, trace, info, remote, warning, error, auth, fatal, bug };" - ) - Q.append("debug protocols all;") - Q.append("protocol device {") - Q.append("}") - Q.append(" protocol direct {") - Q.append(" disabled; # Disable by default") - Q.append(" ipv4; # Connect to default IPv4 table") - Q.append(" ipv6; # ... and to default IPv6 table") - Q.append("}") - Q.append("protocol kernel {") - Q.append( - " ipv4 { # Connect protocol to IPv4 table by channel") - Q.append( - " export all; # Export to protocol. default is export none" - ) - Q.append(" };") - Q.append("}") - Q.append("protocol static {") - Q.append( - " ipv4; # Again, IPv6 channel with default options") - Q.append("}") - Q.append("protocol ospf {") - Q.append(" ipv4 {") - Q.append(" import all;") - Q.append(" };") - Q.append(" area 0 {") - Q.append(" interface \"B%d-eth0\" {" % ID) - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(self.hello_interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - Q.append(" interface \"inter_machine\" {") - Q.append(" type broadcast; # Detected by default") - Q.append(" cost 256;") - Q.append(" hello " + str(interval) + - "; # Default hello perid 10 is too long") - Q.append(" };") - Q.append(" };") - Q.append(" }") - self.print_conf(self.orbit_number * self.sat_number, - len(self.GS_lat_long), ID, Q, remote_ftp) + gsls_t = gsl_styles[self.link_policy]( + sat_t_shell, to_cbf(self.gs_lat_long), + self.antenna_number, self.antenna_elevation, self.shell_lst + ) + + return sat_t_shell, ( + list(_gs_name(i) for i in range(len(self.gs_lat_long))), + to_cbf(self.gs_lat_long), + gsls_t + ) - return error +#TODO: More GSL styles +gsl_styles = { + 'LeastDelay':_gsl_least_delay, +} diff --git a/starrynet/sn_orchestrater.py b/starrynet/sn_orchestrater.py deleted file mode 100644 index d677469..0000000 --- a/starrynet/sn_orchestrater.py +++ /dev/null @@ -1,589 +0,0 @@ -import os -import threading -import sys -from time import sleep -import numpy -""" -Used in the remote machine for link updating, initializing links, damaging and recovering links and other functionalities。 -author: Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) and Zeqi Lai (zeqilai@tsinghua.edu.cn) -""" - - -def sn_get_right_satellite(current_sat_id, current_orbit_id, orbit_num): - if current_orbit_id == orbit_num - 1: - return [current_sat_id, 0] - else: - return [current_sat_id, current_orbit_id + 1] - - -def sn_get_down_satellite(current_sat_id, current_orbit_id, sat_num): - if current_sat_id == sat_num - 1: - return [0, current_orbit_id] - else: - return [current_sat_id + 1, current_orbit_id] - - -def sn_ISL_establish(current_sat_id, current_orbit_id, container_id_list, - orbit_num, sat_num, constellation_size, matrix, bw, loss): - current_id = current_orbit_id * sat_num + current_sat_id - isl_idx = current_id * 2 + 1 - # Establish intra-orbit ISLs - # (Down): - [down_sat_id, - down_orbit_id] = sn_get_down_satellite(current_sat_id, current_orbit_id, - sat_num) - print("[" + str(isl_idx) + "/" + str(constellation_size * 2) + - "] Establish intra-orbit ISL from: (" + str(current_sat_id) + "," + - str(current_orbit_id) + ") to (" + str(down_sat_id) + "," + - str(down_orbit_id) + ")") - ISL_name = "Le_" + str(current_sat_id) + "-" + str(current_orbit_id) + \ - "_" + str(down_sat_id) + "-" + str(down_orbit_id) - address_16_23 = isl_idx >> 8 - address_8_15 = isl_idx & 0xff - # Create internal network in docker. - os.system('docker network create ' + ISL_name + " --subnet 10." + - str(address_16_23) + "." + str(address_8_15) + ".0/24") - print('[Create ISL:]' + 'docker network create ' + ISL_name + - " --subnet 10." + str(address_16_23) + "." + str(address_8_15) + - ".0/24") - os.system('docker network connect ' + ISL_name + " " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + " --ip 10." + - str(address_16_23) + "." + str(address_8_15) + ".40") - delay = matrix[current_orbit_id * sat_num + - current_sat_id][down_orbit_id * sat_num + down_sat_id] - with os.popen( - "docker exec -it " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip addr | grep -B 2 10." + str(address_16_23) + "." + - str(address_8_15) + - ".40 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev " + target_interface + " down") - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev " + target_interface + " name " + "B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(down_orbit_id * sat_num + down_sat_id + 1)) - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(down_orbit_id * sat_num + down_sat_id + 1) + - " up") - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " tc qdisc add dev B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(down_orbit_id * sat_num + down_sat_id + 1) + - " root netem delay " + str(delay) + "ms loss " + str(loss) + "% rate " + str(bw) + "Gbps") - print('[Add current node:]' + 'docker network connect ' + ISL_name + " " + - str(container_id_list[current_orbit_id * sat_num + current_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + ".40") - os.system('docker network connect ' + ISL_name + " " + - str(container_id_list[down_orbit_id * sat_num + down_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + - ".10") - with os.popen( - "docker exec -it " + - str(container_id_list[down_orbit_id * sat_num + down_sat_id]) + - " ip addr | grep -B 2 10." + str(address_16_23) + "." + - str(address_8_15) + - ".10 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + - str(container_id_list[down_orbit_id * sat_num + - down_sat_id]) + " ip link set dev " + - target_interface + " down") - os.system("docker exec -d " + - str(container_id_list[down_orbit_id * sat_num + - down_sat_id]) + " ip link set dev " + - target_interface + " name " + "B" + - str(down_orbit_id * sat_num + down_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1)) - os.system("docker exec -d " + - str(container_id_list[down_orbit_id * sat_num + - down_sat_id]) + " ip link set dev B" + - str(down_orbit_id * sat_num + down_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1) + " up") - os.system("docker exec -d " + - str(container_id_list[down_orbit_id * sat_num + - down_sat_id]) + " tc qdisc add dev B" + - str(down_orbit_id * sat_num + down_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - " root netem delay " + str(delay) + "ms loss " + str(loss) + "% rate " + str(bw) + "Gbps") - print('[Add down node:]' + 'docker network connect ' + ISL_name + " " + - str(container_id_list[down_orbit_id * sat_num + down_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + ".10") - - print("Add 10." + str(address_16_23) + "." + str(address_8_15) + - ".40/24 and 10." + str(address_16_23) + "." + str(address_8_15) + - ".10/24 to (" + str(current_sat_id) + "," + str(current_orbit_id) + - ") to (" + str(down_sat_id) + "," + str(down_orbit_id) + ")") - isl_idx = isl_idx + 1 - - # Establish inter-orbit ISLs - # (Right): - [right_sat_id, - right_orbit_id] = sn_get_right_satellite(current_sat_id, current_orbit_id, - orbit_num) - print("[" + str(isl_idx) + "/" + str(constellation_size * 2) + - "] Establish inter-orbit ISL from: (" + str(current_sat_id) + "," + - str(current_orbit_id) + ") to (" + str(right_sat_id) + "," + - str(right_orbit_id) + ")") - ISL_name = "La_" + str(current_sat_id) + "-" + str(current_orbit_id) + \ - "_" + str(right_sat_id) + "-" + str(right_orbit_id) - address_16_23 = isl_idx >> 8 - address_8_15 = isl_idx & 0xff - # Create internal network in docker. - os.system('docker network create ' + ISL_name + " --subnet 10." + - str(address_16_23) + "." + str(address_8_15) + ".0/24") - print('[Create ISL:]' + 'docker network create ' + ISL_name + - " --subnet 10." + str(address_16_23) + "." + str(address_8_15) + - ".0/24") - os.system('docker network connect ' + ISL_name + " " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + " --ip 10." + - str(address_16_23) + "." + str(address_8_15) + ".30") - delay = matrix[current_orbit_id * sat_num + - current_sat_id][right_orbit_id * sat_num + right_sat_id] - with os.popen( - "docker exec -it " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip addr | grep -B 2 10." + str(address_16_23) + "." + - str(address_8_15) + - ".30 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev " + target_interface + " down") - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev " + target_interface + " name " + "B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(right_orbit_id * sat_num + right_sat_id + 1)) - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " ip link set dev B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(right_orbit_id * sat_num + right_sat_id + 1) + - " up") - os.system("docker exec -d " + - str(container_id_list[current_orbit_id * sat_num + - current_sat_id]) + - " tc qdisc add dev B" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - "-eth" + str(right_orbit_id * sat_num + right_sat_id + 1) + - " root netem delay " + str(delay) + "ms loss " + str(loss) + "% rate " + str(bw) + "Gbps") - print('[Add current node:]' + 'docker network connect ' + ISL_name + " " + - str(container_id_list[current_orbit_id * sat_num + current_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + ".30") - os.system('docker network connect ' + ISL_name + " " + - str(container_id_list[right_orbit_id * sat_num + right_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + - ".20") - - with os.popen( - "docker exec -it " + - str(container_id_list[right_orbit_id * sat_num + right_sat_id]) + - " ip addr | grep -B 2 10." + str(address_16_23) + "." + - str(address_8_15) + - ".20 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + - str(container_id_list[right_orbit_id * sat_num + - right_sat_id]) + " ip link set dev " + - target_interface + " down") - os.system("docker exec -d " + - str(container_id_list[right_orbit_id * sat_num + - right_sat_id]) + " ip link set dev " + - target_interface + " name " + "B" + - str(right_orbit_id * sat_num + right_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1)) - os.system("docker exec -d " + - str(container_id_list[right_orbit_id * sat_num + - right_sat_id]) + " ip link set dev B" + - str(right_orbit_id * sat_num + right_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1) + " up") - os.system("docker exec -d " + - str(container_id_list[right_orbit_id * sat_num + - right_sat_id]) + - " tc qdisc add dev B" + - str(right_orbit_id * sat_num + right_sat_id + 1) + "-eth" + - str(current_orbit_id * sat_num + current_sat_id + 1) + - " root netem delay " + str(delay) + "ms loss " + str(loss) + "% rate " + str(bw) + "Gbps") - print('[Add right node:]' + 'docker network connect ' + ISL_name + " " + - str(container_id_list[right_orbit_id * sat_num + right_sat_id]) + - " --ip 10." + str(address_16_23) + "." + str(address_8_15) + ".20") - - print("Add 10." + str(address_16_23) + "." + str(address_8_15) + - ".30/24 and 10." + str(address_16_23) + "." + str(address_8_15) + - ".20/24 to (" + str(current_sat_id) + "," + str(current_orbit_id) + - ") to (" + str(right_sat_id) + "," + str(right_orbit_id) + ")") - - -def sn_establish_ISLs(container_id_list, matrix, orbit_num, sat_num, - constellation_size, bw, loss): - ISL_threads = [] - for current_orbit_id in range(0, orbit_num): - for current_sat_id in range(0, sat_num): - ISL_thread = threading.Thread( - target=sn_ISL_establish, - args=(current_sat_id, current_orbit_id, container_id_list, - orbit_num, sat_num, constellation_size, matrix, bw, - loss)) - ISL_threads.append(ISL_thread) - for ISL_thread in ISL_threads: - ISL_thread.start() - for ISL_thread in ISL_threads: - ISL_thread.join() - - -def sn_get_param(file_): - f = open(file_) - ADJ = f.readlines() - for i in range(len(ADJ)): - ADJ[i] = ADJ[i].strip('\n') - ADJ = [x.split(',') for x in ADJ] - f.close() - return ADJ - - -def sn_get_container_info(): - # Read all container information in all_container_info - with os.popen("docker ps") as f: - all_container_info = f.readlines() - n_container = len(all_container_info) - 1 - - container_id_list = [] - for container_idx in range(1, n_container + 1): - container_id_list.append(all_container_info[container_idx].split()[0]) - - return container_id_list - - -def sn_establish_GSL(container_id_list, matrix, GS_num, constellation_size, bw, - loss): - # starting links among satellites and ground stations - for i in range(1, constellation_size + 1): - for j in range(constellation_size + 1, - constellation_size + GS_num + 1): - # matrix[i-1][j-1])==1 means a link between node i and node j - if ((float(matrix[i - 1][j - 1])) <= 0.01): - continue - # IP address (there is a link between i and j) - delay = str(matrix[i - 1][j - 1]) - address_16_23 = (j - constellation_size) & 0xff - address_8_15 = i & 0xff - GSL_name = "GSL_" + str(i) + "-" + str(j) - # Create internal network in docker. - os.system('docker network create ' + GSL_name + " --subnet 9." + - str(address_16_23) + "." + str(address_8_15) + ".0/24") - print('[Create GSL:]' + 'docker network create ' + GSL_name + - " --subnet 9." + str(address_16_23) + "." + - str(address_8_15) + ".0/24") - os.system('docker network connect ' + GSL_name + " " + - str(container_id_list[i - 1]) + " --ip 9." + - str(address_16_23) + "." + str(address_8_15) + ".50") - with os.popen( - "docker exec -it " + str(container_id_list[i - 1]) + - " ip addr | grep -B 2 9." + str(address_16_23) + "." + - str(address_8_15) + - ".50 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]" - ) as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev " + target_interface + " down") - os.system("docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev " + target_interface + " name " + - "B" + str(i - 1 + 1) + "-eth" + str(j)) - os.system("docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev B" + str(i - 1 + 1) + "-eth" + - str(j) + " up") - os.system("docker exec -d " + str(container_id_list[i - 1]) + - " tc qdisc add dev B" + str(i - 1 + 1) + "-eth" + - str(j) + " root netem delay " + str(delay) + "ms loss " + str(loss) + "% rate " + str(bw) + "Gbps") - print('[Add current node:]' + 'docker network connect ' + - GSL_name + " " + str(container_id_list[i - 1]) + " --ip 9." + - str(address_16_23) + "." + str(address_8_15) + ".50") - - os.system('docker network connect ' + GSL_name + " " + - str(container_id_list[j - 1]) + " --ip 9." + - str(address_16_23) + "." + str(address_8_15) + ".60") - with os.popen( - "docker exec -it " + str(container_id_list[j - 1]) + - " ip addr | grep -B 2 9." + str(address_16_23) + "." + - str(address_8_15) + - ".60 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]" - ) as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " down") - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " name " + - "B" + str(j) + "-eth" + str(i - 1 + 1)) - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev B" + str(j) + "-eth" + - str(i - 1 + 1) + " up") - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " tc qdisc add dev B" + str(j) + "-eth" + - str(i - 1 + 1) + " root netem delay " + str(delay) + - "ms loss " + str(loss) + "% rate " + str(bw) + - "Gbps") - print('[Add right node:]' + 'docker network connect ' + GSL_name + - " " + str(container_id_list[j - 1]) + " --ip 9." + - str(address_16_23) + "." + str(address_8_15) + ".60") - for j in range(constellation_size + 1, constellation_size + GS_num + 1): - GS_name = "GS_" + str(j) - # Create default network and interface for GS. - os.system('docker network create ' + GS_name + " --subnet 9." + - str(j) + "." + str(j) + ".0/24") - print('[Create GS network:]' + 'docker network create ' + GS_name + - " --subnet 9." + str(j) + "." + str(j) + ".10/24") - os.system('docker network connect ' + GS_name + " " + - str(container_id_list[j - 1]) + " --ip 9." + str(j) + "." + - str(j) + ".10") - with os.popen( - "docker exec -it " + str(container_id_list[j - 1]) + - " ip addr | grep -B 2 9." + str(j) + "." + str(j) + - ".10 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]" - ) as f: - ifconfig_output = f.readline() - target_interface = str(ifconfig_output).split("@")[0] - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " down") - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " name " + "B" + - str(j - 1 + 1) + "-default") - os.system("docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev B" + str(j - 1 + 1) + "-default" + - " up") - print('[Add current node:]' + 'docker network connect ' + GS_name + - " " + str(container_id_list[j - 1]) + " --ip 9." + str(j) + "." + - str(j) + ".10") - - -def sn_copy_run_conf(container_idx, Path, current, total): - os.system("docker cp " + Path + "/B" + str(current + 1) + ".conf " + - str(container_idx) + ":/B" + str(current + 1) + ".conf") - print("[" + str(current + 1) + "/" + str(total) + "]" + - " docker cp bird.conf " + str(container_idx) + ":/bird.conf") - os.system("docker exec -it " + str(container_idx) + " bird -c B" + - str(current + 1) + ".conf") - print("[" + str(current + 1) + "/" + str(total) + - "] Bird routing process for container: " + str(container_idx) + - " has started. ") - - -def sn_copy_run_conf_to_each_container(container_id_list, sat_node_number, - fac_node_number, path): - print( - "Copy bird configuration file to each container and run routing process." - ) - total = len(container_id_list) - copy_threads = [] - for current in range(0, total): - copy_thread = threading.Thread( - target=sn_copy_run_conf, - args=(container_id_list[current], path + "/conf/bird-" + - str(sat_node_number) + "-" + str(fac_node_number), current, - total)) - copy_threads.append(copy_thread) - for copy_thread in copy_threads: - copy_thread.start() - for copy_thread in copy_threads: - copy_thread.join() - print("Initializing routing...") - sleep(120) - print("Routing initialized!") - - -def sn_damage_link(sat_index, container_id_list): - with os.popen( - "docker exec -it " + str(container_id_list[sat_index]) + - " ifconfig | sed 's/[ \t].*//;/^\(eth0\|\)\(lo\|\)$/d'") as f: - ifconfig_output = f.readlines() - for intreface in range(0, len(ifconfig_output), 2): - os.system("docker exec -d " + str(container_id_list[sat_index]) + - " tc qdisc change dev " + - ifconfig_output[intreface][:-1] + - " root netem loss 100%") - print("docker exec -d " + str(container_id_list[sat_index]) + - " tc qdisc change dev " + ifconfig_output[intreface][:-1] + - " root netem loss 100%") - - -def sn_damage(random_list, container_id_list): - damage_threads = [] - for random_satellite in random_list: - damage_thread = threading.Thread(target=sn_damage_link, - args=(int(random_satellite), - container_id_list)) - damage_threads.append(damage_thread) - for damage_thread in damage_threads: - damage_thread.start() - for damage_thread in damage_threads: - damage_thread.join() - - -def sn_recover_link( - damaged_satellite, - container_id_list, - sat_loss, -): - with os.popen( - "docker exec -it " + str(container_id_list[damaged_satellite]) + - " ifconfig | sed 's/[ \t].*//;/^\(eth0\|\)\(lo\|\)$/d'") as f: - ifconfig_output = f.readlines() - for i in range(0, len(ifconfig_output), 2): - os.system("docker exec -d " + - str(container_id_list[damaged_satellite]) + - " tc qdisc change dev " + ifconfig_output[i][:-1] + - " root netem loss " + str(sat_loss) + "%") - print("docker exec -d " + - str(container_id_list[damaged_satellite]) + - " tc qdisc change dev " + ifconfig_output[i][:-1] + - " root netem loss " + str(sat_loss) + "%") - - -def sn_del_network(network_name): - os.system('docker network rm ' + network_name) - - -def sn_stop_emulation(): - os.system("docker service rm constellation-test") - with os.popen("docker rm -f $(docker ps -a -q)") as f: - f.readlines() - with os.popen("docker network ls") as f: - all_br_info = f.readlines() - del_threads = [] - for line in all_br_info: - if "La" in line or "Le" or "GS" in line: - network_name = line.split()[1] - del_thread = threading.Thread(target=sn_del_network, - args=(network_name, )) - del_threads.append(del_thread) - for del_thread in del_threads: - del_thread.start() - for del_thread in del_threads: - del_thread.join() - - -def sn_recover(damage_list, container_id_list, sat_loss): - recover_threads = [] - for damaged_satellite in damage_list: - recover_thread = threading.Thread(target=sn_recover_link, - args=(int(damaged_satellite), - container_id_list, sat_loss)) - recover_threads.append(recover_thread) - for recover_thread in recover_threads: - recover_thread.start() - for recover_thread in recover_threads: - recover_thread.join() - - -def sn_update_delay(matrix, container_id_list, - constellation_size): # updating delays - delay_threads = [] - for row in range(len(matrix)): - for col in range(row, len(matrix[row])): - if float(matrix[row][col]) > 0: - if row < col: - delay_thread = threading.Thread( - target=sn_delay_change, - args=(row, col, matrix[row][col], container_id_list, - constellation_size)) - delay_threads.append(delay_thread) - else: - delay_thread = threading.Thread( - target=sn_delay_change, - args=(col, row, matrix[col][row], container_id_list, - constellation_size)) - delay_threads.append(delay_thread) - for delay_thread in delay_threads: - delay_thread.start() - for delay_thread in delay_threads: - delay_thread.join() - print("Delay updating done.\n") - - -def sn_delay_change(link_x, link_y, delay, container_id_list, - constellation_size): # multi-thread updating delays - if link_y <= constellation_size: - os.system("docker exec -d " + str(container_id_list[link_x]) + - " tc qdisc change dev B" + str(link_x + 1) + "-eth" + - str(link_y + 1) + " root netem delay " + str(delay) + "ms") - os.system("docker exec -d " + str(container_id_list[link_y]) + - " tc qdisc change dev B" + str(link_y + 1) + "-eth" + - str(link_x + 1) + " root netem delay " + str(delay) + "ms") - else: - os.system("docker exec -d " + str(container_id_list[link_x]) + - " tc qdisc change dev B" + str(link_x + 1) + "-eth" + - str(link_y + 1) + " root netem delay " + str(delay) + "ms") - os.system("docker exec -d " + str(container_id_list[link_y]) + - " tc qdisc change dev B" + str(link_y + 1) + "-eth" + - str(link_x + 1) + " root netem delay " + str(delay) + "ms") - - -if __name__ == '__main__': - if len(sys.argv) == 10: - orbit_num = int(sys.argv[1]) - sat_num = int(sys.argv[2]) - constellation_size = int(sys.argv[3]) - GS_num = int(sys.argv[4]) - sat_bandwidth = float(sys.argv[5]) - sat_loss = float(sys.argv[6]) - sat_ground_bandwidth = float(sys.argv[7]) - sat_ground_loss = float(sys.argv[8]) - current_topo_path = sys.argv[9] - matrix = sn_get_param(current_topo_path) - container_id_list = sn_get_container_info() - sn_establish_ISLs(container_id_list, matrix, orbit_num, sat_num, - constellation_size, sat_bandwidth, sat_loss) - sn_establish_GSL(container_id_list, matrix, GS_num, constellation_size, - sat_ground_bandwidth, sat_ground_loss) - elif len(sys.argv) == 4: - if sys.argv[3] == "update": - current_delay_path = sys.argv[1] - constellation_size = int(sys.argv[2]) - matrix = sn_get_param(current_delay_path) - container_id_list = sn_get_container_info() - sn_update_delay(matrix, container_id_list, constellation_size) - else: - constellation_size = int(sys.argv[1]) - GS_num = int(sys.argv[2]) - path = sys.argv[3] - container_id_list = sn_get_container_info() - sn_copy_run_conf_to_each_container(container_id_list, - constellation_size, GS_num, - path) - elif len(sys.argv) == 2: - path = sys.argv[1] - random_list = numpy.loadtxt(path + "/damage_list.txt") - container_id_list = sn_get_container_info() - sn_damage(random_list, container_id_list) - elif len(sys.argv) == 3: - path = sys.argv[1] - sat_loss = float(sys.argv[2]) - damage_list = numpy.loadtxt(path + "/damage_list.txt") - container_id_list = sn_get_container_info() - sn_recover(damage_list, container_id_list, sat_loss) - elif len(sys.argv) == 1: - sn_stop_emulation() diff --git a/starrynet/sn_orchestrator.py b/starrynet/sn_orchestrator.py new file mode 100644 index 0000000..e750f17 --- /dev/null +++ b/starrynet/sn_orchestrator.py @@ -0,0 +1,358 @@ +#!/usr/bin/python3 +import os +import subprocess +import ctypes +import ipaddress +from typing import Dict, List +from enum import Enum +# from line_profiler import LineProfiler + +try: + import pyctr + import pynetlink +except ModuleNotFoundError as exc: + raise RuntimeError( + "StarryNet C extensions are not installed. " + "Reinstall the package or rebuild the extensions before running." + ) from exc + +# FIXME +NETNS_DIR = '/run/netns' + +_libc = ctypes.CDLL(None) + +def _switch_netns(node_pid: int): + CLONE_NEWNET = 0x40000000 + pid_fd = os.open(f'/proc/{node_pid}/ns/net', os.O_RDONLY) + _libc.setns(pid_fd, CLONE_NEWNET) + os.close(pid_fd) + +def _sysctl(param: str, value: str, check: bool = True): + try: + fd = os.open(f'/proc/sys/{param.replace(".", "/")}', os.O_WRONLY) + os.write(fd, value.encode()) + os.close(fd) + except (FileNotFoundError, PermissionError): + if check: + raise + +class NetInterface: + def __init__(self, if_idx: int, ipv4: ipaddress.IPv4Interface = None, ipv6: ipaddress.IPv6Interface = None): + self.if_idx = if_idx + self.ipv4 = ipv4 + self.ipv6 = ipv6 + +class NodeStatus(Enum): + UP = 1 + DOWN = 2 + +class Node: + """Node object representing a network node in the orchestrator""" + + def __init__(self, name: str, node_dir: str, node_id: int = 0): + self.name = name + self.node_id = node_id + self.pid = pyctr.container_run(node_dir, name) + self.status = NodeStatus.UP + netns_link = f'{NETNS_DIR}/{name}' + if os.path.islink(netns_link): + os.unlink(netns_link) + subprocess.check_call(('ln', '-s', f'/proc/{self.pid}/ns/net', netns_link)) + _switch_netns(self.pid) + _sysctl('net.ipv4.conf.all.forwarding', '1') + _sysctl('net.ipv6.conf.all.forwarding', '1', check=False) + + self.socket_fd = pynetlink.init_socket(self.pid) + + self.idle_links: List[NetInterface] = list() + self.peer2link: Dict[str, NetInterface] = dict() + + # Initialize loopback addresses + self._init_loopback() + + def __lt__(self, other): + return self.pid < other.pid + + def _init_loopback(self): + + _switch_netns(self.pid) + pynetlink.if_up('lo', self.socket_fd) + _sysctl('net.mpls.conf.lo.input', '1', check=False) + + addr4 = ipaddress.IPv4Interface( + f"16.{(self.node_id >> 8) & 0xFF}.{self.node_id & 0xFF}.1/32") + addr6 = ipaddress.IPv6Interface( + f"2000::{self.node_id:04x}/128" + ) + lo_link = NetInterface(1, addr4, addr6) + + pynetlink.modify_addr(True, 'lo', addr4.packed, addr4.network.prefixlen, self.socket_fd) + pynetlink.modify_addr(True, 'lo', addr6.packed, addr6.network.prefixlen, self.socket_fd) + pynetlink.if_up('lo', self.socket_fd) + + self.peer2link['lo'] = lo_link + + def __del__(self): + """Destructor to cleanup resources""" + try: + os.kill(self.pid, 9) + netns_link = f'{NETNS_DIR}/{self.name}' + if os.path.islink(netns_link): + os.remove(netns_link) + if self.socket_fd >= 0: + pynetlink.close_socket(self.socket_fd) + except: + pass # Ignore errors during cleanup + + def run_command(self, command, *args, **kwargs): + """Run a command inside the node's network namespace""" + return subprocess.Popen( + ('nsenter', '-m', '-u', '-i', '-n', '-p', '-t', str(self.pid), *command), + *args, **kwargs + ) + + def register_if(self, if_name: str, if_idx: int): + self.peer2link[if_name] = NetInterface(if_idx = if_idx) + + def init_if(self, if_name: str, addr: str, addr6: str, delay: str, bw: str, loss: str): + addr = ipaddress.IPv4Interface(addr) + addr6 = ipaddress.IPv6Interface(addr6) + _switch_netns(self.pid) + pynetlink.modify_addr(True, if_name, addr.packed, addr.network.prefixlen, self.socket_fd) + pynetlink.modify_addr(True, if_name, addr6.packed, addr6.network.prefixlen, self.socket_fd) + pynetlink.traffic_control(if_name, delay, bw, loss, self.socket_fd) + pynetlink.if_up(if_name, self.socket_fd) + link = self.peer2link.get(if_name) + if link is not None: + link.ipv4 = addr + link.ipv6 = addr6 + + _sysctl(f'net.mpls.conf.{if_name}.input', '1', check=False) + + def update_if(self, if_name: str, delay: str, bw: str, loss: str): + _switch_netns(self.pid) + pynetlink.traffic_control(if_name, delay, bw, loss, self.socket_fd) + + def modify_routes(self, routes: List): + _switch_netns(self.pid) + pynetlink.modify_routes(routes, self.socket_fd) + + def del_if(self, ifname: str): + _switch_netns(self.pid) + pynetlink.del_link(ifname, self.socket_fd) + +class OrchestratorContext: + """Context object to maintain state for orchestrator""" + + def __init__(self, workdir): + self.workdir = workdir + + self._main_net_sock_fd = pynetlink.init_socket() + + self.nodes: Dict[str, Node] = {} + self.damage_lst: List[Node] = [] + + def __del__(self): + try: + os.close(self._main_net_sock_fd) + except: + pass + + def clean(self): + for node in self.nodes.values(): + del node + + self.nodes.clear() + self.damage_lst.clear() + + def init_nodes(self, base_dir, node_configs): + """ + Initialize nodes with unique loopback addresses + + Args: + base_dir: Base directory for node overlays + node_configs: Dict mapping node names to their global unique IDs + e.g., {'node1': 1, 'node2': 5, 'node3': 10} + """ + _sysctl('net.ipv4.neigh.default.gc_thresh1', '4096') + _sysctl('net.ipv4.neigh.default.gc_thresh2', '8192') + _sysctl('net.ipv4.neigh.default.gc_thresh3', '16384') + _sysctl('net.ipv4.fib_multipath_hash_policy', '1') + _sysctl('net.ipv4.conf.all.rp_filter', '0') + _sysctl('net.ipv6.neigh.default.gc_thresh1', '4096', check=False) + _sysctl('net.ipv6.neigh.default.gc_thresh2', '8192', check=False) + _sysctl('net.ipv6.neigh.default.gc_thresh3', '16384', check=False) + _sysctl('net.ipv6.fib_multipath_hash_policy', '1', check=False) + _sysctl('net.mpls.platform_labels', '10000', check=False) + + self.clean() + os.makedirs(NETNS_DIR, exist_ok=True) + + for node_name, node_id in node_configs.items(): + node_dir = f"{base_dir}/overlay/{node_name}" + os.makedirs(node_dir, exist_ok=True) + self.nodes[node_name] = Node(node_name, node_dir, node_id=node_id) + + _switch_netns(os.getpid()) + return { + name: (node.peer2link['lo'].ipv4.compressed, node.peer2link['lo'].ipv6.compressed) + for name, node in self.nodes.items() + } + + def add_link_intra_machine(self, + name1: str, name2: str, + src_ifidx:int, src_addr4: str, src_addr6: str, + dst_ifidx:int, dst_addr4: str, dst_addr6, + delay: str, bw: str, loss: str + ): + node1 = self.nodes.get(name1) + node2 = self.nodes.get(name2) + if node1 is None or node2 is None: + return + + pynetlink.add_link_veth(node1.pid, src_ifidx, name2, node2.pid, dst_ifidx, name1, self._main_net_sock_fd) + node1.register_if(name2, src_ifidx) + node2.register_if(name1, dst_ifidx) + node1.init_if(name2, src_addr4, src_addr6, delay, bw, loss) + node2.init_if(name1, dst_addr4, dst_addr6, delay, bw, loss) + + def add_link_inter_machine(self, + name1: str, name2: str, + ifidx: int, remote_ip: str, addr4: str, addr6: str, + delay: str, bw: str, loss: str + ): + node1 = self.nodes.get(name1) + if node1 is None: + return + + pynetlink.add_link_vxlan( + node1.pid, name2, + ifidx, ipaddress.ip_address(remote_ip).packed, + self._main_net_sock_fd + ) + node1.register_if(name2, ifidx) + node1.init_if(name2, addr4, addr6, delay, bw, loss) + + def init_route_daemons(self, conf_path: str, nodes: str): + conf_path = os.path.abspath(conf_path) + ctl_path = os.path.join(os.path.dirname(conf_path), 'bird.ctl') + if nodes == 'all': + nodes_lst = self.nodes.keys() + else: + nodes_lst = nodes.split(',') + + for node_name in nodes_lst: + node = self.nodes.get(node_name) + if node is None: + continue + proc = node.run_command(('bird', '-c', conf_path, '-s', ctl_path)) + proc.wait() + + def set_static_route(self, src: str, dst: str, next_hop: str): + src_node = self.nodes.get(src) + dst_node = self.nodes.get(dst) + next_hop_node = self.nodes.get(next_hop) + if src_node is None or dst_node is None or next_hop_node is None: + return + + dst_network = dst_node.peer2link['lo'].ipv4.network + via_addr = next_hop_node.peer2link[src].ipv4.ip.packed + src_node.modify_routes([ + (True, dst_network.network_address.packed, dst_network.prefixlen, next_hop, via_addr) + ]) + + def get_ping_command(self, src: str, dst: str, extra_args: List[str] = []): + src_node = self.nodes.get(src) + dst_node = self.nodes.get(dst) + if src_node is None or dst_node is None: + return None + + dst_addr = dst_node.peer2link['lo'].ipv4.ip.compressed + return src_node, ('ping', '-c', '4', '-i', '0.01', *extra_args, dst_addr) + + def get_iperf_commands(self, src: str, dst: str, src_args: List[str] = [], dst_args: List[str] = []): + src_node = self.nodes.get(src) + dst_node = self.nodes.get(dst) + if src_node is None or dst_node is None: + return None + + src_addr = src_node.peer2link['lo'].ipv4.ip.compressed + dst_addr = dst_node.peer2link['lo'].ipv4.ip.compressed + return ( + (dst_node, ('iperf3', *dst_args, '-s', '-1', '-B', dst_addr)), + (src_node, ('iperf3', *src_args, '-c', dst_addr, '-B', src_addr)), + ) + + def get_exec_command(self, node_name: str, cmd: str): + node = self.nodes.get(node_name) + if node is None: + return None + return node, ('sh', '-lc', cmd) + + def netlink(self, routes): + for name, nlmsg in routes: + node = self.nodes.get(name) + if node is None: + print('warning: ', name, 'not exists, netlink skipped') + continue + + pynetlink.netlink_request(node.pid, nlmsg) + + def check_route(self, node_name: str): + node = self.nodes.get(node_name) + if node is None: + return '' + + return subprocess.check_output( + ('nsenter', '-n', '-t', str(node.pid), 'ip', 'route'), + text=True + ) + + def damage(self, random_list: List[str]): + for node_name in random_list: + node = self.nodes.get(node_name) + if node is None: + print(f"Node {node_name} not found") + continue + + _switch_netns(node.pid) + for ifname, link in node.peer2link.items(): + if link.if_idx == 1: + continue + pynetlink.if_down(ifname, node.socket_fd) + + self.damage_lst.append(node) + + def recover(self): + if not self.damage_lst: + return + + for node in self.damage_lst: + _switch_netns(node.pid) + + for ifname, link in node.peer2link.items(): + if link.if_idx == 1: + continue + try: + pynetlink.if_up(ifname, node.socket_fd) + pynetlink.modify_addr( + True, ifname, link.ipv6.ip.packed, link.ipv6.network.prefixlen, node.socket_fd + ) + except: + pass + + self.damage_lst.clear() + + def update_if(self, node_name: str, ifname: str, delay: str, bw: str, loss: str): + node = self.nodes.get(node_name) + if node is None: + return + + node.update_if(ifname, delay, bw, loss) + + def del_if(self, node_name: str, ifname: str): + node = self.nodes.get(node_name) + if node is None: + return + + node.del_if(ifname) diff --git a/starrynet/sn_orchestrator_daemon.py b/starrynet/sn_orchestrator_daemon.py new file mode 100644 index 0000000..03f00be --- /dev/null +++ b/starrynet/sn_orchestrator_daemon.py @@ -0,0 +1,726 @@ +#!/usr/bin/python3 +import json +import logging +import os +import queue +import resource +import selectors +import socket +import struct +import subprocess +import threading +import time +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Dict, Optional + +import paramiko + +from .sn_orchestrator import OrchestratorContext + +MSG_MAX_SIZE = 10 * 1024 * 1024 # 10MB +DEFAULT_PORT = 18888 +SOCKET_PATH = "/tmp/starrynet_orchestrater.sock" + + +class CommandStatus(Enum): + SUCCESS = "success" + ERROR = "error" + + +class TaskStatus(Enum): + SCHEDULED = "scheduled" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +@dataclass +class TaskRecord: + task_id: str + task_type: str + node: str + cmd: str + output_file: str + status: str + created_at: float + scheduled_at: float + started_at: Optional[float] = None + finished_at: Optional[float] = None + returncode: Optional[int] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +class SSHServerInterface(paramiko.ServerInterface): + def __init__(self, username=None, password=None): + self.username = username + self.password = password + + def check_auth_password(self, username, password): + if (self.username and username == self.username and + self.password and password == self.password): + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def check_auth_publickey(self, username, key): + return paramiko.AUTH_FAILED + + def check_channel_request(self, kind, chanid): + if kind == "session": + return paramiko.OPEN_SUCCEEDED + return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + def check_channel_shell_request(self, channel): + return True + + def check_channel_pty_request( + self, channel, term, width, height, pixelwidth, pixelheight, modes): + return True + + +class RunContextState: + def __init__(self, run_id: str, base_workdir: str, machine_id: int, + logger: logging.Logger): + self.run_id = run_id + self.machine_id = machine_id + self.logger = logger + self.safe_run_id = self._safe_run_id(run_id) + self.root_dir = os.path.join(base_workdir, "runs", self.safe_run_id) + os.makedirs(self.root_dir, exist_ok=True) + + self.node_mid_dict: Dict[str, int] = {} + self.ip_lst = [] + self.task_seq = 0 + self.tasks: Dict[str, TaskRecord] = {} + self.task_lock = threading.Lock() + self.task_queue = queue.PriorityQueue() + self.running_tasks = {} + self.stop_event = threading.Event() + self.orchestrator_context: Optional[OrchestratorContext] = None + + self.task_thread = threading.Thread(target=self._task_loop, daemon=True) + self.task_thread.start() + + @staticmethod + def _safe_run_id(run_id: str) -> str: + safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in run_id) + return safe[:48] or "default" + + def ensure_context(self): + if self.orchestrator_context is None: + self.orchestrator_context = OrchestratorContext(self.root_dir) + return self.orchestrator_context + + def _next_task_id(self): + with self.task_lock: + self.task_seq += 1 + return f"{self.safe_run_id}-w{self.machine_id}-t{self.task_seq}" + + def task_to_dict(self, task: TaskRecord): + return asdict(task) + + def enqueue_task(self, task_type, node, cmdline, delay=0.0, metadata=None): + task_id = self._next_task_id() + output_file = f"{task_id}.out" + now = time.time() + task = TaskRecord( + task_id=task_id, + task_type=task_type, + node=node.name, + cmd=" ".join(cmdline), + output_file=output_file, + status=TaskStatus.SCHEDULED.value, + created_at=now, + scheduled_at=now + delay, + metadata=metadata or {}, + ) + with self.task_lock: + self.tasks[task_id] = task + self.task_queue.put(( + task.scheduled_at, + task_id, + { + "node": node, + "cmdline": tuple(cmdline), + } + )) + return task + + def _task_loop(self): + current = None + while not self.stop_event.is_set(): + time.sleep(0.1) + + if current is None: + try: + current = self.task_queue.get(block=False) + except queue.Empty: + current = None + + now = time.time() + while current is not None and current[0] <= now: + _, task_id, payload = current + task = self.tasks.get(task_id) + if task is not None: + output_path = os.path.join(self.root_dir, task.output_file) + task.status = TaskStatus.RUNNING.value + task.started_at = now + fd = os.open( + output_path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + ) + cmdline = payload["cmdline"] + os.write(fd, f"{now}: {' '.join(cmdline)}\n".encode()) + proc = payload["node"].run_command( + cmdline, stdout=fd, stderr=subprocess.STDOUT + ) + os.close(fd) + self.running_tasks[proc] = task_id + try: + current = self.task_queue.get(block=False) + except queue.Empty: + current = None + + finished = [] + for proc, task_id in list(self.running_tasks.items()): + rc = proc.poll() + if rc is None: + continue + task = self.tasks.get(task_id) + if task is not None: + task.finished_at = time.time() + task.returncode = rc + task.status = ( + TaskStatus.SUCCEEDED.value if rc == 0 + else TaskStatus.FAILED.value + ) + finished.append(proc) + for proc in finished: + self.running_tasks.pop(proc, None) + + def cleanup(self): + self.stop_event.set() + if self.orchestrator_context is not None: + self.orchestrator_context.clean() + self.orchestrator_context = None + with self.task_lock: + self.tasks.clear() + self.running_tasks.clear() + self.task_queue = queue.PriorityQueue() + + +class OrchestraterDaemon: + def __init__(self, workdir=None, machine_id=0, log_level=logging.WARNING, + port=DEFAULT_PORT, username="starrynet", password="123456"): + self.workdir = workdir or os.path.curdir + self.machine_id = machine_id + self.port = port + self.username = username + self.password = password + self.socket_path = SOCKET_PATH + self.running = False + self.run_contexts: Dict[str, RunContextState] = {} + self.run_contexts_lock = threading.Lock() + + os.makedirs(self.workdir, exist_ok=True) + + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(os.path.join(self.workdir, "orchestrater_daemon.log")), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger(str(self.__class__)) + + self._generate_ssh_keys() + + def _generate_ssh_keys(self): + self.host_key_path = os.path.join(self.workdir, "ssh_host_key") + + if os.path.exists(self.host_key_path): + with open(self.host_key_path, "r") as f: + self.host_key = paramiko.RSAKey.from_private_key(f) + else: + self.host_key = paramiko.RSAKey.generate(2048) + with open(self.host_key_path, "w") as f: + self.host_key.write_private_key(f) + + def _safe_run_id(self, run_id: str): + return RunContextState._safe_run_id(run_id) + + def _get_run(self, run_id: str, create: bool = True): + safe_run_id = self._safe_run_id(run_id or "default") + with self.run_contexts_lock: + run_ctx = self.run_contexts.get(safe_run_id) + if run_ctx is None and create: + run_ctx = RunContextState(safe_run_id, self.workdir, self.machine_id, self.logger) + self.run_contexts[safe_run_id] = run_ctx + return run_ctx + + def _drop_run(self, run_id: str): + safe_run_id = self._safe_run_id(run_id or "default") + with self.run_contexts_lock: + run_ctx = self.run_contexts.pop(safe_run_id, None) + if run_ctx is not None: + run_ctx.cleanup() + + def run(self): + sel = selectors.DefaultSelector() + self._start_unix_socket_server() + sel.register(self.unix_socket, selectors.EVENT_READ, self._unix_socket_server_accept) + + self._start_ssh_server() + sel.register(self.ssh_socket, selectors.EVENT_READ, self._ssh_server_accept) + + self.running = True + self.logger.info("Orchestrater running ...") + while self.running: + events = sel.select() + for key, _mask in events: + key.data() + self.stop() + + def _start_unix_socket_server(self): + if os.path.exists(self.socket_path): + os.remove(self.socket_path) + + self.unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.unix_socket.bind(self.socket_path) + self.unix_socket.listen(5) + + def _start_ssh_server(self): + self.ssh_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.ssh_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.ssh_socket.bind(("0.0.0.0", self.port)) + self.ssh_socket.listen(100) + + def _unix_socket_server_accept(self): + try: + conn, _addr = self.unix_socket.accept() + threading.Thread( + target=self._handle_client, + args=(conn,), + daemon=True, + ).start() + except OSError as exc: + if self.running: + self.logger.error(f"Unix socket accept error: {exc}") + + def _ssh_server_accept(self): + try: + client, addr = self.ssh_socket.accept() + threading.Thread( + target=self._handle_ssh_client, + args=(client, addr), + daemon=True, + ).start() + except Exception as exc: + if self.running: + self.logger.error(f"SSH accept error: {exc}") + + def _handle_ssh_client(self, client, addr): + transport = None + try: + transport = paramiko.Transport(client) + transport.add_server_key(self.host_key) + server = SSHServerInterface(self.username, self.password) + transport.start_server(server=server) + channel = transport.accept(20) + if channel is None: + return + self._handle_client(channel) + except Exception as exc: + self.logger.error(f"SSH client handling error from {addr}: {exc}") + finally: + if transport is not None: + try: + transport.close() + except Exception: + pass + + def stop(self): + self.running = False + if hasattr(self, "unix_socket"): + self.unix_socket.close() + if hasattr(self, "ssh_socket"): + self.ssh_socket.close() + if os.path.exists(self.socket_path): + os.remove(self.socket_path) + for run_id in list(self.run_contexts): + self._drop_run(run_id) + + def _send_message_with_length(self, conn, data): + if isinstance(data, str): + data = data.encode("utf-8") + conn.sendall(struct.pack("!I", len(data)) + data) + + def _receive_message_with_length(self, conn): + length_data = self._recv_exact(conn, 4) + if not length_data: + return None + message_length = struct.unpack("!I", length_data)[0] + if message_length > MSG_MAX_SIZE: + raise Exception(f"Message too large: {message_length} bytes") + return self._recv_exact(conn, message_length) + + def _recv_exact(self, conn, length): + data = bytearray() + while len(data) < length: + chunk = conn.recv(length - len(data)) + if not chunk: + return None + data.extend(chunk) + return bytes(data) + + def _handle_client(self, conn): + try: + while self.running: + message_data = self._receive_message_with_length(conn) + if not message_data: + break + try: + command = json.loads(message_data.decode("utf-8")) + response = self._process_command(command) + except json.JSONDecodeError as exc: + response = { + "status": CommandStatus.ERROR.value, + "message": f"Invalid JSON: {exc}", + } + except Exception as exc: + response = { + "status": CommandStatus.ERROR.value, + "message": f"Command processing error: {exc}", + } + self._send_message_with_length(conn, json.dumps(response)) + finally: + conn.close() + + def _update_rlimits(self, wanted_soft=65536): + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + new_soft = min(hard, wanted_soft) + if soft < new_soft: + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard)) + + def _process_command(self, command): + t_begin = time.time() + cmd_type = command.get("c") + timestamp = command.get("t", time.time()) + run_id = command.get("rid", "default") + params = command.get("p", {}) + + if cmd_type == "utility": + result = self._handle_utility(params) + else: + run_ctx = self._get_run(run_id) + if cmd_type == "config": + result = self._handle_config(run_ctx, params) + elif cmd_type == "nodes": + result = self._handle_nodes(run_ctx, params) + elif cmd_type == "damage": + result = self._handle_damage(run_ctx, params) + elif cmd_type == "recovery": + result = self._handle_recovery(run_ctx, params) + elif cmd_type == "routed": + result = self._handle_routed(run_ctx, params) + elif cmd_type == "sr": + result = self._handle_route_batch(run_ctx, params) + elif cmd_type == "list": + result = self._handle_list(run_ctx, params) + elif cmd_type == "ping": + result = self._handle_ping(run_ctx, params) + elif cmd_type == "iperf": + result = self._handle_iperf(run_ctx, params) + elif cmd_type == "rtable": + result = self._handle_rtable(run_ctx, params) + elif cmd_type == "clean": + result = self._handle_clean(run_ctx, params) + self._drop_run(run_id) + elif cmd_type == "exec": + result = self._handle_exec(run_ctx, params) + elif cmd_type == "tasks": + result = self._handle_tasks(run_ctx, params) + elif cmd_type == "task": + result = self._handle_task(run_ctx, params) + elif cmd_type == "task_output": + result = self._handle_task_output(run_ctx, params) + elif cmd_type == "update_network_batch": + result = self._handle_update_network_batch(run_ctx, params) + elif cmd_type == "netlink": + result = self._handle_netlink(run_ctx, params) + else: + return { + "status": CommandStatus.ERROR.value, + "message": f"Unknown command: {cmd_type}", + } + + self.logger.info( + "Command: %s run=%s at %.3f duration=%.6f", + cmd_type, run_id, timestamp, time.time() - t_begin + ) + return { + "status": CommandStatus.SUCCESS.value, + "result": result, + "timestamp": timestamp, + } + + def _handle_config(self, run_ctx: RunContextState, params): + run_ctx.node_mid_dict = params.get("node_mid_dict") or {} + run_ctx.ip_lst = params.get("ip_lst") or [] + return { + "message": "Configuration loaded successfully", + "nodes_count": len(run_ctx.node_mid_dict), + } + + def _handle_nodes(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + all_node_names = sorted(run_ctx.node_mid_dict.keys()) + node_configs = {} + for i, node_name in enumerate(all_node_names): + if run_ctx.node_mid_dict[node_name] == self.machine_id: + node_configs[node_name] = i + self._update_rlimits(len(node_configs) * 4) + return context.init_nodes(run_ctx.root_dir, node_configs) + + def _handle_damage(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + context.damage(params.get("nodes", [])) + return {"message": "Damage applied successfully"} + + def _handle_recovery(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + context.recover() + return {"message": "Recovery completed successfully"} + + def _handle_routed(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + conf_path = os.path.join(run_ctx.root_dir, "bird.conf") + with open(conf_path, "w") as f: + f.write(params["conf"]) + context.init_route_daemons(conf_path, params.get("nodes", "all")) + return {"message": "Routing daemon initialized successfully"} + + def _handle_list(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + damaged = {node.name for node in context.damage_lst} + result = [] + for name in context.nodes.keys(): + result.append({ + "name": name, + "state": "Damaged" if name in damaged else "OK", + }) + return {"nodes": result} + + def _handle_ping(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + results = [] + for cmd in params.get("batch", []): + src, dst = cmd[0], cmd[1] + extra_args = cmd[2] if len(cmd) > 2 else [] + prepared = context.get_ping_command(src, dst, extra_args) + if prepared is None: + results.append({ + "src": src, + "dst": dst, + "ok": False, + "error": "src or dst node not found", + }) + continue + node, cmdline = prepared + task = run_ctx.enqueue_task( + "ping", node, cmdline, metadata={"src": src, "dst": dst} + ) + results.append({ + "src": src, + "dst": dst, + "ok": True, + "task_id": task.task_id, + "status": task.status, + "output_file": task.output_file, + }) + return results + + def _handle_iperf(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + results = [] + for cmd in params.get("batch", []): + src, dst, src_args, dst_args = cmd[0], cmd[1], cmd[2], cmd[3] + prepared = context.get_iperf_commands(src, dst, src_args, dst_args) + if prepared is None: + results.append({ + "src": src, + "dst": dst, + "ok": False, + "error": "src or dst node not found", + }) + continue + server_node, server_cmd = prepared[0] + client_node, client_cmd = prepared[1] + server_task = run_ctx.enqueue_task( + "iperf_server", server_node, server_cmd, metadata={"src": src, "dst": dst} + ) + client_task = run_ctx.enqueue_task( + "iperf_client", client_node, client_cmd, delay=1.0, + metadata={"src": src, "dst": dst} + ) + results.append({ + "src": src, + "dst": dst, + "ok": True, + "server_task_id": server_task.task_id, + "client_task_id": client_task.task_id, + }) + return results + + def _handle_route_batch(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + total_routes = 0 + for src, dst, next_hop in params.get("batch", []): + context.set_static_route(src, dst, next_hop) + total_routes += 1 + return { + "message": "Batch static routes set successfully", + "total_routes": total_routes, + } + + def _handle_netlink(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + context.netlink(params.get("batch", [])) + return {"message": "netlink commands submitted"} + + def _handle_rtable(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + return context.check_route(params.get("node")) + + def _handle_utility(self, params): + return subprocess.check_output(("vmstat", "-s"), text=True) + + def _handle_clean(self, run_ctx: RunContextState, params): + run_ctx.cleanup() + return {"message": "Clean completed successfully"} + + def _handle_exec(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + results = [] + for node_name, cmd in params.get("batch", []): + prepared = context.get_exec_command(node_name, cmd) + if prepared is None: + results.append({ + "node": node_name, + "cmd": cmd, + "ok": False, + "error": "node not found", + }) + continue + node, cmdline = prepared + task = run_ctx.enqueue_task( + "exec", node, cmdline, metadata={"raw_cmd": cmd} + ) + results.append({ + "node": node_name, + "cmd": cmd, + "ok": True, + "task_id": task.task_id, + "status": task.status, + "output_file": task.output_file, + }) + return results + + def _handle_tasks(self, run_ctx: RunContextState, params): + task_type = params.get("type") + status = params.get("status") + node = params.get("node") + with run_ctx.task_lock: + tasks = list(run_ctx.tasks.values()) + result = [] + for task in tasks: + if task_type and task.task_type != task_type: + continue + if status and task.status != status: + continue + if node and task.node != node: + continue + result.append(run_ctx.task_to_dict(task)) + result.sort(key=lambda item: item["created_at"]) + return result + + def _handle_task(self, run_ctx: RunContextState, params): + task_id = params.get("task_id") + if not task_id: + raise Exception("task_id is required") + with run_ctx.task_lock: + task = run_ctx.tasks.get(task_id) + if task is None: + raise Exception(f"task not found: {task_id}") + return run_ctx.task_to_dict(task) + + def _handle_task_output(self, run_ctx: RunContextState, params): + task_id = params.get("task_id") + if not task_id: + raise Exception("task_id is required") + with run_ctx.task_lock: + task = run_ctx.tasks.get(task_id) + if task is None: + raise Exception(f"task not found: {task_id}") + output_path = os.path.join(run_ctx.root_dir, task.output_file) + content = "" + if os.path.exists(output_path): + with open(output_path, "r") as f: + content = f.read() + return { + "task": run_ctx.task_to_dict(task), + "output": content, + } + + def _handle_update_network_batch(self, run_ctx: RunContextState, params): + context = run_ctx.ensure_context() + + isl_bw = params.get("isl_bw", "1000") + isl_loss = params.get("isl_loss", "0") + del_lst = params.get("del", []) + upd_lst = params.get("update", []) + add_lst = params.get("add", []) + + for src, dst in del_lst: + if run_ctx.node_mid_dict[src] == self.machine_id: + context.del_if(src, dst) + elif run_ctx.node_mid_dict[dst] == self.machine_id: + context.del_if(dst, src) + + for src, dst, delay in upd_lst: + if run_ctx.node_mid_dict[src] == self.machine_id: + context.update_if(src, dst, delay, isl_bw, isl_loss) + if run_ctx.node_mid_dict[dst] == self.machine_id: + context.update_if(dst, src, delay, isl_bw, isl_loss) + + for link in add_lst: + src, dst, delay = link[0], link[1], link[2] + src_ifidx, src_addr4, src_addr6 = link[3], link[4], link[5] + dst_ifidx, dst_addr4, dst_addr6 = link[6], link[7], link[8] + if run_ctx.node_mid_dict[src] == self.machine_id: + if run_ctx.node_mid_dict[dst] == self.machine_id: + context.add_link_intra_machine( + src, dst, + src_ifidx, src_addr4, src_addr6, + dst_ifidx, dst_addr4, dst_addr6, + delay, isl_bw, isl_loss, + ) + else: + context.add_link_inter_machine( + src, dst, + src_ifidx, + run_ctx.ip_lst[run_ctx.node_mid_dict[dst]], + src_addr4, src_addr6, + delay, isl_bw, isl_loss, + ) + elif run_ctx.node_mid_dict[dst] == self.machine_id: + context.add_link_inter_machine( + dst, src, + dst_ifidx, + run_ctx.ip_lst[run_ctx.node_mid_dict[src]], + dst_addr4, dst_addr6, + delay, isl_bw, isl_loss, + ) + + return f"Delete {len(del_lst)}, update {len(upd_lst)}, add {len(add_lst)} links" diff --git a/starrynet/sn_synchronizer.py b/starrynet/sn_synchronizer.py index 0a8a711..5c62324 100644 --- a/starrynet/sn_synchronizer.py +++ b/starrynet/sn_synchronizer.py @@ -4,237 +4,975 @@ StarryNet: empowering researchers to evaluate futuristic integrated space and terrestrial networks. author: Zeqi Lai (zeqilai@tsinghua.edu.cn) and Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) """ -from starrynet.sn_observer import * -from starrynet.sn_utils import * +import time +import threading +import math +import os +import glob +import random +import ipaddress +import heapq +from enum import Enum +from collections import defaultdict +from typing import List, Tuple, Dict, Any +from dataclasses import dataclass, field +from .sn_observer import * +from .sn_utils import * +from .sn_daemon_client import SSHDaemonClient +EXTRA_LINK_DELAY = 1 # ms, for extra nodes connected to GS + +class NodeType(Enum): + SAT = 1 + GS = 2 + EXTRA = 3 + +@dataclass +class LinkInfo: + dst: str + addr4: ipaddress.IPv4Interface + addr6: ipaddress.IPv6Interface + if_id: int = None + +@dataclass +class NodeInfo: + name: str + node_type: NodeType = NodeType.SAT + cbf_t: List[Tuple[float, float, float]] = field(default_factory=list) + lla_t: List[Tuple[float, float, float]] = field(default_factory=list) + links_t: List[Dict[str, LinkInfo]] = field(default_factory=list) + ifidx_next: int = 100 + addr4: ipaddress.IPv4Address = None + addr6: ipaddress.IPv6Address = None + worker: SSHDaemonClient = None + +@dataclass +class EventRecord: + event_id: str + time: int + event_type: str + params: Dict[str, Any] = field(default_factory=dict) + result_mode: str = 'none' + status: str = 'queuing' + created_at: float = field(default_factory=time.time) + triggered_at: float = None + finished_at: float = None + result: Any = None + error: str = None + task_refs: List[Dict[str, Any]] = field(default_factory=list) + + def __lt__(self, other): + return (self.time, self.created_at, self.event_id) < ( + other.time, other.created_at, other.event_id + ) class StarryNet(): - def __init__(self, - configuration_file_path, - GS_lat_long, - hello_interval=10, - AS=[]): + def __init__(self, configuration_file_path, GS_lat_long, GS_links = None, + extra_nodes_links = None, run_id: str = "default", + artifact_root: str = None): # Initialize constellation information. - sn_args = sn_load_file(configuration_file_path, GS_lat_long) - self.name = sn_args.cons_name - self.satellite_altitude = sn_args.satellite_altitude - self.inclination = sn_args.inclination - self.orbit_number = sn_args.orbit_number - self.sat_number = sn_args.sat_number - self.fac_num = sn_args.fac_num - self.constellation_size = self.orbit_number * self.sat_number - self.node_size = self.orbit_number * self.sat_number + sn_args.fac_num + sn_args = sn_load_file(configuration_file_path) + self.run_id = run_id + self.shell_lst = sn_args.shell_lst + self.gs_lat_long = GS_lat_long self.link_style = sn_args.link_style - self.IP_version = sn_args.IP_version self.link_policy = sn_args.link_policy - self.update_interval = sn_args.update_interval + self.IP_version = sn_args.IP_version + self.step = sn_args.step self.duration = sn_args.duration - self.inter_routing = sn_args.inter_routing - self.intra_routing = sn_args.intra_routing - self.cycle = sn_args.cycle - self.time_slot = sn_args.time_slot self.sat_bandwidth = sn_args.sat_bandwidth self.sat_ground_bandwidth = sn_args.sat_ground_bandwidth self.sat_loss = sn_args.sat_loss self.sat_ground_loss = sn_args.sat_ground_loss - self.ground_num = sn_args.ground_num - self.multi_machine = sn_args.multi_machine self.antenna_number = sn_args.antenna_number - self.antenna_inclination = sn_args.antenna_inclination - self.container_global_idx = 1 - self.hello_interval = hello_interval - self.AS = AS - self.configuration_file_path = os.path.dirname( + self.elevation = sn_args.antenna_elevation + self.configuration_dir = os.path.dirname( os.path.abspath(configuration_file_path)) - self.file_path = './' + sn_args.cons_name + '-' + str( - sn_args.orbit_number) + '-' + str(sn_args.sat_number) + '-' + str( - sn_args.satellite_altitude) + '-' + str( - sn_args.inclination - ) + '-' + sn_args.link_style + '-' + sn_args.link_policy - self.observer = Observer(self.file_path, self.configuration_file_path, - self.inclination, self.satellite_altitude, - self.orbit_number, self.sat_number, - self.duration, self.antenna_number, - GS_lat_long, self.antenna_inclination, - self.intra_routing, self.hello_interval, - self.AS) - self.docker_service_name = 'constellation-test' - self.isl_idx = 0 - self.ISL_hub = 'ISL_hub' - self.container_id_list = [] - self.n_container = 0 - # Get ssh handler. - self.remote_ssh, self.transport = sn_init_remote_machine( - sn_args.remote_machine_IP, sn_args.remote_machine_username, - sn_args.remote_machine_password) - if self.remote_ssh is None: - print('Remote SSH login failure.') - return - if self.transport is None: - print('Remote transport login failure.') - return - self.remote_ftp = sn_init_remote_ftp(self.transport) - if self.remote_ftp is None: - print('Remote ftp login failure.') - return - self.utility_checking_time = [] - self.ping_src = [] - self.ping_des = [] - self.ping_time = [] - self.perf_src = [] - self.perf_des = [] - self.perf_time = [] - self.sr_src = [] - self.sr_des = [] - self.sr_target = [] - self.sr_time = [] - self.damage_ratio = [] - self.damage_time = [] - self.damage_list = [] - self.recovery_time = [] - self.route_src = [] - self.route_time = [] - - # Initiate a working directory - sn_thread = sn_init_directory_thread(self.file_path, - self.configuration_file_path, - self.remote_ssh) - sn_thread.start() - sn_thread.join() - # Initiate a necessary delay and position data for emulation - self.observer.calculate_delay() - # Generate configuration file for routing - self.observer.generate_conf(self.remote_ssh, self.remote_ftp) + self.experiment_name = sn_args.cons_name\ + +'-'+ sn_args.link_style +'-'+ sn_args.link_policy + self.gs_dirname = 'GS' + for shell_id, shell in enumerate(self.shell_lst): + shell['name'] = f"shell{shell_id}" + + base_artifact_root = artifact_root or os.path.join( + self.configuration_dir, self.experiment_name + ) + self.local_dir = os.path.join(base_artifact_root, self.run_id) + self._init_local() + + # Initialize Observer for topology computation + self.observer = Observer(configuration_file_path, GS_lat_long, + self.antenna_number, self.elevation) + + # Compute topology and get updates + sat_t_shell, gs_t = self.observer.compute_topology(GS_links) + self.nodes, self.changes_t = self._process_topo(sat_t_shell, gs_t, extra_nodes_links) + + self._assign_worker([shell[0] for shell in sat_t_shell], sn_args.machine_lst) + + self.events: List[EventRecord] = [] + self.event_history: Dict[str, EventRecord] = {} + self._event_seq = 0 + self._event_lock = threading.Lock() + self._stop_event = threading.Event() + + def _init_local(self): + for txt_file in glob.glob(os.path.join(self.local_dir, '*.txt')): + os.remove(txt_file) + for shell in self.shell_lst: + os.makedirs(os.path.join(self.local_dir, shell['name']), exist_ok=True) + os.makedirs(os.path.join(self.local_dir, self.gs_dirname), exist_ok=True) + + def _process_topo(self, sat_t_shell, gs_t, extra_nodes_links): + gs_name_lst, gs_cbf, gsls_t = gs_t + nodes = {} + idx_dict = {} + cnt = 0 + changes_t = [{'add': [], 'update':[], 'del': []} for _ in range(len(gsls_t))] + + for name_lst, sat_cbf_t, sat_lla_t, isls_t in sat_t_shell: + for sat_name in name_lst: + nodes[sat_name] = NodeInfo(name=sat_name, node_type=NodeType.SAT, cbf_t=[], links_t=[{} for _ in range(len(isls_t))]) + for name_lst, sat_cbf_t, sat_lla_t, isls_t in sat_t_shell: + old_states = {sat_name: {} for sat_name in name_lst} + for t, (cbf_lst, lla_lst, isls_lst) in enumerate(zip(sat_cbf_t, sat_lla_t, isls_t)): + for sat_name, cbf, lla, isls in zip(name_lst, cbf_lst, lla_lst, isls_lst): + nodes[sat_name].cbf_t.append((cbf[0], cbf[1], cbf[2])) + nodes[sat_name].lla_t.append((lla[0], lla[1], lla[2])) + new_state = {} + for isl in isls: + dst = isl[0] + new_state[dst] = isl[1] + key = (sat_name, dst) + if key in idx_dict: + idx = idx_dict[key] + else: + idx = cnt + idx_dict[key] = idx + cnt += 1 + nodes[sat_name].links_t[t][dst] = LinkInfo( + dst=dst, + addr4=ipaddress.IPv4Interface(f'10.{idx >> 8}.{idx & 0xFF}.10/24'), + addr6=ipaddress.IPv6Interface(f'2000:{idx >> 8}:{idx & 0xFF}::10/48') + ) + nodes[dst].links_t[t][sat_name] = LinkInfo( + dst=sat_name, + addr4=ipaddress.IPv4Interface(f'10.{idx >> 8}.{idx & 0xFF}.40/24'), + addr6=ipaddress.IPv6Interface(f'2000:{idx >> 8}:{idx & 0xFF}::40/48') + ) + old_state = old_states[sat_name] + for dst, old_delay in old_state.items(): + if dst not in new_state: + changes_t[t]['del'].append((sat_name, dst)) + elif abs(old_delay - new_state[dst]) > 1e-2: + changes_t[t]['update'].append((sat_name, dst, f'{new_state[dst]:.2f}')) + else: + # not accumlate + new_state[dst] = old_delay + for dst, delay in new_state.items(): + if dst not in old_state: + src_node = nodes[sat_name] + src_link = src_node.links_t[t][dst] + src_link.if_id = src_node.ifidx_next + src_node.ifidx_next += 1 + dst_node = nodes[dst] + dst_link = dst_node.links_t[t][sat_name] + dst_link.if_id = dst_node.ifidx_next + dst_node.ifidx_next += 1 + changes_t[t]['add'].append(( + sat_name, dst, f'{delay:.2f}', + src_link.if_id, src_link.addr4.compressed, src_link.addr6.compressed, + dst_link.if_id, dst_link.addr4.compressed, dst_link.addr6.compressed, + )) + old_states[sat_name] = new_state + + old_state = {} + gs_lla = self.gs_lat_long + for gs_index, gs_name in enumerate(gs_name_lst): + nodes[gs_name] = NodeInfo(name=gs_name, node_type=NodeType.GS, cbf_t=[], links_t=[]) + if gs_index < len(gs_lla): + lat_lon = gs_lla[gs_index] + altitude = lat_lon[2] if len(lat_lon) > 2 else 0 + nodes[gs_name].lla_t = [(lat_lon[0], lat_lon[1], altitude) for _ in range(len(gsls_t))] + old_states[gs_name] = {} + for t, gsls_lst in enumerate(gsls_t): + for gs_name, cbf, gsls in zip(gs_name_lst, gs_cbf, gsls_lst): + nodes[gs_name].cbf_t.append((cbf[0], cbf[1], cbf[2])) + links = {} + new_state = {} + for gsl in gsls: + sat = gsl[0] + new_state[sat] = gsl[1] + key = (gs_name, sat) + if key in idx_dict: + idx = idx_dict[key] + else: + idx = cnt + idx_dict[key] = idx + cnt += 1 + links[sat] = LinkInfo( + dst=sat, + addr4=ipaddress.IPv4Interface(f'11.{idx >> 8}.{idx & 0xFF}.40/24'), + addr6=ipaddress.IPv6Interface(f'2001:{idx >> 8}:{idx & 0xFF}::40/48') + ) + nodes[sat].links_t[t][gs_name] = LinkInfo( + dst=gs_name, + addr4=ipaddress.IPv4Interface(f'11.{idx >> 8}.{idx & 0xFF}.10/24'), + addr6=ipaddress.IPv6Interface(f'2001:{idx >> 8}:{idx & 0xFF}::10/48') + ) + nodes[gs_name].links_t.append(links) + + old_state = old_states[gs_name] + for dst, old_delay in old_state.items(): + if dst not in new_state: + changes_t[t]['del'].append((gs_name, dst)) + elif abs(old_delay - new_state[dst]) > 1e-2: + changes_t[t]['update'].append((gs_name, dst, f'{new_state[dst]:.2f}')) + else: + # not accumlate + new_state[dst] = old_delay + for dst, delay in new_state.items(): + if dst not in old_state: + src_node = nodes[gs_name] + src_link = links[dst] + src_link.if_id = src_node.ifidx_next + src_node.ifidx_next += 1 + dst_node = nodes[dst] + dst_link = dst_node.links_t[t][gs_name] + dst_link.if_id = dst_node.ifidx_next + dst_node.ifidx_next += 1 + changes_t[t]['add'].append(( + gs_name, dst, f'{delay:.2f}', + src_link.if_id, src_link.addr4.compressed, src_link.addr6.compressed, + dst_link.if_id, dst_link.addr4.compressed, dst_link.addr6.compressed, + )) + old_states[gs_name] = new_state + if extra_nodes_links: + for name, links in extra_nodes_links.items(): + extra_node = NodeInfo(name=name, node_type=NodeType.EXTRA, cbf_t=[], links_t=[{} for _ in range(len(gsls_t))]) + anchor_node = None + for dst in links: + dst_node = nodes.get(dst) + if dst_node is None: + continue # single directed + # raise ValueError(f"Extra node {name} links to non-existent node {dst}") + if dst_node.node_type == NodeType.SAT: + raise ValueError(f"Extra node {name} cannot link to satellite {dst}") + if anchor_node is None: + anchor_node = dst_node + for t in range(len(dst_node.links_t)): + dst_links = dst_node.links_t[t] + key = (name, dst) + if key in idx_dict: + idx = idx_dict[key] + else: + idx = cnt + idx_dict[key] = idx + cnt += 1 + extra_node.links_t[t][dst] = LinkInfo( + dst=dst, + addr4=ipaddress.IPv4Interface(f'12.{idx >> 8}.{idx & 0xFF}.40/24'), + addr6=ipaddress.IPv6Interface(f'2002:{idx >> 8}:{idx & 0xFF}::40/48') + ) + dst_links[name] = LinkInfo( + dst=name, + addr4=ipaddress.IPv4Interface(f'12.{idx >> 8}.{idx & 0xFF}.10/24'), + addr6=ipaddress.IPv6Interface(f'2002:{idx >> 8}:{idx & 0xFF}::10/48') + ) + src_link = extra_node.links_t[0][dst] + src_link.if_id = extra_node.ifidx_next + extra_node.ifidx_next += 1 + dst_link = dst_node.links_t[0][name] + dst_link.if_id = dst_node.ifidx_next + dst_node.ifidx_next += 1 + changes_t[0]['add'].append(( + name, dst, f'{EXTRA_LINK_DELAY:.2f}', + src_link.if_id, src_link.addr4.compressed, src_link.addr6.compressed, + dst_link.if_id, dst_link.addr4.compressed, dst_link.addr6.compressed, + )) + if anchor_node is not None: + extra_node.cbf_t = list(anchor_node.cbf_t) + extra_node.lla_t = list(anchor_node.lla_t) + nodes[name] = extra_node + return nodes, changes_t + + def _assign_worker(self, sat_names_shell, machine_lst): + assert len(sat_names_shell) == len(self.shell_lst) + + # TODO: better partition + node_mid_dict = {} + assigned_shell_lst = [] + if len(sat_names_shell) * 2 <= len(machine_lst): + # need intra-shell partition + sat_total = sum(len(sat_names) for sat_names in sat_names_shell) + sat_per_machine = sat_total // len(machine_lst) + remainder = sat_total % len(machine_lst) + + shell_id, sat_id = 0, 0 + sat_names = [] + for i, worker in enumerate(machine_lst): + sat_nr = sat_per_machine + if i < remainder: + sat_nr += 1 + + assigned_shells = [] + for _ in range(sat_nr): + sat_names.append(sat_names_shell[shell_id][sat_id]) + node_mid_dict[sat_names[-1]] = i + sat_id += 1 + if(sat_id >= len(sat_names_shell[shell_id])): + assigned_shells.append((self.shell_lst[shell_id]['name'], sat_names)) + shell_id += 1 + sat_id = 0 + sat_names = [] + if len(sat_names) > 0: + assigned_shells.append((self.shell_lst[shell_id]['name'], sat_names)) + sat_names = [] + print(assigned_shells) + assigned_shell_lst.append(assigned_shells) + else: + # only divide shell + shell_per_machine = len(self.shell_lst) // len(machine_lst) + remainder = len(sat_names_shell) % len(machine_lst) + + shell_id = 0 + for i, worker in enumerate(machine_lst): + shell_num = shell_per_machine + if i < remainder: + shell_num += 1 + assigned_shells = [ + (self.shell_lst[j]['name'], sat_names_shell[j]) + for j in range(shell_id, shell_id + shell_num) + ] + # all satellites of a shell assigned to a single machine + for shell_name, sat_names in assigned_shells: + for sat_name in sat_names: + node_mid_dict[sat_name] = i + assigned_shell_lst.append(assigned_shells) + shell_id += shell_num + + # TODO: better ground station assign + for name, node in self.nodes.items(): + if name in node_mid_dict: + continue + # assigned to the machine of its first neighbor + for dst in node.links_t[0]: + dst_mid = node_mid_dict.get(dst) + if dst_mid is not None: + node_mid_dict[name] = dst_mid + break + # TODO: otherwise? + if name not in node_mid_dict: + node_mid_dict[name] = 0 + + ip_lst = [worker['IP'] for worker in machine_lst] + assign_obj = { + 'shell_num': len(self.shell_lst), + 'node_mid_dict': node_mid_dict, + 'ip': ip_lst, + } + + worker_lst = [] + for i, worker in enumerate(machine_lst): + worker_lst.append(SSHDaemonClient( + host=worker.get('IP', '127.0.0.1'), + port=worker.get('port', 18888), + username=worker.get('username', 'root'), + password=worker.get('password', ''), + timeout=30, + run_id=self.run_id, + )) + self.worker_lst: List[SSHDaemonClient] = worker_lst + self.config_json = assign_obj def create_nodes(self): - # Initialize each machine in multiple threads. - sn_thread = sn_Node_Init_Thread(self.remote_ssh, - self.docker_service_name, - self.node_size, self.container_id_list, - self.container_global_idx) - sn_thread.start() - sn_thread.join() - self.container_id_list = sn_get_container_info(self.remote_ssh) - print("Constellation initialization done. " + - str(len(self.container_id_list)) + " have been created.") + print('Initializing nodes ...') + begin = time.time() + self.undamaged_lst = list() + self.total_sat_lst = list() + for worker in self.worker_lst: + worker.send_config( + self.config_json['shell_num'], + self.config_json['node_mid_dict'], + self.config_json['ip'] + ) + node_addrs = worker.init_nodes() + for name, (addr4, addr6) in node_addrs.items(): + node = self.nodes[name] + node.addr4 = ipaddress.IPv4Interface(addr4) + node.addr6 = ipaddress.IPv6Interface(addr6) + node.worker = worker + if node.node_type == NodeType.SAT: + self.undamaged_lst.append(name) + self.total_sat_lst.append(name) + print("Node initialization:", time.time() - begin, "s consumed.") def create_links(self): - print("Create Links.") - isl_thread = sn_Link_Init_Thread( - self.remote_ssh, self.remote_ftp, self.orbit_number, - self.sat_number, self.constellation_size, self.fac_num, - self.file_path, self.configuration_file_path, self.sat_bandwidth, - self.sat_ground_bandwidth, self.sat_loss, self.sat_ground_loss) - isl_thread.start() - isl_thread.join() - print("Link initialization done.") - - def run_routing_deamon(self): - routing_thread = sn_Routing_Init_Thread( - self.remote_ssh, self.remote_ftp, self.orbit_number, - self.sat_number, self.constellation_size, self.fac_num, - self.file_path, self.sat_bandwidth, self.sat_ground_bandwidth, - self.sat_loss, self.sat_ground_loss) - routing_thread.start() - routing_thread.join() - print("Bird routing in all containers are running.") - - def get_distance(self, sat1_index, sat2_index, time_index): - delaypath = self.configuration_file_path + "/" + self.file_path + '/delay/' + str( - time_index) + '.txt' - adjacency_matrix = sn_get_param(delaypath) - delay = float(adjacency_matrix[sat1_index - 1][sat2_index - 1]) - dis = delay * (17.31 / 29.5 * 299792.458) / 1000 # km - return dis - - def get_neighbors(self, sat_index, time_index): - neighbors = [] - delaypath = self.configuration_file_path + "/" + self.file_path + '/delay/' + str( - time_index) + '.txt' - adjacency_matrix = sn_get_param(delaypath) - sats = self.orbit_number * self.sat_number - for i in range(sats): - if (float(adjacency_matrix[i][sat_index - 1]) > 0.01): - neighbors.append(i + 1) - return neighbors - - def get_GSes(self, sat_index, time_index): + print('Initializing links using pre-computed topology data...') + thread_lst = [] + begin = time.time() + + # Get initial topology data from Observer (t=1 corresponds to initial state) + initial_network_update = self.changes_t[0] + + initial_network_update['isl_bw'] = str(self.sat_bandwidth) + initial_network_update['isl_loss'] = str(self.sat_loss) + initial_network_update['gsl_bw'] = str(self.sat_ground_bandwidth) + initial_network_update['gsl_loss'] = str(self.sat_ground_loss) + + for worker in self.worker_lst: + thread = threading.Thread( + target=worker.update_network, + args=(initial_network_update,) + ) + thread.start() + thread_lst.append(thread) + + for thread in thread_lst: + thread.join() + + print("Link initialization:", time.time() - begin, 's consumed.') + + def run_routing_daemon(self, bird_conf_path, node_lst='all'): + with open(bird_conf_path, 'r') as f: + bird_conf = f.read() + print('Initializing routing ...') + if node_lst == 'all': + for worker in self.worker_lst: + worker.init_routing('all', bird_conf) + print("Routing daemon initialized. Wait 30s for route converged") + else: + rtd_lsts = defaultdict(list) + for name, node in self.nodes.items(): + rtd_lsts[node.worker].append(name) + for worker, names in rtd_lsts.items(): + worker.init_routing(','.join(names), bird_conf) + + for i in range(30): + if self._stop_event.is_set(): + break + print(f'\r{i} / 30', end=' ') + time.sleep(1) + print("Routing started!") + + def request_stop(self): + self._stop_event.set() + + def _worker_for_node(self, node): + node_info = self.nodes.get(node) + if node_info is None: + return None + return node_info.worker + + def _next_event_id(self): + self._event_seq += 1 + return f"e{self._event_seq}" + + def _event_to_dict(self, event: EventRecord): + result = { + "event_id": event.event_id, + "time": event.time, + "type": event.event_type, + "params": event.params, + "result_mode": event.result_mode, + "status": event.status, + "created_at": event.created_at, + "triggered_at": event.triggered_at, + "finished_at": event.finished_at, + "error": event.error, + "task_refs": event.task_refs, + } + if event.result is not None: + result["result"] = event.result + return result + + def _queue_event(self, event_type, t, params=None, result_mode='none'): + with self._event_lock: + event = EventRecord( + event_id=self._next_event_id(), + time=self._validate_t(t), + event_type=event_type, + params=params or {}, + result_mode=result_mode, + ) + heapq.heappush(self.events, event) + self.event_history[event.event_id] = event + return event.event_id + + def _set_event_result(self, event: EventRecord, result=None, error=None, task_refs=None): + event.finished_at = time.time() + event.result = result + event.error = error + if task_refs is not None: + event.task_refs = task_refs + event.status = 'failed' if error else 'succeeded' + return event + + def list_events(self): + with self._event_lock: + events = list(self.event_history.values()) + events.sort(key=lambda item: (item.time, item.created_at, item.event_id)) + return [self._event_to_dict(event) for event in events] + + def get_event(self, event_id): + with self._event_lock: + event = self.event_history.get(event_id) + if event is None: + return {} + return self._event_to_dict(event) + + def update_event(self, event_id, event_type, t, params=None): + with self._event_lock: + event = self.event_history.get(event_id) + if event is None: + raise KeyError(event_id) + if event.status != 'queuing': + raise ValueError(f'event {event_id} cannot be edited from status {event.status}') + event.time = self._validate_t(t) + event.event_type = event_type + event.params = params or {} + event.result_mode = self._event_result_mode(event_type) + heapq.heapify(self.events) + return self._event_to_dict(event) + + def delete_event(self, event_id): + with self._event_lock: + event = self.event_history.get(event_id) + if event is None: + raise KeyError(event_id) + if event.status != 'queuing': + raise ValueError(f'event {event_id} cannot be deleted from status {event.status}') + del self.event_history[event_id] + self.events = [item for item in self.events if item.event_id != event_id] + heapq.heapify(self.events) + return True + + def restore_event(self, data): + event_id = data.get('event_id') + event_type = data.get('event_type') or data.get('type') + if not event_id or not event_type: + return None + with self._event_lock: + if event_id in self.event_history: + return event_id + status = data.get('status', 'queuing') + event = EventRecord( + event_id=event_id, + time=self._validate_t(data.get('time', 0)), + event_type=event_type, + params=data.get('params') or {}, + result_mode=data.get('result_mode') or self._event_result_mode(event_type), + status=status, + created_at=data.get('created_at') or time.time(), + triggered_at=data.get('triggered_at'), + finished_at=data.get('finished_at'), + result=data.get('result'), + error=data.get('error'), + task_refs=data.get('task_refs') or [], + ) + if status == 'queuing': + heapq.heappush(self.events, event) + self.event_history[event.event_id] = event + if event_id.startswith('e') and event_id[1:].isdigit(): + self._event_seq = max(self._event_seq, int(event_id[1:])) + return event_id + + def _event_result_mode(self, event_type): + if event_type in {'check_utility', 'check_routing_table'}: + return 'inline' + if event_type in {'ping', 'iperf', 'exec'}: + return 'task' + return 'none' + + def list_tasks(self, node=None, status=None, task_type=None): + if node is not None: + worker = self._worker_for_node(node) + if worker is None: + return [] + return worker.list_tasks(node=node, status=status, task_type=task_type) + + tasks = [] + seen = set() + for worker in self.worker_lst: + worker_tasks = worker.list_tasks(status=status, task_type=task_type) + for task in worker_tasks: + task_id = task.get('task_id') + if task_id in seen: + continue + seen.add(task_id) + tasks.append(task) + tasks.sort(key=lambda item: item.get('created_at', 0)) + return tasks + + def get_task(self, task_id, node=None): + if node is not None: + worker = self._worker_for_node(node) + if worker is None: + return {} + return worker.get_task(task_id) + + for worker in self.worker_lst: + result = worker.get_task(task_id) + if result: + return result + return {} + + def get_task_output(self, task_id, node=None): + if node is not None: + worker = self._worker_for_node(node) + if worker is None: + return {} + return worker.get_task_output(task_id) + + for worker in self.worker_lst: + result = worker.get_task_output(task_id) + if result: + return result + return {} + + # static information + def get_distance(self, node1, node2, t): + node1, node2 = self.nodes.get(node1), self.nodes.get(node2) + if node1 is None or node2 is None: + return None + + tid = t // self.step + xyz1 = node1.cbf_t[tid] if tid < len(node1.cbf_t) else node1.cbf_t[-1] + xyz2 = node2.cbf_t[tid] if tid < len(node2.cbf_t) else node2.cbf_t[-1] + dx, dy, dz = xyz1[0] - xyz2[0], xyz1[1] - xyz2[1], xyz1[2] - xyz2[2] + return math.sqrt(dx * dx + dy * dy + dz * dz) + + def get_neighbors(self, node, t): + node = self.nodes.get(node) + if node is None: + return [] + + tid = t // self.step + return list(node.links_t[tid].keys()) + + def get_GSes(self, node, t): + node = self.nodes.get(node) + if node is None: + return [] + + tid = t // self.step GSes = [] - delaypath = self.configuration_file_path + "/" + self.file_path + '/delay/' + str( - time_index) + '.txt' - adjacency_matrix = sn_get_param(delaypath) - sats = self.orbit_number * self.sat_number - for i in range(sats, len(adjacency_matrix)): - if (float(adjacency_matrix[i][sat_index - 1]) > 0.01): - GSes.append(i + 1) + for dst in node.links_t[tid]: + dst_node = self.nodes[dst] + if dst_node.node_type == NodeType.GS: + GSes.append(dst) return GSes - def get_utility(self, time_index): - self.utility_checking_time.append(time_index) - - def get_position(self, sat_index, time_index): - path = self.configuration_file_path + "/" + self.file_path + '/position/' + '/%d.txt' % time_index - f = open(path) - ADJ = f.readlines() - return ADJ[sat_index - 1] - - def get_IP(self, sat_index): - IP_info = sn_remote_cmd( - self.remote_ssh, "docker inspect" + - " --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}\n{{end}}'" - + " ovs_container_" + str(sat_index)) - ip_list = [] - for i in range(len(IP_info) - 2): - ip_list.append(IP_info[i].split()[0]) - return ip_list - - def set_damage(self, damaging_ratio, time_index): - self.damage_ratio.append(damaging_ratio) - self.damage_time.append(time_index) - - def set_recovery(self, time_index): - self.recovery_time.append(time_index) - - def check_routing_table(self, sat_index, time_index): - self.route_src.append(sat_index) - self.route_time.append(time_index) - - def set_next_hop(self, sat_index, des, next_hop_sat, time_index): - self.sr_src.append(sat_index) - self.sr_des.append(des) - self.sr_target.append(next_hop_sat) - self.sr_time.append(time_index) - - def set_ping(self, sat1_index, sat2_index, time_index): - self.ping_src.append(sat1_index) - self.ping_des.append(sat2_index) - self.ping_time.append(time_index) - - def set_perf(self, sat1_index, sat2_index, time_index): - self.perf_src.append(sat1_index) - self.perf_des.append(sat2_index) - self.perf_time.append(time_index) + def get_position(self, node, t): + node = self.nodes.get(node) + if node is None: + return None + + tid = t // self.step + return node.cbf_t[tid] if tid < len(node.cbf_t) else node.cbf_t[-1] + + def get_lla(self, node, t): + node = self.nodes.get(node) + if node is None or not node.lla_t: + return None + + tid = t // self.step + return node.lla_t[tid] if tid < len(node.lla_t) else node.lla_t[-1] + + def get_IP(self, node): + node = self.nodes.get(node) + if node is None: + return () + return node.addr4.compressed, node.addr6.compressed + + # dynamic events + def _validate_t(self, t): + if t >= self.duration: + t = round(self.duration) - 1 + elif t < 0: + t = 0 + else: + t = round(t) + return t + + def check_utility(self, t): + return self._queue_event('check_utility', t, result_mode='inline') + + def _run_check_utility_event(self, event: EventRecord): + results = {} + for mid, worker in enumerate(self.worker_lst): + result = worker.check_utility() + key = f'machine{mid}' + results[key] = result + self._set_event_result(event, result=results) + + def check_routing_table(self, node, t): + return self._queue_event( + 'check_routing_table', + t, + params={'node': node}, + result_mode='inline', + ) + + def _run_check_routing_table_event(self, event: EventRecord): + node = event.params['node'] + result = self.nodes[node].worker.check_routing_table(node) + self._set_event_result(event, result=result) + + def set_damage(self, damaging_ratio, t): + return self._queue_event( + 'damage', + t, + params={'damaging_ratio': damaging_ratio}, + ) + + def _run_damage_event(self, event: EventRecord): + damaging_ratio = event.params['damaging_ratio'] + damage_lsts = {worker: [] for worker in self.worker_lst} + cur_num = len(self.undamaged_lst) + need_damage_num = min(len(self.total_sat_lst) * damaging_ratio, cur_num) + while cur_num - len(self.undamaged_lst) < need_damage_num and self.undamaged_lst: + sat = self.undamaged_lst.pop( + random.randint(0, len(self.undamaged_lst) - 1) + ) + worker = self.nodes[sat].worker + damage_lsts[worker].append(sat) + for worker, lst in damage_lsts.items(): + if lst: + worker.damage_nodes(lst) + machine_map = {} + for mid, worker in enumerate(self.worker_lst): + if damage_lsts[worker]: + machine_map[f'machine{mid}'] = damage_lsts[worker] + self._set_event_result( + event, + result={ + 'damaged_nodes': sum(len(lst) for lst in damage_lsts.values()), + 'machines': machine_map, + }, + ) + + def set_recovery(self, t): + return self._queue_event('recovery', t) + + def _run_recovery_event(self, event: EventRecord): + for worker in self.worker_lst: + worker.recover_nodes() + self.undamaged_lst = self.total_sat_lst.copy() + self._set_event_result(event, result={'recovered': True}) + + def set_static_route(self, src, dst, next_hop, t): + return self._queue_event( + 'static_route', + t, + params={'src': src, 'dst': dst, 'next_hop': next_hop}, + ) + + def set_netlink(self, node, nlmsg, t): + return self._queue_event( + 'netlink', + t, + params={'node': node, 'nlmsg': nlmsg}, + ) + + def set_ping(self, src, dst, t, extra_args=[]): + return self._queue_event( + 'ping', + t, + params={'src': src, 'dst': dst, 'extra_args': list(extra_args)}, + result_mode='task', + ) + + def set_iperf(self, src, dst, t, src_args = [], dst_args = []): + return self._queue_event( + 'iperf', + t, + params={ + 'src': src, + 'dst': dst, + 'src_args': list(src_args), + 'dst_args': list(dst_args), + }, + result_mode='task', + ) + + def exec_at(self, node, cmd, t): + return self._queue_event( + 'exec', + t, + params={'node': node, 'cmd': cmd}, + result_mode='task', + ) + + def _run_batch_events(self, events: List[EventRecord], event_type, builder, sender, task_ref_builder=None): + worker_args = defaultdict(list) + worker_events = defaultdict(list) + for event in events: + args = builder(event) + worker = self.nodes[args[0]].worker + worker_args[worker].append(args) + worker_events[worker].append(event) + + for worker, args_lst in worker_args.items(): + response = sender(worker, args_lst) or {} + results = response.get('result', []) if isinstance(response, dict) else [] + if not isinstance(results, list): + for event in worker_events[worker]: + self._set_event_result(event, result=results) + continue + for index, event in enumerate(worker_events[worker]): + if index >= len(results): + self._set_event_result( + event, + error=f'{event_type} returned fewer results than expected', + ) + continue + result = results[index] + if result.get('ok', True): + task_refs = task_ref_builder(result) if task_ref_builder else None + self._set_event_result(event, result=result, task_refs=task_refs) + else: + self._set_event_result(event, error=result.get('error', f'{event_type} failed'), result=result) + + def _pop_due_events(self, real_t): + due_events = [] + with self._event_lock: + while self.events and self.events[0].time <= real_t: + due_events.append(heapq.heappop(self.events)) + return due_events + + def _event(self, real_t): + due_events = self._pop_due_events(real_t) + if not due_events: + return + + grouped = defaultdict(list) + for event in due_events: + event.status = 'running' + event.triggered_at = time.time() + grouped[event.event_type].append(event) + + for event in grouped.get('damage', []): + self._run_damage_event(event) + for event in grouped.get('recovery', []): + self._run_recovery_event(event) + for event in grouped.get('check_utility', []): + self._run_check_utility_event(event) + for event in grouped.get('check_routing_table', []): + self._run_check_routing_table_event(event) + + if grouped.get('static_route'): + self._run_batch_events( + grouped['static_route'], + 'static_route', + lambda event: ( + event.params['src'], + event.params['dst'], + event.params['next_hop'], + ), + lambda worker, args_lst: worker.static_route_batch(args_lst), + ) + if grouped.get('netlink'): + self._run_batch_events( + grouped['netlink'], + 'netlink', + lambda event: ( + event.params['node'], + event.params['nlmsg'], + ), + lambda worker, args_lst: worker.netlink_batch(args_lst), + ) + if grouped.get('ping'): + self._run_batch_events( + grouped['ping'], + 'ping', + lambda event: ( + event.params['src'], + event.params['dst'], + event.params.get('extra_args', []), + ), + lambda worker, args_lst: worker.ping_batch(args_lst), + lambda result: [{'task_id': result['task_id'], 'output_file': result.get('output_file')}], + ) + if grouped.get('iperf'): + self._run_batch_events( + grouped['iperf'], + 'iperf', + lambda event: ( + event.params['src'], + event.params['dst'], + event.params.get('src_args', []), + event.params.get('dst_args', []), + ), + lambda worker, args_lst: worker.iperf_batch(args_lst), + lambda result: [ + {'task_id': result['server_task_id'], 'role': 'server'}, + {'task_id': result['client_task_id'], 'role': 'client'}, + ], + ) + if grouped.get('exec'): + self._run_batch_events( + grouped['exec'], + 'exec', + lambda event: ( + event.params['node'], + event.params['cmd'], + ), + lambda worker, args_lst: worker.exec_batch(args_lst), + lambda result: [{'task_id': result['task_id'], 'output_file': result.get('output_file')}], + ) def start_emulation(self): - # Start emulation in a new thread. - sn_thread = sn_Emulation_Start_Thread( - self.remote_ssh, self.remote_ftp, self.sat_loss, - self.sat_ground_bandwidth, self.sat_ground_loss, - self.container_id_list, self.file_path, - self.configuration_file_path, self.update_interval, - self.constellation_size, self.ping_src, self.ping_des, - self.ping_time, self.sr_src, self.sr_des, self.sr_target, - self.sr_time, self.damage_ratio, self.damage_time, - self.damage_list, self.recovery_time, self.route_src, - self.route_time, self.duration, self.utility_checking_time, - self.perf_src, self.perf_des, self.perf_time) - sn_thread.start() - sn_thread.join() - - def stop_emulation(self): - # Stop emulation in a new thread. - sn_thread = sn_Emulation_Stop_Thread(self.remote_ssh, self.remote_ftp, - self.file_path) - sn_thread.start() - sn_thread.join() + start_time = time.time() + print('Tick event at 0 s') + self._event(0) + + tid = 1 + while tid < len(self.changes_t): + if self._stop_event.is_set(): + break + t = tid * self.step + target_time = start_time + t + + now = time.time() + if now < target_time: + sleep_time = target_time - now + print('Sleeping', sleep_time, 's until', t, 's') + if self._stop_event.wait(sleep_time): + break + + start = time.time() + print("\nUpdate networks using pre-computed topology...") + + network_change = self.changes_t[tid] + network_change['isl_bw'] = str(self.sat_bandwidth) + network_change['isl_loss'] = str(self.sat_loss) + network_change['gsl_bw'] = str(self.sat_ground_bandwidth) + network_change['gsl_loss'] = str(self.sat_ground_loss) + + conn_threads = [] + for worker in self.worker_lst: + thread = threading.Thread( + target=worker.update_network, + args=(network_change,) + ) + thread.start() + conn_threads.append(thread) + for thread in conn_threads: + thread.join() + update_end = time.time() + + print("Trigger events at", t, "s ...") + self._event(t) + end = time.time() + elapsed = end - start + print(elapsed, "s elapsed,", update_end-start, "s for network update") + tid += 1 + + def clean(self): + self.request_stop() + print("Removing containers and links...") + for worker in self.worker_lst: + worker.clean() + print("All containers and links removed.") diff --git a/starrynet/sn_utils.py b/starrynet/sn_utils.py index a0b9ae3..56441f9 100644 --- a/starrynet/sn_utils.py +++ b/starrynet/sn_utils.py @@ -1,905 +1,68 @@ import os -import threading import json -import copy import argparse -import os -from time import sleep -import time +import paramiko import numpy import random -""" -Starrynet utils that are used in sn_synchronizer -author: Yangtao Deng (dengyt21@mails.tsinghua.edu.cn) and Zeqi Lai (zeqilai@tsinghua.edu.cn) -""" -try: - import threading -except ImportError: - os.system("pip3 install threading") - import threading - -try: - import paramiko -except ImportError: - os.system("pip3 install paramiko") - import paramiko - -try: - import requests -except ImportError: - os.system("pip3 install requests") - import requests - - -def get_right_satellite(current_sat_id, current_orbit_id, orbit_num): - if current_orbit_id == orbit_num - 1: - return [current_sat_id, 0] - else: - return [current_sat_id, current_orbit_id + 1] - -def get_down_satellite(current_sat_id, current_orbit_id, sat_num): - if current_sat_id == sat_num - 1: - return [0, current_orbit_id] - else: - return [current_sat_id + 1, current_orbit_id] - - -def sn_load_file(path, GS_lat_long): - f = open("./config.json", "r", encoding='utf8') +def sn_load_file(path): + f = open(path, 'r', encoding='utf8') table = json.load(f) - data = {} - data['cons_name'] = table["Name"] - data['altitude'] = table["Altitude (km)"] - data['cycle'] = table["Cycle (s)"] - data['inclination'] = table["Inclination"] - data['phase_shift'] = table["Phase shift"] - data['orbit'] = table["# of orbit"] - data['sat'] = table["# of satellites"] - data['link'] = table["Satellite link"] - data['duration'] = table["Duration (s)"] - data['ip'] = table["IP version"] - data['intra_as_routing'] = table["Intra-AS routing"] - data['inter_as_routing'] = table["Inter-AS routing"] - data['link_policy'] = table["Link policy"] - data['handover_policy'] = table["Handover policy"] - data['update_time'] = table["update_time (s)"] - data['sat_bw'] = table["satellite link bandwidth (\"X\" Gbps)"] - data['sat_ground_bw'] = table["sat-ground bandwidth (\"X\" Gbps)"] - data['sat_loss'] = table["satellite link loss (\"X\"% )"] - data['sat_ground_loss'] = table["sat-ground loss (\"X\"% )"] - data['ground_num'] = table["GS number"] - data['multi_machine'] = table[ - "multi-machine (\"0\" for no, \"1\" for yes)"] - data['antenna_number'] = table["antenna number"] - data['antenna_inclination'] = table["antenna_inclination_angle"] - data['remote_machine_IP'] = table["remote_machine_IP"] - data['remote_machine_username'] = table["remote_machine_username"] - data['remote_machine_password'] = table["remote_machine_password"] - parser = argparse.ArgumentParser(description='manual to this script') - parser.add_argument('--cons_name', type=str, default=data['cons_name']) - parser.add_argument('--satellite_altitude', - type=int, - default=data['altitude']) - parser.add_argument('--inclination', type=int, default=data['inclination']) - parser.add_argument('--orbit_number', type=int, default=data['orbit']) - parser.add_argument('--sat_number', type=int, default=data['sat']) - parser.add_argument('--fac_num', type=int, default=len(GS_lat_long)) - parser.add_argument('--link_style', type=str, default=data['link']) - parser.add_argument('--IP_version', type=str, default=data['ip']) - parser.add_argument('--link_policy', type=str, default=data['link_policy']) + parser.add_argument('--cons_name', type=str, default=table['Name']) + parser.add_argument('--link_style', type=str, default=table['Satellite link']) + parser.add_argument('--IP_version', type=str, default=table['IP version']) + parser.add_argument('--link_policy', type=str, default=table['Link policy']) # link delay updating granularity - parser.add_argument('--update_interval', + parser.add_argument('--step', type=int, - default=data['update_time']) - parser.add_argument('--duration', type=int, default=data['duration']) - parser.add_argument('--inter_routing', - type=str, - default=data['inter_as_routing']) - parser.add_argument('--intra_routing', - type=str, - default=data['intra_as_routing']) - parser.add_argument('--cycle', type=int, default=data['cycle']) - parser.add_argument('--time_slot', type=int, default=100) - parser.add_argument('--sat_bandwidth', type=int, default=data['sat_bw']) + default=table['step (s)']) + parser.add_argument('--duration', type=int, default=(table['Duration (s)'] if 'Duration (s)' in table else 0)) + parser.add_argument('--sat_bandwidth', + type=int, + default=table['satellite link bandwidth ("X" Gbps)']) parser.add_argument('--sat_ground_bandwidth', type=int, - default=data['sat_ground_bw']) - parser.add_argument('--sat_loss', type=int, default=data['sat_loss']) - parser.add_argument('--sat_ground_loss', + default=table['sat-ground bandwidth ("X" Gbps)']) + parser.add_argument('--sat_loss', type=int, - default=data['sat_ground_loss']) - parser.add_argument('--ground_num', type=int, default=data['ground_num']) - parser.add_argument('--multi_machine', + default=table['satellite link loss ("X"% )']) + parser.add_argument('--sat_ground_loss', type=int, - default=data['multi_machine']) + default=table['sat-ground loss ("X"% )']) parser.add_argument('--antenna_number', type=int, - default=data['antenna_number']) - parser.add_argument('--antenna_inclination', + default=table['antenna number']) + parser.add_argument('--antenna_elevation', type=int, - default=data['antenna_inclination']) - parser.add_argument('--user_num', type=int, default=0) - parser.add_argument('--remote_machine_IP', - type=str, - default=data['remote_machine_IP']) - parser.add_argument('--remote_machine_username', - type=str, - default=data['remote_machine_username']) - parser.add_argument('--remote_machine_password', - type=str, - default=data['remote_machine_password']) - - parser.add_argument('--path', - '-p', - type=str, - default="starrynet/config.xls") - parser.add_argument('--hello_interval', '-i', type=int, default=10) - parser.add_argument('--node_number', '-n', type=int, default=27) - parser.add_argument('--GS', - '-g', - type=str, - default="50.110924/8.682127/46.635700/14.311817") - - sn_args = parser.parse_args() + default=table['antenna elevation angle']) + # TODO: parser.add_argument('--handover', default=table["Handover policy"]) + # TODO: parser.add_argument('--time_slot', type=int, default=100) + # TODO: parser.add_argument('--user_num', type=int, default=0) + # Library callers should not inherit unrelated process argv flags. + sn_args = parser.parse_args(args=[]) + sn_args.__setattr__('machine_lst', table['Machines']) + shell_lst = table['Shells'] + for shell in shell_lst: + # for compatibility + update_keys = [ + ('Altitude (km)', 'altitude'), ('Inclination', 'inclination'), + ('Phase shift', 'phase_shift'), ('Orbits', 'orbit'), ('Satellites per orbit', 'sat')] + for key, new_key in update_keys: + if key in shell: + shell[new_key] = shell[key] + sn_args.__setattr__('shell_lst', shell_lst) return sn_args - -def sn_get_param(file_): - f = open(file_) - ADJ = f.readlines() - for i in range(len(ADJ)): - ADJ[i] = ADJ[i].strip('\n') - ADJ = [x.split(',') for x in ADJ] - f.close() - return ADJ - - -def sn_init_remote_machine(host, username, password): - # transport = paramiko.Transport((host, 22)) - # transport.connect(username=username, password=password) - remote_machine_ssh = paramiko.SSHClient() - # remote_machine_ssh._transport = transport - remote_machine_ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - remote_machine_ssh.connect(hostname=host, - port=22, - username=username, - password=password) - transport = paramiko.Transport((host, 22)) - transport.connect(username=username, password=password) - return remote_machine_ssh, transport - # transport.close() - - -def sn_init_remote_ftp(transport): - ftp_client = paramiko.SFTPClient.from_transport(transport) ## ftp client - return ftp_client - +def sn_connect_remote(host, port, username, password): + remote_ssh = paramiko.SSHClient() + remote_ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + remote_ssh.connect(hostname=host, port=port, username=username, password=password) + return remote_ssh, remote_ssh.open_sftp() def sn_remote_cmd(remote_ssh, cmd): - stdin, stdout, stderr = remote_ssh.exec_command(cmd, get_pty=True) - lines = stdout.readlines() - return lines - - -# A thread designed for initializing working directory. -class sn_init_directory_thread(threading.Thread): - - def __init__(self, file_path, configuration_file_path, remote_ssh): - threading.Thread.__init__(self) - self.file_path = file_path - self.remote_ssh = remote_ssh - self.configuration_file_path = configuration_file_path - - def run(self): - # Reset docker environment. - os.system("rm " + self.configuration_file_path + "/" + self.file_path + - "/*.txt") - if os.path.exists(self.file_path + "/mid_files") == False: - os.system("mkdir " + self.configuration_file_path + "/" + - self.file_path) - os.system("mkdir " + self.configuration_file_path + "/" + - self.file_path + "/delay") - os.system("mkdir " + self.configuration_file_path + "/" + - self.file_path + "/mid_files") - sn_remote_cmd(self.remote_ssh, "mkdir ~/" + self.file_path) - sn_remote_cmd(self.remote_ssh, "mkdir ~/" + self.file_path + "/delay") - - -# A thread designed for initializing constellation nodes. -class sn_Node_Init_Thread(threading.Thread): - - def __init__(self, remote_ssh, docker_service_name, node_size, - container_id_list, container_global_idx): - threading.Thread.__init__(self) - self.remote_ssh = remote_ssh - self.docker_service_name = docker_service_name - self.node_size = node_size - self.container_global_idx = container_global_idx - self.container_id_list = copy.deepcopy(container_id_list) - - def run(self): - - # Reset docker environment. - sn_reset_docker_env(self.remote_ssh, self.docker_service_name, - self.node_size) - # Get container list in each machine. - self.container_id_list = sn_get_container_info(self.remote_ssh) - # Rename all containers with the global idx - sn_rename_all_container(self.remote_ssh, self.container_id_list, - self.container_global_idx) - - -def sn_get_container_info(remote_machine_ssh): - # Read all container information in all_container_info - all_container_info = sn_remote_cmd(remote_machine_ssh, "docker ps") - n_container = len(all_container_info) - 1 - container_id_list = [] - for container_idx in range(1, n_container + 1): - container_id_list.append(all_container_info[container_idx].split()[0]) - - return container_id_list - - -def sn_delete_remote_network_bridge(remote_ssh): - all_br_info = sn_remote_cmd(remote_ssh, "docker network ls") - for line in all_br_info: - if "La" in line or "Le" in line or "GS" in line: - network_name = line.split()[1] - print('docker network rm ' + network_name) - sn_remote_cmd(remote_ssh, 'docker network rm ' + network_name) - - -def sn_reset_docker_env(remote_ssh, docker_service_name, node_size): - print("Reset docker environment for constellation emulation ...") - print("Remove legacy containers.") - print(sn_remote_cmd(remote_ssh, - "docker service rm " + docker_service_name)) - print(sn_remote_cmd(remote_ssh, "docker rm -f $(docker ps -a -q)")) - print("Remove legacy emulated ISLs.") - sn_delete_remote_network_bridge(remote_ssh) - print("Creating new containers...") - sn_remote_cmd( - remote_ssh, "docker service create --replicas " + str(node_size) + - " --name " + str(docker_service_name) + - " --cap-add ALL lwsen/starlab_node:1.0 ping www.baidu.com") - - -def sn_rename_all_container(remote_ssh, container_id_list, new_idx): - print("Rename all containers ...") - new_idx = 1 - for container_id in container_id_list: - sn_remote_cmd( - remote_ssh, "docker rename " + str(container_id) + - " ovs_container_" + str(new_idx)) - new_idx = new_idx + 1 - - -# A thread designed for initializing constellation links. -class sn_Link_Init_Thread(threading.Thread): - - def __init__(self, remote_ssh, remote_ftp, orbit_num, sat_num, - constellation_size, fac_num, file_path, - configuration_file_path, sat_bandwidth, sat_ground_bandwidth, - sat_loss, sat_ground_loss): - threading.Thread.__init__(self) - self.remote_ssh = remote_ssh - self.constellation_size = constellation_size - self.fac_num = fac_num - self.orbit_num = orbit_num - self.sat_num = sat_num - self.file_path = file_path - self.configuration_file_path = configuration_file_path - self.sat_bandwidth = sat_bandwidth - self.sat_ground_bandwidth = sat_ground_bandwidth - self.sat_loss = sat_loss - self.sat_ground_loss = sat_ground_loss - self.remote_ftp = remote_ftp - - def run(self): - print('Run in link init thread.') - self.remote_ftp.put( - os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - self.file_path + "/sn_orchestrater.py") - self.remote_ftp.put( - self.configuration_file_path + "/" + self.file_path + - '/delay/1.txt', self.file_path + "/1.txt") - print('Initializing links ...') - sn_remote_cmd( - self.remote_ssh, "python3 " + self.file_path + - "/sn_orchestrater.py" + " " + str(self.orbit_num) + " " + - str(self.sat_num) + " " + str(self.constellation_size) + " " + - str(self.fac_num) + " " + str(self.sat_bandwidth) + " " + - str(self.sat_loss) + " " + str(self.sat_ground_bandwidth) + " " + - str(self.sat_ground_loss) + " " + self.file_path + "/1.txt") - - -# A thread designed for initializing bird routing. -class sn_Routing_Init_Thread(threading.Thread): - - def __init__(self, remote_ssh, remote_ftp, orbit_num, sat_num, - constellation_size, fac_num, file_path, sat_bandwidth, - sat_ground_bandwidth, sat_loss, sat_ground_loss): - threading.Thread.__init__(self) - self.remote_ssh = remote_ssh - self.constellation_size = constellation_size - self.fac_num = fac_num - self.orbit_num = orbit_num - self.sat_num = sat_num - self.file_path = file_path - self.sat_bandwidth = sat_bandwidth - self.sat_ground_bandwidth = sat_ground_bandwidth - self.sat_loss = sat_loss - self.sat_ground_loss = sat_ground_loss - self.remote_ftp = remote_ftp - - def run(self): - print( - "Copy bird configuration file to each container and run routing process." - ) - self.remote_ftp.put( - os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - self.file_path + "/sn_orchestrater.py") - print('Initializing routing ...') - sn_remote_cmd( - self.remote_ssh, "python3 " + self.file_path + - "/sn_orchestrater.py" + " " + str(self.constellation_size) + " " + - str(self.fac_num) + " " + self.file_path) - print("Routing initialized!") - - -# A thread designed for emulation. -class sn_Emulation_Start_Thread(threading.Thread): - - def __init__(self, remote_ssh, remote_ftp, sat_loss, sat_ground_bw, - sat_ground_loss, container_id_list, file_path, - configuration_file_path, update_interval, constellation_size, - ping_src, ping_des, ping_time, sr_src, sr_des, sr_target, - sr_time, damage_ratio, damage_time, damage_list, - recovery_time, route_src, route_time, duration, - utility_checking_time, perf_src, perf_des, perf_time): - threading.Thread.__init__(self) - self.remote_ssh = remote_ssh - self.remote_ftp = remote_ftp - self.sat_loss = sat_loss - self.sat_ground_bw = sat_ground_bw - self.sat_ground_loss = sat_ground_loss - self.container_id_list = copy.deepcopy(container_id_list) - self.file_path = file_path - self.configuration_file_path = configuration_file_path - self.update_interval = update_interval - self.constellation_size = constellation_size - self.ping_src = ping_src - self.ping_des = ping_des - self.ping_time = ping_time - self.perf_src = perf_src - self.perf_des = perf_des - self.perf_time = perf_time - self.sr_src = sr_src - self.sr_des = sr_des - self.sr_target = sr_target - self.sr_time = sr_time - self.damage_ratio = damage_ratio - self.damage_time = damage_time - self.damage_list = damage_list - self.recovery_time = recovery_time - self.route_src = route_src - self.route_time = route_time - self.duration = duration - self.utility_checking_time = utility_checking_time - if self.container_id_list == []: - self.container_id_list = sn_get_container_info(self.remote_ssh) - - def run(self): - ping_threads = [] - perf_threads = [] - timeptr = 2 # current emulating time - topo_change_file_path = self.configuration_file_path + "/" + self.file_path + '/Topo_leo_change.txt' - fi = open(topo_change_file_path, 'r') - line = fi.readline() - while line: # starting reading change information and emulating - words = line.split() - if words[0] == 'time': - print('Emulation in No.' + str(timeptr) + ' second.') - # the time when the new change occurrs - current_time = str(int(words[1][:-1])) - while int(current_time) > timeptr: - start_time = time.time() - if timeptr in self.utility_checking_time: - sn_check_utility( - timeptr, self.remote_ssh, - self.configuration_file_path + "/" + - self.file_path) - if timeptr % self.update_interval == 0: - # updating link delays after link changes - sn_update_delay(self.file_path, - self.configuration_file_path, timeptr, - self.constellation_size, - self.remote_ssh, self.remote_ftp) - if timeptr in self.damage_time: - sn_damage( - self.damage_ratio[self.damage_time.index(timeptr)], - self.damage_list, self.constellation_size, - self.remote_ssh, self.remote_ftp, self.file_path, - self.configuration_file_path) - if timeptr in self.recovery_time: - sn_recover(self.damage_list, self.sat_loss, - self.remote_ssh, self.remote_ftp, - self.file_path, - self.configuration_file_path) - if timeptr in self.sr_time: - index = [ - i for i, val in enumerate(self.sr_time) - if val == timeptr - ] - for index_num in index: - sn_sr(self.sr_src[index_num], - self.sr_des[index_num], - self.sr_target[index_num], - self.container_id_list, self.remote_ssh) - if timeptr in self.ping_time: - if timeptr in self.ping_time: - index = [ - i for i, val in enumerate(self.ping_time) - if val == timeptr - ] - for index_num in index: - ping_thread = threading.Thread( - target=sn_ping, - args=(self.ping_src[index_num], - self.ping_des[index_num], - self.ping_time[index_num], - self.constellation_size, - self.container_id_list, - self.file_path, - self.configuration_file_path, - self.remote_ssh)) - ping_thread.start() - ping_threads.append(ping_thread) - if timeptr in self.perf_time: - if timeptr in self.perf_time: - index = [ - i for i, val in enumerate(self.perf_time) - if val == timeptr - ] - for index_num in index: - perf_thread = threading.Thread( - target=sn_perf, - args=(self.perf_src[index_num], - self.perf_des[index_num], - self.perf_time[index_num], - self.constellation_size, - self.container_id_list, - self.file_path, - self.configuration_file_path, - self.remote_ssh)) - perf_thread.start() - perf_threads.append(perf_thread) - if timeptr in self.route_time: - index = [ - i for i, val in enumerate(self.route_time) - if val == timeptr - ] - for index_num in index: - sn_route(self.route_src[index_num], - self.route_time[index_num], - self.file_path, - self.configuration_file_path, - self.container_id_list, self.remote_ssh) - timeptr += 1 - end_time = time.time() - passed_time = ( - end_time - - start_time) if (end_time - start_time) < 1 else 1 - sleep(1 - passed_time) - if timeptr >= self.duration: - return - print('Emulation in No.' + str(timeptr) + ' second.') - print("A change in time " + current_time + ':') - line = fi.readline() - words = line.split() - line = fi.readline() - line = fi.readline() - words = line.split() - while words[0] != 'del:': # addlink - word = words[0].split('-') - s = int(word[0]) - f = int(word[1]) - if s > f: - tmp = s - s = f - f = tmp - print("add link", s, f) - current_topo_path = self.configuration_file_path + "/" + self.file_path + '/delay/' + str( - current_time) + '.txt' - matrix = sn_get_param(current_topo_path) - sn_establish_new_GSL(self.container_id_list, matrix, - self.constellation_size, - self.sat_ground_bw, - self.sat_ground_loss, s, f, - self.remote_ssh) - line = fi.readline() - words = line.split() - line = fi.readline() - words = line.split() - if len(words) == 0: - return - while words[0] != 'time': # delete link - word = words[0].split('-') - s = int(word[0]) - f = int(word[1]) - if s > f: - tmp = s - s = f - f = tmp - print("del link " + str(s) + "-" + str(f) + "\n") - sn_del_link(s, f, self.container_id_list, self.remote_ssh) - line = fi.readline() - words = line.split() - if len(words) == 0: - return - if timeptr in self.utility_checking_time: - sn_check_utility( - timeptr, self.remote_ssh, - self.configuration_file_path + "/" + self.file_path) - if timeptr % self.update_interval == 0: - # updating link delays after link changes - sn_update_delay(self.file_path, - self.configuration_file_path, timeptr, - self.constellation_size, self.remote_ssh, - self.remote_ftp) - if timeptr in self.damage_time: - sn_damage( - self.damage_ratio[self.damage_time.index(timeptr)], - self.damage_list, self.constellation_size, - self.remote_ssh, self.remote_ftp, self.file_path, - self.configuration_file_path) - if timeptr in self.recovery_time: - sn_recover(self.damage_list, self.sat_loss, - self.remote_ssh, self.remote_ftp, - self.file_path, self.configuration_file_path) - if timeptr in self.sr_time: - index = [ - i for i, val in enumerate(self.sr_time) - if val == timeptr - ] - for index_num in index: - sn_sr(self.sr_src[index_num], self.sr_des[index_num], - self.sr_target[index_num], - self.container_id_list, self.remote_ssh) - if timeptr in self.ping_time: - if timeptr in self.ping_time: - index = [ - i for i, val in enumerate(self.ping_time) - if val == timeptr - ] - for index_num in index: - ping_thread = threading.Thread( - target=sn_ping, - args=(self.ping_src[index_num], - self.ping_des[index_num], - self.ping_time[index_num], - self.constellation_size, - self.container_id_list, self.file_path, - self.configuration_file_path, - self.remote_ssh)) - ping_thread.start() - ping_threads.append(ping_thread) - if timeptr in self.perf_time: - if timeptr in self.perf_time: - index = [ - i for i, val in enumerate(self.perf_time) - if val == timeptr - ] - for index_num in index: - perf_thread = threading.Thread( - target=sn_perf, - args=(self.perf_src[index_num], - self.perf_des[index_num], - self.perf_time[index_num], - self.constellation_size, - self.container_id_list, self.file_path, - self.configuration_file_path, - self.remote_ssh)) - perf_thread.start() - perf_threads.append(perf_thread) - if timeptr in self.route_time: - index = [ - i for i, val in enumerate(self.route_time) - if val == timeptr - ] - for index_num in index: - sn_route(self.route_src[index_num], - self.route_time[index_num], self.file_path, - self.configuration_file_path, - self.container_id_list, self.remote_ssh) - timeptr += 1 # current emulating time - if timeptr >= self.duration: - return - fi.close() - for ping_thread in ping_threads: - ping_thread.join() - for perf_thread in perf_threads: - perf_thread.join() - - -def sn_check_utility(time_index, remote_ssh, file_path): - result = sn_remote_cmd(remote_ssh, "vmstat") - f = open(file_path + "/utility-info" + "_" + str(time_index) + ".txt", "w") - f.writelines(result) - f.close() - - -def sn_update_delay(file_path, configuration_file_path, timeptr, - constellation_size, remote_ssh, - remote_ftp): # updating delays - remote_ftp.put(os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - file_path + "/sn_orchestrater.py") - remote_ftp.put( - configuration_file_path + "/" + file_path + '/delay/' + str(timeptr) + - '.txt', file_path + '/' + str(timeptr) + '.txt') - sn_remote_cmd( - remote_ssh, - "python3 " + file_path + "/sn_orchestrater.py " + file_path + '/' + - str(timeptr) + '.txt ' + str(constellation_size) + " update") - print("Delay updating done.\n") - - -def sn_damage(ratio, damage_list, constellation_size, remote_ssh, remote_ftp, - file_path, configuration_file_path): - print("Randomly setting damaged links...\n") - random_list = [] - cumulated_damage_list = damage_list - while len(random_list) < (int(constellation_size * ratio)): - target = int(random.uniform(0, constellation_size - 1)) - random_list.append(target) - cumulated_damage_list.append(target) - numpy.savetxt( - configuration_file_path + "/" + file_path + - '/mid_files/damage_list.txt', random_list) - remote_ftp.put(os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - file_path + "/sn_orchestrater.py") - remote_ftp.put( - configuration_file_path + "/" + file_path + - '/mid_files/damage_list.txt', file_path + "/damage_list.txt") - sn_remote_cmd(remote_ssh, - "python3 " + file_path + "/sn_orchestrater.py " + file_path) - print("Damage done.\n") - - -def sn_recover(damage_list, sat_loss, remote_ssh, remote_ftp, file_path, - configuration_file_path): - print("Recovering damaged links...\n") - cumulated_damage_list = damage_list - numpy.savetxt( - configuration_file_path + "/" + file_path + - '/mid_files/damage_list.txt', cumulated_damage_list) - remote_ftp.put(os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - file_path + "/sn_orchestrater.py") - remote_ftp.put( - configuration_file_path + "/" + file_path + - '/mid_files/damage_list.txt', file_path + "/damage_list.txt") - sn_remote_cmd( - remote_ssh, "python3 " + file_path + "/sn_orchestrater.py " + - file_path + " " + str(sat_loss)) - cumulated_damage_list.clear() - print("Link recover done.\n") - - -def sn_sr(src, des, target, container_id_list, remote_ssh): - ifconfig_output = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig | sed 's/[ \t].*//;/^\(eth0\|\)\(lo\|\)$/d'") - des_IP = sn_remote_cmd( - remote_ssh, - "docker exec -it " + str(container_id_list[des - 1]) + " ifconfig " + - ifconfig_output[0][:-1] + "|awk -F '[ :]+' 'NR==2{print $4}'") - target_IP = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[target - 1]) + - " ifconfig B" + str(target) + "-eth" + str(src) + - "|awk -F '[ :]+' 'NR==2{print $4}'") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[src - 1]) + - " ip route del " + str(des_IP[0][:-3]) + "0/24") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[src - 1]) + - " ip route add " + str(des_IP[0][:-3]) + "0/24 dev B%d-eth%d via " % - (src, target) + target_IP[0]) - print("docker exec -d " + str(container_id_list[src - 1]) + - " ip route add " + str(des_IP[0][:-3]) + "0/24 dev B%d-eth%d via " % - (src, target) + target_IP[0]) - - -def sn_ping(src, des, time_index, constellation_size, container_id_list, - file_path, configuration_file_path, remote_ssh): - if des <= constellation_size: - ifconfig_output = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig | sed 's/[ \t].*//;/^\(eth0\|\)\(lo\|\)$/d'") - des_IP = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig " + ifconfig_output[0][:-1] + - "|awk -F '[ :]+' 'NR==2{print $4}'") - else: - des_IP = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig B" + str(des) + - "-default |awk -F '[ :]+' 'NR==2{print $4}'") - ping_result = sn_remote_cmd( - remote_ssh, "docker exec -i " + str(container_id_list[src - 1]) + - " ping " + str(des_IP[0][:-1]) + " -c 4 -i 0.01 ") - f = open( - configuration_file_path + "/" + file_path + "/ping-" + str(src) + "-" + - str(des) + "_" + str(time_index) + ".txt", "w") - f.writelines(ping_result) - f.close() - - -def sn_perf(src, des, time_index, constellation_size, container_id_list, - file_path, configuration_file_path, remote_ssh): - if des <= constellation_size: - ifconfig_output = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig | sed 's/[ \t].*//;/^\(eth0\|\)\(lo\|\)$/d'") - des_IP = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig " + ifconfig_output[0][:-1] + - "|awk -F '[ :]+' 'NR==2{print $4}'") - else: - des_IP = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[des - 1]) + - " ifconfig B" + str(des) + - "-default |awk -F '[ :]+' 'NR==2{print $4}'") - - sn_remote_cmd( - remote_ssh, - "docker exec -id " + str(container_id_list[des - 1]) + " iperf3 -s ") - print("iperf server success") - perf_result = sn_remote_cmd( - remote_ssh, "docker exec -i " + str(container_id_list[src - 1]) + - " iperf3 -c " + str(des_IP[0][:-1]) + " -t 5 ") - print("iperf client success") - f = open( - configuration_file_path + "/" + file_path + "/perf-" + str(src) + "-" + - str(des) + "_" + str(time_index) + ".txt", "w") - f.writelines(perf_result) - f.close() - - -def sn_route(src, time_index, file_path, configuration_file_path, - container_id_list, remote_ssh): - route_result = sn_remote_cmd( - remote_ssh, - "docker exec -it " + str(container_id_list[src - 1]) + " route ") - f = open( - configuration_file_path + "/" + file_path + "/route-" + str(src) + - "_" + str(time_index) + ".txt", "w") - f.writelines(route_result) - f.close() - - -def sn_establish_new_GSL(container_id_list, matrix, constellation_size, bw, - loss, sat_index, GS_index, remote_ssh): - i = sat_index - j = GS_index - # IP address (there is a link between i and j) - delay = str(matrix[i - 1][j - 1]) - address_16_23 = (j - constellation_size) & 0xff - address_8_15 = i & 0xff - GSL_name = "GSL_" + str(i) + "-" + str(j) - # Create internal network in docker. - sn_remote_cmd( - remote_ssh, 'docker network create ' + GSL_name + " --subnet 9." + - str(address_16_23) + "." + str(address_8_15) + ".0/24") - print('[Create GSL:]' + 'docker network create ' + GSL_name + - " --subnet 9." + str(address_16_23) + "." + str(address_8_15) + - ".0/24") - sn_remote_cmd( - remote_ssh, 'docker network connect ' + GSL_name + " " + - str(container_id_list[i - 1]) + " --ip 9." + str(address_16_23) + "." + - str(address_8_15) + ".50") - ifconfig_output = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[i - 1]) + - " ip addr | grep -B 2 9." + str(address_16_23) + "." + - str(address_8_15) + - ".50 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") - target_interface = str(ifconfig_output[0]).split("@")[0] - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev " + target_interface + " down") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev " + target_interface + " name " + "B" + - str(i - 1 + 1) + "-eth" + str(j)) - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " ip link set dev B" + str(i - 1 + 1) + "-eth" + str(j) + " up") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " tc qdisc add dev B" + str(i - 1 + 1) + "-eth" + str(j) + - " root netem delay " + str(delay) + "ms") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " tc qdisc add dev B" + str(i - 1 + 1) + "-eth" + str(j) + - " root netem loss " + str(loss) + "%") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[i - 1]) + - " tc qdisc add dev B" + str(i - 1 + 1) + "-eth" + str(j) + - " root netem rate " + str(bw) + "Gbps") - print('[Add current node:]' + 'docker network connect ' + GSL_name + " " + - str(container_id_list[i - 1]) + " --ip 10." + str(address_16_23) + - "." + str(address_8_15) + ".50") - sn_remote_cmd( - remote_ssh, 'docker network connect ' + GSL_name + " " + - str(container_id_list[j - 1]) + " --ip 9." + str(address_16_23) + "." + - str(address_8_15) + ".60") - ifconfig_output = sn_remote_cmd( - remote_ssh, "docker exec -it " + str(container_id_list[j - 1]) + - " ip addr | grep -B 2 9." + str(address_16_23) + "." + - str(address_8_15) + - ".60 | head -n 1 | awk -F: '{ print $2 }' | tr -d [:blank:]") - target_interface = str(ifconfig_output[0]).split("@")[0] - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " down") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev " + target_interface + " name " + "B" + str(j) + - "-eth" + str(i - 1 + 1)) - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " ip link set dev B" + str(j) + "-eth" + str(i - 1 + 1) + " up") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " tc qdisc add dev B" + str(j) + "-eth" + str(i - 1 + 1) + - " root netem delay " + str(delay) + "ms") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " tc qdisc add dev B" + str(j) + "-eth" + str(i - 1 + 1) + - " root netem loss " + str(loss) + "%") - sn_remote_cmd( - remote_ssh, "docker exec -d " + str(container_id_list[j - 1]) + - " tc qdisc add dev B" + str(j) + "-eth" + str(i - 1 + 1) + - " root netem rate " + str(bw) + "Gbps") - print('[Add right node:]' + 'docker network connect ' + GSL_name + " " + - str(container_id_list[j - 1]) + " --ip 10." + str(address_16_23) + - "." + str(address_8_15) + ".60") - - -def sn_del_link(first_index, second_index, container_id_list, remote_ssh): - sn_remote_cmd( - remote_ssh, "docker exec -d " + - str(container_id_list[second_index - 1]) + " ip link set dev B" + - str(second_index) + "-eth" + str(first_index) + " down") - sn_remote_cmd( - remote_ssh, "docker exec -d " + - str(container_id_list[first_index - 1]) + " ip link set dev B" + - str(first_index) + "-eth" + str(second_index) + " down") - GSL_name = "GSL_" + str(first_index) + "-" + str(second_index) - sn_remote_cmd( - remote_ssh, 'docker network disconnect ' + GSL_name + " " + - str(container_id_list[first_index - 1])) - sn_remote_cmd( - remote_ssh, 'docker network disconnect ' + GSL_name + " " + - str(container_id_list[second_index - 1])) - sn_remote_cmd(remote_ssh, 'docker network rm ' + GSL_name) - - -# A thread designed for stopping the emulation. -class sn_Emulation_Stop_Thread(threading.Thread): - - def __init__(self, remote_ssh, remote_ftp, file_path): - threading.Thread.__init__(self) - self.remote_ssh = remote_ssh - self.remote_ftp = remote_ftp - self.file_path = file_path + return remote_ssh.exec_command(cmd)[1].read().decode().strip() - def run(self): - print("Deleting all native bridges and containers...") - self.remote_ftp.put( - os.path.join(os.getcwd(), "starrynet/sn_orchestrater.py"), - self.file_path + "/sn_orchestrater.py") - sn_remote_cmd(self.remote_ssh, - "python3 " + self.file_path + "/sn_orchestrater.py") +def sn_remote_wait_output(remote_ssh, cmd): + for line in remote_ssh.exec_command(cmd, get_pty=True)[1]: + print(line, end='') diff --git a/tools/requirements.txt b/tools/requirements.txt index 49fb3ac..de18124 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,8 +1,8 @@ setuptools -xlrd -argparse numpy -requests skyfield sgp4 -paramiko \ No newline at end of file +paramiko +fastapi +pydantic +uvicorn diff --git a/web/backend/app/__init__.py b/web/backend/app/__init__.py new file mode 100644 index 0000000..10fd1c7 --- /dev/null +++ b/web/backend/app/__init__.py @@ -0,0 +1 @@ +"""StarryNet backend application package.""" diff --git a/web/backend/app/api/__init__.py b/web/backend/app/api/__init__.py new file mode 100644 index 0000000..1ce04c3 --- /dev/null +++ b/web/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API routes.""" diff --git a/web/backend/app/api/routes.py b/web/backend/app/api/routes.py new file mode 100644 index 0000000..bf4195f --- /dev/null +++ b/web/backend/app/api/routes.py @@ -0,0 +1,393 @@ +import os +import re + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from ..core.dependencies import ARTIFACTS_DIR, get_current_user_id, get_runtime_manager, get_store +from ..core.configuration import write_bird_conf_artifact, write_config_artifact +from ..core.models import CoreEventType, EventCreate, EventUpdate, ExperimentCreate, ExperimentUpdate, RunStatus + +router = APIRouter() +NODE_NAME_RE = re.compile(r"^(SH\d+O\d+S\d+|GS\d+)$") + + +def _require_experiment(store, experiment_id: str, user_id: str): + experiment = store.get_experiment(experiment_id) + if experiment is None or experiment.owner_user_id != user_id: + raise HTTPException(status_code=404, detail="experiment not found") + return experiment + + +def _require_run(store, run_id: str, user_id: str): + run = store.get_run(run_id) + if run is None or run.owner_user_id != user_id: + raise HTTPException(status_code=404, detail="run not found") + return run + + +def _experiment_dir(experiment_id: str): + return ARTIFACTS_DIR / "experiments" / experiment_id + + +def _persist_experiment_artifacts(store, experiment): + experiment_dir = _experiment_dir(experiment.experiment_id) + config_path = experiment_dir / "experiment_config.json" + generated_config_path = write_config_artifact(experiment, config_path) + generated_bird_conf_path = write_bird_conf_artifact( + experiment, + experiment_dir / "bird.conf", + ) + return store.update_experiment_fields( + experiment.experiment_id, + config_path=generated_config_path, + bird_conf_path=generated_bird_conf_path, + ) + + +def _event_type_value(event_type): + return event_type.value if hasattr(event_type, "value") else str(event_type) + + +def _require_string_param(params, key: str, event_label: str, node_names=None): + value = params.get(key) + if not isinstance(value, str) or not value.strip(): + raise HTTPException(status_code=400, detail=f"{event_label} requires non-empty '{key}'") + value = value.strip() + if not NODE_NAME_RE.match(value): + raise HTTPException( + status_code=400, + detail=f"{event_label} parameter '{key}' must be a node name like SH1O1S1 or GS0", + ) + if node_names is not None and value not in node_names: + raise HTTPException( + status_code=400, + detail=f"{event_label} parameter '{key}' references unknown node '{value}'", + ) + return value + + +def _validate_optional_args(params, key: str, event_label: str): + if key not in params: + return + value = params[key] + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise HTTPException(status_code=400, detail=f"{event_label} optional '{key}' must be a list of strings") + + +def _validate_run_event(event, duration_s: int, node_names=None): + if event.time > duration_s: + raise HTTPException( + status_code=400, + detail=f"event time must be between 0 and experiment duration {duration_s}", + ) + + event_type = _event_type_value(event.event_type) + params = event.params or {} + + if event_type == CoreEventType.CHECK_ROUTING_TABLE.value: + _require_string_param(params, "node", "event", node_names) + elif event_type == CoreEventType.DAMAGE.value: + ratio = params.get("damaging_ratio") + if not isinstance(ratio, (int, float)) or isinstance(ratio, bool) or ratio < 0 or ratio > 1: + raise HTTPException(status_code=400, detail="event damaging_ratio must be between 0 and 1") + elif event_type == CoreEventType.STATIC_ROUTE.value: + for key in ("src", "dst", "next_hop"): + _require_string_param(params, key, "event", node_names) + elif event_type == CoreEventType.PING.value: + for key in ("src", "dst"): + _require_string_param(params, key, "event", node_names) + _validate_optional_args(params, "extra_args", "event") + elif event_type == CoreEventType.IPERF.value: + for key in ("src", "dst"): + _require_string_param(params, key, "event", node_names) + _validate_optional_args(params, "src_args", "event") + _validate_optional_args(params, "dst_args", "event") + + +@router.post("/experiments", status_code=status.HTTP_201_CREATED) +def create_experiment( + payload: ExperimentCreate, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + experiment = store.create_experiment(user_id, payload) + return _persist_experiment_artifacts(store, experiment) + + +@router.get("/experiments") +def list_experiments( + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + return store.list_experiments(user_id) + + +@router.get("/experiments/{experiment_id}") +def get_experiment( + experiment_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + return _require_experiment(store, experiment_id, user_id) + + +@router.patch("/experiments/{experiment_id}") +def update_experiment( + experiment_id: str, + payload: ExperimentUpdate, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + _require_experiment(store, experiment_id, user_id) + updated = store.update_experiment(experiment_id, payload) + if updated is None: + raise HTTPException(status_code=404, detail="experiment not found") + if ( + payload.configuration is not None + or payload.name is not None + or payload.bird_routing_enabled is not None + or payload.bird_conf_content is not None + ): + updated = _persist_experiment_artifacts(store, updated) + return updated + + +@router.delete("/experiments/{experiment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_experiment( + experiment_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + _require_experiment(store, experiment_id, user_id) + active_statuses = { + RunStatus.PROVISIONING, + RunStatus.ACTIVE, + RunStatus.STOPPING, + } + active_runs = [ + run + for run in store.list_runs(experiment_id=experiment_id, owner_user_id=user_id) + if run.status in active_statuses + ] + if active_runs: + raise HTTPException( + status_code=409, + detail="experiment has active runs; stop or clean them before deletion", + ) + if not store.delete_experiment(experiment_id): + raise HTTPException(status_code=404, detail="experiment not found") + + +@router.get("/experiments/{experiment_id}/runs") +def list_runs_for_experiment( + experiment_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + _require_experiment(store, experiment_id, user_id) + return store.list_runs(experiment_id=experiment_id, owner_user_id=user_id) + + +@router.post("/experiments/{experiment_id}/runs", status_code=status.HTTP_201_CREATED) +def create_run( + experiment_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + experiment = _require_experiment(store, experiment_id, user_id) + artifact_dir = ARTIFACTS_DIR / "experiments" / experiment_id / "runs" + os.makedirs(artifact_dir, exist_ok=True) + run = store.create_run(experiment, str(artifact_dir / "pending")) + final_artifact_dir = artifact_dir / run.run_id + os.makedirs(final_artifact_dir, exist_ok=True) + run = store.update_run(run.run_id, artifact_dir=str(final_artifact_dir)) + runtime_manager.get_or_create(experiment, run).ensure_runtime() + return run + + +@router.get("/runs/{run_id}") +def get_run( + run_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store)): + return _require_run(store, run_id, user_id) + + +@router.post("/runs/{run_id}/start") +def start_run( + run_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + if run.status not in (RunStatus.READY, RunStatus.FAILED): + raise HTTPException(status_code=409, detail=f"run cannot be started from status {run.status}") + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + managed.start() + return store.get_run(run_id) + + +@router.post("/runs/{run_id}/stop") +def stop_run( + run_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + managed.stop() + return store.get_run(run_id) + + +@router.post("/runs/{run_id}/cleanup") +def cleanup_run( + run_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + managed.cleanup() + return store.get_run(run_id) + + +@router.get("/runs/{run_id}/topology") +def get_run_topology( + run_id: str, + time: int = Query(default=0, ge=0), + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.get_topology_snapshot(at_time=time) + + +@router.get("/runs/{run_id}/map") +def get_run_map( + run_id: str, + time: int = Query(default=0, ge=0), + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.get_map_snapshot(at_time=time) + + +@router.get("/runs/{run_id}/nodes") +def get_run_nodes( + run_id: str, + time: int = Query(default=0, ge=0), + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.list_nodes(at_time=time) + + +@router.get("/runs/{run_id}/events") +def list_run_events( + run_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.list_events() + + +@router.post("/runs/{run_id}/events", status_code=status.HTTP_201_CREATED) +def create_run_event( + run_id: str, + payload: EventCreate, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + _validate_run_event(payload, experiment.configuration.duration_s, managed.node_names()) + try: + return managed.schedule_event(payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/runs/{run_id}/events/{event_id}") +def update_run_event( + run_id: str, + event_id: str, + payload: EventUpdate, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + _validate_run_event(payload, experiment.configuration.duration_s, managed.node_names()) + try: + return managed.update_event(event_id, payload) + except KeyError as exc: + raise HTTPException(status_code=404, detail="event not found") from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.delete("/runs/{run_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_run_event( + run_id: str, + event_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + try: + managed.delete_event(event_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="event not found") from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get("/runs/{run_id}/tasks") +def list_run_tasks( + run_id: str, + node: str | None = Query(default=None), + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.list_tasks(node=node) + + +@router.get("/runs/{run_id}/tasks/{task_id}") +def get_run_task( + run_id: str, + task_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.get_task(task_id) + + +@router.get("/runs/{run_id}/tasks/{task_id}/output") +def get_run_task_output( + run_id: str, + task_id: str, + user_id: str = Depends(get_current_user_id), + store=Depends(get_store), + runtime_manager=Depends(get_runtime_manager)): + run = _require_run(store, run_id, user_id) + experiment = _require_experiment(store, run.experiment_id, user_id) + managed = runtime_manager.get_or_create(experiment, run) + return managed.get_task_output(task_id) diff --git a/web/backend/app/core/__init__.py b/web/backend/app/core/__init__.py new file mode 100644 index 0000000..3fb528a --- /dev/null +++ b/web/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core backend services and models.""" diff --git a/web/backend/app/core/configuration.py b/web/backend/app/core/configuration.py new file mode 100644 index 0000000..3be8ccf --- /dev/null +++ b/web/backend/app/core/configuration.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path + +from .models import ExperimentRecord + + +ROOT = Path(__file__).resolve().parents[4] +DEFAULT_CONFIG_TEMPLATE = ROOT / "config.json" +DEFAULT_BIRD_CONFIG_TEMPLATE = ROOT / "bird.conf" + + +def build_config_payload(experiment: ExperimentRecord): + cfg = experiment.configuration + with DEFAULT_CONFIG_TEMPLATE.open("r", encoding="utf-8") as fh: + template = json.load(fh) + + template["Name"] = experiment.name + template["Shells"] = [ + { + "Altitude (km)": shell.altitude_km, + "Inclination": shell.inclination, + "Orbits": shell.orbits, + "Satellites per orbit": shell.satellites_per_orbit, + "Phase shift": shell.phase_shift, + } + for shell in cfg.shells + ] + template["Duration (s)"] = cfg.duration_s + template["step (s)"] = cfg.step_s + template['satellite link bandwidth ("X" Gbps)'] = cfg.satellite_link_bandwidth_gbps + template['sat-ground bandwidth ("X" Gbps)'] = cfg.sat_ground_bandwidth_gbps + template['satellite link loss ("X"% )'] = cfg.satellite_link_loss_percent + template['sat-ground loss ("X"% )'] = cfg.sat_ground_loss_percent + template["antenna number"] = cfg.antenna_number + template["antenna elevation angle"] = cfg.antenna_elevation_angle + template["Satellite link"] = cfg.satellite_link + template["IP version"] = cfg.ip_version + template["Link policy"] = cfg.link_policy + template["Handover policy"] = cfg.handover_policy + return template + + +def write_config_artifact(experiment: ExperimentRecord, config_path: Path): + config_path.parent.mkdir(parents=True, exist_ok=True) + payload = build_config_payload(experiment) + with config_path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + return str(config_path) + + +def write_bird_conf_artifact(experiment: ExperimentRecord, bird_conf_path: Path): + bird_conf_path.parent.mkdir(parents=True, exist_ok=True) + if not experiment.bird_routing_enabled: + if bird_conf_path.exists(): + bird_conf_path.unlink() + return None + content = experiment.bird_conf_content + if content is None or not content.strip(): + content = DEFAULT_BIRD_CONFIG_TEMPLATE.read_text(encoding="utf-8") + with bird_conf_path.open("w", encoding="utf-8") as fh: + fh.write(content) + return str(bird_conf_path) diff --git a/web/backend/app/core/dependencies.py b/web/backend/app/core/dependencies.py new file mode 100644 index 0000000..52365b4 --- /dev/null +++ b/web/backend/app/core/dependencies.py @@ -0,0 +1,34 @@ +import os +from functools import lru_cache +from pathlib import Path + +from fastapi import Header, HTTPException, status + +from .runtime import RuntimeManager +from .store import MetadataStore + + +ROOT = Path(__file__).resolve().parents[4] +DATA_DIR = ROOT / "web" / "backend" / "data" +ARTIFACTS_DIR = DATA_DIR / "artifacts" +METADATA_PATH = DATA_DIR / "metadata.json" + + +def get_current_user_id(x_user_id: str = Header(default=None)): + if not x_user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-User-Id header is required", + ) + return x_user_id + + +@lru_cache(maxsize=1) +def get_store(): + os.makedirs(ARTIFACTS_DIR, exist_ok=True) + return MetadataStore(str(METADATA_PATH)) + + +@lru_cache(maxsize=1) +def get_runtime_manager(): + return RuntimeManager(get_store()) diff --git a/web/backend/app/core/models.py b/web/backend/app/core/models.py new file mode 100644 index 0000000..896e516 --- /dev/null +++ b/web/backend/app/core/models.py @@ -0,0 +1,264 @@ +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + + +SATELLITE_LINK_OPTIONS = {"Grid"} +LINK_POLICY_OPTIONS = {"LeastDelay"} + + +class ShellDefinition(BaseModel): + altitude_km: float = Field(gt=0) + inclination: float + orbits: int = Field(gt=0) + satellites_per_orbit: int = Field(gt=0) + phase_shift: int = Field(ge=0) + + +class ExperimentConfiguration(BaseModel): + shells: List[ShellDefinition] = Field(min_length=1) + duration_s: int = Field(gt=0) + step_s: int = Field(gt=0) + satellite_link_bandwidth_gbps: int = Field(gt=0) + sat_ground_bandwidth_gbps: int = Field(gt=0) + satellite_link_loss_percent: int = Field(ge=0, le=100) + sat_ground_loss_percent: int = Field(ge=0, le=100) + antenna_number: int = Field(gt=0) + antenna_elevation_angle: int + satellite_link: str + ip_version: str + link_policy: str + handover_policy: str = "instant handover" + + @field_validator("satellite_link") + @classmethod + def validate_satellite_link(cls, value: str): + if value not in SATELLITE_LINK_OPTIONS: + options = ", ".join(sorted(SATELLITE_LINK_OPTIONS)) + raise ValueError(f"satellite_link must be one of: {options}") + return value + + @field_validator("link_policy") + @classmethod + def validate_link_policy(cls, value: str): + if value not in LINK_POLICY_OPTIONS: + options = ", ".join(sorted(LINK_POLICY_OPTIONS)) + raise ValueError(f"link_policy must be one of: {options}") + return value + + +class ExperimentStatus(str, Enum): + DRAFT = "draft" + READY = "ready" + ARCHIVED = "archived" + + +class RunStatus(str, Enum): + READY = "ready" + PROVISIONING = "provisioning" + ACTIVE = "active" + STOPPING = "stopping" + COMPLETED = "completed" + FAILED = "failed" + CLEANED = "cleaned" + + +class CoreEventType(str, Enum): + CHECK_UTILITY = "check_utility" + CHECK_ROUTING_TABLE = "check_routing_table" + DAMAGE = "damage" + RECOVERY = "recovery" + STATIC_ROUTE = "static_route" + PING = "ping" + IPERF = "iperf" + + +class ExperimentRecord(BaseModel): + experiment_id: str + owner_user_id: str + name: str + configuration: ExperimentConfiguration + config_path: str + gs_lat_long: List[List[float]] + bird_routing_enabled: bool = False + bird_conf_content: Optional[str] = None + bird_conf_path: Optional[str] = None + extra_nodes_links: Dict[str, List[str]] = Field(default_factory=dict) + status: ExperimentStatus = ExperimentStatus.READY + created_at: float + updated_at: float + + @model_validator(mode="before") + @classmethod + def infer_legacy_routing_enabled(cls, data): + if isinstance(data, dict) and "bird_routing_enabled" not in data and data.get("bird_conf_path"): + return {**data, "bird_routing_enabled": True} + return data + + +class RunRecord(BaseModel): + run_id: str + experiment_id: str + owner_user_id: str + status: RunStatus = RunStatus.READY + artifact_dir: str + created_at: float + updated_at: float + started_at: Optional[float] = None + finished_at: Optional[float] = None + error: Optional[str] = None + + +class ExperimentCreate(BaseModel): + name: str + configuration: ExperimentConfiguration + gs_lat_long: List[List[float]] = Field(min_length=1) + bird_routing_enabled: bool = False + bird_conf_content: Optional[str] = None + extra_nodes_links: Dict[str, List[str]] = Field(default_factory=dict) + + @field_validator("gs_lat_long") + @classmethod + def validate_gs_lat_long(cls, value: List[List[float]]): + return _validate_gs_lat_long(value) + + +class ExperimentUpdate(BaseModel): + name: Optional[str] = None + configuration: Optional[ExperimentConfiguration] = None + gs_lat_long: Optional[List[List[float]]] = None + bird_routing_enabled: Optional[bool] = None + bird_conf_content: Optional[str] = None + extra_nodes_links: Optional[Dict[str, List[str]]] = None + status: Optional[ExperimentStatus] = None + + @field_validator("gs_lat_long") + @classmethod + def validate_optional_gs_lat_long(cls, value: Optional[List[List[float]]]): + if value is None: + return value + return _validate_gs_lat_long(value) + + +def _validate_gs_lat_long(value: List[List[float]]): + if len(value) == 0: + raise ValueError("at least one ground station is required") + for index, point in enumerate(value): + if len(point) != 2: + raise ValueError(f"GS{index} must contain latitude and longitude") + lat, lon = point + if lat < -90 or lat > 90: + raise ValueError(f"GS{index} latitude must be between -90 and 90") + if lon < -180 or lon > 180: + raise ValueError(f"GS{index} longitude must be between -180 and 180") + return value + + +class RunCreate(BaseModel): + pass + + +class EventCreate(BaseModel): + time: int = Field(ge=0) + event_type: CoreEventType + params: Dict[str, Any] = Field(default_factory=dict) + + +class EventUpdate(BaseModel): + time: int = Field(ge=0) + event_type: CoreEventType + params: Dict[str, Any] = Field(default_factory=dict) + + +class TopologyNode(BaseModel): + name: str + node_type: str + position: Optional[List[float]] = None + neighbors: List[str] = Field(default_factory=list) + ground_stations: List[str] = Field(default_factory=list) + ipv4: Optional[str] = None + ipv6: Optional[str] = None + + +class TopologyLink(BaseModel): + source: str + target: str + link_type: str + source_ipv4: Optional[str] = None + target_ipv4: Optional[str] = None + source_ipv6: Optional[str] = None + target_ipv6: Optional[str] = None + + +class TopologySnapshot(BaseModel): + run_id: str + time: int + nodes: List[TopologyNode] = Field(default_factory=list) + links: List[TopologyLink] = Field(default_factory=list) + + +class MapEntity(BaseModel): + id: str + name: str + entity_type: str + lat: float + lon: float + altitude_km: float = 0 + status: str = "nominal" + neighbor_count: int = 0 + connected_ground_stations: List[str] = Field(default_factory=list) + ipv4: Optional[str] = None + ipv6: Optional[str] = None + shell: Optional[int] = None + orbit: Optional[int] = None + satellite: Optional[int] = None + + +class MapLinkEndpoint(BaseModel): + id: str + lat: float + lon: float + + +class MapLink(BaseModel): + source: str + target: str + link_type: str + source_ipv4: Optional[str] = None + target_ipv4: Optional[str] = None + source_ipv6: Optional[str] = None + target_ipv6: Optional[str] = None + endpoints: List[MapLinkEndpoint] = Field(default_factory=list) + + +class MapOverlay(BaseModel): + id: str + overlay_type: str + status: str + time: Optional[int] = None + label: str + entity_ids: List[str] = Field(default_factory=list) + link_ids: List[str] = Field(default_factory=list) + severity: str = "info" + payload: Dict[str, Any] = Field(default_factory=dict) + + +class MapSummary(BaseModel): + satellites: int = 0 + ground_stations: int = 0 + extra_nodes: int = 0 + links: int = 0 + overlays: int = 0 + + +class MapSnapshot(BaseModel): + run_id: str + experiment_id: str + time: int + duration_s: int + step_s: int + entities: List[MapEntity] = Field(default_factory=list) + links: List[MapLink] = Field(default_factory=list) + overlays: List[MapOverlay] = Field(default_factory=list) + summary: MapSummary = Field(default_factory=MapSummary) diff --git a/web/backend/app/core/runtime.py b/web/backend/app/core/runtime.py new file mode 100644 index 0000000..efb51e8 --- /dev/null +++ b/web/backend/app/core/runtime.py @@ -0,0 +1,515 @@ +import os +import re +import threading +import time +from pathlib import Path +from typing import Dict, Optional + +from starrynet.sn_synchronizer import StarryNet + +from .models import ( + EventCreate, + ExperimentRecord, + MapEntity, + MapLink, + MapLinkEndpoint, + MapOverlay, + MapSnapshot, + MapSummary, + RunRecord, + RunStatus, + TopologyLink, + TopologyNode, + TopologySnapshot, +) +from .store import MetadataStore + + +SATELLITE_NAME_RE = re.compile(r"^SH(?P\d+)O(?P\d+)S(?P\d+)$") + + +class ManagedRun: + def __init__(self, experiment: ExperimentRecord, run: RunRecord, store: MetadataStore): + self.experiment = experiment + self.run = run + self.store = store + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._runtime: Optional[StarryNet] = None + + def ensure_runtime(self): + with self._lock: + if self._runtime is None: + runtime_root = os.path.join(self.run.artifact_dir, "runtime") + os.makedirs(runtime_root, exist_ok=True) + self._runtime = StarryNet( + self.experiment.config_path, + self.experiment.gs_lat_long, + extra_nodes_links=self.experiment.extra_nodes_links, + run_id=self.run.run_id, + artifact_root=runtime_root, + ) + for event in self.store.list_events(self.run.run_id): + self._runtime.restore_event(event) + return self._runtime + + def start(self): + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + self.store.update_run( + self.run.run_id, + status=RunStatus.PROVISIONING, + error=None, + started_at=time.time(), + finished_at=None, + ) + self.run = self.store.get_run(self.run.run_id) + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def _run_loop(self): + runtime = self.ensure_runtime() + try: + runtime.create_nodes() + runtime.create_links() + if self.experiment.bird_routing_enabled and self.experiment.bird_conf_path: + runtime.run_routing_daemon(self.experiment.bird_conf_path) + self.store.update_run(self.run.run_id, status=RunStatus.ACTIVE) + runtime.start_emulation() + final_status = RunStatus.CLEANED if runtime._stop_event.is_set() else RunStatus.COMPLETED + self.store.update_run( + self.run.run_id, + status=final_status, + finished_at=time.time(), + ) + except Exception as exc: + current = self.store.get_run(self.run.run_id) + if current is not None and current.status in (RunStatus.STOPPING, RunStatus.CLEANED): + self.store.update_run( + self.run.run_id, + status=RunStatus.CLEANED, + finished_at=time.time(), + ) + else: + self.store.update_run( + self.run.run_id, + status=RunStatus.FAILED, + error=str(exc), + finished_at=time.time(), + ) + + def stop(self): + runtime = self.ensure_runtime() + self.store.update_run(self.run.run_id, status=RunStatus.STOPPING) + runtime.request_stop() + runtime.clean() + self.store.update_run( + self.run.run_id, + status=RunStatus.CLEANED, + finished_at=time.time(), + ) + + def cleanup(self): + runtime = self.ensure_runtime() + runtime.clean() + self.store.update_run( + self.run.run_id, + status=RunStatus.CLEANED, + finished_at=time.time(), + ) + + def _dispatch_event(self, runtime: StarryNet, event): + dispatcher = { + "check_utility": lambda: runtime.check_utility(event.time), + "check_routing_table": lambda: runtime.check_routing_table( + node=event.params["node"], t=event.time + ), + "damage": lambda: runtime.set_damage( + damaging_ratio=event.params["damaging_ratio"], t=event.time + ), + "recovery": lambda: runtime.set_recovery(t=event.time), + "static_route": lambda: runtime.set_static_route( + src=event.params["src"], + dst=event.params["dst"], + next_hop=event.params["next_hop"], + t=event.time, + ), + "ping": lambda: runtime.set_ping( + src=event.params["src"], + dst=event.params["dst"], + t=event.time, + extra_args=event.params.get("extra_args", []), + ), + "iperf": lambda: runtime.set_iperf( + src=event.params["src"], + dst=event.params["dst"], + t=event.time, + src_args=event.params.get("src_args", []), + dst_args=event.params.get("dst_args", []), + ), + "exec": lambda: runtime.exec_at( + node=event.params["node"], + cmd=event.params["cmd"], + t=event.time, + ), + "netlink": lambda: runtime.set_netlink( + node=event.params["node"], + nlmsg=event.params["nlmsg"], + t=event.time, + ), + } + event_type = event.event_type.value if hasattr(event.event_type, "value") else event.event_type + try: + handler = dispatcher[event_type] + except KeyError as exc: + raise ValueError(f"unsupported event_type: {event_type}") from exc + event_id = handler() + return event_id + + def schedule_event(self, event: EventCreate): + runtime = self.ensure_runtime() + event_id = self._dispatch_event(runtime, event) + events = runtime.list_events() + self.store.replace_events(self.run.run_id, events) + return runtime.get_event(event_id) + + def update_event(self, event_id: str, event: EventCreate): + runtime = self.ensure_runtime() + event_type = event.event_type.value if hasattr(event.event_type, "value") else event.event_type + if event_id not in runtime.event_history: + for stored_event in self.store.list_events(self.run.run_id): + if stored_event.get("event_id") == event_id: + runtime.restore_event(stored_event) + break + updated = runtime.update_event(event_id, event_type, event.time, event.params) + self.store.replace_events(self.run.run_id, runtime.list_events()) + return updated + + def delete_event(self, event_id: str): + runtime = self.ensure_runtime() + if event_id not in runtime.event_history: + for stored_event in self.store.list_events(self.run.run_id): + if stored_event.get("event_id") == event_id: + runtime.restore_event(stored_event) + break + runtime.delete_event(event_id) + self.store.replace_events(self.run.run_id, runtime.list_events()) + + def list_events(self): + runtime = self.ensure_runtime() + events = runtime.list_events() + self.store.replace_events(self.run.run_id, events) + return events + + def node_names(self): + runtime = self.ensure_runtime() + return set(runtime.nodes.keys()) + + def list_nodes(self, at_time: int = 0): + return self.get_topology_snapshot(at_time).nodes + + def get_topology_snapshot(self, at_time: int = 0): + runtime = self.ensure_runtime() + time_index = self._resolve_time_index(runtime, at_time) + nodes = [] + links = [] + seen_links = set() + for name, node in sorted(runtime.nodes.items()): + ipv4 = ipv6 = None + if node.addr4 is not None: + ipv4 = node.addr4.compressed + if node.addr6 is not None: + ipv6 = node.addr6.compressed + nodes.append(TopologyNode( + name=name, + node_type=node.node_type.name.lower(), + position=list(runtime.get_position(name, at_time) or []), + neighbors=runtime.get_neighbors(name, at_time), + ground_stations=runtime.get_GSes(name, at_time), + ipv4=ipv4, + ipv6=ipv6, + )) + + current_links = node.links_t[time_index] if time_index < len(node.links_t) else node.links_t[-1] + for dst_name, link in current_links.items(): + dedupe_key = tuple(sorted((name, dst_name))) + if dedupe_key in seen_links: + continue + seen_links.add(dedupe_key) + dst_node = runtime.nodes[dst_name] + reverse_link = dst_node.links_t[time_index].get(name) if time_index < len(dst_node.links_t) else dst_node.links_t[-1].get(name) + link_type = self._resolve_link_type(node.node_type.name.lower(), dst_node.node_type.name.lower()) + links.append(TopologyLink( + source=name, + target=dst_name, + link_type=link_type, + source_ipv4=link.addr4.compressed if link.addr4 is not None else None, + target_ipv4=reverse_link.addr4.compressed if reverse_link and reverse_link.addr4 is not None else None, + source_ipv6=link.addr6.compressed if link.addr6 is not None else None, + target_ipv6=reverse_link.addr6.compressed if reverse_link and reverse_link.addr6 is not None else None, + )) + return TopologySnapshot(run_id=self.run.run_id, time=at_time, nodes=nodes, links=links) + + def get_map_snapshot(self, at_time: int = 0): + runtime = self.ensure_runtime() + time_index = self._resolve_time_index(runtime, at_time) + resolved_time = self._resolve_time(runtime, at_time) + entities: list[MapEntity] = [] + entity_by_id: dict[str, MapEntity] = {} + damaged_nodes = self._damaged_nodes(runtime) + + for name, node in sorted(runtime.nodes.items()): + lla = runtime.get_lla(name, resolved_time) + if lla is None: + continue + ipv4 = node.addr4.compressed if node.addr4 is not None else None + ipv6 = node.addr6.compressed if node.addr6 is not None else None + neighbors = runtime.get_neighbors(name, resolved_time) + ground_stations = runtime.get_GSes(name, resolved_time) + entity_type = self._map_entity_type(node.node_type.name.lower()) + shell, orbit, satellite = self._parse_satellite_name(name) + entity = MapEntity( + id=name, + name=name, + entity_type=entity_type, + lat=float(lla[0]), + lon=self._normalize_lon(float(lla[1])), + altitude_km=float(lla[2]) if len(lla) > 2 else 0, + status="damaged" if name in damaged_nodes else "nominal", + neighbor_count=len(neighbors), + connected_ground_stations=ground_stations, + ipv4=ipv4, + ipv6=ipv6, + shell=shell, + orbit=orbit, + satellite=satellite, + ) + entities.append(entity) + entity_by_id[entity.id] = entity + + links = self._build_map_links(runtime, time_index, entity_by_id) + overlays = self._build_map_overlays(runtime, entity_by_id, links, resolved_time) + summary = MapSummary( + satellites=sum(1 for entity in entities if entity.entity_type == "satellite"), + ground_stations=sum(1 for entity in entities if entity.entity_type == "ground_station"), + extra_nodes=sum(1 for entity in entities if entity.entity_type == "extra_node"), + links=len(links), + overlays=len(overlays), + ) + return MapSnapshot( + run_id=self.run.run_id, + experiment_id=self.experiment.experiment_id, + time=resolved_time, + duration_s=int(runtime.duration), + step_s=int(runtime.step), + entities=entities, + links=links, + overlays=overlays, + summary=summary, + ) + + def _build_map_links(self, runtime: StarryNet, time_index: int, entity_by_id: dict[str, MapEntity]): + links = [] + seen_links = set() + for name, node in sorted(runtime.nodes.items()): + current_links = node.links_t[time_index] if time_index < len(node.links_t) else node.links_t[-1] + for dst_name, link in current_links.items(): + dedupe_key = tuple(sorted((name, dst_name))) + if dedupe_key in seen_links: + continue + if name not in entity_by_id or dst_name not in entity_by_id: + continue + seen_links.add(dedupe_key) + dst_node = runtime.nodes[dst_name] + reverse_links = dst_node.links_t[time_index] if time_index < len(dst_node.links_t) else dst_node.links_t[-1] + reverse_link = reverse_links.get(name) + source = entity_by_id[name] + target = entity_by_id[dst_name] + links.append(MapLink( + source=name, + target=dst_name, + link_type=self._resolve_link_type( + node.node_type.name.lower(), + dst_node.node_type.name.lower(), + ), + source_ipv4=link.addr4.compressed if link.addr4 is not None else None, + target_ipv4=reverse_link.addr4.compressed if reverse_link and reverse_link.addr4 is not None else None, + source_ipv6=link.addr6.compressed if link.addr6 is not None else None, + target_ipv6=reverse_link.addr6.compressed if reverse_link and reverse_link.addr6 is not None else None, + endpoints=[ + MapLinkEndpoint(id=source.id, lat=source.lat, lon=source.lon), + MapLinkEndpoint(id=target.id, lat=target.lat, lon=target.lon), + ], + )) + return links + + def _build_map_overlays(self, runtime: StarryNet, entity_by_id: dict[str, MapEntity], links: list[MapLink], at_time: int): + overlays: list[MapOverlay] = [] + for event in runtime.list_events(): + event_time = event.get("time") + if event_time is not None and event_time > at_time: + continue + event_type = self._event_type(event) + entity_ids = self._event_entity_ids(event, entity_by_id) + link_ids = self._link_ids_for_entities(entity_ids, links) + if not entity_ids and not link_ids and event_type not in {"damage", "recovery"}: + continue + overlays.append(MapOverlay( + id=event.get("event_id") or f"event-{len(overlays)}", + overlay_type=f"event:{event_type}", + status=event.get("status", "unknown"), + time=event_time, + label=self._event_label(event), + entity_ids=entity_ids, + link_ids=link_ids, + severity=self._event_severity(event), + payload=event, + )) + task_entities = self._task_overlays(runtime, entity_by_id, links) + overlays.extend(task_entities) + return overlays + + def _event_entity_ids(self, event: dict, entity_by_id: dict[str, MapEntity]): + params = event.get("params") or {} + entity_ids = [] + for key in ("node", "src", "dst", "next_hop"): + value = params.get(key) + if isinstance(value, str) and value in entity_by_id and value not in entity_ids: + entity_ids.append(value) + result = event.get("result") or {} + damaged = result.get("machines", {}) if isinstance(result, dict) else {} + if isinstance(damaged, dict): + for nodes in damaged.values(): + if isinstance(nodes, list): + for node in nodes: + if node in entity_by_id and node not in entity_ids: + entity_ids.append(node) + return entity_ids + + def _task_overlays(self, runtime: StarryNet, entity_by_id: dict[str, MapEntity], links: list[MapLink]): + overlays = [] + for task in runtime.list_tasks(): + node = task.get("node") + if not isinstance(node, str) or node not in entity_by_id: + continue + task_id = task.get("task_id") or f"task-{len(overlays)}" + status = task.get("status", "unknown") + overlays.append(MapOverlay( + id=str(task_id), + overlay_type=f"task:{task.get('task_type', task.get('type', 'command'))}", + status=status, + label=f"Task {task_id}", + entity_ids=[node], + link_ids=self._link_ids_for_entities([node], links), + severity="error" if status == "failed" else "info", + payload=task, + )) + return overlays + + def _link_ids_for_entities(self, entity_ids: list[str], links: list[MapLink]): + if len(entity_ids) < 2: + return [] + selected = set(entity_ids) + return [ + f"{link.source}->{link.target}" + for link in links + if link.source in selected and link.target in selected + ] + + def _event_type(self, event: dict): + return event.get("event_type") or event.get("type") or "unknown" + + def _event_label(self, event: dict): + event_type = self._event_type(event) + status = event.get("status", "unknown") + return f"{event_type} ({status})" + + def _event_severity(self, event: dict): + if event.get("status") == "failed": + return "error" + if self._event_type(event) == "damage": + return "warning" + return "info" + + def _damaged_nodes(self, runtime: StarryNet): + total_satellites = set(getattr(runtime, "total_sat_lst", [])) + undamaged = set(getattr(runtime, "undamaged_lst", [])) + if not total_satellites: + return set() + return total_satellites - undamaged + + def _resolve_time(self, runtime: StarryNet, at_time: int) -> int: + if runtime.duration <= 0: + return 0 + return max(0, min(int(at_time), int(runtime.duration) - 1)) + + def _resolve_time_index(self, runtime: StarryNet, at_time: int) -> int: + max_steps = max((len(node.links_t) for node in runtime.nodes.values()), default=1) + if max_steps <= 1: + return 0 + step = max(1, int(runtime.step)) + return max(0, min(at_time // step, max_steps - 1)) + + def _resolve_link_type(self, source_type: str, target_type: str) -> str: + node_types = {source_type, target_type} + if node_types == {"sat"}: + return "inter-satellite" + if node_types == {"gs", "sat"}: + return "ground-satellite" + if "extra" in node_types: + return "extra" + return "mixed" + + def _map_entity_type(self, node_type: str): + if node_type == "sat": + return "satellite" + if node_type == "gs": + return "ground_station" + if node_type == "extra": + return "extra_node" + return node_type + + def _parse_satellite_name(self, name: str): + match = SATELLITE_NAME_RE.match(name) + if not match: + return None, None, None + return ( + int(match.group("shell")), + int(match.group("orbit")), + int(match.group("satellite")), + ) + + def _normalize_lon(self, lon: float): + while lon > 180: + lon -= 360 + while lon < -180: + lon += 360 + return lon + + def list_tasks(self, node: Optional[str] = None): + runtime = self.ensure_runtime() + return runtime.list_tasks(node=node) + + def get_task(self, task_id: str): + runtime = self.ensure_runtime() + return runtime.get_task(task_id) + + def get_task_output(self, task_id: str): + runtime = self.ensure_runtime() + return runtime.get_task_output(task_id) + + +class RuntimeManager: + def __init__(self, store: MetadataStore): + self.store = store + self._runs: Dict[str, ManagedRun] = {} + self._lock = threading.Lock() + + def get_or_create(self, experiment: ExperimentRecord, run: RunRecord): + with self._lock: + managed = self._runs.get(run.run_id) + if managed is None: + managed = ManagedRun(experiment, run, self.store) + self._runs[run.run_id] = managed + return managed diff --git a/web/backend/app/core/store.py b/web/backend/app/core/store.py new file mode 100644 index 0000000..b64207c --- /dev/null +++ b/web/backend/app/core/store.py @@ -0,0 +1,187 @@ +import json +import os +import threading +import time +import uuid +from pathlib import Path +from typing import List, Optional + +from .models import ExperimentCreate, ExperimentRecord, ExperimentUpdate, RunRecord + + +class MetadataStore: + def __init__(self, metadata_path: str): + self.metadata_path = Path(metadata_path) + self.metadata_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + + def _default_payload(self): + return { + "experiments": {}, + "runs": {}, + "events": {}, + } + + def _read(self): + if not self.metadata_path.exists(): + return self._default_payload() + with self.metadata_path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + def _write(self, payload): + tmp_path = self.metadata_path.with_suffix(".tmp") + with tmp_path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + os.replace(tmp_path, self.metadata_path) + + def create_experiment(self, owner_user_id: str, data: ExperimentCreate, + config_path: str = "", experiment_id: Optional[str] = None): + now = time.time() + experiment = ExperimentRecord( + experiment_id=experiment_id or f"exp-{uuid.uuid4().hex[:12]}", + owner_user_id=owner_user_id, + name=data.name, + configuration=data.configuration, + config_path=config_path, + gs_lat_long=data.gs_lat_long, + bird_routing_enabled=data.bird_routing_enabled, + bird_conf_content=data.bird_conf_content, + bird_conf_path=None, + extra_nodes_links=data.extra_nodes_links, + created_at=now, + updated_at=now, + ) + with self._lock: + payload = self._read() + payload["experiments"][experiment.experiment_id] = experiment.model_dump(mode="json") + self._write(payload) + return experiment + + def list_experiments(self, owner_user_id: str): + with self._lock: + payload = self._read() + experiments = [ + ExperimentRecord.model_validate(item) + for item in payload["experiments"].values() + if item["owner_user_id"] == owner_user_id + ] + experiments.sort(key=lambda item: item.created_at) + return experiments + + def get_experiment(self, experiment_id: str) -> Optional[ExperimentRecord]: + with self._lock: + payload = self._read() + item = payload["experiments"].get(experiment_id) + if item is None: + return None + return ExperimentRecord.model_validate(item) + + def update_experiment(self, experiment_id: str, patch: ExperimentUpdate): + with self._lock: + payload = self._read() + item = payload["experiments"].get(experiment_id) + if item is None: + return None + current = ExperimentRecord.model_validate(item) + updates = patch.model_dump(exclude_unset=True) + updated = ExperimentRecord.model_validate({ + **current.model_dump(mode="json"), + **updates, + "updated_at": time.time(), + }) + payload["experiments"][experiment_id] = updated.model_dump(mode="json") + self._write(payload) + return updated + + def update_experiment_fields(self, experiment_id: str, **updates): + with self._lock: + payload = self._read() + item = payload["experiments"].get(experiment_id) + if item is None: + return None + current = ExperimentRecord.model_validate(item) + updated = ExperimentRecord.model_validate({ + **current.model_dump(mode="json"), + **updates, + "updated_at": time.time(), + }) + payload["experiments"][experiment_id] = updated.model_dump(mode="json") + self._write(payload) + return updated + + def delete_experiment(self, experiment_id: str): + with self._lock: + payload = self._read() + if experiment_id not in payload["experiments"]: + return False + + run_ids = [ + run_id + for run_id, run in payload["runs"].items() + if run["experiment_id"] == experiment_id + ] + del payload["experiments"][experiment_id] + for run_id in run_ids: + payload["runs"].pop(run_id, None) + payload["events"].pop(run_id, None) + self._write(payload) + return True + + def create_run(self, experiment: ExperimentRecord, artifact_dir: str): + now = time.time() + run = RunRecord( + run_id=f"run-{uuid.uuid4().hex[:12]}", + experiment_id=experiment.experiment_id, + owner_user_id=experiment.owner_user_id, + artifact_dir=artifact_dir, + created_at=now, + updated_at=now, + ) + with self._lock: + payload = self._read() + payload["runs"][run.run_id] = run.model_dump(mode="json") + payload["events"][run.run_id] = [] + self._write(payload) + return run + + def get_run(self, run_id: str) -> Optional[RunRecord]: + with self._lock: + payload = self._read() + item = payload["runs"].get(run_id) + if item is None: + return None + return RunRecord.model_validate(item) + + def list_runs(self, experiment_id: Optional[str] = None, owner_user_id: Optional[str] = None): + with self._lock: + payload = self._read() + runs = [RunRecord.model_validate(item) for item in payload["runs"].values()] + if experiment_id is not None: + runs = [item for item in runs if item.experiment_id == experiment_id] + if owner_user_id is not None: + runs = [item for item in runs if item.owner_user_id == owner_user_id] + runs.sort(key=lambda item: item.created_at) + return runs + + def update_run(self, run_id: str, **updates): + with self._lock: + payload = self._read() + item = payload["runs"].get(run_id) + if item is None: + return None + current = RunRecord.model_validate(item) + updated = current.model_copy(update={**updates, "updated_at": time.time()}) + payload["runs"][run_id] = updated.model_dump(mode="json") + self._write(payload) + return updated + + def replace_events(self, run_id: str, events: List[dict]): + with self._lock: + payload = self._read() + payload["events"][run_id] = events + self._write(payload) + + def list_events(self, run_id: str): + with self._lock: + payload = self._read() + return payload["events"].get(run_id, []) diff --git a/web/backend/app/main.py b/web/backend/app/main.py new file mode 100644 index 0000000..f91601e --- /dev/null +++ b/web/backend/app/main.py @@ -0,0 +1,22 @@ +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .api.routes import router + + +app = FastAPI(title="StarryNet Control Plane", version="0.1.0") +cors_origin_regex = os.getenv("STARRYNET_CORS_ALLOW_ORIGIN_REGEX", ".*") +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://127.0.0.1:5173", + "http://localhost:5173", + ], + allow_origin_regex=cors_origin_regex, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(router) diff --git a/web/frontend/README.md b/web/frontend/README.md new file mode 100644 index 0000000..9e53324 --- /dev/null +++ b/web/frontend/README.md @@ -0,0 +1,33 @@ +# StarryNet Frontend + +This directory contains the standalone web GUI scaffold for StarryNet. + +## Development + +Start the backend: + +```bash +uvicorn web.backend.app.main:app --reload +``` + +Start the frontend: + +```bash +cd web/frontend +npm install +npm run dev +``` + +The frontend expects: + +- `VITE_API_BASE_URL` pointing at the FastAPI backend +- `VITE_USER_ID` for the required `X-User-Id` header + +## Current scope + +- experiments list +- experiment detail +- run detail +- topology snapshot +- events list +- tasks list and output preview diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 0000000..3fd9ae9 --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + StarryNet + + +
+ + + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json new file mode 100644 index 0000000..e836f42 --- /dev/null +++ b/web/frontend/package-lock.json @@ -0,0 +1,1910 @@ +{ + "name": "starrynet-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "starrynet-web", + "version": "0.1.0", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/topojson-client": "^3.1.5", + "d3-geo": "^3.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" + }, + "devDependencies": { + "@types/d3-geo": "^3.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..b852429 --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "starrynet-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/topojson-client": "^3.1.5", + "d3-geo": "^3.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" + }, + "devDependencies": { + "@types/d3-geo": "^3.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/web/frontend/src/app/AppShell.tsx b/web/frontend/src/app/AppShell.tsx new file mode 100644 index 0000000..8aa027b --- /dev/null +++ b/web/frontend/src/app/AppShell.tsx @@ -0,0 +1,80 @@ +import { NavLink, Outlet, useLocation } from "react-router-dom"; + +import { appRoutes } from "../routes"; + +export function AppShell() { + const location = useLocation(); + const runMatch = location.pathname.match(/^\/runs\/([^/]+)/); + const experimentMatch = location.pathname.match(/^\/experiments\/([^/]+)/); + const runId = runMatch?.[1] ?? null; + const experimentId = + experimentMatch?.[1] && experimentMatch[1] !== "new" ? experimentMatch[1] : null; + + const workspaceLinks = [ + { label: "Experiments", to: appRoutes.experiments(), caption: "Browse and monitor" }, + ]; + + const contextLinks = runId + ? [ + { label: "Map", to: appRoutes.runMapPath(runId) }, + { label: "Topology", to: appRoutes.runTopologyPath(runId) }, + { label: "Events", to: appRoutes.runEventsPath(runId) }, + { label: "Tasks", to: appRoutes.runTasksPath(runId) } + ] + : experimentId + ? [{ label: "Experiment Overview", to: appRoutes.experimentDetailPath(experimentId) }] + : []; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/web/frontend/src/components/Breadcrumbs.tsx b/web/frontend/src/components/Breadcrumbs.tsx new file mode 100644 index 0000000..af1c766 --- /dev/null +++ b/web/frontend/src/components/Breadcrumbs.tsx @@ -0,0 +1,23 @@ +import { Link } from "react-router-dom"; + +export interface BreadcrumbItem { + label: string; + to?: string; +} + +interface BreadcrumbsProps { + items: BreadcrumbItem[]; +} + +export function Breadcrumbs({ items }: BreadcrumbsProps) { + return ( + + ); +} diff --git a/web/frontend/src/components/EmptyState.tsx b/web/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..bfc88ca --- /dev/null +++ b/web/frontend/src/components/EmptyState.tsx @@ -0,0 +1,13 @@ +interface EmptyStateProps { + title: string; + body: string; +} + +export function EmptyState({ title, body }: EmptyStateProps) { + return ( +
+

{title}

+

{body}

+
+ ); +} diff --git a/web/frontend/src/components/ErrorPanel.tsx b/web/frontend/src/components/ErrorPanel.tsx new file mode 100644 index 0000000..dc3742c --- /dev/null +++ b/web/frontend/src/components/ErrorPanel.tsx @@ -0,0 +1,12 @@ +interface ErrorPanelProps { + message: string; +} + +export function ErrorPanel({ message }: ErrorPanelProps) { + return ( +
+

Request failed

+

{message}

+
+ ); +} diff --git a/web/frontend/src/components/LoadingBlock.tsx b/web/frontend/src/components/LoadingBlock.tsx new file mode 100644 index 0000000..1b3ac28 --- /dev/null +++ b/web/frontend/src/components/LoadingBlock.tsx @@ -0,0 +1,3 @@ +export function LoadingBlock() { + return
Loading...
; +} diff --git a/web/frontend/src/components/MetricCard.tsx b/web/frontend/src/components/MetricCard.tsx new file mode 100644 index 0000000..c1d7a4e --- /dev/null +++ b/web/frontend/src/components/MetricCard.tsx @@ -0,0 +1,13 @@ +interface MetricCardProps { + label: string; + value: string | number; +} + +export function MetricCard({ label, value }: MetricCardProps) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/web/frontend/src/components/PageHeader.tsx b/web/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..6171f2d --- /dev/null +++ b/web/frontend/src/components/PageHeader.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +import { BreadcrumbItem, Breadcrumbs } from "./Breadcrumbs"; + +interface PageHeaderProps { + title: string; + description?: string; + eyebrow?: string; + tone?: "default" | "experiments" | "experiment" | "run" | "topology" | "events" | "tasks" | "create"; + breadcrumbs?: BreadcrumbItem[]; + actions?: ReactNode; +} + +export function PageHeader({ + title, + description, + eyebrow, + tone = "default", + breadcrumbs, + actions +}: PageHeaderProps) { + return ( +
+
+ {breadcrumbs?.length ? : null} + {eyebrow ?

{eyebrow}

: null} +

{title}

+

{description}

+
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/web/frontend/src/components/SectionNav.tsx b/web/frontend/src/components/SectionNav.tsx new file mode 100644 index 0000000..f5a0801 --- /dev/null +++ b/web/frontend/src/components/SectionNav.tsx @@ -0,0 +1,34 @@ +import { NavLink } from "react-router-dom"; + +interface SectionNavItem { + label: string; + to: string; + description?: string; +} + +interface SectionNavProps { + title: string; + items: SectionNavItem[]; +} + +export function SectionNav({ title, items }: SectionNavProps) { + return ( +
+

{title}

+
+ {items.map(item => ( + + isActive ? "section-nav-link section-nav-link-active" : "section-nav-link" + } + > + {item.label} + {item.description ? {item.description} : null} + + ))} +
+
+ ); +} diff --git a/web/frontend/src/components/StatusPill.tsx b/web/frontend/src/components/StatusPill.tsx new file mode 100644 index 0000000..8f39ae7 --- /dev/null +++ b/web/frontend/src/components/StatusPill.tsx @@ -0,0 +1,8 @@ +interface StatusPillProps { + status: string; +} + +export function StatusPill({ status }: StatusPillProps) { + const tone = `status-pill status-${status.toLowerCase()}`; + return {status}; +} diff --git a/web/frontend/src/lib/api/client.ts b/web/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..63ba13c --- /dev/null +++ b/web/frontend/src/lib/api/client.ts @@ -0,0 +1,96 @@ +import type { + EventRecord, + EventPayload, + ExperimentCreatePayload, + ExperimentRecord, + MapSnapshot, + RunRecord, + TaskRecord, + TopologySnapshot +} from "../models"; + +// FIXME +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "http://127.0.0.1:8000"; +const USER_ID = (import.meta.env.VITE_USER_ID as string | undefined) ?? "demo-user"; + +export class ApiError extends Error { + status: number; + detail: unknown; + + constructor(message: string, status: number, detail: unknown) { + super(message); + this.status = status; + this.detail = detail; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + "X-User-Id": USER_ID, + ...(init?.headers ?? {}) + } + }); + + if (!response.ok) { + let detail: unknown = null; + try { + detail = await response.json(); + } catch { + detail = await response.text(); + } + const detailMessage = + typeof detail === "object" && detail !== null && "detail" in detail + ? String((detail as { detail: unknown }).detail) + : ""; + const message = detailMessage || `Request failed with status ${response.status}`; + throw new ApiError(message, response.status, detail); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export const apiClient = { + listExperiments: () => request("/experiments"), + createExperiment: (payload: ExperimentCreatePayload) => + request("/experiments", { + method: "POST", + body: JSON.stringify(payload) + }), + getExperiment: (experimentId: string) => request(`/experiments/${experimentId}`), + deleteExperiment: (experimentId: string) => + request(`/experiments/${experimentId}`, { method: "DELETE" }), + listRunsForExperiment: (experimentId: string) => request(`/experiments/${experimentId}/runs`), + createRun: (experimentId: string) => + request(`/experiments/${experimentId}/runs`, { method: "POST" }), + getRun: (runId: string) => request(`/runs/${runId}`), + startRun: (runId: string) => request(`/runs/${runId}/start`, { method: "POST" }), + stopRun: (runId: string) => request(`/runs/${runId}/stop`, { method: "POST" }), + cleanupRun: (runId: string) => request(`/runs/${runId}/cleanup`, { method: "POST" }), + getTopology: (runId: string, time = 0) => + request(`/runs/${runId}/topology?time=${encodeURIComponent(String(time))}`), + getMap: (runId: string, time = 0) => + request(`/runs/${runId}/map?time=${encodeURIComponent(String(time))}`), + listEvents: (runId: string) => request(`/runs/${runId}/events`), + createEvent: (runId: string, payload: EventPayload) => + request(`/runs/${runId}/events`, { + method: "POST", + body: JSON.stringify(payload) + }), + updateEvent: (runId: string, eventId: string, payload: EventPayload) => + request(`/runs/${runId}/events/${eventId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + deleteEvent: (runId: string, eventId: string) => + request(`/runs/${runId}/events/${eventId}`, { method: "DELETE" }), + listTasks: (runId: string) => request(`/runs/${runId}/tasks`), + getTaskOutput: (runId: string, taskId: string) => + request(`/runs/${runId}/tasks/${taskId}/output`) +}; diff --git a/web/frontend/src/lib/format.ts b/web/frontend/src/lib/format.ts new file mode 100644 index 0000000..d80a34c --- /dev/null +++ b/web/frontend/src/lib/format.ts @@ -0,0 +1,27 @@ +export function formatDateTime(value: number | null): string { + if (!value) { + return "Not available"; + } + return new Date(value * 1000).toLocaleString(); +} + +export function formatRelativeDuration(start: number | null, end: number | null): string { + if (!start) { + return "Not started"; + } + const endMs = end ? end * 1000 : Date.now(); + const durationMs = Math.max(0, endMs - start * 1000); + const seconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +export function formatCoordinates(points: number[][]): string { + if (!points.length) { + return "None"; + } + return points.map(([lat, lon]) => `${lat.toFixed(3)}, ${lon.toFixed(3)}`).join(" | "); +} diff --git a/web/frontend/src/lib/hooks.ts b/web/frontend/src/lib/hooks.ts new file mode 100644 index 0000000..8b5b03a --- /dev/null +++ b/web/frontend/src/lib/hooks.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +export function useAsyncData(load: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + load() + .then(result => { + if (!cancelled) { + setData(result); + } + }) + .catch(err => { + if (!cancelled) { + setError(err instanceof Error ? err : new Error("Unknown error")); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, deps); + + return { data, loading, error, reload: () => load().then(setData) }; +} diff --git a/web/frontend/src/lib/models.ts b/web/frontend/src/lib/models.ts new file mode 100644 index 0000000..d94ef96 --- /dev/null +++ b/web/frontend/src/lib/models.ts @@ -0,0 +1,191 @@ +export type ExperimentStatus = "draft" | "ready" | "archived"; +export type RunStatus = "ready" | "provisioning" | "active" | "stopping" | "completed" | "failed" | "cleaned"; + +export interface ShellDefinition { + altitude_km: number; + inclination: number; + orbits: number; + satellites_per_orbit: number; + phase_shift: number; +} + +export interface ExperimentConfiguration { + shells: ShellDefinition[]; + duration_s: number; + step_s: number; + satellite_link_bandwidth_gbps: number; + sat_ground_bandwidth_gbps: number; + satellite_link_loss_percent: number; + sat_ground_loss_percent: number; + antenna_number: number; + antenna_elevation_angle: number; + satellite_link: string; + ip_version: string; + link_policy: string; + handover_policy: string; +} + +export interface ExperimentRecord { + experiment_id: string; + owner_user_id: string; + name: string; + configuration: ExperimentConfiguration; + config_path: string; + gs_lat_long: number[][]; + bird_routing_enabled: boolean; + bird_conf_content: string | null; + bird_conf_path: string | null; + extra_nodes_links: Record; + status: ExperimentStatus; + created_at: number; + updated_at: number; +} + +export interface ExperimentCreatePayload { + name: string; + configuration: ExperimentConfiguration; + gs_lat_long: number[][]; + bird_routing_enabled: boolean; + bird_conf_content: string; + extra_nodes_links: Record; +} + +export type CoreEventType = + | "check_utility" + | "check_routing_table" + | "damage" + | "recovery" + | "static_route" + | "ping" + | "iperf"; + +export interface RunRecord { + run_id: string; + experiment_id: string; + owner_user_id: string; + status: RunStatus; + artifact_dir: string; + created_at: number; + updated_at: number; + started_at: number | null; + finished_at: number | null; + error: string | null; +} + +export interface TopologyNode { + name: string; + node_type: string; + position: number[] | null; + neighbors: string[]; + ground_stations: string[]; + ipv4: string | null; + ipv6: string | null; +} + +export interface TopologyLink { + source: string; + target: string; + link_type: string; + source_ipv4: string | null; + target_ipv4: string | null; + source_ipv6: string | null; + target_ipv6: string | null; +} + +export interface TopologySnapshot { + run_id: string; + time: number; + nodes: TopologyNode[]; + links: TopologyLink[]; +} + +export type MapEntityType = "satellite" | "ground_station" | "extra_node" | string; + +export interface MapEntity { + id: string; + name: string; + entity_type: MapEntityType; + lat: number; + lon: number; + altitude_km: number; + status: string; + neighbor_count: number; + connected_ground_stations: string[]; + ipv4: string | null; + ipv6: string | null; + shell: number | null; + orbit: number | null; + satellite: number | null; +} + +export interface MapLinkEndpoint { + id: string; + lat: number; + lon: number; +} + +export interface MapLink { + source: string; + target: string; + link_type: string; + source_ipv4: string | null; + target_ipv4: string | null; + source_ipv6: string | null; + target_ipv6: string | null; + endpoints: MapLinkEndpoint[]; +} + +export interface MapOverlay { + id: string; + overlay_type: string; + status: string; + time: number | null; + label: string; + entity_ids: string[]; + link_ids: string[]; + severity: string; + payload: Record; +} + +export interface MapSummary { + satellites: number; + ground_stations: number; + extra_nodes: number; + links: number; + overlays: number; +} + +export interface MapSnapshot { + run_id: string; + experiment_id: string; + time: number; + duration_s: number; + step_s: number; + entities: MapEntity[]; + links: MapLink[]; + overlays: MapOverlay[]; + summary: MapSummary; +} + +export interface EventRecord { + event_id?: string; + time: number; + event_type?: string; + type?: string; + params: Record; + status?: string; + error?: string | null; +} + +export interface EventPayload { + time: number; + event_type: CoreEventType; + params: Record; +} + +export interface TaskRecord { + task_id?: string; + node?: string; + status?: string; + [key: string]: unknown; +} diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx new file mode 100644 index 0000000..fa74112 --- /dev/null +++ b/web/frontend/src/main.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; + +import { AppShell } from "./app/AppShell"; +import { CreateExperimentPage } from "./pages/CreateExperimentPage"; +import { ExperimentDetailPage } from "./pages/ExperimentDetailPage"; +import { ExperimentsPage } from "./pages/ExperimentsPage"; +import { RunDetailPage } from "./pages/RunDetailPage"; +import { RunEventsPage } from "./pages/RunEventsPage"; +import { RunMapPage } from "./pages/RunMapPage"; +import { RunTasksPage } from "./pages/RunTasksPage"; +import { RunTopologyPage } from "./pages/RunTopologyPage"; +import { appRoutes } from "./routes"; +import "./styles.css"; + +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: appRoutes.experiments(), element: }, + { path: appRoutes.experimentCreate(), element: }, + { path: appRoutes.experimentDetail(), element: }, + { path: appRoutes.runDetail(), element: }, + { path: appRoutes.runMap(), element: }, + { path: appRoutes.runTopology(), element: }, + { path: appRoutes.runEvents(), element: }, + { path: appRoutes.runTasks(), element: } + ] + } +]); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/web/frontend/src/pages/CreateExperimentPage.tsx b/web/frontend/src/pages/CreateExperimentPage.tsx new file mode 100644 index 0000000..1cd6d62 --- /dev/null +++ b/web/frontend/src/pages/CreateExperimentPage.tsx @@ -0,0 +1,446 @@ +import { FormEvent, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ErrorPanel } from "../components/ErrorPanel"; +import { PageHeader } from "../components/PageHeader"; +import { apiClient } from "../lib/api/client"; +import type { ExperimentCreatePayload, ShellDefinition } from "../lib/models"; +import { appRoutes } from "../routes"; + +const SATELLITE_LINK_OPTIONS = [{ value: "Grid", label: "Grid" }]; +const LINK_POLICY_OPTIONS = [{ value: "LeastDelay", label: "Least delay" }]; + +const DEFAULT_BIRD_CONF = `log "/bird.log" { warning, error, auth, fatal, bug }; +protocol device { +} +protocol direct { + disabled; + ipv4; + ipv6; +} +protocol kernel { + ipv4 { + export all; + }; +} +protocol ospf{ + ipv4 { + import all; + }; + area 0 { + interface "SH*O*S*" { + type broadcast; + cost 10; + hello 5; + }; + interface "GS*" { + type broadcast; + cost 10; + hello 5; + }; + interface "POP" { + type broadcast; + cost 10; + hello 5; + }; + interface "eth*" { + type broadcast; + cost 10; + hello 5; + }; + }; +} +`; + +function buildDefaultShell(): ShellDefinition { + return { + altitude_km: 550, + inclination: 53, + orbits: 5, + satellites_per_orbit: 5, + phase_shift: 1 + }; +} + +function buildDefaultPayload(): ExperimentCreatePayload { + return { + name: "demo-constellation", + configuration: { + shells: [buildDefaultShell()], + duration_s: 120, + step_s: 2, + satellite_link_bandwidth_gbps: 10, + sat_ground_bandwidth_gbps: 10, + satellite_link_loss_percent: 1, + sat_ground_loss_percent: 1, + antenna_number: 1, + antenna_elevation_angle: 25, + satellite_link: "Grid", + ip_version: "IPv4", + link_policy: "LeastDelay", + handover_policy: "instant handover" + }, + gs_lat_long: [ + [50.110924, 8.682127], + [46.6357, 14.311817] + ], + bird_routing_enabled: false, + bird_conf_content: "", + extra_nodes_links: {} + }; +} + +function numericValue(value: string, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function CreateExperimentPage() { + const navigate = useNavigate(); + const [payload, setPayload] = useState(buildDefaultPayload); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + function updateConfiguration( + key: K, + value: ExperimentCreatePayload["configuration"][K] + ) { + setPayload(current => ({ + ...current, + configuration: { + ...current.configuration, + [key]: value + } + })); + } + + function updateShell(index: number, patch: Partial) { + setPayload(current => ({ + ...current, + configuration: { + ...current.configuration, + shells: current.configuration.shells.map((shell, shellIndex) => + shellIndex === index ? { ...shell, ...patch } : shell + ) + } + })); + } + + function addShell() { + setPayload(current => ({ + ...current, + configuration: { + ...current.configuration, + shells: [...current.configuration.shells, buildDefaultShell()] + } + })); + } + + function removeShell(index: number) { + setPayload(current => { + if (current.configuration.shells.length === 1) { + return current; + } + return { + ...current, + configuration: { + ...current.configuration, + shells: current.configuration.shells.filter((_, shellIndex) => shellIndex !== index) + } + }; + }); + } + + function updateGroundStation(index: number, axis: 0 | 1, value: number) { + setPayload(current => ({ + ...current, + gs_lat_long: current.gs_lat_long.map((station, stationIndex) => + stationIndex === index ? ([axis === 0 ? value : station[0], axis === 1 ? value : station[1]] as number[]) : station + ) + })); + } + + function addGroundStation() { + setPayload(current => ({ + ...current, + gs_lat_long: [...current.gs_lat_long, [0, 0]] + })); + } + + function removeGroundStation(index: number) { + setPayload(current => { + if (current.gs_lat_long.length === 1) { + return current; + } + return { + ...current, + gs_lat_long: current.gs_lat_long.filter((_, stationIndex) => stationIndex !== index) + }; + }); + } + + function setRoutingEnabled(enabled: boolean) { + setPayload(current => ({ + ...current, + bird_routing_enabled: enabled, + bird_conf_content: enabled && !current.bird_conf_content.trim() ? DEFAULT_BIRD_CONF : current.bird_conf_content + })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const created = await apiClient.createExperiment(payload); + navigate(`/experiments/${created.experiment_id}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create experiment"); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + {error ? : null} + +
+
+ + + + + + + + + + + +
+ +
+
+

Shells

+ +
+
+ {payload.configuration.shells.map((shell, index) => ( +
+
+ Shell {index + 1} + +
+
+ + + + + + + + + +
+
+ ))} +
+
+ +
+
+

Ground stations

+ +
+
+ {payload.gs_lat_long.map(([lat, lon], index) => ( +
+
+ GS{index} + +
+
+ + + +
+
+ ))} +
+
+ +
+ + + {payload.bird_routing_enabled ? ( +