From eb7c527441559913ee10525fd9517f77951816aa Mon Sep 17 00:00:00 2001 From: Ben Zhang Date: Sat, 30 Mar 2024 00:51:13 -0700 Subject: [PATCH] Revamp SSH command generator (#2543) Revamp SSH command generator using a Graph-based approach. Also pilot dynamically-generated MDX. This is useful in #2017 Closes #1751 (replaced) --- components/machine-card.tsx | 24 ++- components/ssh-command-generator.tsx | 213 ++++++++------------------- lib/data.ts | 11 ++ lib/wato-utils.ts | 26 ++++ pages/docs/compute-cluster/ssh.mdx | 9 +- pages/machines.mdx | 2 +- requirements.txt | 4 +- scripts/generate-fixtures.sh | 6 + scripts/generate-mdx-strings.py | 72 +++++++++ scripts/generate-ssh-info.py | 190 ++++++++++++++++++++++++ tsconfig.json | 11 +- 11 files changed, 391 insertions(+), 177 deletions(-) create mode 100644 scripts/generate-mdx-strings.py create mode 100644 scripts/generate-ssh-info.py diff --git a/components/machine-card.tsx b/components/machine-card.tsx index f9f6503..5b9d636 100644 --- a/components/machine-card.tsx +++ b/components/machine-card.tsx @@ -168,26 +168,20 @@ export function MachineCard({

*.cluster.watonomous.ca hostnames resolve to internal IP addresses in the cluster. They are accessible only from within the cluster.

-

*.watonomous.ca hostnames resolve to external IP addresses. They are accessible from anywhere. However, they may be behind the UWaterloo firewall. To access them, you may need to use a VPN or a bastion server.

+

*.ext.watonomous.ca hostnames resolve to external IP addresses. They are accessible from anywhere. However, they may be behind the UWaterloo firewall. To access them, you may need to use a VPN or a bastion server.

+

There may be other hostnames that resolve to this machine. For example, mesh networks may have additional hostnames.

diff --git a/components/ssh-command-generator.tsx b/components/ssh-command-generator.tsx index 0191b83..c70dc6b 100644 --- a/components/ssh-command-generator.tsx +++ b/components/ssh-command-generator.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect, useMemo, useRef } from 'react' import { useRouter } from 'next/router' import { Pre, Code } from 'nextra/components' import stripIndent from 'strip-indent'; @@ -12,81 +12,37 @@ import { } from "@/components/ui/popover" import { ComboBox } from '@/components/ui/combo-box' import { Input } from "@/components/ui/input" -import { machineInfo } from '@/lib/data' -import { hostnameSorter } from '@/lib/wato-utils' +import { lookupStringMDX, sshInfo } from '@/lib/data' +import { htmlEncode } from '@/lib/wato-utils' -const DEFAULT_MACHINE_NAME = machineInfo.dev_vms[0].name -const ACCESSIBLE_MACHINES = Object.fromEntries([...machineInfo.dev_vms, ...machineInfo.bastions].map(m => [m.name, m])) -const BASTION_NAMES = machineInfo.bastions.map(m => m.name) -const ACCESSIBLE_MACHINE_LIST = Object.keys(ACCESSIBLE_MACHINES).map(m => ({value: m, label: m})) -const HOSTNAME_TO_MACHINE_NAME = Object.fromEntries(Object.entries(ACCESSIBLE_MACHINES).flatMap(([machineName, machine]) => machine.hostnames?.map(hostname => [hostname, machineName]) || [])) -const ALL_ENTRYPOINTS: Map = new Map(Object.entries({ - "direct": "Direct", - "uw-vpn": "UW VPN", - "uw-campus": "UW Campus", - ...Object.fromEntries(machineInfo.bastions.map(b => [b.name, b.name])), -})) - -function getEntrypointToHostnamesMap(machineName: string, hostnames: string[]): Map> { - const ret: Map> = new Map() - - // All bastions are to be accessed directly - if (BASTION_NAMES.includes(machineName)) { - ret.set('direct', new Set(hostnames)) - return ret - } - - for (const hostname of hostnames) { - if (hostname.endsWith(".ext.watonomous.ca")) { - ret.set('uw-vpn', ret.get('uw-vpn') || new Set()) - ret.get('uw-vpn')!.add(hostname) - ret.set('uw-campus', ret.get('uw-campus') || new Set()) - ret.get('uw-campus')!.add(hostname) - for (const bastion of machineInfo.bastions) { - ret.set(bastion.name, ret.get(bastion.name) || new Set()) - ret.get(bastion.name)!.add(hostname) - } - } else if (hostname.endsWith(".cluster.watonomous.ca")) { - for (const bastion of machineInfo.bastions) { - ret.set(bastion.name, ret.get(bastion.name) || new Set()) - ret.get(bastion.name)!.add(hostname) - } +const STRING_TO_MDX: Record = {} +for (const { paths } of Object.values(sshInfo)) { + for (const { instructions } of paths) { + for (const instruction of instructions) { + STRING_TO_MDX[instruction] = React.createElement((await lookupStringMDX(instruction)).default) } } - - return ret } export function SSHCommandGenerator() { const router = useRouter() - const queryHostname = Array.isArray(router.query.hostname) ? router.query.hostname[0] : router.query.hostname || "" + const queryMachineName = Array.isArray(router.query.machinename) + ? router.query.machinename[0] + : router.query.machinename || ""; + + const instructionsRef = useRef(null) + const machineNames = Object.keys(sshInfo) as (keyof typeof sshInfo)[] - const [_machineName, _setMachineName] = useState("") - const [_hostname, _setHostname] = useState("") - const [_entrypoint, _setEntrypoint] = useState("") + const [_machineName, setMachineName] = useState("") const [username, _setUsername] = useState("") const [sshKeyPath, _setSSHKeyPath] = useState("") - const machineName = _machineName || HOSTNAME_TO_MACHINE_NAME[queryHostname] || DEFAULT_MACHINE_NAME - const machineHostnames = useMemo(() => ACCESSIBLE_MACHINES[machineName]?.hostnames?.toSorted(hostnameSorter) || [], [machineName]) - const entrypointToHostnamesMap = useMemo(() => getEntrypointToHostnamesMap(machineName, machineHostnames), [machineName, machineHostnames]) - const entrypoint = _entrypoint || entrypointToHostnamesMap.keys().next()?.value || "" - const hostname = entrypointToHostnamesMap.get(entrypoint)?.has(_hostname || queryHostname) ? (_hostname || queryHostname) : (entrypointToHostnamesMap.get(entrypoint)?.values().next()?.value || "") + const machineName: keyof typeof sshInfo = + _machineName || + (machineNames.includes(queryMachineName as keyof typeof sshInfo) + ? queryMachineName + : machineNames[0]) as keyof typeof sshInfo; - const entrypointOptions = [...entrypointToHostnamesMap.keys()].map(k => ({value: k, label: ALL_ENTRYPOINTS.get(k)!})) - const hostnameOptions = [...entrypointToHostnamesMap.get(entrypoint) || []].map(h => ({value: h, label: h})) - - function setHostname(h: string) { - _setHostname(h) - } - function setEntrypoint(e: string) { - _setEntrypoint(e) - setHostname("") - } - function setMachineName(n: string) { - _setMachineName(n) - setEntrypoint("") - } function setUsername(u: string) { _setUsername(u) localStorage.setItem("wato_ssh_command_generator_username", u) @@ -121,49 +77,24 @@ export function SSHCommandGenerator() { } }, []) // eslint-disable-line react-hooks/exhaustive-deps - const displaySSHKeyPath = sshKeyPath || "" - const displayUsername = (username || "").replace(/\$$/, "\\$") - const displayHostname = hostname || "" + const displayUsername = (username || htmlEncode("")).replace(/\$$/, "\\$") + const displaySSHKeyPath = sshKeyPath || htmlEncode("") - let sshCommand = ""; - if (["direct", "uw-vpn", "uw-campus"].includes(entrypoint)) { - let preamble = ""; - if (entrypoint === "uw-vpn") { - preamble = stripIndent(` - # 1. Connect to the UW VPN: - # https://uwaterloo.ca/web-resources/resources/virtual-private-network-vpn - # - # 2. Run the following command to connect to ${machineName}: - `).trim() + "\n" - } else if (entrypoint === "uw-campus") { - preamble = stripIndent(` - # 1. Connect to the UW Campus network (e.g. using Eduroam) - # - # 2. Run the following command to connect to ${machineName}: - `).trim() + "\n" - } - sshCommand = preamble + stripIndent(` - ssh -v -i "${displaySSHKeyPath}" "${displayUsername}@${displayHostname}" - `).trim() - } else if (BASTION_NAMES.includes(entrypoint)) { - const jump_host = ACCESSIBLE_MACHINES[entrypoint] - const jump_host_hostname = jump_host.hostnames?.[0] - if (!jump_host_hostname) { - console.error(`Error generating SSH command! Jump host ${entrypoint} has no hostnames.`) + // Replace placeholders in instructions + useEffect(() => { + if (instructionsRef.current) { + const instructions = instructionsRef.current.querySelectorAll("code > span") + instructions.forEach((instruction) => { + if (!instruction.getAttribute("data-original-inner-html")) { + instruction.setAttribute("data-original-inner-html", instruction.innerHTML) + } + const originalInnerHTML = instruction.getAttribute("data-original-inner-html") || '' + instruction.innerHTML = originalInnerHTML + .replace(/__SSH_USER__/g, displayUsername) + .replace(/__SSH_KEY_PATH__/g, displaySSHKeyPath) + }) } - sshCommand = stripIndent(` - # Connect to ${machineName} via ${jump_host.name} (${jump_host_hostname}) - ssh -v -o ProxyCommand="ssh -W %h:%p -i \\"${displaySSHKeyPath}\\" \\"${displayUsername}@${jump_host_hostname}\\"" -i "${displaySSHKeyPath}" "${displayUsername}@${displayHostname}" - `).trim() - } else { - sshCommand = stripIndent(` - # Error generating SSH command! Please report this issue to the WATO team. - # Debug info: - # machineName: ${machineName} - # hostname: ${hostname} - # entrypoint: ${entrypoint} - `).trim() - } + }, [displayUsername, displaySSHKeyPath, machineName]) return ( <> @@ -171,54 +102,15 @@ export function SSHCommandGenerator() {
Machine
({value: m, label: m}))} value={machineName} - setValue={setMachineName} + setValue={setMachineName as any} selectPlaceholder="Select machine" searchPlaceholder="Find machine..." emptySearchResultText="No machines found" allowDeselect={false} />
-
- - Entrypoint{} - - The entrypoint determines how you connect to the machine. - - -
-
- -
-
- - Hostname{} - - A machine may be reachable via multiple hostnames. - - -
-
- -
Compute Cluster Username{} @@ -255,13 +147,30 @@ export function SSHCommandGenerator() { autoComplete='off' /> -
Generated SSH Command
-
-
-                        {sshCommand}
-                    
-
+
+

Results

+

+ Below are options for connecting to {machineName}. + Please choose the option that best fits your use case. +

+
+
+ { + sshInfo[machineName].paths.map(({hops, instructions}) => ( +
")} className="mt-8"> +

{hops.length === 1 ? "Direct Connection" : hops.join(" -> ")}

+
    + {instructions.map((instruction, i) => ( +
  1. + {STRING_TO_MDX[instruction]} +
  2. + ))} +
+
+ )) + } +
) } diff --git a/lib/data.ts b/lib/data.ts index 314f87a..cf145b7 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -3,7 +3,18 @@ import { Convert as MachineInfoConvert } from '@/build/fixtures/machine-info' export type { MachineInfo } from '@/build/fixtures/machine-info' export const machineInfo = MachineInfoConvert.toMachineInfo(JSON.stringify(machineInfoJSON)) +import sshInfoJSON from '@/build/fixtures/ssh-info.json' +import { Convert as SshInfoConvert } from '@/build/fixtures/ssh-info' +export type { SSHInfo } from '@/build/fixtures/ssh-info' +export const sshInfo = SshInfoConvert.toSSHInfo(JSON.stringify(sshInfoJSON)) + import websiteConfigJSON from '@/build/fixtures/website-config.json' import { Convert as WebsiteConfigConvert } from '@/build/fixtures/website-config' export type { WebsiteConfig } from '@/build/fixtures/website-config' export const websiteConfig = WebsiteConfigConvert.toWebsiteConfig(JSON.stringify(websiteConfigJSON)) + +import { hashCode } from './wato-utils' +export function lookupStringMDX(str: string) { + const basename = `${hashCode(str)}.mdx` + return import(`@/build/fixtures/strings/${basename}`) +} \ No newline at end of file diff --git a/lib/wato-utils.ts b/lib/wato-utils.ts index ac4a0f4..759f94b 100644 --- a/lib/wato-utils.ts +++ b/lib/wato-utils.ts @@ -6,4 +6,30 @@ export function hostnameSorter(a: string, b: string) { const aIsClusterHostname = a.endsWith(".cluster.watonomous.ca") const bIsClusterHostname = b.endsWith(".cluster.watonomous.ca") return +bIsClusterHostname - +aIsClusterHostname +} + +/** + * Returns a hash code from a string + * @param {String} str The string to hash. + * @return {Number} A 32bit integer + * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + * @see https://stackoverflow.com/a/8831937 + */ +export function hashCode(str: string) { + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + let chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + + // Convert to unsigned integer + // https://stackoverflow.com/a/47612303 + return hash >>> 0; +} + + +// Derived from https://stackoverflow.com/a/18750001 +export function htmlEncode(str: string) { + return str.replace(/[\u00A0-\u9999<>\&]/g, (i) => "&#" + i.charCodeAt(0) + ";"); } \ No newline at end of file diff --git a/pages/docs/compute-cluster/ssh.mdx b/pages/docs/compute-cluster/ssh.mdx index c73924d..022529e 100644 --- a/pages/docs/compute-cluster/ssh.mdx +++ b/pages/docs/compute-cluster/ssh.mdx @@ -19,14 +19,17 @@ Choose your preferred machine and entrypoint[^entrypoint] below. Your personaliz because the cluster is behind a [firewall](./firewall) and you cannot connect to it directly. import { SSHCommandGenerator } from '@/components/ssh-command-generator' +import { Separator } from "@/components/ui/separator" + +
-The above is your personalized SSH command. Copy and paste it into your terminal to connect to the cluster. + -The generated command does *not* require setting up ssh agent[^ssh-agent] or ssh config[^ssh-config]. +The generated commands do *not* require setting up ssh agent[^ssh-agent] or ssh config[^ssh-config]. However, you may soon find that setting them up will make your life easier. If you are interested in learning more about these tools, please check out [Tips and Tricks](#tips-and-tricks) and the official documentation linked in the footnotes. @@ -143,6 +146,4 @@ ssh -v trpro-ubuntu2 { // Separate footnotes from the main content } -import { Separator } from "@/components/ui/separator" - diff --git a/pages/machines.mdx b/pages/machines.mdx index 04e984a..e21c5e1 100644 --- a/pages/machines.mdx +++ b/pages/machines.mdx @@ -23,7 +23,7 @@ The following machines are used to run SLURM jobs. To use them, please follow th ### General-Use Machines -The following machines are available for general use. To access them, please follow the instructions in the [Getting Access](/docs/compute-cluster/getting-access) guide. +The following machines are available for general use. To access them, please follow the instructions in the [SSH](/docs/compute-cluster/ssh) guide. {
diff --git a/requirements.txt b/requirements.txt index 76c6dfe..9fa091d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ PyYAML>=5.3.1 jsonschema>=4.17.0 python-slugify>=6.1.1 -dnspython>=2.2.1 \ No newline at end of file +dnspython>=2.2.1 +networkx>=3.2.1 +typer>=0.12.0 \ No newline at end of file diff --git a/scripts/generate-fixtures.sh b/scripts/generate-fixtures.sh index 51d94ce..c2be9f0 100755 --- a/scripts/generate-fixtures.sh +++ b/scripts/generate-fixtures.sh @@ -58,6 +58,7 @@ mkdir -p "$PROJECT_DIR/build/fixtures" if [ -n "$__fetch_from" ]; then echo "Fetching fixtures from $__fetch_from..." wget --quiet -O "$PROJECT_DIR/build/fixtures/machine-info.json" "$__fetch_from/machine-info.json" + wget --quiet -O "$PROJECT_DIR/build/fixtures/ssh-info.json" "$__fetch_from/ssh-info.json" wget --quiet -O "$PROJECT_DIR/build/fixtures/website-config.json" "$__fetch_from/website-config.json" wget --quiet -O "$PROJECT_DIR/build/fixtures/affiliation.schema.json" "$__fetch_from/affiliation.schema.json" wget --quiet -O "$PROJECT_DIR/build/fixtures/user.schema.json" "$__fetch_from/user.schema.json" @@ -67,6 +68,7 @@ else git worktree add "$PROJECT_DIR/build/data" origin/data # Generate fixtures python3 "$SCRIPT_DIR/generate-machine-info.py" "$PROJECT_DIR/build/data" "$PROJECT_DIR/build/fixtures" + python3 "$SCRIPT_DIR/generate-ssh-info.py" "$PROJECT_DIR/build/fixtures" python3 "$SCRIPT_DIR/generate-website-config.py" "$PROJECT_DIR/../outputs" "$PROJECT_DIR/build/fixtures" cp "$PROJECT_DIR/../directory/affiliations/affiliation.schema.json" "$PROJECT_DIR/build/fixtures" cp "$PROJECT_DIR/../outputs/directory/users/user.schema.json" "$PROJECT_DIR/build/fixtures" @@ -75,8 +77,12 @@ fi # Add typescript types echo "Generating fixture types..." ./node_modules/.bin/quicktype -o "$PROJECT_DIR"/build/fixtures/machine-info.{ts,json} +./node_modules/.bin/quicktype -o "$PROJECT_DIR"/build/fixtures/ssh-info.{ts,json} ./node_modules/.bin/quicktype -o "$PROJECT_DIR"/build/fixtures/website-config.{ts,json} +echo "Generating mdx files from data..." +python3 "$SCRIPT_DIR/generate-mdx-strings.py" json-to-mdx "$PROJECT_DIR/build/fixtures/ssh-info.json" "$PROJECT_DIR/build/fixtures/strings" + echo "Compiling JSON schema validators..." node "$PROJECT_DIR/scripts/compile-json-schema-validators.js" "$PROJECT_DIR/build/fixtures" diff --git a/scripts/generate-mdx-strings.py b/scripts/generate-mdx-strings.py new file mode 100644 index 0000000..c141408 --- /dev/null +++ b/scripts/generate-mdx-strings.py @@ -0,0 +1,72 @@ +import json +import typer +import hashlib +from pathlib import Path + +app = typer.Typer() + +def hash_code(s): + """Returns a hash code from a string. + + Args: + s (str): The string to hash. + + Returns: + int: A 32-bit integer hash of the string. + + References: + - http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + - https://stackoverflow.com/a/8831937 + """ + hash = 0 + for char in s: + hash = (hash << 5) - hash + ord(char) + hash &= 0xFFFFFFFF # Convert to 32bit integer + return hash + + +# Derived from: +# https://chat.openai.com/share/41d568ff-b124-4144-a19e-b51938adf7ce +@app.command() +def get_all_strings(json_file_path): + # Load the JSON data from the file + with open(json_file_path, "r") as file: + data = json.load(file) + + def extract_strings(element, result): + if isinstance(element, dict): # If element is a dictionary + for value in element.values(): + extract_strings(value, result) + elif isinstance(element, list): # If element is a list + for item in element: + extract_strings(item, result) + elif isinstance(element, str): # If element is a string + result.append(element) + + # Initialize an empty list to hold the strings + strings = [] + # Extract strings from the loaded JSON data + extract_strings(data, strings) + + return strings + +@app.command() +def dump_mdx(strings: list[str], output_dir: str): + output_dir = Path(output_dir) + Path.mkdir(output_dir, exist_ok=True) + + for s in strings: + basename = f"{hash_code(s)}.mdx" + + with open(output_dir / basename, "w") as file: + file.write(s) + + print(f"Dumped {len(strings)} strings to {output_dir}") + +@app.command() +def json_to_mdx(json_file_path: str, output_dir: str): + strings = get_all_strings(json_file_path) + dump_mdx(strings, output_dir) + +if __name__ == '__main__': + app() \ No newline at end of file diff --git a/scripts/generate-ssh-info.py b/scripts/generate-ssh-info.py new file mode 100644 index 0000000..0fce636 --- /dev/null +++ b/scripts/generate-ssh-info.py @@ -0,0 +1,190 @@ +import argparse +import csv +import json +import re +import sys +import textwrap +from itertools import chain +from pathlib import Path + +import networkx as nx + +sys.path.append(str(Path(__file__).parent.parent.parent)) + +from directory.scripts.host_utils import get_host_config, get_hosts_in_group + + +def print_graph_ascii(G): + print("Nodes:") + for node in G.nodes: + print(f"{node}: {G.nodes[node]}") + print("\nEdges:") + for edge in G.edges: + print(f"{edge[0]} -- {edge[1]}: {G.edges[edge]}") + + +def get_network_from_hostname(hostname, networks): + for network in networks: + for regex in network["hostname_regexes"]: + if re.match(regex, hostname): + return network + + return None + + +def generate_network_graph(): + host_config = get_host_config() + + networks = host_config["networks"] + login_nodes = get_hosts_in_group(host_config, "login_nodes") + bastion_nodes = get_hosts_in_group(host_config, "bastion_nodes") + + G = nx.Graph() + + G.add_node("_entrypoint", type="entrypoint") + + for network in networks: + G.add_node(network["name"], type="network", display_name=f"{network['name'].capitalize()} Network") + + for node in chain(login_nodes, bastion_nodes): + G.add_node(node["name"], type="host") + + for nn in node["networks"]: + is_entrypoint = nn["is_accessible_from_internet"] + + for record in nn.get("dns_records", []): + hostname = record["name"] + if is_entrypoint: + G.add_edge(node["name"], "_entrypoint", hostname=hostname) + + network = get_network_from_hostname(hostname, networks) + if network is None: + if not is_entrypoint: + print( + f"WARNING: No network found for hostname {hostname}, skipping", + file=sys.stderr, + ) + continue + + G.add_edge( + node["name"], + network["name"], + hostname=hostname, + ) + + G.add_node("UWaterloo VPN", type="service") + G.add_edge( + "UWaterloo VPN", + "_entrypoint", + instructions=[ + "Connect to the [UWaterloo VPN](https://uwaterloo.ca/web-resources/resources/virtual-private-network-vpn)", + ], + ) + G.add_edge("UWaterloo VPN", "university") + + G.add_node("UWaterloo Campus", type="service") + G.add_edge( + "UWaterloo Campus", + "_entrypoint", + instructions=[ + "Connect to the UWaterloo network (e.g. on-campus Ethernet or Eduroam Wi-Fi)", + ], + ) + G.add_edge("UWaterloo Campus", "university") + + return G + + +def generate_ssh_command(hostnames): + assert len(hostnames) > 0, "Expected at least one hostname, got 0" + assert ( + len(hostnames) <= 2 + ), f"Expected at most 2 hostnames, got {len(hostnames)}: {hostnames}" + + if len(hostnames) == 1: + return f"ssh -v -i '__SSH_KEY_PATH__' __SSH_USER__@{hostnames[0]}" + + return ( + r""" + ssh -v -o ProxyCommand="ssh -W %h:%p -i '__SSH_KEY_PATH__' __SSH_USER__@__JUMP_HOST__" -i '__SSH_KEY_PATH__' __SSH_USER__@__HOST__ + """.replace("__JUMP_HOST__", hostnames[0]).replace("__HOST__", hostnames[1]).strip() + ) + + +def generate_ssh_markdown(hostnames): + return textwrap.dedent(f""" + Run the following command: + + ```bash copy + {generate_ssh_command(hostnames)} + ``` + """).strip() + + +def generate_ssh_info(): + G = generate_network_graph() + + print( + f"Generated SSH network graph with {G.number_of_nodes()} graph nodes and {G.number_of_edges()} graph edges" + ) + print_graph_ascii(G) + + shortest_paths = { + n: list(nx.all_shortest_paths(G, source="_entrypoint", target=n)) + for n in G.nodes + if G.nodes[n]["type"] == "host" + } + + ssh_info = {} + for n, paths in shortest_paths.items(): + ssh_info[n] = {"paths": []} + for path in paths: + assert ( + len(path) <= 4 + ), f"Expected at most 4 path nodes (2 hops), got {len(path)}: {path}" + + instructions = [] + ssh_host_chain = [] + + for edge in zip(path, path[1:]): + _source, target = edge + + edge_props = G.edges[edge] + + if G.nodes[target]["type"] == "host": + ssh_host_chain.append(edge_props["hostname"]) + elif G.nodes[target]["type"] == "service": + if len(ssh_host_chain) > 0: + instructions.append(generate_ssh_markdown(ssh_host_chain)) + ssh_host_chain = [] + instructions.extend(edge_props["instructions"]) + elif G.nodes[target]["type"] == "network": + pass # noop + else: + raise ValueError( + f"Unexpected node type: {G.nodes[target]} in edge {edge}. Path: {path}" + ) + + if len(ssh_host_chain) > 0: + instructions.append(generate_ssh_markdown(ssh_host_chain)) + + ssh_info[n]["paths"].append( + { + "hops": [ + G.nodes[n].get("display_name", n) for n in path if G.nodes[n]["type"] in ["host", "service", "network"] + ], + "instructions": instructions, + } + ) + + return ssh_info + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate information required for SSH instructions" + ) + parser.add_argument("fixtures_path", type=str, help="Path to fixtures") + args = parser.parse_args() + fixtures = generate_ssh_info() + with open(Path(args.fixtures_path, "ssh-info.json"), "w") as file: + json.dump(fixtures, file, indent=2) diff --git a/tsconfig.json b/tsconfig.json index 9b62a16..de2d94d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ @@ -22,14 +22,16 @@ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "esnext", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ /* Specify a set of entries that re-map imports to additional lookup locations. */ "paths": { // Add support for importing modules from the root directory using the @ symbol - "@/*": [ "./*" ] + "@/*": [ + "./*" + ] }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ @@ -77,7 +79,8 @@ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. *//* Type Checking */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */