From 0af990ba87345cf286706b78bb6815d74fa12c49 Mon Sep 17 00:00:00 2001 From: Ruffin Date: Wed, 24 May 2023 22:07:37 +0200 Subject: [PATCH] Add example Dev Container (#1) * Copy template from Nav2 * Simplify Dockerfile to use installable ros distro binaries * Simplify Dev Container config * Copy template from Nav2 * Simplify dot ignore files * Cleanup Docker ignore file * Apt install pip before pip install * Fix absolute path * Install nav2_bringup as overall dependency * Rename dev container * Source ros setup instead of overlay * Copy nav2 launch file from rolling branch with added headless LaunchConfiguration as this hasn't been back ported to humble - https://github.com/ros-planning/navigation2/pull/3527 * Add example snippet from Nav2 * Simplify launch snippet * Rename Action * Simplify Action * Target visualizer stage to minimize the need for Codespaces Prebuilds * Omit version tab and label info for now * Extend readme for demo * Fix missing turtlebot3 meshes in gzweb by installing turtlebot3-description and symlinking to common mesh folder given this is still broken for humble --- .devcontainer/caddy/Caddyfile | 132 ++ .../caddy/srv/assets/foxglove/manifest.json | 40 + .../srv/assets/foxglove/nav2_layout.json | 463 +++++++ .../caddy/srv/assets/glances/manifest.json | 40 + .../caddy/srv/assets/gzweb/manifest.json | 23 + .../caddy/srv/assets/nav2/manifest.json | 23 + .../caddy/srv/nav2/github-markdown.css | 1102 +++++++++++++++++ .devcontainer/caddy/srv/nav2/index.html | 36 + .devcontainer/caddy/srv/nav2/index.md | 51 + .devcontainer/devcontainer.json | 38 + .devcontainer/on-create-command.sh | 12 + .devcontainer/post-create-command.sh | 18 + .devcontainer/update-content-command.sh | 10 + .dockerignore | 11 + .github/workflows/update_docker_image.yaml | 123 ++ .gitignore | 5 + .vscode/tasks.json | 176 +++ Dockerfile | 93 ++ README.md | 31 +- launch/security_demo_launch.py | 98 ++ 20 files changed, 2524 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/caddy/Caddyfile create mode 100644 .devcontainer/caddy/srv/assets/foxglove/manifest.json create mode 100644 .devcontainer/caddy/srv/assets/foxglove/nav2_layout.json create mode 100644 .devcontainer/caddy/srv/assets/glances/manifest.json create mode 100644 .devcontainer/caddy/srv/assets/gzweb/manifest.json create mode 100644 .devcontainer/caddy/srv/assets/nav2/manifest.json create mode 100644 .devcontainer/caddy/srv/nav2/github-markdown.css create mode 100644 .devcontainer/caddy/srv/nav2/index.html create mode 100644 .devcontainer/caddy/srv/nav2/index.md create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/on-create-command.sh create mode 100755 .devcontainer/post-create-command.sh create mode 100755 .devcontainer/update-content-command.sh create mode 100644 .dockerignore create mode 100644 .github/workflows/update_docker_image.yaml create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 Dockerfile create mode 100644 launch/security_demo_launch.py diff --git a/.devcontainer/caddy/Caddyfile b/.devcontainer/caddy/Caddyfile new file mode 100644 index 0000000..1150579 --- /dev/null +++ b/.devcontainer/caddy/Caddyfile @@ -0,0 +1,132 @@ +# Snippet for global matchers and variables +# to logically expression request conditions +# E.g. for conditionally changing redirects +(globals) { + # Use gzip compression for all responses + encode gzip + + # Matcher for http request scheme. E.g. "http" or "https" + @http_scheme { + expression {http.request.scheme}=="https" || {header.X-Forwarded-Scheme}=="https" || {header.X-Forwarded-Proto}=="https" + } + # If any http scheme is "https", then use "wss" + vars @http_scheme WsScheme "wss" + # Else default to "ws" + vars WsScheme "ws" + + # Matcher for forwarded request headers + @host_forwarded { + header X-Forwarded-Host * + } + # If http headers exists, then use them + vars @host_forwarded ReqHost {header.X-Forwarded-Host} + # Else default to host in request + vars ReqHost {http.request.hostport} + + # Matcher for websocket connection upgrade requests + @websockets { + # Avoid case sensitivity issues when matching field values + # E.g. when values are rewritten by Codespace port forwarding + header_regexp Connection (?i)(Upgrade) + header Upgrade websocket + } +} + +# Snippet for redirect with given URL queries values +# to simplify remote development with web apps +# E.g auto redirect websocket URL to match request scheme +(redirect) { + # Configure redirect to match request scheme + vars LayoutUrl "/assets/foxglove/nav2_layout.json" + vars DataSourceUrl "{vars.WsScheme}://{vars.ReqHost}{args.0}/" + redir /autoconnect "{args.0}/?ds=foxglove-websocket&ds.url={vars.DataSourceUrl}" + redir /autolayout "{args.0}/?ds=foxglove-websocket&ds.url={vars.DataSourceUrl}&layoutUrl={vars.LayoutUrl}" +} + +# Snippet for dummy imports +(dummy) { +} + +# Snippet for enabling mobile web app features +# to improve user experience on small screen devices +# E.g. for enabling fullscreen mode on iOS and Android +(mobile) { + # Match for directory redirects to index.html + route / { + # Inject link to manifest just after tag + # https://developer.mozilla.org/docs/Web/Manifest + replace `` `` + } + # Redirect relative handle_path'ed manifest.json to /manifests directory + redir /manifest.json /assets{http.request.orig_uri.path.dir}manifest.json +} + +# Snippet for hosted web app using websockets +# to serve static files and reverse proxying connections +# E.g. for serving GzWeb and Foxglove web apps +(app) { + # handle and strip path prefix from redirect + handle_path {args.0}/* { + # Set root directory for static files + root * {http.vars.root}{args.0} + # Enable mobile web app features + import mobile + # Reverse proxy websockets to backend address + reverse_proxy @websockets {args.1} + # Import custom snippets + import {args.2} {args.0} + } +} + +# Listen for http requests on port 8080 +# regardless of hostname or domain address +# E.g. whatever Codespaces assigns to host +:8080 { + # Include global matchers and variables + import globals + root * {$ROOT_SRV:/srv} + file_server browse + + # Handle root content + # I.e. assets internal to workspace + handle /* { + # Template manifest.json files + templates */manifest.json { + mime application/json + } + } + + # Handle nav2 web app + # I.e. main landing page + handle_path /nav2/* { + root * {http.vars.root}/nav2 + import mobile + # Render markdown files as html + templates + } + + # Matcher for requests without browse query + @no_browse { + path / + not query browse=true + } + # Redirect to nav2 web app by default + redir @no_browse /nav2/ + + # Import app snippets for web apps + import app "/gzweb" "localhost:9090" "dummy" + import app "/foxglove" "localhost:8765" "redirect" + + # Handle glances web app + redir /glances /glances/ + handle_path /glances/* { + import mobile + # Reverse proxy to glances backend + reverse_proxy * "localhost:61208" + } + + # For debugging + # log { + # output file /var/log/caddy/server.log + # } +} diff --git a/.devcontainer/caddy/srv/assets/foxglove/manifest.json b/.devcontainer/caddy/srv/assets/foxglove/manifest.json new file mode 100644 index 0000000..0b9b8b8 --- /dev/null +++ b/.devcontainer/caddy/srv/assets/foxglove/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Foxglove: {{placeholder "http.vars.ReqHost"}}", + "short_name": "Foxglove: {{placeholder "http.vars.ReqHost"}}", + "icons": [ + { + "src": "/media/icons/foxglove/any_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any" + }, + { + "src": "/media/icons/foxglove/maskable_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "maskable" + } + ], + "id": "/foxglove/", + "start_url": "/foxglove/autoconnect", + "theme_color": "#6F3BE8", + "background_color": "#6F3BE8", + "display": "fullscreen", + "shortcuts" : [ + { + "name": "Auto Connect", + "url": "/foxglove/autoconnect", + "description": "Auto connect to default data source" + }, + { + "name": "Auto Layout", + "url": "/foxglove/autolayout", + "description": "Auto connect using default layout" + }, + { + "name": "Manual Connect", + "url": "/foxglove/", + "description": "Manually connect to data source" + } + ] +} diff --git a/.devcontainer/caddy/srv/assets/foxglove/nav2_layout.json b/.devcontainer/caddy/srv/assets/foxglove/nav2_layout.json new file mode 100644 index 0000000..f92821f --- /dev/null +++ b/.devcontainer/caddy/srv/assets/foxglove/nav2_layout.json @@ -0,0 +1,463 @@ +{ + "configById": { + "3D!18i6zy7": { + "layers": { + "845139cb-26bc-40b3-8161-8ab60af4baf5": { + "visible": true, + "frameLocked": true, + "label": "Grid", + "instanceId": "845139cb-26bc-40b3-8161-8ab60af4baf5", + "layerId": "foxglove.Grid", + "size": 10, + "divisions": 10, + "lineWidth": 1, + "color": "#A0A0A4ff", + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "order": 1 + } + }, + "cameraState": { + "perspective": true, + "distance": 21.05263157877764, + "phi": 38.925517117715195, + "thetaOffset": -138.92710744521386, + "targetOffset": [ + -2.6847696124888896, + 0.2191229688744439, + 3.6086809432821955e-16 + ], + "target": [ + 0, + 0, + 0 + ], + "targetOrientation": [ + 0, + 0, + 0, + 1 + ], + "fovy": 45, + "near": 0.5, + "far": 5000 + }, + "followMode": "follow-pose", + "scene": { + "transforms": { + "showLabel": false, + "editable": false, + "labelSize": 0.049999999999999975, + "enablePreloading": false, + "lineWidth": 2 + } + }, + "transforms": { + "frame:camera_link": { + "visible": false + }, + "frame:camera_depth_frame": { + "visible": false + }, + "frame:camera_depth_optical_frame": { + "visible": false + }, + "frame:camera_rgb_frame": { + "visible": false + }, + "frame:camera_rgb_optical_frame": { + "visible": false + }, + "frame:imu_link": { + "visible": false + }, + "frame:caster_back_right_link": { + "visible": false + }, + "frame:caster_back_left_link": { + "visible": false + }, + "frame:odom": { + "visible": true + }, + "frame:base_footprint": { + "visible": true + }, + "frame:wheel_left_link": { + "visible": false + }, + "frame:wheel_right_link": { + "visible": false + }, + "frame:base_link": { + "visible": false + }, + "frame:base_scan": { + "visible": false + }, + "frame:map": { + "visible": true + } + }, + "topics": { + "/scan": { + "visible": true, + "colorField": "intensity", + "colorMode": "flat", + "colorMap": "turbo", + "pointSize": 5, + "flatColor": "#ff0000" + }, + "/global_costmap/costmap": { + "visible": true, + "maxColor": "#E800174d", + "unknownColor": "#5700ff4d", + "minColor": "#ffffff4d", + "invalidColor": "#ff00ff4d", + "colorMode": "costmap", + "alpha": 0.3 + }, + "/global_costmap/obstacle_layer": { + "visible": false + }, + "/global_costmap/voxel_marked_cloud": { + "visible": false + }, + "/goal_pose": { + "visible": false + }, + "/local_costmap/costmap": { + "visible": false + }, + "/local_costmap/voxel_layer": { + "visible": false + }, + "/local_costmap/clearing_endpoints": { + "visible": false, + "colorField": "x", + "colorMode": "colormap", + "colorMap": "turbo" + }, + "/map": { + "visible": true, + "minColor": "#ffffff", + "maxColor": "#000000", + "unknownColor": "#708986ff", + "frameLocked": false, + "colorMode": "map" + }, + "/amcl_pose": { + "visible": false + }, + "/local_plan": { + "visible": true, + "lineWidth": 0.01, + "gradient": [ + "#c8ff00c7", + "#00c8ffba" + ] + }, + "/plan": { + "visible": false, + "gradient": [ + "rgba(124, 107, 255, 1)", + "#ff6b6b" + ], + "lineWidth": 1 + }, + "/plan_smoothed": { + "visible": false + }, + "/received_global_plan": { + "visible": true, + "gradient": [ + "#ff0000c7", + "#6b70ffc2" + ], + "lineWidth": 0.02, + "type": "line", + "arrowScale": [ + 0.02, + 0.0015, + 0.0015 + ] + }, + "/transformed_global_plan": { + "visible": false + }, + "/robot_description": { + "visible": false + }, + "/cost_cloud": { + "visible": false + }, + "/initialpose": { + "visible": false + } + }, + "publish": { + "type": "pose_estimate", + "poseTopic": "/move_base_simple/goal", + "pointTopic": "", + "poseEstimateTopic": "/initialpose", + "poseEstimateXDeviation": 0.5, + "poseEstimateYDeviation": 0.5, + "poseEstimateThetaDeviation": 0.26179939 + }, + "followTf": "map" + }, + "DiagnosticSummary!3bo4e39": { + "minLevel": 0, + "pinnedIds": [], + "hardwareIdFilter": "", + "topicToRender": "/diagnostics", + "sortByLevel": true + }, + "RosOut!1iib9dq": { + "searchTerms": [], + "minLogLevel": 1 + }, + "3D!2agiaqk": { + "layers": { + "845139cb-26bc-40b3-8161-8ab60af4baf5": { + "visible": false, + "frameLocked": true, + "label": "Grid", + "instanceId": "845139cb-26bc-40b3-8161-8ab60af4baf5", + "layerId": "foxglove.Grid", + "size": 10, + "divisions": 10, + "lineWidth": 1, + "color": "#A0A0A4ff", + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "order": 1 + } + }, + "cameraState": { + "perspective": true, + "distance": 4.078136514883917, + "phi": 56.068374260572, + "thetaOffset": 92.50000000000723, + "targetOffset": [ + 0.03816360663426963, + 0.15755079607173259, + 7.341598429161142e-18 + ], + "target": [ + 0, + 0, + 0 + ], + "targetOrientation": [ + 0, + 0, + 0, + 1 + ], + "fovy": 45, + "near": 0.5, + "far": 5000 + }, + "followMode": "follow-pose", + "scene": { + "transforms": { + "showLabel": true, + "editable": false, + "labelSize": 0.049999999999999975, + "enablePreloading": false, + "lineWidth": 2 + } + }, + "transforms": { + "frame:camera_link": { + "visible": false + }, + "frame:camera_depth_frame": { + "visible": false + }, + "frame:camera_depth_optical_frame": { + "visible": false + }, + "frame:camera_rgb_frame": { + "visible": false + }, + "frame:camera_rgb_optical_frame": { + "visible": false + }, + "frame:imu_link": { + "visible": false + }, + "frame:caster_back_right_link": { + "visible": false + }, + "frame:caster_back_left_link": { + "visible": false + }, + "frame:odom": { + "visible": true + }, + "frame:base_footprint": { + "visible": true + }, + "frame:wheel_left_link": { + "visible": false + }, + "frame:wheel_right_link": { + "visible": false + }, + "frame:base_link": { + "visible": false + }, + "frame:base_scan": { + "visible": false + }, + "frame:map": { + "visible": true + } + }, + "topics": { + "/scan": { + "visible": true, + "colorField": "intensity", + "colorMode": "flat", + "colorMap": "turbo", + "pointSize": 5, + "flatColor": "#ff0000" + }, + "/global_costmap/costmap": { + "visible": true, + "maxColor": "#E800174d", + "unknownColor": "#5700ff4d", + "minColor": "#ffffff4d", + "invalidColor": "#ff00ff4d", + "colorMode": "costmap", + "alpha": 0.3 + }, + "/global_costmap/obstacle_layer": { + "visible": false + }, + "/global_costmap/voxel_marked_cloud": { + "visible": false + }, + "/goal_pose": { + "visible": false + }, + "/local_costmap/costmap": { + "visible": false + }, + "/local_costmap/voxel_layer": { + "visible": false + }, + "/local_costmap/clearing_endpoints": { + "visible": false, + "colorField": "x", + "colorMode": "colormap", + "colorMap": "turbo" + }, + "/map": { + "visible": true, + "minColor": "#ffffff", + "maxColor": "#000000", + "unknownColor": "#708986ff", + "frameLocked": false, + "colorMode": "map" + }, + "/amcl_pose": { + "visible": false + }, + "/local_plan": { + "visible": true, + "lineWidth": 0.01, + "gradient": [ + "#c8ff00c7", + "#00c8ffba" + ] + }, + "/plan": { + "visible": false, + "gradient": [ + "rgba(124, 107, 255, 1)", + "#ff6b6b" + ], + "lineWidth": 1 + }, + "/plan_smoothed": { + "visible": false + }, + "/received_global_plan": { + "visible": true, + "gradient": [ + "#ff0000c7", + "#6b70ffc2" + ], + "lineWidth": 0.02, + "type": "line", + "arrowScale": [ + 0.02, + 0.0015, + 0.0015 + ] + }, + "/transformed_global_plan": { + "visible": false + }, + "/robot_description": { + "visible": true + }, + "/cost_cloud": { + "visible": false + }, + "/initialpose": { + "visible": false + } + }, + "publish": { + "type": "pose_estimate", + "poseTopic": "/move_base_simple/goal", + "pointTopic": "", + "poseEstimateTopic": "/initialpose", + "poseEstimateXDeviation": 0.5, + "poseEstimateYDeviation": 0.5, + "poseEstimateThetaDeviation": 0.26179939 + }, + "followTf": "base_link" + } + }, + "globalVariables": {}, + "userNodes": {}, + "playbackConfig": { + "speed": 1 + }, + "layout": { + "first": "3D!18i6zy7", + "second": { + "first": "DiagnosticSummary!3bo4e39", + "second": { + "first": "RosOut!1iib9dq", + "second": "3D!2agiaqk", + "direction": "column" + }, + "direction": "column", + "splitPercentage": 28.227360308285164 + }, + "direction": "row", + "splitPercentage": 74.87855655794587 + } + } \ No newline at end of file diff --git a/.devcontainer/caddy/srv/assets/glances/manifest.json b/.devcontainer/caddy/srv/assets/glances/manifest.json new file mode 100644 index 0000000..78d27cb --- /dev/null +++ b/.devcontainer/caddy/srv/assets/glances/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Glances: {{placeholder "http.vars.ReqHost"}}", + "short_name": "Glances: {{placeholder "http.vars.ReqHost"}}", + "icons": [ + { + "src": "/media/icons/glances/any_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any" + }, + { + "src": "/media/icons/glances/maskable_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "maskable" + } + ], + "id": "/glances/", + "start_url": "/glances/", + "theme_color": "#2C363F", + "background_color": "#2C363F", + "display": "fullscreen", + "shortcuts" : [ + { + "name": "Refresh 1sec", + "url": "/glances/1", + "description": "Refresh page every 1 second" + }, + { + "name": "Refresh 5sec", + "url": "/glances/5", + "description": "Refresh page every 5 seconds" + }, + { + "name": "Refresh 10sec", + "url": "/glances/10", + "description": "Refresh page every 10 seconds" + } + ] +} diff --git a/.devcontainer/caddy/srv/assets/gzweb/manifest.json b/.devcontainer/caddy/srv/assets/gzweb/manifest.json new file mode 100644 index 0000000..0596075 --- /dev/null +++ b/.devcontainer/caddy/srv/assets/gzweb/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Gzweb: {{placeholder "http.vars.ReqHost"}}", + "short_name": "Gzweb: {{placeholder "http.vars.ReqHost"}}", + "icons": [ + { + "src": "/media/icons/gzweb/any_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any" + }, + { + "src": "/media/icons/gzweb/maskable_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "maskable" + } + ], + "id": "/gzweb/", + "start_url": "/gzweb/", + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "fullscreen" +} diff --git a/.devcontainer/caddy/srv/assets/nav2/manifest.json b/.devcontainer/caddy/srv/assets/nav2/manifest.json new file mode 100644 index 0000000..f482a47 --- /dev/null +++ b/.devcontainer/caddy/srv/assets/nav2/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Nav2: {{placeholder "http.vars.ReqHost"}}", + "short_name": "Nav2: {{placeholder "http.vars.ReqHost"}}", + "icons": [ + { + "src": "/media/icons/nav2/any_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any" + }, + { + "src": "/media/icons/nav2/maskable_icon_x512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "maskable" + } + ], + "id": "/nav2/", + "start_url": "/nav2/", + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/.devcontainer/caddy/srv/nav2/github-markdown.css b/.devcontainer/caddy/srv/nav2/github-markdown.css new file mode 100644 index 0000000..049cae6 --- /dev/null +++ b/.devcontainer/caddy/srv/nav2/github-markdown.css @@ -0,0 +1,1102 @@ +@media (prefers-color-scheme: dark) { + .markdown-body { + color-scheme: dark; + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-fg-default: #c9d1d9; + --color-fg-muted: #8b949e; + --color-fg-subtle: #6e7681; + --color-canvas-default: #0d1117; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-accent-fg: #58a6ff; + --color-accent-emphasis: #1f6feb; + --color-attention-subtle: rgba(187,128,9,0.15); + --color-danger-fg: #f85149; + } + } + + @media (prefers-color-scheme: light) { + .markdown-body { + color-scheme: light; + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #24292f; + --color-fg-muted: #57606a; + --color-fg-subtle: #6e7781; + --color-canvas-default: #ffffff; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-neutral-muted: rgba(175,184,193,0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #cf222e; + } + } + + .markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--color-fg-default); + background-color: var(--color-canvas-default); + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; + } + + .markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; + } + + .markdown-body h1:hover .anchor .octicon-link:before, + .markdown-body h2:hover .anchor .octicon-link:before, + .markdown-body h3:hover .anchor .octicon-link:before, + .markdown-body h4:hover .anchor .octicon-link:before, + .markdown-body h5:hover .anchor .octicon-link:before, + .markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); + } + + .markdown-body details, + .markdown-body figcaption, + .markdown-body figure { + display: block; + } + + .markdown-body summary { + display: list-item; + } + + .markdown-body [hidden] { + display: none !important; + } + + .markdown-body a { + background-color: transparent; + color: var(--color-accent-fg); + text-decoration: none; + } + + .markdown-body abbr[title] { + border-bottom: none; + text-decoration: underline dotted; + } + + .markdown-body b, + .markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); + } + + .markdown-body dfn { + font-style: italic; + } + + .markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--color-border-muted); + } + + .markdown-body mark { + background-color: var(--color-attention-subtle); + color: var(--color-fg-default); + } + + .markdown-body small { + font-size: 90%; + } + + .markdown-body sub, + .markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + + .markdown-body sub { + bottom: -0.25em; + } + + .markdown-body sup { + top: -0.5em; + } + + .markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: var(--color-canvas-default); + } + + .markdown-body code, + .markdown-body kbd, + .markdown-body pre, + .markdown-body samp { + font-family: monospace; + font-size: 1em; + } + + .markdown-body figure { + margin: 1em 40px; + } + + .markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--color-border-muted); + height: .25em; + padding: 0; + margin: 24px 0; + background-color: var(--color-border-default); + border: 0; + } + + .markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; + } + + .markdown-body [type=button], + .markdown-body [type=reset], + .markdown-body [type=submit] { + -webkit-appearance: button; + } + + .markdown-body [type=checkbox], + .markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; + } + + .markdown-body [type=number]::-webkit-inner-spin-button, + .markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; + } + + .markdown-body [type=search]::-webkit-search-cancel-button, + .markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + } + + .markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; + } + + .markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; + } + + .markdown-body a:hover { + text-decoration: underline; + } + + .markdown-body ::placeholder { + color: var(--color-fg-subtle); + opacity: 1; + } + + .markdown-body hr::before { + display: table; + content: ""; + } + + .markdown-body hr::after { + display: table; + clear: both; + content: ""; + } + + .markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + } + + .markdown-body td, + .markdown-body th { + padding: 0; + } + + .markdown-body details summary { + cursor: pointer; + } + + .markdown-body details:not([open])>*:not(summary) { + display: none !important; + } + + .markdown-body a:focus, + .markdown-body [role=button]:focus, + .markdown-body input[type=radio]:focus, + .markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; + } + + .markdown-body a:focus:not(:focus-visible), + .markdown-body [role=button]:focus:not(:focus-visible), + .markdown-body input[type=radio]:focus:not(:focus-visible), + .markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; + } + + .markdown-body a:focus-visible, + .markdown-body [role=button]:focus-visible, + .markdown-body input[type=radio]:focus-visible, + .markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; + } + + .markdown-body a:not([class]):focus, + .markdown-body a:not([class]):focus-visible, + .markdown-body input[type=radio]:focus, + .markdown-body input[type=radio]:focus-visible, + .markdown-body input[type=checkbox]:focus, + .markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; + } + + .markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + line-height: 10px; + color: var(--color-fg-default); + vertical-align: middle; + background-color: var(--color-canvas-subtle); + border: solid 1px var(--color-neutral-muted); + border-bottom-color: var(--color-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--color-neutral-muted); + } + + .markdown-body h1, + .markdown-body h2, + .markdown-body h3, + .markdown-body h4, + .markdown-body h5, + .markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; + } + + .markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--color-border-muted); + } + + .markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; + } + + .markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; + } + + .markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; + } + + .markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--color-fg-muted); + } + + .markdown-body p { + margin-top: 0; + margin-bottom: 10px; + } + + .markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--color-fg-muted); + border-left: .25em solid var(--color-border-default); + } + + .markdown-body ul, + .markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; + } + + .markdown-body ol ol, + .markdown-body ul ol { + list-style-type: lower-roman; + } + + .markdown-body ul ul ol, + .markdown-body ul ol ol, + .markdown-body ol ul ol, + .markdown-body ol ol ol { + list-style-type: lower-alpha; + } + + .markdown-body dd { + margin-left: 0; + } + + .markdown-body tt, + .markdown-body code, + .markdown-body samp { + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + } + + .markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + word-wrap: normal; + } + + .markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; + } + + .markdown-body input::-webkit-outer-spin-button, + .markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; + } + + .markdown-body::before { + display: table; + content: ""; + } + + .markdown-body::after { + display: table; + clear: both; + content: ""; + } + + .markdown-body>*:first-child { + margin-top: 0 !important; + } + + .markdown-body>*:last-child { + margin-bottom: 0 !important; + } + + .markdown-body a:not([href]) { + color: inherit; + text-decoration: none; + } + + .markdown-body .absent { + color: var(--color-danger-fg); + } + + .markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; + } + + .markdown-body .anchor:focus { + outline: none; + } + + .markdown-body p, + .markdown-body blockquote, + .markdown-body ul, + .markdown-body ol, + .markdown-body dl, + .markdown-body table, + .markdown-body pre, + .markdown-body details { + margin-top: 0; + margin-bottom: 16px; + } + + .markdown-body blockquote>:first-child { + margin-top: 0; + } + + .markdown-body blockquote>:last-child { + margin-bottom: 0; + } + + .markdown-body h1 .octicon-link, + .markdown-body h2 .octicon-link, + .markdown-body h3 .octicon-link, + .markdown-body h4 .octicon-link, + .markdown-body h5 .octicon-link, + .markdown-body h6 .octicon-link { + color: var(--color-fg-default); + vertical-align: middle; + visibility: hidden; + } + + .markdown-body h1:hover .anchor, + .markdown-body h2:hover .anchor, + .markdown-body h3:hover .anchor, + .markdown-body h4:hover .anchor, + .markdown-body h5:hover .anchor, + .markdown-body h6:hover .anchor { + text-decoration: none; + } + + .markdown-body h1:hover .anchor .octicon-link, + .markdown-body h2:hover .anchor .octicon-link, + .markdown-body h3:hover .anchor .octicon-link, + .markdown-body h4:hover .anchor .octicon-link, + .markdown-body h5:hover .anchor .octicon-link, + .markdown-body h6:hover .anchor .octicon-link { + visibility: visible; + } + + .markdown-body h1 tt, + .markdown-body h1 code, + .markdown-body h2 tt, + .markdown-body h2 code, + .markdown-body h3 tt, + .markdown-body h3 code, + .markdown-body h4 tt, + .markdown-body h4 code, + .markdown-body h5 tt, + .markdown-body h5 code, + .markdown-body h6 tt, + .markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; + } + + .markdown-body summary h1, + .markdown-body summary h2, + .markdown-body summary h3, + .markdown-body summary h4, + .markdown-body summary h5, + .markdown-body summary h6 { + display: inline-block; + } + + .markdown-body summary h1 .anchor, + .markdown-body summary h2 .anchor, + .markdown-body summary h3 .anchor, + .markdown-body summary h4 .anchor, + .markdown-body summary h5 .anchor, + .markdown-body summary h6 .anchor { + margin-left: -40px; + } + + .markdown-body summary h1, + .markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; + } + + .markdown-body ul.no-list, + .markdown-body ol.no-list { + padding: 0; + list-style-type: none; + } + + .markdown-body ol[type=a] { + list-style-type: lower-alpha; + } + + .markdown-body ol[type=A] { + list-style-type: upper-alpha; + } + + .markdown-body ol[type=i] { + list-style-type: lower-roman; + } + + .markdown-body ol[type=I] { + list-style-type: upper-roman; + } + + .markdown-body ol[type="1"] { + list-style-type: decimal; + } + + .markdown-body div>ol:not([type]) { + list-style-type: decimal; + } + + .markdown-body ul ul, + .markdown-body ul ol, + .markdown-body ol ol, + .markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; + } + + .markdown-body li>p { + margin-top: 16px; + } + + .markdown-body li+li { + margin-top: .25em; + } + + .markdown-body dl { + padding: 0; + } + + .markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); + } + + .markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; + } + + .markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); + } + + .markdown-body table th, + .markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--color-border-default); + } + + .markdown-body table tr { + background-color: var(--color-canvas-default); + border-top: 1px solid var(--color-border-muted); + } + + .markdown-body table tr:nth-child(2n) { + background-color: var(--color-canvas-subtle); + } + + .markdown-body table img { + background-color: transparent; + } + + .markdown-body img[align=right] { + padding-left: 20px; + } + + .markdown-body img[align=left] { + padding-right: 20px; + } + + .markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; + } + + .markdown-body span.frame { + display: block; + overflow: hidden; + } + + .markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--color-border-default); + } + + .markdown-body span.frame span img { + display: block; + float: left; + } + + .markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--color-fg-default); + } + + .markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; + } + + .markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; + } + + .markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; + } + + .markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; + } + + .markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; + } + + .markdown-body span.align-right span img { + margin: 0; + text-align: right; + } + + .markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; + } + + .markdown-body span.float-left span { + margin: 13px 0 0; + } + + .markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; + } + + .markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; + } + + .markdown-body code, + .markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--color-neutral-muted); + border-radius: 6px; + } + + .markdown-body code br, + .markdown-body tt br { + display: none; + } + + .markdown-body del code { + text-decoration: inherit; + } + + .markdown-body samp { + font-size: 85%; + } + + .markdown-body pre code { + font-size: 100%; + } + + .markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; + } + + .markdown-body .highlight { + margin-bottom: 16px; + } + + .markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; + } + + .markdown-body .highlight pre, + .markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: var(--color-canvas-subtle); + border-radius: 6px; + } + + .markdown-body pre code, + .markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; + } + + .markdown-body .csv-data td, + .markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; + } + + .markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--color-canvas-default); + border: 0; + } + + .markdown-body .csv-data tr { + border-top: 0; + } + + .markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--color-canvas-subtle); + border-top: 0; + } + + .markdown-body [data-footnote-ref]::before { + content: "["; + } + + .markdown-body [data-footnote-ref]::after { + content: "]"; + } + + .markdown-body .footnotes { + font-size: 12px; + color: var(--color-fg-muted); + border-top: 1px solid var(--color-border-default); + } + + .markdown-body .footnotes ol { + padding-left: 16px; + } + + .markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; + } + + .markdown-body .footnotes li { + position: relative; + } + + .markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--color-accent-emphasis); + border-radius: 6px; + } + + .markdown-body .footnotes li:target { + color: var(--color-fg-default); + } + + .markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; + } + + .markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); + } + + .markdown-body .pl-c1, + .markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); + } + + .markdown-body .pl-e, + .markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); + } + + .markdown-body .pl-smi, + .markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); + } + + .markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); + } + + .markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); + } + + .markdown-body .pl-s, + .markdown-body .pl-pds, + .markdown-body .pl-s .pl-pse .pl-s1, + .markdown-body .pl-sr, + .markdown-body .pl-sr .pl-cce, + .markdown-body .pl-sr .pl-sre, + .markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); + } + + .markdown-body .pl-v, + .markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); + } + + .markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); + } + + .markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); + } + + .markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); + } + + .markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); + } + + .markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); + } + + .markdown-body .pl-mh, + .markdown-body .pl-mh .pl-en, + .markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); + } + + .markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); + } + + .markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); + } + + .markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); + } + + .markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); + } + + .markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); + } + + .markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); + } + + .markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); + } + + .markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); + } + + .markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); + } + + .markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); + } + + .markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; + } + + .markdown-body g-emoji img { + width: 1em; + height: 1em; + } + + .markdown-body .task-list-item { + list-style-type: none; + } + + .markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); + } + + .markdown-body .task-list-item.enabled label { + cursor: pointer; + } + + .markdown-body .task-list-item+.task-list-item { + margin-top: 4px; + } + + .markdown-body .task-list-item .handle { + display: none; + } + + .markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; + } + + .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; + } + + .markdown-body .contains-task-list { + position: relative; + } + + .markdown-body .contains-task-list:hover .task-list-item-convert-container, + .markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; + } + + .markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); + } \ No newline at end of file diff --git a/.devcontainer/caddy/srv/nav2/index.html b/.devcontainer/caddy/srv/nav2/index.html new file mode 100644 index 0000000..4bebe27 --- /dev/null +++ b/.devcontainer/caddy/srv/nav2/index.html @@ -0,0 +1,36 @@ +{{$pathParts := splitList "/" .OriginalReq.URL.Path}} +{{$markdownFilename := default "index" (slice $pathParts 2 | join "/")}} +{{$markdownFilePath := printf "/%s.md" $markdownFilename}} +{{if not (fileExists $markdownFilePath)}}{{httpError 404}}{{end}} +{{$markdownFile := (include $markdownFilePath | splitFrontMatter)}} +{{$title := default $markdownFilename $markdownFile.Meta.title}} + + + + + + {{$title}} + + + + + + + +
{{markdown $markdownFile.Body}}
+ + diff --git a/.devcontainer/caddy/srv/nav2/index.md b/.devcontainer/caddy/srv/nav2/index.md new file mode 100644 index 0000000..60e2db1 --- /dev/null +++ b/.devcontainer/caddy/srv/nav2/index.md @@ -0,0 +1,51 @@ +{ + "title": "Nav2 App" +} +## Progressive Web Apps + +| PWAs | Shortcuts | +|-|-| +| [](/foxglove/autoconnect)
**Foxglove** | [**Auto Connect**](/foxglove/autoconnect)
[Auto Layout](/foxglove/autolayout)
[Manual](/foxglove/) | +| [](/gzweb/)
**Gzweb** | [**Auto Connect**](/gzweb/) | +| [](/glances/)
**Glances** | [**System Monitor**](/glances/)
[Refresh 1sec](/glances/1)
[Refresh 10sec](/glances/10) | +| [](/nav2/)
**Nav2** | [**App Launcher**](/nav2/)
[File Browser](/?browse=true) | + +## External Resources + +For more related documentation: + +- [Nav2 Documentation](https://navigation.ros.org) + - [Development Guides](https://navigation.ros.org/development_guides) + - [Dev Containers](https://navigation.ros.org/development_guides/devcontainer_docs) + +## Session Info + +Useful information about host server and remote client: + +|Key | Value | +|-|-| +| Host | `{{.Host}}` | +| Remote IP | `{{placeholder "http.request.remote.host"}}` | +| Date | `{{now}}` | + +### Server Diagnostics + +
+Websocket Debug + +For troubleshooting websocket connections: + +|Key | Value | +|-|-| +| `header.X-Forwarded-Host` | `{{placeholder "http.request.header.X-Forwarded-Host"}}` | +| `http.request.hostport` | `{{placeholder "http.request.hostport"}}` | +| `http.vars.ReqHost` | `{{placeholder "http.vars.ReqHost"}}` | + +|Key | Value | +|-|-| +| `http.request.scheme` | `{{placeholder "http.request.scheme"}}` | +| `header.X-Forwarded-Scheme` | `{{placeholder "http.request.header.X-Forwarded-Scheme"}}` | +| `header.X-Forwarded-Proto` | `{{placeholder "http.request.header.X-Forwarded-Proto"}}` | +| `http.vars.WsScheme` | `{{placeholder "http.vars.WsScheme"}}` | + +
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9fcc5b0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,38 @@ +{ + "name": "Sandbox", + "build": { + "dockerfile": "../Dockerfile", + "context": "..", + "target": "visualizer", + "cacheFrom": "ghcr.io/rplayground/sandbox:main" + }, + "runArgs": [ + // "--cap-add=SYS_PTRACE", // enable debugging, e.g. gdb + // "--ipc=host", // shared memory transport with host, e.g. rviz GUIs + // "--network=host", // network access to host interfaces, e.g. eth0 + // "--pid=host", // DDS discovery with host, without --network=host + // "--privileged", // device access to host peripherals, e.g. USB + // "--security-opt=seccomp=unconfined", // enable debugging, e.g. gdb + ], + "workspaceFolder": "/opt/overlay_ws/src/sandbox", + "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind", + "onCreateCommand": ".devcontainer/on-create-command.sh", + "updateContentCommand": ".devcontainer/update-content-command.sh", + "postCreateCommand": ".devcontainer/post-create-command.sh", + "customizations": { + "codespaces": { + "openFiles": [ + "README.md" + ] + }, + "vscode": { + "settings": {}, + "extensions": [ + "eamodio.gitlens", + "GitHub.copilot", + "ms-iot.vscode-ros", + "streetsidesoftware.code-spell-checker" + ] + } + } +} diff --git a/.devcontainer/on-create-command.sh b/.devcontainer/on-create-command.sh new file mode 100755 index 0000000..f86d22b --- /dev/null +++ b/.devcontainer/on-create-command.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Immediately catch all errors +set -eo pipefail + +# Uncomment for debugging +# set -x +# env + +git config --global --add safe.directory "*" + +.devcontainer/update-content-command.sh diff --git a/.devcontainer/post-create-command.sh b/.devcontainer/post-create-command.sh new file mode 100755 index 0000000..9f9d59a --- /dev/null +++ b/.devcontainer/post-create-command.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Immediately catch all errors +set -eo pipefail + +# Uncomment for debugging +# set -x +# env + +# Enable autocomplete for user +cp /etc/skel/.bashrc ~/ + +# Check if srv folder exists +if [ -d "$ROOT_SRV" ]; then + # Setup Nav2 web app + for dir in $PWD/.devcontainer/caddy/srv/*; \ + do if [ -d "$dir" ]; then ln -s "$dir" $ROOT_SRV; fi done +fi diff --git a/.devcontainer/update-content-command.sh b/.devcontainer/update-content-command.sh new file mode 100755 index 0000000..1ee5e89 --- /dev/null +++ b/.devcontainer/update-content-command.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Immediately catch all errors +set -eo pipefail + +# Uncomment for debugging +# set -x +# env + +echo "Done!" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7bcccec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Folders +.devcontainer/ +.git/ +.github/ + +# Files +.dockerignore +.gitignore + +# Python +__pycache__/ diff --git a/.github/workflows/update_docker_image.yaml b/.github/workflows/update_docker_image.yaml new file mode 100644 index 0000000..036f488 --- /dev/null +++ b/.github/workflows/update_docker_image.yaml @@ -0,0 +1,123 @@ +--- +name: Update Docker Image + +on: + schedule: + # 7am UTC, 12am PDT + - cron: '0 7 * * *' + push: + branches: + - main + paths: + - 'Dockerfile' + - '.github/workflows/update_docker_image.yaml' + +jobs: + check_docker_files: + name: Check Docker Files + runs-on: ubuntu-latest + outputs: + trigger: ${{ steps.check.outputs.trigger }} + no_cache: ${{ steps.check.outputs.no_cache }} + steps: + - name: "Check package updates" + id: check + if: github.event_name == 'push' + run: | + echo "trigger=true" >> $GITHUB_OUTPUT + echo "no_cache=false" >> $GITHUB_OUTPUT + check_docker_image: + name: Check Docker Image + if: github.event_name == 'schedule' + needs: check_docker_files + runs-on: ubuntu-latest + outputs: + trigger: ${{ steps.check.outputs.trigger }} + no_cache: ${{ steps.check.outputs.no_cache }} + container: + image: ghcr.io/${{ github.repository }}:${{ github.ref_name }} + steps: + - name: "Check apt updates" + id: check + env: + SOURCELIST: sources.list.d/ros2.list + run: | + apt-get update \ + -o Dir::Etc::sourcelist="${SOURCELIST}" + apt-get --simulate upgrade \ + -o Dir::Etc::sourcelist="${SOURCELIST}" \ + > upgrade.log + cat upgrade.log + cat upgrade.log \ + | grep "^0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.$" \ + && echo "trigger=false" >> $GITHUB_OUTPUT \ + || echo "trigger=true" >> $GITHUB_OUTPUT + echo "no_cache=true" >> $GITHUB_OUTPUT + rebuild_docker_image: + name: Rebuild Docker Image + if: always() + needs: + - check_docker_files + - check_docker_image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set build config + id: config + run: | + created=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + echo "created=${created}" >> $GITHUB_OUTPUT + + no_cache=false + if [ "${{needs.check_docker_files.outputs.no_cache}}" == 'true' ] || \ + [ "${{needs.check_docker_image.outputs.no_cache}}" == 'true' ] + then + no_cache=true + fi + echo "no_cache=${no_cache}" >> $GITHUB_OUTPUT + + trigger=false + if [ "${{needs.check_docker_files.outputs.trigger}}" == 'true' ] || \ + [ "${{needs.check_docker_image.outputs.trigger}}" == 'true' ] + then + trigger=true + fi + echo "trigger=${trigger}" >> $GITHUB_OUTPUT + - name: Build and push ${{ github.ref_name }} + if: steps.config.outputs.trigger == 'true' + id: docker_build + uses: docker/build-push-action@v4 + with: + context: . + pull: true + push: true + provenance: false + no-cache: ${{ steps.config.outputs.no_cache }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:${{ github.ref_name }} + cache-to: type=inline + target: visualizer + tags: | + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + labels: | + org.opencontainers.image.authors=${{ github.event.repository.owner.html_url }} + org.opencontainers.image.created=${{ steps.config.outputs.created }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.documentation=${{ github.event.repository.homepage }} + org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} + org.opencontainers.image.ref.name=${{ github.ref }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.event.repository.clone_url }} + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.vendor=${{ github.event.repository.owner.login }} + - name: Image digest + if: steps.config.outputs.trigger == 'true' + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa3c638 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Visual Studio Code files +.vscode + +# Python +__pycache__/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4493a20 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,176 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Web Server", + "icon": { + "id": "debug-start" + }, + "type": "process", + "command": "caddy", + "args": [ + "run", + "--watch" + ], + "options": { + "cwd": "${workspaceFolder}/.devcontainer/caddy" + }, + "isBackground": true, + "hide": true, + "problemMatcher": [] + }, + { + "label": "Stop Web Server", + "icon": { + "id": "debug-stop" + }, + "type": "shell", + "command": "echo ${input:Terminate Web Server}", + "hide": true, + "problemMatcher": [] + }, + { + "label": "System Monitor", + "icon": { + "id": "debug-start" + }, + "type": "process", + "command": "glances", + "args": [ + "-w" + ], + "isBackground": true, + "hide": true, + "problemMatcher": [] + }, + { + "label": "Stop System Monitor", + "icon": { + "id": "debug-stop" + }, + "type": "shell", + "command": "echo ${input:Terminate System Monitor}", + "hide": true, + "problemMatcher": [] + }, + { + "label": "Gzweb Bridge", + "icon": { + "id": "debug-start" + }, + "type": "process", + "command": "npm", + "args": [ + "start" + ], + "options": { + "cwd": "${env:GZWEB_WS}", + "env": { + "npm_config_port": "9090" + } + }, + "hide": true, + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Stop Gzweb Bridge", + "icon": { + "id": "debug-stop" + }, + "type": "shell", + "command": "echo ${input:Terminate Gzweb Bridge}", + "hide": true, + "problemMatcher": [] + }, + { + "label": "Foxglove Bridge", + "icon": { + "id": "debug-start" + }, + "type": "shell", + "command": "source /opt/ros/$ROS_DISTRO/setup.bash && ros2 run foxglove_bridge foxglove_bridge", + "isBackground": true, + "hide": true, + "problemMatcher": [] + }, + { + "label": "Stop Foxglove Bridge", + "icon": { + "id": "debug-stop" + }, + "type": "shell", + "command": "echo ${input:Terminate Foxglove Bridge}", + "hide": true, + "problemMatcher": [] + }, + { + "label": "Start Visualizations", + "icon": { + "id": "debug-start" + }, + "dependsOn": [ + "Web Server", + "System Monitor", + "Gzweb Bridge", + "Foxglove Bridge" + ], + // "hide": true, + "problemMatcher": [] + }, + { + "label": "Stop Visualizations", + "icon": { + "id": "debug-stop" + }, + "dependsOn": [ + "Stop Web Server", + "Stop System Monitor", + "Stop Gzweb Bridge", + "Stop Foxglove Bridge" + ], + // "hide": true, + "problemMatcher": [] + }, + { + "label": "Restart Visualizations", + "icon": { + "id": "debug-restart" + }, + "dependsOn": [ + "Stop Visualizations", + "Start Visualizations" + ], + "dependsOrder": "sequence", + "problemMatcher": [] + } + ], + "inputs": [ + { + "id": "Terminate Web Server", + "type": "command", + "command": "workbench.action.tasks.terminate", + "args": "Web Server" + }, + { + "id": "Terminate System Monitor", + "type": "command", + "command": "workbench.action.tasks.terminate", + "args": "System Monitor" + }, + { + "id": "Terminate Gzweb Bridge", + "type": "command", + "command": "workbench.action.tasks.terminate", + "args": "Gzweb Bridge" + }, + { + "id": "Terminate Foxglove Bridge", + "type": "command", + "command": "workbench.action.tasks.terminate", + "args": "Foxglove Bridge" + }, + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3edaf1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,93 @@ +ARG FROM_IMAGE=ros:humble + +# multi-stage for building +FROM $FROM_IMAGE AS builder + +# install ros dependencies +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y \ + ros-$ROS_DISTRO-aws-robomaker-small-warehouse-world \ + ros-$ROS_DISTRO-foxglove-bridge \ + ros-$ROS_DISTRO-nav2-bringup \ + ros-$ROS_DISTRO-rviz2 \ + ros-$ROS_DISTRO-turtlebot3-description \ + ros-$ROS_DISTRO-turtlebot3-simulations \ + && rm -rf /var/lib/apt/lists/* + +# multi-stage for developing +FROM builder AS dever + +# edit apt for caching +RUN mv /etc/apt/apt.conf.d/docker-clean /etc/apt/ + +# install developer dependencies +RUN apt-get update && apt-get install -y \ + bash-completion \ + python3-pip \ + wget && \ + pip3 install \ + bottle \ + glances + +# multi-stage for caddy +FROM caddy:builder AS caddyer + +# build custom modules +RUN xcaddy build \ + --with github.com/caddyserver/replace-response + +# multi-stage for visualizing +FROM dever AS visualizer + +ENV ROOT_SRV /srv +RUN mkdir -p $ROOT_SRV + +# install gzweb dependacies +RUN apt-get install -y --no-install-recommends \ + imagemagick \ + libboost-all-dev \ + libgazebo-dev \ + libgts-dev \ + libjansson-dev \ + libtinyxml-dev \ + nodejs \ + npm \ + psmisc \ + xvfb + +# clone gzweb +ENV GZWEB_WS /opt/gzweb +RUN git clone https://github.com/osrf/gzweb.git $GZWEB_WS + +# setup gzweb +RUN cd $GZWEB_WS && . /usr/share/gazebo/setup.sh && \ + GAZEBO_MODEL_PATH=$GAZEBO_MODEL_PATH:$(find /opt/ros/$ROS_DISTRO/share \ + -mindepth 1 -maxdepth 2 -type d -name "models" | paste -s -d: -) && \ + sed -i "s|var modelList =|var modelList = []; var oldModelList =|g" gz3d/src/gzgui.js && \ + xvfb-run -s "-screen 0 1280x1024x24" ./deploy.sh -m local && \ + ln -s $GZWEB_WS/http/client/assets http/client/assets/models && \ + ln -s $GZWEB_WS/http/client/assets/turtlebot3_common/meshes \ + $GZWEB_WS/http/client/assets/turtlebot3_waffle/meshes && \ + ln -s $GZWEB_WS/http/client $ROOT_SRV/gzweb + +# patch gzsever +RUN GZSERVER=$(which gzserver) && \ + mv $GZSERVER $GZSERVER.orig && \ + echo '#!/bin/bash' > $GZSERVER && \ + echo 'exec xvfb-run -s "-screen 0 1280x1024x24" gzserver.orig "$@"' >> $GZSERVER && \ + chmod +x $GZSERVER + +# setup foxglove +# Use custom fork until PR is merged: +# https://github.com/foxglove/studio/pull/5987 +# COPY --from=ghcr.io/foxglove/studio /src $ROOT_SRV/foxglove +COPY --from=ghcr.io/ruffsl/foxglove_studio@sha256:8a2f2be0a95f24b76b0d7aa536f1c34f3e224022eed607cbf7a164928488332e /src $ROOT_SRV/foxglove + +# install web server +COPY --from=caddyer /usr/bin/caddy /usr/bin/caddy + +# download media files +RUN mkdir -p $ROOT_SRV/media && cd /tmp && \ + export ICONS="icons.tar.gz" && wget https://github.com/ros-planning/navigation2/files/11506823/$ICONS && \ + echo "cae5e2a5230f87b004c8232b579781edb4a72a7431405381403c6f9e9f5f7d41 $ICONS" | sha256sum -c && \ + tar xvz -C $ROOT_SRV/media -f $ICONS && rm $ICONS diff --git a/README.md b/README.md index a2416fa..45b7735 100644 --- a/README.md +++ b/README.md @@ -1 +1,30 @@ -# sandbox \ No newline at end of file +# RPlayground Sandbox + +A sandbox playground for robotics development. + +## Background + +For more related documentation: + +- [Nav2 Documentation](https://navigation.ros.org) + - [Development Guides](https://navigation.ros.org/development_guides) + - [Dev Containers](https://navigation.ros.org/development_guides/devcontainer_docs) + +## Demo + +- Open the project from VS Code with the Dev Container extension installed +- From the command palette ``(Crtl+Shift+P)``, type and enter `Dev containers: Reopen in Container` +- Open open a new shell ``(Crtl+Shift+`)`` from the terminal panel and run: + +```bash +source /opt/ros/$ROS_DISTRO/setup.bash +source /usr/share/gazebo/setup.sh +GAZEBO_MODEL_PATH=$GAZEBO_MODEL_PATH:$(find /opt/ros/$ROS_DISTRO/share \ + -mindepth 1 -maxdepth 2 -type d -name "models" | paste -s -d: -) +ros2 launch ./launch/security_demo_launch.py \ + use_rviz:=False headless:=True +``` + +- From the command palette, type and enter `Tasks: Run Task` and select `Start Visualizations` +- From the port panel, click the `Open in Browser` button for port `8080` forwarded from the container +- Finally, play around with the various sandboxed web apps using the include launcher page diff --git a/launch/security_demo_launch.py b/launch/security_demo_launch.py new file mode 100644 index 0000000..c04de67 --- /dev/null +++ b/launch/security_demo_launch.py @@ -0,0 +1,98 @@ +# Copyright (c) 2021 Samsung Research America +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from ament_index_python.packages import get_package_share_directory + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, ExecuteProcess, IncludeLaunchDescription +from launch.conditions import IfCondition +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration, PythonExpression +from launch_ros.actions import Node + + +def generate_launch_description(): + warehouse_dir = get_package_share_directory('aws_robomaker_small_warehouse_world') + nav2_bringup_dir = get_package_share_directory('nav2_bringup') + python_commander_dir = get_package_share_directory('nav2_simple_commander') + + map_yaml_file = os.path.join(warehouse_dir, 'maps', '005', 'map.yaml') + world = os.path.join(python_commander_dir, 'warehouse.world') + + # Launch configuration variables + use_rviz = LaunchConfiguration('use_rviz') + headless = LaunchConfiguration('headless') + + # Declare the launch arguments + declare_use_rviz_cmd = DeclareLaunchArgument( + 'use_rviz', + default_value='True', + description='Whether to start RVIZ') + + declare_simulator_cmd = DeclareLaunchArgument( + 'headless', + default_value='False', + description='Whether to execute gzclient)') + + # start the simulation + start_gazebo_server_cmd = ExecuteProcess( + cmd=['gzserver', '-s', 'libgazebo_ros_factory.so', world], + cwd=[warehouse_dir], output='screen') + + start_gazebo_client_cmd = ExecuteProcess( + condition=IfCondition(PythonExpression(['not ', headless])), + cmd=['gzclient'], + cwd=[warehouse_dir], output='screen') + + urdf = os.path.join(nav2_bringup_dir, 'urdf', 'turtlebot3_waffle.urdf') + start_robot_state_publisher_cmd = Node( + package='robot_state_publisher', + executable='robot_state_publisher', + name='robot_state_publisher', + output='screen', + arguments=[urdf]) + + # start the visualization + rviz_cmd = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(nav2_bringup_dir, 'launch', 'rviz_launch.py')), + condition=IfCondition(use_rviz), + launch_arguments={'namespace': '', + 'use_namespace': 'False'}.items()) + + # start navigation + bringup_cmd = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(nav2_bringup_dir, 'launch', 'bringup_launch.py')), + launch_arguments={'map': map_yaml_file}.items()) + + # start the demo autonomy task + demo_cmd = Node( + package='nav2_simple_commander', + executable='demo_security', + emulate_tty=True, + output='screen') + + ld = LaunchDescription() + ld.add_action(declare_use_rviz_cmd) + ld.add_action(declare_simulator_cmd) + ld.add_action(start_gazebo_server_cmd) + ld.add_action(start_gazebo_client_cmd) + ld.add_action(start_robot_state_publisher_cmd) + ld.add_action(rviz_cmd) + ld.add_action(bringup_cmd) + ld.add_action(demo_cmd) + return ld