diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 2fcd0acc0e64..8bfda2114e98 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -34,6 +34,7 @@ const compilations = [ 'extensions/positron-catalog-explorer/tsconfig.json', 'extensions/positron-code-cells/tsconfig.json', 'extensions/positron-connections/tsconfig.json', + 'extensions/positron-dev-containers/tsconfig.json', 'extensions/positron-duckdb/tsconfig.json', 'extensions/positron-environment/tsconfig.json', 'extensions/positron-ipywidgets/renderer/tsconfig.json', diff --git a/build/npm/dirs.js b/build/npm/dirs.js index 28ec4bca292a..0106541373a2 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -17,6 +17,7 @@ const dirs = [ 'extensions/positron-code-cells', 'extensions/positron-copilot-chat', 'extensions/positron-connections', + 'extensions/positron-dev-containers', 'extensions/positron-duckdb', 'extensions/positron-environment', 'extensions/positron-ipywidgets', diff --git a/extensions/positron-dev-containers/README.md b/extensions/positron-dev-containers/README.md new file mode 100644 index 000000000000..14005c30a52e --- /dev/null +++ b/extensions/positron-dev-containers/README.md @@ -0,0 +1,109 @@ +# Dev Containers Extension for Positron + +## Overview + +The Dev Containers extension enables you to open any folder or repository inside a Docker container and take advantage of Positron's full feature set within that containerized environment. This allows you to define your project's dependencies declaratively, install them in a lightweight container, and run the entire project inside the container with a consistent, reproducible development environment. + +For compatibility with VS Code, this extension uses most of the same command IDs and setting names from VS Code's version of the extension. The extension itself is novel code, with the exception of the contents of the `spec` folder, which is adapted from the MIT-licensed [dev container reference implementation](https://github.com/devcontainers/cli). + +## Requirements + +- Docker or Podman installed and running +- A workspace with a `.devcontainer.json` or `.devcontainer/devcontainer.json` file + +## Configuration + +Enable the extension in your settings: + +```json +{ + "dev.containers.enable": true +} +``` + +## Usage + +### Opening a Folder in a Container + +1. Open a folder that contains a `.devcontainer.json` file +2. Click the notification prompt, or +3. Use the command palette: **Dev Containers: Reopen in Container** + +### Attaching to a Running Container + +1. Open the Remote Explorer view +2. Expand the "Dev Containers" section +3. Right-click a running container and select "Attach in Current Window" or "Attach in New Window" + +### Rebuilding a Container + +When you've made changes to your `devcontainer.json` or `Dockerfile`: + +- **Dev Containers: Rebuild Container** - Rebuild using cache +- **Dev Containers: Rebuild Without Cache** - Full rebuild from scratch +- **Dev Containers: Rebuild and Reopen in Container** - Rebuild and automatically reopen + +### Key Components + +#### Extension Entry Point (`extension.ts`) +- Activates the extension when enabled +- Registers commands, views, and authority resolvers +- Initializes core managers and services +- Handles pending rebuild requests + +#### Remote Authority Resolver (`remote/authorityResolver.ts`) +- Resolves `dev-container://` and `attached-container://` URIs +- Manages connections to containers +- Handles workspace folder resolution +- Implements VS Code's remote development protocol + +#### Connection Manager (`remote/connectionManager.ts`) +- Manages active connections to containers +- Tracks connection state and lifecycle +- Handles connection failures and recovery +- Coordinates with port forwarding + +#### Dev Container Manager (`container/devContainerManager.ts`) +- Creates and starts containers from `devcontainer.json` +- Handles container building and rebuilding +- Manages container lifecycle (start, stop, remove) +- Retrieves container information and logs + +#### Server Installer (`server/serverInstaller.ts`) +- Downloads the Positron server for the container platform +- Installs and configures the server inside containers +- Generates connection tokens for secure communication +- Handles server updates and versioning + +#### Workspace Mapping Storage (`common/workspaceMappingStorage.ts`) +- Persists mappings between container IDs and workspace paths +- Enables proper workspace resolution across window reloads +- Provides cleanup for stale mappings + +#### Dev Container Reference CLI (spec/) +- Copy of the Microsoft Dev Container Reference CLI +- Used to manage containers and form Docker commands + +### Remote Development Flow + +The workflow typically looks like this; + +1. User invokes "Reopen in Container" +2. Extension reads `devcontainer.json` and creates/starts container +3. Positron server is downloaded and installed in container +4. VS Code resolves the remote authority and establishes connection +5. Extension maps local paths to container paths +6. Necessary ports are forwarded from container to host +7. User can now work with code inside the container + +## Known Limitations + +- Requires glibc-based Linux since Positron Server builds of Linux require glibc (e.g. Alpine will not work) +- Doesn't support "Create from template"; you need to create Dockerfiles / devcontainer JSON files by hand +- Doesn't support development volumes (popular feature from VS Code's implementation, used for e.g. faster I/O) +- Container management views and features are not available if you are inside a container/remote +- Currently experimental and requires explicit enablement +- Requires Docker or Podman to be installed and running +- GPU support is platform-dependent +- Some features have limited support in containers + diff --git a/extensions/positron-dev-containers/extension.webpack.config.js b/extensions/positron-dev-containers/extension.webpack.config.js new file mode 100644 index 000000000000..b1e945fb41d0 --- /dev/null +++ b/extensions/positron-dev-containers/extension.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config.mjs').default; + +module.exports.default = withDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts', + } +}); diff --git a/extensions/positron-dev-containers/package-lock.json b/extensions/positron-dev-containers/package-lock.json new file mode 100644 index 000000000000..ea364f43ab88 --- /dev/null +++ b/extensions/positron-dev-containers/package-lock.json @@ -0,0 +1,947 @@ +{ + "name": "dev-containers", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dev-containers", + "version": "0.0.1", + "dependencies": { + "chalk": "^4.1.2", + "follow-redirects": "^1.15.6", + "js-yaml": "^4.1.0", + "jsonc-parser": "^3.2.1", + "ncp": "^2.0.0", + "proxy-agent": "^6.5.0", + "pull-stream": "^3.7.0", + "recursive-readdir": "^2.2.3", + "semver": "^7.6.0", + "shell-quote": "^1.8.1", + "stream-to-pull-stream": "^1.7.3", + "tar": "^6.2.0", + "text-table": "^0.2.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/ncp": "^2.0.8", + "@types/recursive-readdir": "^2.2.4", + "chai": "^4.3.10" + }, + "engines": { + "vscode": "^1.105.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/ncp": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/ncp/-/ncp-2.0.8.tgz", + "integrity": "sha512-pLNWVLCVWBLVM4F2OPjjK6FWFtByFKD7LhHryF+MbVLws7ENj09mKxRFlhkGPOXfJuaBAG+2iADKJsZwnAbYDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/recursive-readdir": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@types/recursive-readdir/-/recursive-readdir-2.2.4.tgz", + "integrity": "sha512-84REEGT3lcgopvpkmGApzmU5UEG0valme5rQS/KGiguTkJ70/Au8UYZTyrzoZnY9svuX9351+1uvrRPzWDD/uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/looper": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/looper/-/looper-3.0.0.tgz", + "integrity": "sha512-LJ9wplN/uSn72oJRsXTx+snxPet5c8XiZmOKCm906NVYu+ag6SB6vUcnJcWxgnl2NfbIyeobAn7Bwv6xRj2XJg==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "license": "MIT", + "bin": { + "ncp": "bin/ncp" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pull-stream": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pull-stream/-/pull-stream-3.7.0.tgz", + "integrity": "sha512-Eco+/R004UaCK2qEDE8vGklcTG2OeZSVm1kTUQNrykEjDwcFXDZhygFDsW49DbXyJMEhHeRL3z5cRVqPAhXlIw==", + "license": "MIT" + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-to-pull-stream": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/stream-to-pull-stream/-/stream-to-pull-stream-1.7.3.tgz", + "integrity": "sha512-6sNyqJpr5dIOQdgNy/xcDWwDuzAsAwVzhzrWlAPAQ7Lkjx/rv0wgvxEyKwTq6FmNd5rjTrELt/CLmaSw7crMGg==", + "license": "MIT", + "dependencies": { + "looper": "^3.0.0", + "pull-stream": "^3.2.3" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/extensions/positron-dev-containers/package.json b/extensions/positron-dev-containers/package.json new file mode 100644 index 000000000000..bd3c7a8ed523 --- /dev/null +++ b/extensions/positron-dev-containers/package.json @@ -0,0 +1,591 @@ +{ + "name": "dev-containers", + "displayName": "%displayName%", + "description": "%description%", + "publisher": "positron", + "version": "0.0.1", + "extensionKind": [ + "ui" + ], + "engines": { + "vscode": "^1.105.0" + }, + "enabledApiProposals": [ + "resolvers", + "contribViewsRemote" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "%capabilities.untrustedWorkspaces.description%" + }, + "virtualWorkspaces": true + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onDebugResolve:extensionHost", + "onResolveRemoteAuthority:attached-container", + "onResolveRemoteAuthority:dev-container", + "onStartupFinished", + "workspaceContains:.devcontainer.json", + "workspaceContains:.devcontainer/devcontainer.json" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "%configuration.title%", + "properties": { + "dev.containers.enable": { + "scope": "application", + "type": "boolean", + "markdownDescription": "%configuration.enable.markdownDescription%", + "default": false, + "tags": ["experimental"] + }, + "dev.containers.defaultExtensions": { + "scope": "application", + "type": "array", + "description": "%configuration.defaultExtensions.description%", + "default": [], + "items": { + "type": "string", + "pattern": "([a-z0-9A-Z][a-z0-9\\-A-Z]*)\\.([a-z0-9A-Z][a-z0-9\\-A-Z]*)", + "errorMessage": "%configuration.defaultExtensions.errorMessage%" + } + }, + "dev.containers.defaultFeatures": { + "scope": "application", + "type": "object", + "description": "%configuration.defaultFeatures%", + "default": {} + }, + "dev.containers.workspaceMountConsistency": { + "scope": "application", + "type": "string", + "enum": [ + "consistent", + "cached", + "delegated" + ], + "enumDescriptions": [ + "%configuration.workspaceMountConsistency.consistent%", + "%configuration.workspaceMountConsistency.cached%", + "%configuration.workspaceMountConsistency.delegated%" + ], + "description": "%configuration.workspaceMountConsistency.description%", + "default": "cached" + }, + "dev.containers.gpuAvailability": { + "scope": "machine", + "type": "string", + "enum": [ + "all", + "detect", + "none" + ], + "enumDescriptions": [ + "%configuration.gpuAvailability.all%", + "%configuration.gpuAvailability.detect%", + "%configuration.gpuAvailability.none%" + ], + "description": "%configuration.gpuAvailability.description%", + "default": "detect" + }, + "dev.containers.logLevel": { + "scope": "application", + "type": "string", + "description": "%configuration.logLevel.description%", + "default": "debug", + "enum": [ + "info", + "debug", + "trace" + ] + }, + "dev.containers.dockerPath": { + "scope": "application", + "type": "string", + "description": "%configuration.dockerPath.description%", + "default": "docker" + }, + "dev.containers.dockerComposePath": { + "scope": "application", + "type": "string", + "description": "%configuration.dockerComposePath.description%", + "default": "docker-compose" + }, + "dev.containers.dockerSocketPath": { + "scope": "application", + "type": "string", + "description": "%configuration.dockerSocketPath.description%", + "default": "/var/run/docker.sock" + }, + "dev.containers.serverDownloadUrlTemplate": { + "scope": "application", + "type": "string", + "default": "https://cdn.posit.co/positron/dailies/reh/${arch-long}/positron-reh-${os}-${arch}-${version}.tar.gz", + "markdownDescription": "The URL from where the Positron server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- `${quality}`: Positron server quality, e.g. stable or insiders\n- `${version}`: Positron server version, e.g. 2024.10.0-123\n- `${commit}`: Positron server release commit\n- `${os}`: Positron server OS, e.g. linux, darwin, win32\n- `${arch}`: Positron server arch, e.g. x64, arm64\n- `${arch-long}`: Positron server arch in long format, e.g. x86_64, arm64" + } + } + }, + "commands": [ + { + "command": "remote-containers.reopenInContainer", + "title": "%command.reopenInContainer%", + "category": "%command.category%", + "icon": "$(remote-explorer)", + "actionBarOptions": { + "controlType": "button", + "displayTitle": true + } + }, + { + "command": "remote-containers.rebuildAndReopenInContainer", + "title": "%command.rebuildAndReopenInContainer%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.rebuildNoCacheAndReopenInContainer", + "title": "%command.rebuildNoCacheAndReopenInContainer%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.reopenLocally", + "title": "%command.reopenLocally%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openFolder", + "title": "%command.openFolder%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openFolderInContainerInCurrentWindow", + "title": "%command.openFolderInContainerInCurrentWindow%", + "category": "%command.category%", + "icon": "$(arrow-right)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openFolderInContainerInNewWindow", + "title": "%command.openFolderInContainerInNewWindow%", + "category": "%command.category%", + "icon": "$(empty-window)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openWorkspace", + "title": "%command.openWorkspace%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.attachToRunningContainer", + "title": "%command.attachToRunningContainer%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.rebuildContainer", + "title": "%command.rebuildContainer%", + "category": "%command.category%", + "icon": "$(refresh)", + "actionBarOptions": { + "controlType": "button", + "displayTitle": true + }, + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.rebuildContainerNoCache", + "title": "%command.rebuildContainerNoCache%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.settings", + "title": "%command.settings%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openDevContainerFile", + "title": "%command.openDevContainerFile%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.revealLogTerminal", + "title": "%command.revealLogTerminal%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openLogFile", + "title": "%command.openLogFile%", + "category": "%command.developerCategory%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.openLastLogFile", + "title": "%command.openLastLogFile%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.testConnection", + "title": "%command.testConnection%", + "category": "%command.developerCategory%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.attachToContainerInCurrentWindow", + "title": "%command.attachToContainerInCurrentWindow%", + "category": "%command.category%", + "icon": "$(arrow-right)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.attachToContainerInNewWindow", + "title": "%command.attachToContainerInNewWindow%", + "category": "%command.category%", + "icon": "$(empty-window)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.stopContainer", + "title": "%command.stopContainer%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.startContainer", + "title": "%command.startContainer%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.removeContainer", + "title": "%command.removeContainer%", + "category": "%command.category%", + "icon": "$(trash)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.explorerTargetsRefresh", + "title": "%command.explorerTargetsRefresh%", + "category": "%command.category%", + "icon": "$(refresh)", + "enablement": "dev.containers.enabled" + }, + { + "command": "remote-containers.showContainerLog", + "title": "%command.showContainerLog%", + "category": "%command.category%", + "enablement": "dev.containers.enabled" + } + ], + "menus": { + "editor/actions/left": [ + { + "command": "remote-containers.reopenInContainer", + "when": "dev.containers.enabled && !hideConnectCommands && !remoteName && (resourceFilename == '.devcontainer.json' || resourcePath =~ /\\.devcontainer[\\/\\\\]devcontainer\\.json$/ || resourcePath =~ /\\.devcontainer[\\/\\\\].*[Dd]ockerfile/)", + "group": "devcontainer@1" + }, + { + "command": "remote-containers.rebuildContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && remoteName =~ /^(dev-container|attached-container)$/ && (resourceFilename == '.devcontainer.json' || resourcePath =~ /\\.devcontainer[\\/\\\\]devcontainer\\.json$/ || resourcePath =~ /\\.devcontainer[\\/\\\\].*[Dd]ockerfile/)", + "group": "devcontainer@1" + } + ], + "commandPalette": [ + { + "command": "remote-containers.reopenInContainer", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount != 0 && !remoteName && !virtualWorkspace" + }, + { + "command": "remote-containers.reopenInContainer", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount != 0 && remoteName =~ /^(wsl|ssh-remote)$/" + }, + { + "command": "remote-containers.reopenInContainer", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount != 0 && remoteName == dev-container && isRecoveryContainer" + }, + { + "command": "remote-containers.rebuildAndReopenInContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && workspaceFolderCount != 0 && !remoteName && !virtualWorkspace" + }, + { + "command": "remote-containers.rebuildAndReopenInContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && workspaceFolderCount != 0 && remoteName =~ /^(wsl|ssh-remote)$/" + }, + { + "command": "remote-containers.rebuildNoCacheAndReopenInContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && workspaceFolderCount != 0 && !remoteName && !virtualWorkspace" + }, + { + "command": "remote-containers.rebuildNoCacheAndReopenInContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && workspaceFolderCount != 0 && remoteName =~ /^(wsl|ssh-remote)$/" + }, + { + "command": "remote-containers.reopenLocally", + "when": "dev.containers.enabled && !hideConnectCommands && canReopenLocally" + }, + { + "command": "remote-containers.openFolder", + "when": "dev.containers.enabled && !hideConnectCommands && remoteName =~ /^(dev-container|attached-container|exec|wsl|ssh-remote)?$/" + }, + { + "command": "remote-containers.openWorkspace", + "when": "dev.containers.enabled && !hideConnectCommands && remoteName =~ /^(dev-container|attached-container|exec|wsl|ssh-remote)?$/" + }, + { + "command": "remote-containers.attachToRunningContainer", + "when": "dev.containers.enabled && !hideConnectCommands && remoteName =~ /^(wsl|ssh-remote)?$/" + }, + { + "command": "remote-containers.rebuildContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && remoteName =~ /^dev-container$/" + }, + { + "command": "remote-containers.rebuildContainerNoCache", + "when": "dev.containers.enabled && !hideRebuildCommands && remoteName =~ /^dev-container$/" + }, + { + "command": "remote-containers.testConnection", + "when": "dev.containers.enabled && remoteName =~ /^(dev|attached)-container$/" + }, + { + "command": "remote-containers.openDevContainerFile", + "when": "dev.containers.enabled && workspaceFolderCount != 0 && !remoteName && !virtualWorkspace" + }, + { + "command": "remote-containers.openDevContainerFile", + "when": "dev.containers.enabled && workspaceFolderCount != 0 && remoteName =~ /^(wsl|ssh-remote)$/" + }, + { + "command": "remote-containers.openDevContainerFile", + "when": "dev.containers.enabled && remoteName =~ /^(dev|attached)-container$/" + }, + { + "command": "remote-containers.attachToContainerInCurrentWindow", + "when": "false" + }, + { + "command": "remote-containers.attachToContainerInNewWindow", + "when": "false" + }, + { + "command": "remote-containers.stopContainer", + "when": "false" + }, + { + "command": "remote-containers.removeContainer", + "when": "false" + }, + { + "command": "remote-containers.startContainer", + "when": "false" + }, + { + "command": "remote-containers.explorerTargetsRefresh", + "when": "false" + }, + { + "command": "remote-containers.openFolderInContainerInCurrentWindow", + "when": "false" + }, + { + "command": "remote-containers.openFolderInContainerInNewWindow", + "when": "false" + }, + { + "command": "remote-containers.showContainerLog", + "when": "false" + } + ], + "statusBar/remoteIndicator": [ + { + "command": "remote-containers.openDevContainerFile", + "group": "remote_30_dev-container_2_actions@0", + "when": "dev.containers.enabled" + }, + { + "command": "remote-containers.attachToRunningContainer", + "group": "remote_30_dev-container_2_actions@2", + "when": "dev.containers.enabled && !hideConnectCommands && remoteName =~ /^(wsl|ssh-remote)?$/" + }, + { + "command": "remote-containers.rebuildContainer", + "group": "remote_30_dev-container_2_actions@6", + "when": "dev.containers.enabled && !hideRebuildCommands && remoteName =~ /^dev-container$/" + }, + { + "command": "remote-containers.reopenLocally", + "group": "remote_30_dev-container_2_actions@7", + "when": "dev.containers.enabled && !hideConnectCommands && canReopenLocally" + }, + { + "command": "remote-containers.reopenInContainer", + "group": "remote_30_dev-container_2_actions@8", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount != 0 && remoteName =~ /^(wsl|ssh-remote)$/" + }, + { + "command": "remote-containers.reopenInContainer", + "group": "remote_30_dev-container_2_actions@8", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount != 0 && !remoteName && !virtualWorkspace" + }, + { + "command": "remote-containers.openFolder", + "group": "remote_30_dev-container_2_actions@8", + "when": "dev.containers.enabled && !hideConnectCommands && workspaceFolderCount == 0 && !remoteName && !virtualWorkspace" + } + ], + "view/title": [ + { + "command": "remote-containers.explorerTargetsRefresh", + "when": "dev.containers.enabled && view == targetsContainers && !remote-containers:needsDockerStartOrInstall && !remote-containers:noContainersWithFolder && !remote-containers:noContainersWithoutFolder", + "group": "navigation@3" + } + ], + "view/item/context": [ + { + "command": "remote-containers.openFolderInContainerInCurrentWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(dev|attached|exited|running).*Folder$/", + "group": "1_folder@1" + }, + { + "command": "remote-containers.openFolderInContainerInCurrentWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(dev|attached|exited|running).*Folder$/", + "group": "inline@1" + }, + { + "command": "remote-containers.openFolderInContainerInNewWindow", + "when": "view == targetsContainers && viewItem =~ /^(dev|attached|exited|running).*Folder$/", + "group": "1_folder@2" + }, + { + "command": "remote-containers.openFolderInContainerInNewWindow", + "when": "view == targetsContainers && viewItem =~ /^(dev|attached|exited|running).*Folder$/", + "group": "inline@2" + }, + { + "command": "workbench.action.closeFolder", + "when": "view == targetsContainers && viewItem =~ /active.*Folder/", + "group": "1_folder@1" + }, + { + "command": "remote-containers.reopenLocally", + "when": "dev.containers.enabled && !hideConnectCommands && canReopenLocally && view == targetsContainers && viewItem =~ /active(d|D)ev/", + "group": "1_folder@1" + }, + { + "command": "remote-containers.attachToContainerInCurrentWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(active|running|exited)(Dev|)Container$/", + "group": "1_container@1" + }, + { + "command": "remote-containers.attachToContainerInCurrentWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(active|running|exited)(Dev|)Container$/", + "group": "inline@1" + }, + { + "command": "remote-containers.attachToContainerInNewWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(active|running|exited)(Dev|)Container$/", + "group": "1_container@2" + }, + { + "command": "remote-containers.attachToContainerInNewWindow", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(active|running|exited)(Dev|)Container$/", + "group": "inline@2" + }, + { + "command": "remote-containers.stopContainer", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^running(Dev|)Container/", + "group": "2_container@2" + }, + { + "command": "remote-containers.removeContainer", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(running|exited)(Dev|)Container/", + "group": "2_container@3" + }, + { + "command": "remote-containers.rebuildContainer", + "when": "dev.containers.enabled && !hideRebuildCommands && view == targetsContainers && viewItem =~ /activeDevContainer/", + "group": "2_container@1" + }, + { + "command": "remote-containers.removeContainer", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(running|exited)(Dev|)Container/", + "group": "inline@3", + "icon": "$(trash)" + }, + { + "command": "remote-containers.startContainer", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^exited(Dev|)Container/", + "group": "1_container@3" + }, + { + "command": "remote-containers.showContainerLog", + "when": "dev.containers.enabled && view == targetsContainers && viewItem =~ /^(active|running|exited)(Dev|)Container/", + "group": "1_container@6" + } + ], + "extension/context": [] + }, + "views": { + "remote": [ + { + "id": "targetsContainers", + "name": "%views.remote.targetsContainers%", + "when": "dev.containers.enabled && !hideConnectCommands && !isInDevContainer", + "group": "targets@2", + "remoteName": [ + "dev-container", + "attached-container", + "exec" + ] + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "pretest": "npm run compile && npm run lint", + "lint": "eslint src --ext ts", + "test": "node ./out/test/runTest.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/posit-dev/positron" + }, + "dependencies": { + "chalk": "^4.1.2", + "follow-redirects": "^1.15.6", + "js-yaml": "^4.1.0", + "jsonc-parser": "^3.2.1", + "ncp": "^2.0.0", + "proxy-agent": "^6.5.0", + "pull-stream": "^3.7.0", + "recursive-readdir": "^2.2.3", + "semver": "^7.6.0", + "shell-quote": "^1.8.1", + "stream-to-pull-stream": "^1.7.3", + "tar": "^6.2.0", + "text-table": "^0.2.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/ncp": "^2.0.8", + "@types/recursive-readdir": "^2.2.4", + "chai": "^4.3.10" + } +} diff --git a/extensions/positron-dev-containers/package.nls.json b/extensions/positron-dev-containers/package.nls.json new file mode 100644 index 000000000000..433b5b3ee44e --- /dev/null +++ b/extensions/positron-dev-containers/package.nls.json @@ -0,0 +1,52 @@ +{ + "displayName": "Dev Containers", + "description": "Open any folder or repository inside a Docker container and take advantage of Positron's full feature set.", + "capabilities.untrustedWorkspaces.description": "Opening a folder in a container requires workspace trust.", + "configuration.title": "Dev Containers", + "configuration.enable.markdownDescription": "Enable Dev Containers for Positron. Dev Containers allow you to define a project's dependencies declaratively, install them in a lightweight container, and run the project inside the container.", + "configuration.defaultExtensions.description": "Configures the list of extensions to always install while creating a container.", + "configuration.defaultExtensions.errorMessage": "Expected format '${publisher}.${name}'. Example: 'vscode.csharp'.", + "configuration.defaultFeatures": "Configures the list of features to always install while creating a container.", + "configuration.workspaceMountConsistency.consistent": "Perfect consistency.", + "configuration.workspaceMountConsistency.cached": "The host's view is authoritative.", + "configuration.workspaceMountConsistency.delegated": "The container's view is authoritative.", + "configuration.workspaceMountConsistency.description": "The consistency level used for the workspace mount (existing containers must be rebuilt to take effect).", + "configuration.gpuAvailability.all": "All GPUs are available. This expects the host to have a GPU.", + "configuration.gpuAvailability.detect": "Availability should be detected automatically.", + "configuration.gpuAvailability.none": "No GPUs are available.", + "configuration.gpuAvailability.description": "Availability of GPUs when a dev container requires any.", + "configuration.logLevel.description": "The log level for the extension.", + "configuration.dockerPath.description": "Docker (or Podman) executable name or path.", + "configuration.dockerComposePath.description": "Docker Compose executable name or path.", + "configuration.dockerSocketPath.description": "Docker socket path. Used, e.g., when connecting to a Dev Container with the devcontainer.json in a Docker Volume.", + "command.category": "Dev Containers", + "command.developerCategory": "Dev Containers Developer", + "command.reopenInContainer": "Reopen in Container", + "command.rebuildAndReopenInContainer": "Rebuild and Reopen in Container", + "command.rebuildNoCacheAndReopenInContainer": "Rebuild Without Cache and Reopen in Container", + "command.reopenLocally": "Reopen Folder Locally", + "command.openFolder": "Open Folder in Container...", + "command.openFolderInContainerInCurrentWindow": "Open in Container in Current Window", + "command.openFolderInContainerInNewWindow": "Open in Container in New Window", + "command.openWorkspace": "Open Workspace in Container...", + "command.attachToRunningContainer": "Attach to Running Container...", + "command.rebuildContainer": "Rebuild Container", + "command.rebuildContainerNoCache": "Rebuild Container Without Cache", + "command.settings": "Settings", + "command.openDevContainerFile": "Open Container Configuration File", + "command.revealLogTerminal": "Show Container Log", + "command.openLogFile": "Show All Logs...", + "command.openLastLogFile": "Show Previous Log", + "command.testConnection": "Test Connection", + "command.attachToContainerInCurrentWindow": "Attach in Current Window", + "command.attachToContainerInNewWindow": "Attach in New Window", + "command.stopContainer": "Stop Container", + "command.startContainer": "Start Container", + "command.removeContainer": "Remove Container", + "command.explorerTargetsRefresh": "Refresh", + "command.showContainerLog": "Show Container Log", + "resourceLabelFormatters.exec.workspaceSuffix": "Exec", + "resourceLabelFormatters.dev-container.workspaceSuffix":"Dev Container", + "resourceLabelFormatters.attached-container.workspaceSuffix":"Container", + "views.remote.targetsContainers": "Dev Containers" +} diff --git a/extensions/positron-dev-containers/src/commands/attach.ts b/extensions/positron-dev-containers/src/commands/attach.ts new file mode 100644 index 000000000000..479c26875107 --- /dev/null +++ b/extensions/positron-dev-containers/src/commands/attach.ts @@ -0,0 +1,368 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import { getLogger } from '../common/logger'; +import { getDevContainerManager } from '../container/devContainerManager'; +import { DevContainerTreeItem } from '../views/devContainersTreeProvider'; +import { WorkspaceMappingStorage } from '../common/workspaceMappingStorage'; +import { encodeDevContainerAuthority } from '../common/authorityEncoding'; + +/** + * Attach to a running container selected from a quick pick menu + */ +export async function attachToRunningContainer(): Promise { + const logger = getLogger(); + logger.debug('Command: attachToRunningContainer'); + + try { + // Get all containers + const manager = getDevContainerManager(); + const containers = await manager.listDevContainers(); + + // Filter to only running containers + const runningContainers = containers.filter(c => c.state === 'running'); + + if (runningContainers.length === 0) { + await vscode.window.showInformationMessage(vscode.l10n.t('No running containers found')); + return; + } + + // Create quick pick items + interface ContainerQuickPickItem extends vscode.QuickPickItem { + container: typeof runningContainers[0]; + } + + const items: ContainerQuickPickItem[] = runningContainers.map(container => ({ + label: container.containerName, + description: container.workspaceFolder ? `$(folder) ${container.workspaceFolder}` : undefined, + detail: vscode.l10n.t('ID: {0}', container.containerId.substring(0, 12)), + container + })); + + // Show quick pick + const selected = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select a running container to attach to'), + matchOnDescription: true, + matchOnDetail: true + }); + + if (!selected) { + return; + } + + const containerInfo = selected.container; + + // Get the workspace folder to open (this is the LOCAL folder path from container labels) + const localWorkspaceFolder = containerInfo.workspaceFolder; + if (!localWorkspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder found for this container')); + return; + } + + // Determine the remote workspace path + const workspaceName = localWorkspaceFolder.split(/[/\\]/).pop(); + const remoteWorkspaceFolder = `/workspaces/${workspaceName}`; + + // Store workspace mapping BEFORE opening the window + // This ensures the mapping is available when the ConnectionManager resolves the authority + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(containerInfo.containerId, localWorkspaceFolder, remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${containerInfo.containerId} -> ${localWorkspaceFolder}`); + } catch (error) { + logger.error('Failed to store workspace mapping', error); + // Continue anyway - ConnectionManager will try to determine paths from container + } + + // Build the remote URI using workspace name for display + const authority = encodeDevContainerAuthority(workspaceName, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${remoteWorkspaceFolder}`); + + logger.info(`Opening container in new window: ${remoteUri.toString()}`); + + // Open the folder in a new window + await vscode.commands.executeCommand('vscode.openFolder', remoteUri, true); + } catch (error) { + logger.error('Failed to attach to running container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to attach to running container: {0}', error)); + } +} + +/** + * Attach to a container in the current window + */ +export async function attachToContainerInCurrentWindow(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: attachToContainerInCurrentWindow'); + + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + try { + // Start the container if it's stopped + const manager = getDevContainerManager(); + if (containerInfo.state !== 'running') { + logger.info(`Starting container: ${containerInfo.containerId}`); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Starting container ${containerInfo.containerName}...`, + cancellable: false + }, + async () => { + await manager.startContainer(containerInfo.containerId); + } + ) + } + + // Get the workspace folder to open (this is the LOCAL folder path from container labels) + const localWorkspaceFolder = containerInfo.workspaceFolder; + if (!localWorkspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder found for this container')); + return; + } + + // Determine the remote workspace path + const workspaceName = localWorkspaceFolder.split(/[/\\]/).pop(); + const remoteWorkspaceFolder = `/workspaces/${workspaceName}`; + + // Store workspace mapping BEFORE opening the window + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(containerInfo.containerId, localWorkspaceFolder, remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${containerInfo.containerId} -> ${localWorkspaceFolder}`); + } catch (error) { + logger.error('Failed to store workspace mapping', error); + // Continue anyway - ConnectionManager will try to determine paths from container + } + + // Build the remote URI using workspace name for display + const authority = encodeDevContainerAuthority(workspaceName, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${remoteWorkspaceFolder}`); + + logger.info(`Opening container in current window: ${remoteUri.toString()}`); + + // Open the folder in the current window + await vscode.commands.executeCommand('vscode.openFolder', remoteUri, false); + } catch (error) { + logger.error('Failed to attach to container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to attach to container: {0}', error)); + } +} + +/** + * Attach to a container in a new window + */ +export async function attachToContainerInNewWindow(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: attachToContainerInNewWindow'); + + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + try { + // Start the container if it's stopped + const manager = getDevContainerManager(); + if (containerInfo.state !== 'running') { + logger.info(`Starting container: ${containerInfo.containerId}`); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Starting container ${containerInfo.containerName}...`, + cancellable: false + }, + async () => { + await manager.startContainer(containerInfo.containerId); + } + ); + } + + // Get the workspace folder to open (this is the LOCAL folder path from container labels) + const localWorkspaceFolder = containerInfo.workspaceFolder; + if (!localWorkspaceFolder) { + await vscode.window.showErrorMessage('No workspace folder found for this container'); + return; + } + + // Determine the remote workspace path + const workspaceName = localWorkspaceFolder.split(/[/\\]/).pop(); + const remoteWorkspaceFolder = `/workspaces/${workspaceName}`; + + // Store workspace mapping BEFORE opening the window + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(containerInfo.containerId, localWorkspaceFolder, remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${containerInfo.containerId} -> ${localWorkspaceFolder}`); + } catch (error) { + logger.error('Failed to store workspace mapping', error); + // Continue anyway - ConnectionManager will try to determine paths from container + } + + // Build the remote URI using workspace name for display + const authority = encodeDevContainerAuthority(workspaceName, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${remoteWorkspaceFolder}`); + + logger.info(`Opening container in new window: ${remoteUri.toString()}`); + + // Open the folder in a new window + await vscode.commands.executeCommand('vscode.openFolder', remoteUri, true); + } catch (error) { + logger.error('Failed to attach to container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to attach to container: {0}', error)); + } +} + +/** + * Stop a running container + */ +export async function stopContainer(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: stopContainer'); + + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + // Check if the container is already stopped + if (containerInfo.state !== 'running') { + await vscode.window.showWarningMessage(vscode.l10n.t("Container '{0}' is not running", containerInfo.containerName)); + return; + } + + try { + logger.info(`Stopping container: ${containerInfo.containerId}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Stopping container {0}...', containerInfo.containerName), + cancellable: false + }, + async () => { + const manager = getDevContainerManager(); + await manager.stopContainer(containerInfo.containerId); + } + ); + + // Refresh the tree view + await vscode.commands.executeCommand('remote-containers.explorerTargetsRefresh'); + + await vscode.window.showInformationMessage(vscode.l10n.t("Container '{0}' stopped successfully", containerInfo.containerName)); + + } catch (error) { + logger.error('Failed to stop container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to stop container: {0}', error)); + } +} + +/** + * Start a stopped container + */ +export async function startContainer(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: startContainer'); + + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + // Check if the container is already running + if (containerInfo.state === 'running') { + await vscode.window.showWarningMessage(vscode.l10n.t("Container '{0}' is already running", containerInfo.containerName)); + return; + } + + try { + logger.info(`Starting container: ${containerInfo.containerId}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Starting container {0}...', containerInfo.containerName), + cancellable: false + }, + async () => { + const manager = getDevContainerManager(); + await manager.startContainer(containerInfo.containerId); + } + ); + + await vscode.window.showInformationMessage(vscode.l10n.t("Container '{0}' started successfully", containerInfo.containerName)); + + // Refresh the tree view + await vscode.commands.executeCommand('remote-containers.explorerTargetsRefresh'); + } catch (error) { + logger.error('Failed to start container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to start container: {0}', error)); + } +} + +/** + * Remove a container + */ +export async function removeContainer(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: removeContainer'); + + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + // Confirm deletion + const answer = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('Remove Container'), + vscode.l10n.t("Are you sure you want to remove container '{0}'?", containerInfo.containerName), + vscode.l10n.t('Remove'), + vscode.l10n.t('Cancel') + ); + + if (!answer) { + return; + } + + try { + logger.info(`Removing container: ${containerInfo.containerId}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Removing container {0}...', containerInfo.containerName), + cancellable: false + }, + async () => { + const manager = getDevContainerManager(); + await manager.removeContainer(containerInfo.containerId, true); + } + ); + + // Refresh the tree view + await vscode.commands.executeCommand('remote-containers.explorerTargetsRefresh'); + + await vscode.window.showInformationMessage(vscode.l10n.t("Container '{0}' removed successfully", containerInfo.containerName)); + + } catch (error) { + logger.error('Failed to remove container', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to remove container: {0}', error)); + } +} diff --git a/extensions/positron-dev-containers/src/commands/open.ts b/extensions/positron-dev-containers/src/commands/open.ts new file mode 100644 index 000000000000..a4c320d1b3b9 --- /dev/null +++ b/extensions/positron-dev-containers/src/commands/open.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import { getLogger } from '../common/logger'; +import { Workspace } from '../common/workspace'; +import { getDevContainerManager } from '../container/devContainerManager'; + +/** + * Open a folder in a dev container (shows folder picker) + */ +export async function openFolder(): Promise { + const logger = getLogger(); + logger.debug('Command: openFolder'); + + try { + // Show folder picker + const folderUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t('Open in Container'), + title: vscode.l10n.t('Select Folder to Open in Container') + }); + + if (!folderUris || folderUris.length === 0) { + return; // User cancelled + } + + const folderPath = folderUris[0].fsPath; + logger.info(`Selected folder: ${folderPath}`); + + // Check if folder has dev container configuration + // Create a temporary WorkspaceFolder object to check for dev container + const tempFolder: vscode.WorkspaceFolder = { + uri: folderUris[0], + name: folderUris[0].fsPath.split('/').pop() || 'folder', + index: 0 + }; + const hasDevContainer = Workspace.hasDevContainer(tempFolder); + if (!hasDevContainer) { + const response = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('No Dev Container Configuration'), + vscode.l10n.t('No dev container configuration found in this folder. Do you want to create one?'), + vscode.l10n.t('Create Configuration'), + vscode.l10n.t('Cancel') + ); + + if (response) { + // Open the folder first, then let user create the configuration + await vscode.commands.executeCommand('vscode.openFolder', folderUris[0]); + // Suggest creating dev container file + await vscode.window.showInformationMessage( + vscode.l10n.t('Use "Dev Containers: Add Dev Container Configuration Files..." to create a configuration.'), + vscode.l10n.t('OK') + ); + } + return; + } + + // Open folder in container + await openFolderInContainer(folderPath, false); + } catch (error) { + logger.error('Failed to open folder in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open folder in container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Open a folder in a dev container in the current window + */ +export async function openFolderInContainerInCurrentWindow(folderPath?: string): Promise { + const logger = getLogger(); + logger.debug('Command: openFolderInContainerInCurrentWindow'); + + try { + // If no folder path provided, show picker + if (!folderPath) { + const folderUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t('Open in Container'), + title: vscode.l10n.t('Select Folder to Open in Container') + }); + + if (!folderUris || folderUris.length === 0) { + return; // User cancelled + } + + folderPath = folderUris[0].fsPath; + } + + logger.info(`Opening folder in container (current window): ${folderPath}`); + await openFolderInContainer(folderPath, false); + } catch (error) { + logger.error('Failed to open folder in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open folder in container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Open a folder in a dev container in a new window + */ +export async function openFolderInContainerInNewWindow(folderPath?: string): Promise { + const logger = getLogger(); + logger.debug('Command: openFolderInContainerInNewWindow'); + + try { + // If no folder path provided, show picker + if (!folderPath) { + const folderUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t('Open in Container'), + title: vscode.l10n.t('Select Folder to Open in Container') + }); + + if (!folderUris || folderUris.length === 0) { + return; // User cancelled + } + + folderPath = folderUris[0].fsPath; + } + + logger.info(`Opening folder in container (new window): ${folderPath}`); + await openFolderInContainer(folderPath, true); + } catch (error) { + logger.error('Failed to open folder in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open folder in container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Open a workspace file in a dev container + */ +export async function openWorkspace(): Promise { + const logger = getLogger(); + logger.debug('Command: openWorkspace'); + + try { + // Show workspace file picker + const workspaceUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: vscode.l10n.t('Open Workspace in Container'), + title: vscode.l10n.t('Select Workspace File to Open in Container'), + filters: { + [vscode.l10n.t('Workspace Files')]: ['code-workspace'] + } + }); + + if (!workspaceUris || workspaceUris.length === 0) { + return; // User cancelled + } + + const workspacePath = workspaceUris[0].fsPath; + logger.info(`Selected workspace: ${workspacePath}`); + + // For now, we'll open the workspace locally and let user reopen in container + // Full workspace support requires handling multi-root workspaces + await vscode.commands.executeCommand('vscode.openFolder', workspaceUris[0]); + + await vscode.window.showInformationMessage( + vscode.l10n.t('Workspace opened. Use "Reopen in Container" to open it in a dev container.'), + vscode.l10n.t('OK') + ); + } catch (error) { + logger.error('Failed to open workspace', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open workspace: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Helper function to open a folder in a container + * @param folderPath Path to the folder to open + * @param forceNewWindow Whether to force opening in a new window + */ +async function openFolderInContainer(folderPath: string, forceNewWindow: boolean): Promise { + const logger = getLogger(); + + // Verify folder has dev container configuration + const folderUri = vscode.Uri.file(folderPath); + const tempFolder: vscode.WorkspaceFolder = { + uri: folderUri, + name: folderUri.fsPath.split('/').pop() || 'folder', + index: 0 + }; + if (!Workspace.hasDevContainer(tempFolder)) { + await vscode.window.showErrorMessage( + vscode.l10n.t('No dev container configuration found. Create a .devcontainer/devcontainer.json or .devcontainer.json file first.') + ); + return; + } + + // Build/Start Container (output will be shown in terminal) + logger.info('Building/starting dev container...'); + + const manager = getDevContainerManager(); + const result = await manager.createOrStartContainer({ + workspaceFolder: folderPath, + rebuild: false, + noCache: false + }); + + logger.info(`Container ready: ${result.containerId}`); + + // Validate that workspace folder is properly determined + if (!result.remoteWorkspaceFolder) { + throw new Error('Remote workspace folder not determined. Workspace may not be mounted.'); + } + + // Open the folder with remote authority + const authority = `dev-container+${result.containerId}`; + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${result.remoteWorkspaceFolder}`); + + logger.info(`Opening folder with authority: ${authority}`); + logger.info(`Remote workspace: ${result.remoteWorkspaceFolder}`); + + // Open folder with the remote authority + await vscode.commands.executeCommand( + 'vscode.openFolder', + remoteUri, + forceNewWindow + ); +} diff --git a/extensions/positron-dev-containers/src/commands/rebuild.ts b/extensions/positron-dev-containers/src/commands/rebuild.ts new file mode 100644 index 000000000000..c3a23c26a795 --- /dev/null +++ b/extensions/positron-dev-containers/src/commands/rebuild.ts @@ -0,0 +1,416 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import { getLogger } from '../common/logger'; +import { Workspace } from '../common/workspace'; +import { getDevContainerManager } from '../container/devContainerManager'; +import { RebuildStateManager, PendingRebuild } from '../common/rebuildState'; +import { encodeDevContainerAuthority, decodeDevContainerAuthority } from '../common/authorityEncoding'; +import { WorkspaceMappingStorage } from '../common/workspaceMappingStorage'; + +/** + * Rebuild and reopen the current workspace in a dev container + */ +export async function rebuildAndReopenInContainer(): Promise { + const logger = getLogger(); + logger.debug('Command: rebuildAndReopenInContainer'); + + try { + // Get current workspace folder + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (!workspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + // Check if workspace has dev container configuration + // Use async version to work with remote filesystems (inside containers) + if (!await Workspace.hasDevContainerAsync(workspaceFolder)) { + await vscode.window.showErrorMessage( + vscode.l10n.t('No dev container configuration found. Create a .devcontainer/devcontainer.json or .devcontainer.json file first.') + ); + return; + } + + // Confirm rebuild action + const confirm = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('Rebuild Container'), + vscode.l10n.t('This will rebuild the container and may take several minutes. Continue?'), + vscode.l10n.t('Rebuild'), + vscode.l10n.t('Cancel') + ); + + if (!confirm) { + return; + } + + // Rebuild the container (output will be shown in terminal) + logger.info('Rebuilding dev container...'); + + const manager = getDevContainerManager(); + const result = await manager.createOrStartContainer({ + workspaceFolder: workspaceFolder.uri.fsPath, + rebuild: true, + noCache: false + }); + + logger.info(`Container rebuilt: ${result.containerId}`); + + // Store workspace mapping BEFORE opening the window + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(result.containerId, workspaceFolder.uri.fsPath, result.remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${result.containerId} -> ${workspaceFolder.uri.fsPath}`); + } catch (error) { + logger.error('Failed to store workspace mapping before window reload', error); + } + + // Reload window with remote authority + // Extract just the folder name from the remote workspace path + const workspaceName = result.remoteWorkspaceFolder.split('/').filter(s => s).pop(); + const authority = encodeDevContainerAuthority(result.containerId, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${result.remoteWorkspaceFolder}`); + + logger.info(`Reloading window with authority: ${authority}`); + + // Reload window with the remote authority + await vscode.commands.executeCommand('vscode.openFolder', remoteUri); + } catch (error) { + logger.error('Failed to rebuild and reopen in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to rebuild dev container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Rebuild (without cache) and reopen the current workspace in a dev container + */ +export async function rebuildNoCacheAndReopenInContainer(): Promise { + const logger = getLogger(); + logger.debug('Command: rebuildNoCacheAndReopenInContainer'); + + try { + // Get current workspace folder + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (!workspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + // Check if workspace has dev container configuration + // Use async version to work with remote filesystems (inside containers) + if (!await Workspace.hasDevContainerAsync(workspaceFolder)) { + await vscode.window.showErrorMessage( + vscode.l10n.t('No dev container configuration found. Create a .devcontainer/devcontainer.json or .devcontainer.json file first.') + ); + return; + } + + // Confirm rebuild action (no cache is more expensive) + const confirm = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('Rebuild Container Without Cache'), + vscode.l10n.t('This will rebuild the container without cache and may take a long time. Continue?'), + vscode.l10n.t('Rebuild'), + vscode.l10n.t('Cancel') + ); + + if (!confirm) { + return; + } + + // Rebuild the container without cache (output will be shown in terminal) + logger.info('Rebuilding dev container without cache...'); + + const manager = getDevContainerManager(); + const result = await manager.createOrStartContainer({ + workspaceFolder: workspaceFolder.uri.fsPath, + rebuild: true, + noCache: true + }); + + logger.info(`Container rebuilt: ${result.containerId}`); + + // Store workspace mapping BEFORE opening the window + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(result.containerId, workspaceFolder.uri.fsPath, result.remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${result.containerId} -> ${workspaceFolder.uri.fsPath}`); + } catch (error) { + logger.error('Failed to store workspace mapping before window reload', error); + } + + // Reload window with remote authority + // Extract just the folder name from the remote workspace path + const workspaceName = result.remoteWorkspaceFolder.split('/').filter(s => s).pop(); + const authority = encodeDevContainerAuthority(result.containerId, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${result.remoteWorkspaceFolder}`); + + logger.info(`Reloading window with authority: ${authority}`); + + // Reload window with the remote authority + await vscode.commands.executeCommand('vscode.openFolder', remoteUri); + } catch (error) { + logger.error('Failed to rebuild (no cache) and reopen in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to rebuild dev container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Rebuild the current dev container (when already in container) + * + * This command runs INSIDE the container and schedules a rebuild to happen + * on the host after the window reloads. + */ +export async function rebuildContainer(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + logger.debug('Command: rebuildContainer'); + + try { + // Check if in a dev container + if (!Workspace.isInDevContainer()) { + await vscode.window.showErrorMessage(vscode.l10n.t('You are not currently in a dev container')); + return; + } + + // Get current workspace folder and remote workspace path + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (!workspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + const remoteWorkspaceFolder = workspaceFolder.uri.fsPath; + + // Extract container identifier from remote authority + const authority = workspaceFolder.uri.authority; + if (!authority) { + await vscode.window.showErrorMessage(vscode.l10n.t('Cannot determine container ID')); + return; + } + + // Decode authority to get identifier (may be workspace name or container ID) + const decoded = decodeDevContainerAuthority(authority); + if (!decoded) { + await vscode.window.showErrorMessage(vscode.l10n.t('Cannot decode container authority')); + return; + } + const identifier = decoded.containerId; // May be workspace name like "js-devc" + + logger.trace(`Authority identifier: ${identifier}`); + logger.trace(`Remote name: ${vscode.env.remoteName}`); + logger.trace(`Extension context: ${context.extensionMode === vscode.ExtensionMode.Production ? 'production' : 'development'}`); + + // Resolve workspace name to actual container ID and get local workspace path + const resolved = resolveContainerIdentifier(identifier, logger); + const containerId = resolved.containerId; + let localWorkspaceFolder = resolved.localWorkspacePath; + + // Fallback 1: Try environment variables (legacy support) + if (!localWorkspaceFolder) { + localWorkspaceFolder = process.env.LOCAL_WORKSPACE_FOLDER; + if (localWorkspaceFolder) { + logger.debug(`Found local workspace path from env var: ${localWorkspaceFolder}`); + } + } + + if (!localWorkspaceFolder) { + await vscode.window.showErrorMessage( + vscode.l10n.t('Cannot determine local workspace folder. Please reopen the container.') + ); + return; + } + + // Confirm rebuild action + const confirm = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('Rebuild Container'), + vscode.l10n.t('This will rebuild the container and reload the window. Continue?'), + vscode.l10n.t('Rebuild'), + vscode.l10n.t('Cancel') + ); + + if (!confirm) { + return; + } + + // Store rebuild intent for the host to pick up after reload + const rebuildState = new RebuildStateManager(context); + const pendingRebuild: PendingRebuild = { + workspaceFolder: localWorkspaceFolder, + containerId, + remoteWorkspaceFolder, + noCache: false, + requestedAt: Date.now() + }; + + await rebuildState.setPendingRebuild(pendingRebuild); + logger.info(`Stored pending rebuild for: ${localWorkspaceFolder}`); + + // Close remote connection and reopen locally + // The extension on the host will detect the pending rebuild and execute it + logger.info('Closing remote window to trigger rebuild on host...'); + const localUri = vscode.Uri.file(localWorkspaceFolder); + await vscode.commands.executeCommand('vscode.openFolder', localUri); + } catch (error) { + logger.error('Failed to initiate container rebuild', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to initiate rebuild: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Rebuild the current dev container without cache (when already in container) + * + * This command runs INSIDE the container and schedules a rebuild to happen + * on the host after the window reloads. + */ +export async function rebuildContainerNoCache(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + logger.debug('Command: rebuildContainerNoCache'); + + try { + // Check if in a dev container + if (!Workspace.isInDevContainer()) { + await vscode.window.showErrorMessage(vscode.l10n.t('You are not currently in a dev container')); + return; + } + + // Get current workspace folder and remote workspace path + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (!workspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + const remoteWorkspaceFolder = workspaceFolder.uri.fsPath; + + // Extract container identifier from remote authority + const authority = workspaceFolder.uri.authority; + if (!authority) { + await vscode.window.showErrorMessage(vscode.l10n.t('Cannot determine container ID')); + return; + } + + // Decode authority to get identifier (may be workspace name or container ID) + const decoded = decodeDevContainerAuthority(authority); + if (!decoded) { + await vscode.window.showErrorMessage(vscode.l10n.t('Cannot decode container authority')); + return; + } + const identifier = decoded.containerId; // May be workspace name like "js-devc" + + logger.trace(`Authority identifier: ${identifier}`); + logger.trace(`Remote name: ${vscode.env.remoteName}`); + + // Resolve workspace name to actual container ID and get local workspace path + const resolved = resolveContainerIdentifier(identifier, logger); + const containerId = resolved.containerId; + let localWorkspaceFolder = resolved.localWorkspacePath; + + // Fallback: Try environment variables (legacy support) + if (!localWorkspaceFolder) { + localWorkspaceFolder = process.env.LOCAL_WORKSPACE_FOLDER; + if (localWorkspaceFolder) { + logger.debug(`Found local workspace path from env var: ${localWorkspaceFolder}`); + } + } + + if (!localWorkspaceFolder) { + await vscode.window.showErrorMessage( + vscode.l10n.t('Cannot determine local workspace folder. Please reopen the container.') + ); + return; + } + + // Confirm rebuild action + const confirm = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('Rebuild Container Without Cache'), + vscode.l10n.t('This will rebuild the container without cache and reload the window. This may take a long time. Continue?'), + vscode.l10n.t('Rebuild'), + vscode.l10n.t('Cancel') + ); + + if (!confirm) { + return; + } + + // Store rebuild intent for the host to pick up after reload + const rebuildState = new RebuildStateManager(context); + const pendingRebuild: PendingRebuild = { + workspaceFolder: localWorkspaceFolder, + containerId, + remoteWorkspaceFolder, + noCache: true, + requestedAt: Date.now() + }; + + await rebuildState.setPendingRebuild(pendingRebuild); + logger.info(`Stored pending rebuild (no cache) for: ${localWorkspaceFolder}`); + + // Close remote connection and reopen locally + logger.info('Closing remote window to trigger rebuild on host...'); + const localUri = vscode.Uri.file(localWorkspaceFolder); + await vscode.commands.executeCommand('vscode.openFolder', localUri); + } catch (error) { + logger.error('Failed to initiate container rebuild (no cache)', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to initiate rebuild: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Helper function to resolve a workspace name or identifier to a container ID + */ +function resolveContainerIdentifier(identifier: string, logger: any): { containerId: string; localWorkspacePath: string | undefined } { + let containerId: string = identifier; + let localWorkspaceFolder: string | undefined; + + try { + const storage = WorkspaceMappingStorage.getInstance(); + logger.trace(`Storage instance retrieved, checking for identifier ${identifier}`); + + const allMappings = storage.getAll(); + logger.trace(`Total mappings in storage: ${allMappings.length}`); + allMappings.forEach(m => { + logger.trace(` Mapping: ${m.containerId} -> ${m.localWorkspacePath} (remote: ${m.remoteWorkspacePath})`); + }); + + // First try direct lookup (if identifier is already a container ID) + let mapping = storage.get(identifier); + + // If not found, try to resolve workspace name to container ID + if (!mapping) { + logger.trace(`No direct mapping found, trying to resolve workspace name to container ID`); + for (const [cid, m] of storage.entries()) { + if (m.remoteWorkspacePath) { + const workspaceName = m.remoteWorkspacePath.split('/').filter(s => s).pop(); + if (workspaceName === identifier) { + logger.debug(`Resolved workspace name "${identifier}" to container ${cid}`); + containerId = cid; + mapping = m; + break; + } + } + } + } + + if (mapping?.localWorkspacePath) { + localWorkspaceFolder = mapping.localWorkspacePath; + logger.debug(`Found local workspace path from storage: ${localWorkspaceFolder}`); + } else { + logger.warn(`No mapping found for identifier ${identifier} in storage`); + } + } catch (error) { + logger.error('Failed to get workspace mapping from storage', error); + } + + return { containerId, localWorkspacePath: localWorkspaceFolder }; +} diff --git a/extensions/positron-dev-containers/src/commands/reopen.ts b/extensions/positron-dev-containers/src/commands/reopen.ts new file mode 100644 index 000000000000..a10c3785d4d8 --- /dev/null +++ b/extensions/positron-dev-containers/src/commands/reopen.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLogger } from '../common/logger'; +import { Workspace } from '../common/workspace'; +import { getDevContainerManager } from '../container/devContainerManager'; +import { encodeDevContainerAuthority } from '../common/authorityEncoding'; +import { WorkspaceMappingStorage } from '../common/workspaceMappingStorage'; + +/** + * Reopen the current workspace in a dev container + */ +export async function reopenInContainer(): Promise { + const logger = getLogger(); + logger.debug('Command: reopenInContainer'); + + try { + // Check if already in a dev container + if (Workspace.isInDevContainer()) { + await vscode.window.showInformationMessage(vscode.l10n.t('You are already in a dev container')); + return; + } + + // Get current workspace folder + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (!workspaceFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + // Check if workspace has dev container configuration + if (!Workspace.hasDevContainer(workspaceFolder)) { + await vscode.window.showErrorMessage( + vscode.l10n.t('No dev container configuration found. Create a .devcontainer/devcontainer.json or .devcontainer.json file first.') + ); + return; + } + + // Build/Start Container (output will be shown in terminal) + logger.info('Building/starting dev container...'); + + const manager = getDevContainerManager(); + const result = await manager.createOrStartContainer({ + workspaceFolder: workspaceFolder.uri.fsPath, + rebuild: false, + noCache: false + }); + + logger.info(`Container ready: ${result.containerId}`); + + // Store workspace mapping BEFORE opening the window + // This ensures it's available when the authority resolver runs + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(result.containerId, workspaceFolder.uri.fsPath, result.remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${result.containerId} -> ${workspaceFolder.uri.fsPath}`); + } catch (error) { + logger.error('Failed to store workspace mapping before window reload', error); + // Continue anyway - connection manager will try to determine paths + } + + // Create authority with workspace folder name for better display + // Extract just the folder name from the remote workspace path + const workspaceName = result.remoteWorkspaceFolder.split('/').filter(s => s).pop(); + const authority = encodeDevContainerAuthority(result.containerId, workspaceName); + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${result.remoteWorkspaceFolder}`); + + logger.info(`Reloading window with authority: ${authority}`); + logger.info(`Remote workspace: ${result.remoteWorkspaceFolder}`); + + // Reload window with the remote authority + // The authority resolver will handle installing the server and establishing the connection + logger.debug(`About to execute vscode.openFolder with URI: ${remoteUri.toString()}`); + await vscode.commands.executeCommand('vscode.openFolder', remoteUri); + logger.debug(`vscode.openFolder command completed`); + } catch (error) { + logger.error('Failed to reopen in container', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open in dev container: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} + +/** + * Reopen the current workspace locally (exit dev container) + */ +export async function reopenLocally(): Promise { + const logger = getLogger(); + logger.debug('Command: reopenLocally'); + + try { + // Check if in a dev container + if (!Workspace.isInDevContainer()) { + await vscode.window.showInformationMessage(vscode.l10n.t('You are not currently in a dev container')); + return; + } + + // Get the local workspace folder path from the remote workspace + // The CONTAINER_WORKSPACE_FOLDER environment variable should contain the local path + const localPath = await Workspace.getLocalWorkspaceFolder(); + if (!localPath) { + await vscode.window.showErrorMessage( + vscode.l10n.t('Cannot determine local workspace folder. Please reopen the workspace manually.') + ); + return; + } + + logger.info(`Reopening workspace locally: ${localPath}`); + + // Open the local folder + const localUri = vscode.Uri.file(localPath); + await vscode.commands.executeCommand('vscode.openFolder', localUri); + } catch (error) { + logger.error('Failed to reopen locally', error); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to reopen locally: {0}', error instanceof Error ? error.message : String(error)) + ); + } +} diff --git a/extensions/positron-dev-containers/src/common/authorityEncoding.ts b/extensions/positron-dev-containers/src/common/authorityEncoding.ts new file mode 100644 index 000000000000..30f7dc62d4f8 --- /dev/null +++ b/extensions/positron-dev-containers/src/common/authorityEncoding.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Authority encoding utilities + * Encodes/decodes information in remote URI authorities without needing external state + */ + +/** + * Encode a dev container authority + * Format: dev-container+ + * + * NOTE: We use the workspace name as the authority identifier for better display + * in the remote status indicator. The full container ID mapping is stored in + * extension global state. + * + * @param containerId Container ID + * @param workspaceName Optional workspace folder name for display purposes + */ +export function encodeDevContainerAuthority(containerId: string, workspaceName?: string): string { + if (workspaceName) { + // Use workspace name as the authority identifier for display + // VS Code will show "Dev Container" in the remote indicator + return `dev-container+${workspaceName}`; + } + return `dev-container+${containerId}`; +} + +/** + * Decode a dev container authority to extract workspace name or container ID + * Format: dev-container+ + * + * @param authority Authority string to decode + * @returns Object with containerId (which may be workspace name), or undefined if invalid format + */ +export function decodeDevContainerAuthority(authority: string): { + containerId: string; + localWorkspacePath: string | undefined; +} | undefined { + const match = authority.match(/^dev-container\+([^+]+)$/); + if (!match) { + return undefined; + } + + // The identifier could be either a workspace name or a container ID + // The ConnectionManager and WorkspaceMappingStorage will resolve it + const identifier = match[1]; + return { containerId: identifier, localWorkspacePath: undefined }; +} + +/** + * Encode an attached container authority + */ +export function encodeAttachedContainerAuthority(containerId: string): string { + return `attached-container+${containerId}`; +} diff --git a/extensions/positron-dev-containers/src/common/configuration.ts b/extensions/positron-dev-containers/src/common/configuration.ts new file mode 100644 index 000000000000..1337f506720b --- /dev/null +++ b/extensions/positron-dev-containers/src/common/configuration.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { DevContainerConfiguration, LogLevel } from './types'; +import { getLogger } from './logger'; + +/** + * Configuration service for dev containers extension + * Reads settings from workspace configuration + */ +export class Configuration { + private static instance: Configuration; + private config: vscode.WorkspaceConfiguration; + + private constructor() { + this.config = vscode.workspace.getConfiguration('dev.containers'); + } + + /** + * Get the singleton configuration instance + */ + static getInstance(): Configuration { + if (!Configuration.instance) { + Configuration.instance = new Configuration(); + } + return Configuration.instance; + } + + /** + * Reload configuration (useful when settings change) + */ + reload(): void { + this.config = vscode.workspace.getConfiguration('dev.containers'); + getLogger().debug('Configuration reloaded'); + } + + /** + * Get all dev container configuration + */ + getConfiguration(): DevContainerConfiguration { + return { + enable: this.getEnable(), + defaultExtensions: this.getDefaultExtensions(), + defaultFeatures: this.getDefaultFeatures(), + workspaceMountConsistency: this.getWorkspaceMountConsistency(), + gpuAvailability: this.getGpuAvailability(), + logLevel: this.getLogLevel(), + dockerPath: this.getDockerPath(), + dockerComposePath: this.getDockerComposePath(), + dockerSocketPath: this.getDockerSocketPath() + }; + } + + /** + * Get enable setting + */ + getEnable(): boolean { + return this.config.get('enable', false); + } + + /** + * Get default extensions to install in containers + */ + getDefaultExtensions(): string[] { + return this.config.get('defaultExtensions', []); + } + + /** + * Get default features to include in containers + */ + getDefaultFeatures(): Record { + return this.config.get>('defaultFeatures', {}); + } + + /** + * Get workspace mount consistency setting + */ + getWorkspaceMountConsistency(): 'consistent' | 'cached' | 'delegated' { + return this.config.get<'consistent' | 'cached' | 'delegated'>('workspaceMountConsistency', 'cached'); + } + + /** + * Get GPU availability setting + */ + getGpuAvailability(): 'all' | 'detect' | 'none' { + return this.config.get<'all' | 'detect' | 'none'>('gpuAvailability', 'detect'); + } + + /** + * Get log level + */ + getLogLevel(): LogLevel { + const level = this.config.get('logLevel', 'debug'); + return level as LogLevel; + } + + /** + * Get Docker path, resolving to absolute path if necessary + */ + getDockerPath(): string { + const configuredPath = this.config.get('dockerPath', 'docker'); + + // If it's already an absolute path, use it as-is + if (configuredPath.startsWith('/') || configuredPath.includes('\\')) { + return configuredPath; + } + + // Try to resolve to absolute path for better spawn compatibility + try { + const { execSync } = require('child_process'); + const os = require('os'); + + // Use appropriate command for the platform + const whichCommand = os.platform() === 'win32' + ? `where ${configuredPath}` + : `which ${configuredPath}`; + + const output = execSync(whichCommand, { + encoding: 'utf8', + env: process.env + }); + + // Split by line and take first result, handling both \r\n and \n + const resolvedPath = output.split(/\r?\n/)[0].trim(); + + if (resolvedPath) { + getLogger().debug(`Resolved docker path: ${configuredPath} -> ${resolvedPath}`); + return resolvedPath; + } + } catch (error) { + getLogger().debug(`Could not resolve docker path, using configured: ${configuredPath}`); + } + + return configuredPath; + } + + /** + * Get Docker Compose path + */ + getDockerComposePath(): string { + return this.config.get('dockerComposePath', 'docker-compose'); + } + + /** + * Get Docker socket path + */ + getDockerSocketPath(): string { + return this.config.get('dockerSocketPath', '/var/run/docker.sock'); + } +} + +/** + * Convenience function to get the configuration instance + */ +export function getConfiguration(): Configuration { + return Configuration.getInstance(); +} diff --git a/extensions/positron-dev-containers/src/common/logger.ts b/extensions/positron-dev-containers/src/common/logger.ts new file mode 100644 index 000000000000..33c43ceadaeb --- /dev/null +++ b/extensions/positron-dev-containers/src/common/logger.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { LogLevel } from './types'; + +/** + * Logger for the dev containers extension + * Provides both output channel logging and file logging + */ +export class Logger { + private static instance: Logger; + private outputChannel: vscode.LogOutputChannel; + private logLevel: LogLevel = LogLevel.Debug; + private logFilePath?: string; + private logFileStream?: fs.WriteStream; + + private constructor() { + this.outputChannel = vscode.window.createOutputChannel('Dev Containers', { log: true }); + } + + /** + * Get the singleton logger instance + */ + static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + /** + * Initialize the logger with context + */ + initialize(context: vscode.ExtensionContext, logLevel: LogLevel): void { + this.logLevel = logLevel; + + // Create log file + const logDir = path.join(context.globalStorageUri.fsPath, 'logs'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + this.logFilePath = path.join(logDir, `dev-containers-${timestamp}.log`); + + try { + this.logFileStream = fs.createWriteStream(this.logFilePath, { flags: 'a' }); + this.info(`Logger initialized. Log file: ${this.logFilePath}`); + } catch (error) { + this.error('Failed to create log file', error); + } + } + + /** + * Set the log level + */ + setLogLevel(level: LogLevel): void { + this.logLevel = level; + this.info(`Log level set to: ${level}`); + } + + /** + * Get the current log file path + */ + getLogFilePath(): string | undefined { + return this.logFilePath; + } + + /** + * Show the output channel + */ + show(): void { + this.outputChannel.show(); + } + + /** + * Log an info message + */ + info(message: string, ...args: any[]): void { + if (this.shouldLog(LogLevel.Info)) { + this.log(LogLevel.Info, message, ...args); + } + } + + /** + * Log a debug message + */ + debug(message: string, ...args: any[]): void { + if (this.shouldLog(LogLevel.Debug)) { + this.log(LogLevel.Debug, message, ...args); + } + } + + /** + * Log a trace message + */ + trace(message: string, ...args: any[]): void { + if (this.shouldLog(LogLevel.Trace)) { + this.log(LogLevel.Trace, message, ...args); + } + } + + /** + * Log an error message + */ + error(message: string, error?: any): void { + if (!this.shouldLog(LogLevel.Error)) { + return; + } + + const errorMessage = error + ? `${message}: ${error instanceof Error ? error.message : String(error)}` + : message; + + this.log(LogLevel.Error, errorMessage); + + // Log stack trace at debug level + if (error instanceof Error && error.stack) { + this.debug(`Stack trace: ${error.stack}`); + } + } + + /** + * Log a warning message + */ + warn(message: string, ...args: any[]): void { + if (this.shouldLog(LogLevel.Warning)) { + this.log(LogLevel.Warning, message, ...args); + } + } + + /** + * Dispose the logger + */ + dispose(): void { + this.outputChannel.dispose(); + if (this.logFileStream) { + this.logFileStream.end(); + } + } + + /** + * Check if a message at the given level should be logged + */ + private shouldLog(level: LogLevel): boolean { + // Log level hierarchy: Trace < Debug < Info < Warning < Error + const levels = [LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error]; + const currentLevelIndex = levels.indexOf(this.logLevel); + const messageLevelIndex = levels.indexOf(level); + return messageLevelIndex >= currentLevelIndex; + } + + /** + * Internal log method + */ + private log(level: LogLevel, message: string, ...args: any[]): void { + const timestamp = new Date().toISOString(); + const formattedMessage = args.length > 0 + ? `${message} ${args.map(arg => JSON.stringify(arg)).join(' ')}` + : message; + + // Log to output channel using appropriate method + switch (level) { + case LogLevel.Trace: + this.outputChannel.trace(formattedMessage); + break; + case LogLevel.Debug: + this.outputChannel.debug(formattedMessage); + break; + case LogLevel.Info: + this.outputChannel.info(formattedMessage); + break; + case LogLevel.Warning: + this.outputChannel.warn(formattedMessage); + break; + case LogLevel.Error: + this.outputChannel.error(formattedMessage); + break; + } + + // Log to file + if (this.logFileStream) { + const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${formattedMessage}\n`; + this.logFileStream.write(logEntry); + } + } +} + +/** + * Convenience function to get the logger instance + */ +export function getLogger(): Logger { + return Logger.getInstance(); +} diff --git a/extensions/positron-dev-containers/src/common/rebuildState.ts b/extensions/positron-dev-containers/src/common/rebuildState.ts new file mode 100644 index 000000000000..102154e6f519 --- /dev/null +++ b/extensions/positron-dev-containers/src/common/rebuildState.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLogger } from './logger'; + +/** + * Pending rebuild request + */ +export interface PendingRebuild { + /** + * Local workspace folder path to rebuild + */ + workspaceFolder: string; + + /** + * Container ID being rebuilt + */ + containerId: string; + + /** + * Remote workspace folder path in the container + */ + remoteWorkspaceFolder: string; + + /** + * Whether to skip cache during rebuild + */ + noCache: boolean; + + /** + * Timestamp when rebuild was requested + */ + requestedAt: number; +} + +const PENDING_REBUILD_KEY = 'positron-dev-containers.pendingRebuild'; + +/** + * Manages pending rebuild state across window reloads + */ +export class RebuildStateManager { + private context: vscode.ExtensionContext; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + /** + * Store a pending rebuild request + */ + async setPendingRebuild(rebuild: PendingRebuild): Promise { + const logger = getLogger(); + logger.debug(`Storing pending rebuild for: ${rebuild.workspaceFolder}`); + await this.context.workspaceState.update(PENDING_REBUILD_KEY, rebuild); + } + + /** + * Get pending rebuild request + */ + getPendingRebuild(): PendingRebuild | undefined { + return this.context.workspaceState.get(PENDING_REBUILD_KEY); + } + + /** + * Clear pending rebuild request + */ + async clearPendingRebuild(): Promise { + const logger = getLogger(); + logger.debug('Clearing pending rebuild state'); + await this.context.workspaceState.update(PENDING_REBUILD_KEY, undefined); + } + + /** + * Check if there's a pending rebuild that matches the current workspace + */ + hasPendingRebuildForWorkspace(workspacePath: string): boolean { + const pending = this.getPendingRebuild(); + return pending?.workspaceFolder === workspacePath; + } +} diff --git a/extensions/positron-dev-containers/src/common/types.ts b/extensions/positron-dev-containers/src/common/types.ts new file mode 100644 index 000000000000..2a066224c815 --- /dev/null +++ b/extensions/positron-dev-containers/src/common/types.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +/** + * Log levels for the extension + */ +export enum LogLevel { + Trace = 'trace', + Debug = 'debug', + Info = 'info', + Warning = 'warning', + Error = 'error' +} + +/** + * Dev container configuration from settings + */ +export interface DevContainerConfiguration { + enable: boolean; + defaultExtensions: string[]; + defaultFeatures: Record; + workspaceMountConsistency: 'consistent' | 'cached' | 'delegated'; + gpuAvailability: 'all' | 'detect' | 'none'; + logLevel: LogLevel; + dockerPath: string; + dockerComposePath: string; + dockerSocketPath: string; +} + +/** + * Container state + */ +export enum ContainerState { + Running = 'running', + Stopped = 'stopped', + Paused = 'paused', + Exited = 'exited', + Unknown = 'unknown' +} + +/** + * Dev container info + */ +export interface DevContainerInfo { + containerId: string; + containerName: string; + state: ContainerState; + workspaceFolder?: string; + configFilePath?: string; + createdAt?: Date; + imageId?: string; + imageName?: string; +} + +/** + * Authority type for remote connections + */ +export enum AuthorityType { + DevContainer = 'dev-container', + AttachedContainer = 'attached-container' +} + +/** + * Remote authority parsed from connection string + */ +export interface RemoteAuthority { + type: AuthorityType; + containerId: string; +} + +/** + * Extension context data stored globally + */ +export interface ExtensionState { + recentContainers: DevContainerInfo[]; + lastLogFilePath?: string; +} + +/** + * Command context + */ +export interface CommandContext { + uri?: vscode.Uri; + containerId?: string; +} + +/** + * Build progress event + */ +export interface BuildProgress { + step: string; + percentage?: number; + message?: string; +} + +/** + * Connection info for resolved authority + */ +export interface ConnectionInfo { + host: string; + port: number; + connectionToken?: string; +} + +/** + * Workspace folder locations + */ +export interface WorkspaceFolderPaths { + workspaceFolder: string; + devContainerFolder: string; + devContainerJsonPath: string; +} diff --git a/extensions/positron-dev-containers/src/common/workspace.ts b/extensions/positron-dev-containers/src/common/workspace.ts new file mode 100644 index 000000000000..dadb9c2484b1 --- /dev/null +++ b/extensions/positron-dev-containers/src/common/workspace.ts @@ -0,0 +1,365 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WorkspaceFolderPaths } from './types'; +import { getLogger } from './logger'; + +/** + * Workspace utilities for dev containers + */ +export class Workspace { + /** + * Check if the workspace has a dev container configuration + */ + static hasDevContainer(workspaceFolder?: vscode.WorkspaceFolder): boolean { + if (!workspaceFolder) { + // Check all workspace folders + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return false; + } + return folders.some(folder => this.hasDevContainer(folder)); + } + + const paths = this.getDevContainerPaths(workspaceFolder); + if (!paths) { + return false; + } + + return fs.existsSync(paths.devContainerJsonPath); + } + + /** + * Check if the workspace has a dev container configuration (async version using VS Code filesystem API) + * This works with remote filesystems (e.g., inside containers) + */ + static async hasDevContainerAsync(workspaceFolder?: vscode.WorkspaceFolder): Promise { + if (!workspaceFolder) { + // Check all workspace folders + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return false; + } + // Check folders sequentially to avoid race conditions + for (const folder of folders) { + if (await this.hasDevContainerAsync(folder)) { + return true; + } + } + return false; + } + + const paths = await this.getDevContainerPathsAsync(workspaceFolder); + return paths !== undefined; + } + + /** + * Get all workspace folders that have dev container configurations + */ + static getWorkspaceFoldersWithDevContainers(): vscode.WorkspaceFolder[] { + const folders = vscode.workspace.workspaceFolders; + if (!folders) { + return []; + } + + return folders.filter(folder => this.hasDevContainer(folder)); + } + + /** + * Get the dev container paths for a workspace folder + */ + static getDevContainerPaths(workspaceFolder: vscode.WorkspaceFolder): WorkspaceFolderPaths | undefined { + const workspacePath = workspaceFolder.uri.fsPath; + + // Check for .devcontainer/devcontainer.json + const devContainerFolder = path.join(workspacePath, '.devcontainer'); + const devContainerJsonPath = path.join(devContainerFolder, 'devcontainer.json'); + + if (fs.existsSync(devContainerJsonPath)) { + return { + workspaceFolder: workspacePath, + devContainerFolder, + devContainerJsonPath + }; + } + + // Check for .devcontainer.json in workspace root + const rootDevContainerJsonPath = path.join(workspacePath, '.devcontainer.json'); + if (fs.existsSync(rootDevContainerJsonPath)) { + return { + workspaceFolder: workspacePath, + devContainerFolder: workspacePath, + devContainerJsonPath: rootDevContainerJsonPath + }; + } + + return undefined; + } + + /** + * Get the dev container paths for a workspace folder (async version using VS Code filesystem API) + * This works with remote filesystems (e.g., inside containers) + */ + static async getDevContainerPathsAsync(workspaceFolder: vscode.WorkspaceFolder): Promise { + const workspaceUri = workspaceFolder.uri; + + // Check for .devcontainer/devcontainer.json + const devContainerFolderUri = vscode.Uri.joinPath(workspaceUri, '.devcontainer'); + const devContainerJsonUri = vscode.Uri.joinPath(devContainerFolderUri, 'devcontainer.json'); + + try { + await vscode.workspace.fs.stat(devContainerJsonUri); + return { + workspaceFolder: workspaceUri.fsPath, + devContainerFolder: devContainerFolderUri.fsPath, + devContainerJsonPath: devContainerJsonUri.fsPath + }; + } catch { + // File doesn't exist, try next location + } + + // Check for .devcontainer.json in workspace root + const rootDevContainerJsonUri = vscode.Uri.joinPath(workspaceUri, '.devcontainer.json'); + try { + await vscode.workspace.fs.stat(rootDevContainerJsonUri); + return { + workspaceFolder: workspaceUri.fsPath, + devContainerFolder: workspaceUri.fsPath, + devContainerJsonPath: rootDevContainerJsonUri.fsPath + }; + } catch { + // File doesn't exist + } + + return undefined; + } + + /** + * Get the current workspace folder + */ + static getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return undefined; + } + + // If there's only one folder, return it + if (folders.length === 1) { + return folders[0]; + } + + // If there's an active text editor, use its workspace folder + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri); + if (workspaceFolder) { + return workspaceFolder; + } + } + + // Default to first folder + return folders[0]; + } + + /** + * Get the current workspace folder with dev container + */ + static getCurrentWorkspaceFolderWithDevContainer(): vscode.WorkspaceFolder | undefined { + const currentFolder = this.getCurrentWorkspaceFolder(); + if (!currentFolder) { + return undefined; + } + + if (this.hasDevContainer(currentFolder)) { + return currentFolder; + } + + // Try to find any folder with dev container + const foldersWithDevContainers = this.getWorkspaceFoldersWithDevContainers(); + if (foldersWithDevContainers.length > 0) { + return foldersWithDevContainers[0]; + } + + return undefined; + } + + /** + * Check if we're currently in a dev container + */ + static isInDevContainer(): boolean { + const remoteName = vscode.env.remoteName; + return remoteName === 'dev-container' || remoteName === 'attached-container'; + } + + /** + * Get the remote name + */ + static getRemoteName(): string | undefined { + return vscode.env.remoteName; + } + + /** + * Get the local workspace folder path when in a dev container + * Returns undefined if not in a dev container or path cannot be determined + */ + static async getLocalWorkspaceFolder(): Promise { + if (!this.isInDevContainer()) { + return undefined; + } + + // Extract container ID from workspace URI authority + const currentFolder = this.getCurrentWorkspaceFolder(); + if (currentFolder && currentFolder.uri.authority) { + const containerId = this.getContainerIdFromAuthority(currentFolder.uri.authority); + if (containerId) { + getLogger().debug(`Extracted container ID from authority: ${containerId}`); + + // Try WorkspaceMappingStorage first (fastest, most reliable) + try { + const { WorkspaceMappingStorage } = await import('./workspaceMappingStorage.js'); + const storage = WorkspaceMappingStorage.getInstance(); + const mapping = storage.get(containerId); + if (mapping?.localWorkspacePath) { + getLogger().info(`Retrieved local folder from storage: ${mapping.localWorkspacePath}`); + return mapping.localWorkspacePath; + } + } catch (error) { + getLogger().warn('Failed to get workspace mapping from storage', error); + } + + // Fallback 1: Inspect container to get local folder from labels + try { + const { getDevContainerManager } = await import('../container/devContainerManager.js'); + const manager = getDevContainerManager(); + const containerDetails = await manager.inspectContainerDetails(containerId); + + const { ContainerLabels } = await import('../container/containerLabels.js'); + const localFolder = ContainerLabels.getLocalFolder(containerDetails.Config.Labels || {}); + + if (localFolder) { + getLogger().info(`Retrieved local folder from container labels: ${localFolder}`); + return localFolder; + } + } catch (error) { + getLogger().error('Failed to inspect container for local folder', error); + } + } + } + + // Fallback 2: Try environment variables (legacy support) + const containerWorkspaceFolder = process.env.CONTAINER_WORKSPACE_FOLDER; + if (containerWorkspaceFolder) { + return containerWorkspaceFolder; + } + + const localWorkspaceFolder = process.env.LOCAL_WORKSPACE_FOLDER; + if (localWorkspaceFolder) { + return localWorkspaceFolder; + } + + // Last resort fallback: return current workspace path with warning + if (currentFolder) { + getLogger().warn('Could not determine local workspace folder, using container path as fallback'); + return currentFolder.uri.fsPath; + } + + return undefined; + } + + /** + * Extract container ID from remote authority + * Authority format: dev-container+ or attached-container+ + */ + private static getContainerIdFromAuthority(authority: string): string | undefined { + const match = authority.match(/^(?:dev-container|attached-container)\+(.+)$/); + return match?.[1]; + } + + /** + * Pick a workspace folder with dev container + * Shows a quick pick if multiple folders exist + */ + static async pickWorkspaceFolderWithDevContainer(): Promise { + const foldersWithDevContainers = this.getWorkspaceFoldersWithDevContainers(); + + if (foldersWithDevContainers.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t('No workspace folders with dev container configuration found')); + return undefined; + } + + if (foldersWithDevContainers.length === 1) { + return foldersWithDevContainers[0]; + } + + // Show quick pick + const items = foldersWithDevContainers.map(folder => ({ + label: folder.name, + description: folder.uri.fsPath, + folder + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select a workspace folder to open in container') + }); + + return selected?.folder; + } + + /** + * Ensure directory exists + */ + static ensureDirectoryExists(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + getLogger().debug(`Created directory: ${dirPath}`); + } + } + + /** + * Read file content + */ + static readFile(filePath: string): string | undefined { + try { + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, 'utf-8'); + } + } catch (error) { + getLogger().error(`Failed to read file: ${filePath}`, error); + } + return undefined; + } + + /** + * Write file content + */ + static writeFile(filePath: string, content: string): boolean { + try { + // Ensure parent directory exists + const parentDir = path.dirname(filePath); + this.ensureDirectoryExists(parentDir); + + fs.writeFileSync(filePath, content, 'utf-8'); + getLogger().debug(`Wrote file: ${filePath}`); + return true; + } catch (error) { + getLogger().error(`Failed to write file: ${filePath}`, error); + return false; + } + } + + /** + * Check if a file exists + */ + static fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath); + } catch (error) { + return false; + } + } +} diff --git a/extensions/positron-dev-containers/src/common/workspaceMappingStorage.ts b/extensions/positron-dev-containers/src/common/workspaceMappingStorage.ts new file mode 100644 index 000000000000..7517272f61dd --- /dev/null +++ b/extensions/positron-dev-containers/src/common/workspaceMappingStorage.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Logger } from './logger'; + +/** + * Workspace mapping between container ID and local workspace path + */ +export interface WorkspaceMapping { + containerId: string; + localWorkspacePath: string; + remoteWorkspacePath?: string; + timestamp: number; // When mapping was last updated +} + +/** + * Manages persistent storage of workspace mappings using extension global state. + * Provides both persistent storage and in-memory cache for synchronous access. + * + * IMPORTANT: Mappings can be cleared at any time, so always write mappings + * when opening/reopening containers (idempotent operations). + */ +export class WorkspaceMappingStorage { + private static readonly STORAGE_KEY = 'positron.devcontainers.workspaceMappings'; + private static instance: WorkspaceMappingStorage | undefined; + + private cache: Map = new Map(); + private context: vscode.ExtensionContext; + private logger: Logger; + + private constructor(context: vscode.ExtensionContext, logger: Logger) { + this.context = context; + this.logger = logger; + } + + /** + * Initialize the singleton instance + */ + static initialize(context: vscode.ExtensionContext, logger: Logger): WorkspaceMappingStorage { + if (!WorkspaceMappingStorage.instance) { + WorkspaceMappingStorage.instance = new WorkspaceMappingStorage(context, logger); + } + return WorkspaceMappingStorage.instance; + } + + /** + * Get the singleton instance (must be initialized first) + */ + static getInstance(): WorkspaceMappingStorage { + if (!WorkspaceMappingStorage.instance) { + throw new Error('WorkspaceMappingStorage not initialized. Call initialize() first.'); + } + return WorkspaceMappingStorage.instance; + } + + /** + * Load all mappings from persistent storage into memory cache. + * Call this eagerly on extension activation to ensure synchronous access. + */ + async load(): Promise { + try { + const stored = this.context.globalState.get>( + WorkspaceMappingStorage.STORAGE_KEY, + {} + ); + + this.cache.clear(); + for (const [containerId, mapping] of Object.entries(stored)) { + this.cache.set(containerId, mapping); + } + + this.logger.info(`Loaded ${this.cache.size} workspace mappings from storage`); + } catch (error) { + this.logger.error('Failed to load workspace mappings', error); + // Start with empty cache if loading fails + this.cache.clear(); + } + } + + /** + * Save a workspace mapping (both to cache and persistent storage). + * This is idempotent - safe to call multiple times for the same container. + * + * @param containerId Container ID + * @param localWorkspacePath Local filesystem path + * @param remoteWorkspacePath Optional remote path inside container + */ + async set( + containerId: string, + localWorkspacePath: string, + remoteWorkspacePath?: string + ): Promise { + const mapping: WorkspaceMapping = { + containerId, + localWorkspacePath, + remoteWorkspacePath, + timestamp: Date.now() + }; + + // Update cache immediately (synchronous) + this.cache.set(containerId, mapping); + + // Persist to global state (async) + try { + const stored = this.context.globalState.get>( + WorkspaceMappingStorage.STORAGE_KEY, + {} + ); + stored[containerId] = mapping; + await this.context.globalState.update(WorkspaceMappingStorage.STORAGE_KEY, stored); + + this.logger.debug(`Stored workspace mapping: ${containerId} -> ${localWorkspacePath}`); + } catch (error) { + this.logger.error(`Failed to persist workspace mapping for ${containerId}`, error); + // Cache is still updated even if persistence fails + } + } + + /** + * Get a workspace mapping (synchronous, from cache) + * + * @param containerId Container ID + * @returns Mapping if found, undefined otherwise + */ + get(containerId: string): WorkspaceMapping | undefined { + return this.cache.get(containerId); + } + + /** + * Get the local workspace path for a container (synchronous) + * + * @param containerId Container ID + * @returns Local path if found, undefined otherwise + */ + getLocalPath(containerId: string): string | undefined { + return this.cache.get(containerId)?.localWorkspacePath; + } + + /** + * Get the remote workspace path for a container (synchronous) + * + * @param containerId Container ID + * @returns Remote path if found, undefined otherwise + */ + getRemotePath(containerId: string): string | undefined { + return this.cache.get(containerId)?.remoteWorkspacePath; + } + + /** + * Delete a workspace mapping + * + * @param containerId Container ID + */ + async delete(containerId: string): Promise { + // Remove from cache immediately + this.cache.delete(containerId); + + // Remove from persistent storage + try { + const stored = this.context.globalState.get>( + WorkspaceMappingStorage.STORAGE_KEY, + {} + ); + delete stored[containerId]; + await this.context.globalState.update(WorkspaceMappingStorage.STORAGE_KEY, stored); + + this.logger.debug(`Deleted workspace mapping for ${containerId}`); + } catch (error) { + this.logger.error(`Failed to delete workspace mapping for ${containerId}`, error); + } + } + + /** + * Get all mappings (synchronous) + */ + getAll(): WorkspaceMapping[] { + return Array.from(this.cache.values()); + } + + /** + * Get all entries as [containerId, mapping] pairs (synchronous) + */ + entries(): IterableIterator<[string, WorkspaceMapping]> { + return this.cache.entries(); + } + + /** + * Clear all mappings (for testing/cleanup) + */ + async clear(): Promise { + this.cache.clear(); + await this.context.globalState.update(WorkspaceMappingStorage.STORAGE_KEY, {}); + this.logger.info('Cleared all workspace mappings'); + } + + /** + * Clean up old/stale mappings (e.g., for containers that no longer exist) + * Can be called periodically or on extension activation. + * + * @param maxAgeMs Maximum age in milliseconds (default: 30 days) + */ + async cleanupStale(maxAgeMs: number = 30 * 24 * 60 * 60 * 1000): Promise { + const now = Date.now(); + const toDelete: string[] = []; + + for (const [containerId, mapping] of this.cache.entries()) { + if (now - mapping.timestamp > maxAgeMs) { + toDelete.push(containerId); + } + } + + if (toDelete.length > 0) { + this.logger.info(`Cleaning up ${toDelete.length} stale workspace mappings`); + for (const containerId of toDelete) { + await this.delete(containerId); + } + } + } +} diff --git a/extensions/positron-dev-containers/src/container/containerLabels.ts b/extensions/positron-dev-containers/src/container/containerLabels.ts new file mode 100644 index 000000000000..711152f4ead2 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/containerLabels.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AuthorityType } from '../common/types'; + +/** + * Label keys used for tracking dev containers + */ +export const DEV_CONTAINER_LABELS = { + /** + * The local workspace folder path that was used to create this container + */ + LOCAL_FOLDER: 'devcontainer.local_folder', + + /** + * The config file path that was used to create this container + */ + CONFIG_FILE: 'devcontainer.config_file', + + /** + * Metadata about the container (JSON string) + */ + METADATA: 'devcontainer.metadata', + + /** + * The type of container (dev-container or attached-container) + */ + TYPE: 'devcontainer.type', + + /** + * The timestamp when the container was created + */ + CREATED_AT: 'devcontainer.created_at', + + /** + * The Positron commit hash used to create this container + */ + POSITRON_COMMIT: 'devcontainer.positron_commit', +} as const; + +/** + * Metadata stored in the container labels + */ +export interface ContainerMetadata { + /** + * The type of container + */ + type: AuthorityType; + + /** + * Created by extension name + */ + createdBy: string; + + /** + * Creation timestamp + */ + timestamp: number; + + /** + * Positron version + */ + positronVersion?: string; + + /** + * Positron commit hash + */ + positronCommit?: string; + + /** + * Whether this container was built or pulled + */ + buildType?: 'built' | 'pulled'; + + /** + * Additional metadata + */ + [key: string]: any; +} + +/** + * Container labels for tracking dev containers + */ +export class ContainerLabels { + /** + * Create labels for a dev container + */ + static createLabels(params: { + localFolder: string; + configFile: string; + type: AuthorityType; + positronCommit?: string; + additionalMetadata?: Record; + }): Record { + const metadata: ContainerMetadata = { + type: params.type, + createdBy: 'positron-dev-containers', + timestamp: Date.now(), + positronCommit: params.positronCommit, + ...params.additionalMetadata, + }; + + return { + [DEV_CONTAINER_LABELS.LOCAL_FOLDER]: params.localFolder, + [DEV_CONTAINER_LABELS.CONFIG_FILE]: params.configFile, + [DEV_CONTAINER_LABELS.TYPE]: params.type, + [DEV_CONTAINER_LABELS.CREATED_AT]: new Date().toISOString(), + [DEV_CONTAINER_LABELS.METADATA]: JSON.stringify(metadata), + ...(params.positronCommit && { + [DEV_CONTAINER_LABELS.POSITRON_COMMIT]: params.positronCommit, + }), + }; + } + + /** + * Parse metadata from container labels + */ + static parseMetadata(labels: Record): ContainerMetadata | undefined { + const metadataJson = labels[DEV_CONTAINER_LABELS.METADATA]; + if (!metadataJson) { + return undefined; + } + + try { + return JSON.parse(metadataJson) as ContainerMetadata; + } catch (error) { + return undefined; + } + } + + /** + * Get the local folder from container labels + */ + static getLocalFolder(labels: Record): string | undefined { + return labels[DEV_CONTAINER_LABELS.LOCAL_FOLDER]; + } + + /** + * Get the config file from container labels + */ + static getConfigFile(labels: Record): string | undefined { + return labels[DEV_CONTAINER_LABELS.CONFIG_FILE]; + } + + /** + * Get the container type from labels + */ + static getType(labels: Record): AuthorityType | undefined { + const type = labels[DEV_CONTAINER_LABELS.TYPE]; + if (type === AuthorityType.DevContainer || type === AuthorityType.AttachedContainer) { + return type as AuthorityType; + } + return undefined; + } + + /** + * Get the creation timestamp from labels + */ + static getCreatedAt(labels: Record): Date | undefined { + const createdAt = labels[DEV_CONTAINER_LABELS.CREATED_AT]; + if (!createdAt) { + return undefined; + } + + try { + return new Date(createdAt); + } catch (error) { + return undefined; + } + } + + /** + * Get the Positron commit from labels + */ + static getPositronCommit(labels: Record): string | undefined { + return labels[DEV_CONTAINER_LABELS.POSITRON_COMMIT]; + } + + /** + * Check if a container is a dev container based on labels + * Uses the standard devcontainer.local_folder label which is set by + * VS Code, devcontainer CLI, and other dev container tools. + */ + static isDevContainer(labels: Record): boolean { + // Check for the standard devcontainer label that indicates this container + // was created from a devcontainer.json configuration + return !!labels[DEV_CONTAINER_LABELS.LOCAL_FOLDER]; + } + + /** + * Check if a container matches a workspace folder + */ + static matchesWorkspace(labels: Record, workspaceFolder: string, configFile: string): boolean { + const localFolder = this.getLocalFolder(labels); + const containerConfigFile = this.getConfigFile(labels); + + return localFolder === workspaceFolder && containerConfigFile === configFile; + } + + /** + * Convert labels to CLI arguments for docker/podman + * Returns an array of ['--label', 'key=value', '--label', 'key=value', ...] + */ + static toCliArgs(labels: Record): string[] { + const args: string[] = []; + for (const [key, value] of Object.entries(labels)) { + args.push('--label', `${key}=${value}`); + } + return args; + } + + /** + * Convert labels to Docker API format + * Returns an object with label key-value pairs + */ + static toDockerApiFormat(labels: Record): Record { + return { ...labels }; + } +} diff --git a/extensions/positron-dev-containers/src/container/containerState.ts b/extensions/positron-dev-containers/src/container/containerState.ts new file mode 100644 index 000000000000..f78f421f40e8 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/containerState.ts @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ContainerState, DevContainerInfo } from '../common/types'; +import { ContainerLabels } from './containerLabels'; +import { getLogger } from '../common/logger'; + +/** + * Represents detailed information about a container from Docker/Podman inspection + */ +export interface ContainerInspectInfo { + /** + * Container ID + */ + Id: string; + + /** + * Container name (with leading slash) + */ + Name: string; + + /** + * Container state + */ + State: { + Status: string; + Running: boolean; + Paused: boolean; + Restarting: boolean; + OOMKilled: boolean; + Dead: boolean; + Pid: number; + ExitCode: number; + Error: string; + StartedAt: string; + FinishedAt: string; + }; + + /** + * Container creation time + */ + Created: string; + + /** + * Container labels + */ + Config: { + Labels?: Record; + Image?: string; + Hostname?: string; + User?: string; + WorkingDir?: string; + Env?: string[]; + }; + + /** + * Image information + */ + Image: string; + + /** + * Network settings + */ + NetworkSettings: { + Ports?: Record; + IPAddress?: string; + Networks?: Record; + }; + + /** + * Mounts + */ + Mounts?: Array<{ + Type: string; + Source: string; + Destination: string; + Mode?: string; + RW?: boolean; + Propagation?: string; + }>; +} + +/** + * Container state manager + */ +export class ContainerStateManager { + /** + * Parse container state from Docker status string + */ + static parseState(status: string): ContainerState { + const statusLower = status.toLowerCase(); + + if (statusLower.includes('running') || statusLower.includes('up')) { + return ContainerState.Running; + } else if (statusLower.includes('paused')) { + return ContainerState.Paused; + } else if (statusLower.includes('exited')) { + return ContainerState.Exited; + } else if (statusLower.includes('stopped')) { + return ContainerState.Stopped; + } else { + return ContainerState.Unknown; + } + } + + /** + * Parse state from inspect info + */ + static parseStateFromInspect(inspect: ContainerInspectInfo): ContainerState { + if (inspect.State.Running) { + return ContainerState.Running; + } else if (inspect.State.Paused) { + return ContainerState.Paused; + } else if (inspect.State.Dead) { + return ContainerState.Exited; + } else { + return ContainerState.Stopped; + } + } + + /** + * Convert inspect info to DevContainerInfo + */ + static fromInspect(inspect: ContainerInspectInfo): DevContainerInfo { + const labels = inspect.Config.Labels || {}; + const state = this.parseStateFromInspect(inspect); + + // Remove leading slash from container name if present + const containerName = inspect.Name.startsWith('/') ? inspect.Name.substring(1) : inspect.Name; + + const info: DevContainerInfo = { + containerId: inspect.Id, + containerName, + state, + imageId: inspect.Image, + imageName: inspect.Config.Image, + }; + + // Add dev container specific information from labels + const localFolder = ContainerLabels.getLocalFolder(labels); + if (localFolder) { + info.workspaceFolder = localFolder; + } + + const configFile = ContainerLabels.getConfigFile(labels); + if (configFile) { + info.configFilePath = configFile; + } + + const createdAt = ContainerLabels.getCreatedAt(labels); + if (createdAt) { + info.createdAt = createdAt; + } else { + // Fallback to container creation time + try { + info.createdAt = new Date(inspect.Created); + } catch (error) { + getLogger().debug(`Failed to parse container creation time: ${error}`); + } + } + + return info; + } + + /** + * Create a simple DevContainerInfo from minimal information + */ + static createInfo(params: { + containerId: string; + containerName: string; + state: ContainerState; + workspaceFolder?: string; + configFilePath?: string; + imageId?: string; + imageName?: string; + }): DevContainerInfo { + return { + containerId: params.containerId, + containerName: params.containerName, + state: params.state, + workspaceFolder: params.workspaceFolder, + configFilePath: params.configFilePath, + imageId: params.imageId, + imageName: params.imageName, + createdAt: new Date(), + }; + } + + /** + * Check if a container is running + */ + static isRunning(info: DevContainerInfo): boolean { + return info.state === ContainerState.Running; + } + + /** + * Check if a container is stopped + */ + static isStopped(info: DevContainerInfo): boolean { + return info.state === ContainerState.Stopped || info.state === ContainerState.Exited; + } + + /** + * Check if a container can be started + */ + static canStart(info: DevContainerInfo): boolean { + return this.isStopped(info); + } + + /** + * Check if a container can be stopped + */ + static canStop(info: DevContainerInfo): boolean { + return info.state === ContainerState.Running || info.state === ContainerState.Paused; + } + + /** + * Get container short ID (first 12 characters) + */ + static getShortId(containerId: string): string { + return containerId.substring(0, 12); + } + + /** + * Format container display name + */ + static getDisplayName(info: DevContainerInfo): string { + return `${info.containerName} (${this.getShortId(info.containerId)})`; + } + + /** + * Format container state for display + */ + static formatState(state: ContainerState): string { + switch (state) { + case ContainerState.Running: + return '$(vm-running) Running'; + case ContainerState.Stopped: + return '$(vm-outline) Stopped'; + case ContainerState.Paused: + return '$(debug-pause) Paused'; + case ContainerState.Exited: + return '$(error) Exited'; + default: + return '$(question) Unknown'; + } + } + + /** + * Extract workspace folder name from path + */ + static getWorkspaceFolderName(workspaceFolder: string): string { + const parts = workspaceFolder.split(/[/\\]/); + return parts[parts.length - 1] || workspaceFolder; + } +} diff --git a/extensions/positron-dev-containers/src/container/devContainerManager.ts b/extensions/positron-dev-containers/src/container/devContainerManager.ts new file mode 100644 index 000000000000..5a27e90b18c3 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/devContainerManager.ts @@ -0,0 +1,680 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { URI } from 'vscode-uri'; +import { getLogger } from '../common/logger'; +import { getConfiguration } from '../common/configuration'; +import { Workspace } from '../common/workspace'; +import { DevContainerInfo } from '../common/types'; +import { ContainerLabels } from './containerLabels'; +import { ContainerStateManager, ContainerInspectInfo } from './containerState'; +import { TerminalBuilder } from './terminalBuilder'; + +// Import spec library +import { createDockerParams } from '../spec/spec-node/devContainers'; +import { readDevContainerConfigFile } from '../spec/spec-node/configContainer'; +import { inspectContainer, inspectContainers, listContainers, dockerCLI, ContainerDetails } from '../spec/spec-shutdown/dockerUtils'; +import { getCLIHost, loadNativeModule } from '../spec/spec-common/commonUtils'; +import { workspaceFromPath } from '../spec/spec-utils/workspaces'; +import { makeLog, LogLevel } from '../spec/spec-utils/log'; + +/** + * Format an error for logging, handling both Error instances and docker command errors + * which have stdout/stderr/code properties. + */ +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'object' && error !== null) { + // Handle docker command errors which have stdout/stderr/code + const err = error as { message?: string; stderr?: Buffer; code?: number }; + const parts: string[] = []; + if (err.message) { + parts.push(err.message); + } + if (err.stderr && Buffer.isBuffer(err.stderr)) { + const stderrText = err.stderr.toString().trim(); + if (stderrText) { + parts.push(stderrText); + } + } + if (err.code !== undefined) { + parts.push(`Exit code: ${err.code}`); + } + return parts.length > 0 ? parts.join(' - ') : JSON.stringify(error); + } + return String(error); +} + +/** + * Options for creating/starting a dev container + */ +export interface DevContainerOptions { + /** + * Workspace folder path + */ + workspaceFolder: string; + + /** + * Config file path (optional, will be auto-detected if not provided) + */ + configFilePath?: string; + + /** + * Whether to remove existing container and rebuild + */ + rebuild?: boolean; + + /** + * Whether to skip cache during build + */ + noCache?: boolean; + + /** + * Additional labels to apply to the container + */ + additionalLabels?: Record; + + /** + * Whether to skip post-create commands + */ + skipPostCreate?: boolean; +} + +/** + * Result from creating/starting a container + */ +export interface DevContainerResult { + /** + * Container ID + */ + containerId: string; + + /** + * Container name + */ + containerName: string; + + /** + * Container info + */ + containerInfo: DevContainerInfo; + + /** + * Remote workspace folder path + */ + remoteWorkspaceFolder: string; + + /** + * Remote user + */ + remoteUser: string; +} + +/** + * Dev Container Manager + * Handles container lifecycle operations using the spec library + */ +export class DevContainerManager { + private static instance: DevContainerManager; + + private constructor() { } + + /** + * Get singleton instance + */ + static getInstance(): DevContainerManager { + if (!DevContainerManager.instance) { + DevContainerManager.instance = new DevContainerManager(); + } + return DevContainerManager.instance; + } + + /** + * Create or start a dev container + */ + async createOrStartContainer(options: DevContainerOptions): Promise { + const logger = getLogger(); + + logger.info(`Creating/starting dev container for: ${options.workspaceFolder}`); + + // Check if workspace has dev container config + const workspaceUri = vscode.Uri.file(options.workspaceFolder); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(workspaceUri); + if (!workspaceFolder) { + throw new Error(`Workspace folder not found: ${options.workspaceFolder}`); + } + + const devContainerPaths = Workspace.getDevContainerPaths(workspaceFolder); + if (!devContainerPaths) { + throw new Error('No dev container configuration found'); + } + + const configFilePath = options.configFilePath || devContainerPaths.devContainerJsonPath; + + // Check for existing container + const existingContainer = await this.findExistingContainer( + options.workspaceFolder, + configFilePath + ); + + // If rebuild is requested, remove existing container + if (existingContainer && options.rebuild) { + logger.info(`Removing existing container for rebuild: ${existingContainer.containerId}`); + await this.removeContainer(existingContainer.containerId); + } else if (existingContainer && !options.rebuild) { + // Start existing container if stopped + if (ContainerStateManager.isStopped(existingContainer)) { + logger.info(`Starting existing container: ${existingContainer.containerId}`); + await this.startContainer(existingContainer.containerId); + } else { + logger.debug(`Container already running: ${existingContainer.containerId}`); + } + + // Get updated container info + const updatedInfo = await this.getContainerInfo(existingContainer.containerId); + const inspectInfo = await this.inspectContainerById(existingContainer.containerId); + + // Extract the actual remote workspace folder from container mounts + let remoteWorkspaceFolder = '/workspaces'; + const workspaceMount = inspectInfo.Mounts?.find(mount => + mount.Type === 'bind' && mount.Destination.startsWith('/workspaces/') + ); + if (workspaceMount) { + remoteWorkspaceFolder = workspaceMount.Destination; + logger.debug(`Found remote workspace folder from mount: ${remoteWorkspaceFolder}`); + } else { + // Fallback: try to generate from local path + const folderName = options.workspaceFolder.split(/[/\\]/).pop() || 'workspace'; + remoteWorkspaceFolder = `/workspaces/${folderName}`; + logger.warn(`No workspace mount found, using fallback: ${remoteWorkspaceFolder}`); + } + + return { + containerId: existingContainer.containerId, + containerName: existingContainer.containerName, + containerInfo: updatedInfo, + remoteWorkspaceFolder, + remoteUser: inspectInfo.Config.User || 'root', + }; + } + + // Create new container + return await this.createContainer(options, configFilePath); + } + + /** + * Create a new dev container + */ + private async createContainer( + options: DevContainerOptions, + _configFilePath: string + ): Promise { + const logger = getLogger(); + + logger.info('Building container using terminal...'); + + // Use terminal-based builder + const result = await TerminalBuilder.buildAndCreate( + options.workspaceFolder, + options.rebuild || false, + options.noCache || false + ); + + // Get container info + const containerInfo = await this.getContainerInfo(result.containerId); + + // Get the remote user from the container inspection + const inspectInfo = await this.inspectContainerById(result.containerId); + const remoteUser = inspectInfo.Config.User || 'root'; + + logger.info(`Container created: ${result.containerId}`); + + return { + containerId: result.containerId, + containerName: result.containerName, + containerInfo, + remoteWorkspaceFolder: result.remoteWorkspaceFolder, + remoteUser, + }; + } + + /** + * Find existing container for a workspace + */ + async findExistingContainer( + workspaceFolder: string, + configFilePath: string + ): Promise { + const logger = getLogger(); + logger.debug(`Looking for existing container: ${workspaceFolder}`); + + try { + // Get docker params for querying + const params = await this.createDockerParams(); + + // List all container IDs + const containerIds = await listContainers(params, true, undefined); + + if (containerIds.length === 0) { + return undefined; + } + + // Inspect all containers to get their details + const containers = await inspectContainers(params, containerIds); + + // Find container matching workspace and config + for (const container of containers) { + const rawLabels = container.Config.Labels || {}; + // Filter out undefined values to match expected type + const labels: Record = {}; + for (const [key, value] of Object.entries(rawLabels)) { + if (value !== undefined && value !== null) { + labels[key] = value; + } + } + + if (ContainerLabels.matchesWorkspace(labels, workspaceFolder, configFilePath)) { + return ContainerStateManager.fromInspect(this.toContainerInspectInfo(container)); + } + } + + return undefined; + } catch (error) { + logger.error('Failed to find existing container:', error instanceof Error ? error.message : String(error)); + if (error instanceof Error && error.stack) { + logger.debug('Stack trace:', error.stack); + } + return undefined; + } + } + + /** + * Get container info by ID + */ + async getContainerInfo(containerId: string): Promise { + const logger = getLogger(); + logger.debug(`Getting container info: ${containerId}`); + + try { + const params = await this.createDockerParams(); + const details = await inspectContainer(params, containerId); + return ContainerStateManager.fromInspect(this.toContainerInspectInfo(details)); + } catch (error) { + // Only log as error if it's not a Docker availability issue + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('ENOENT') || errorMsg.includes('spawn')) { + logger.debug('Failed to get container info - Docker not available', error); + } else { + logger.error('Failed to get container info', error); + } + throw new Error(`Failed to get container info: ${errorMsg}`); + } + } + + /** + * Get detailed container inspection info including mounts + */ + async inspectContainerDetails(containerId: string): Promise { + const logger = getLogger(); + logger.debug(`Inspecting container details: ${containerId}`); + + try { + const params = await this.createDockerParams(); + const details = await inspectContainer(params, containerId); + return this.toContainerInspectInfo(details); + } catch (error) { + // Only log as error if it's not a Docker availability issue + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('ENOENT') || errorMsg.includes('spawn')) { + logger.debug('Failed to inspect container details - Docker not available', error); + } else { + logger.error('Failed to inspect container details', error); + } + throw new Error(`Failed to inspect container details: ${errorMsg}`); + } + } + + /** + * List all dev containers + */ + async listDevContainers(): Promise { + const logger = getLogger(); + logger.debug('Listing dev containers'); + + try { + const params = await this.createDockerParams(); + const containerIds = await listContainers(params, true, undefined); + + if (containerIds.length === 0) { + return []; + } + + const containers = await inspectContainers(params, containerIds); + + const devContainers: DevContainerInfo[] = []; + + for (const container of containers) { + const rawLabels = container.Config.Labels || {}; + // Filter out undefined values to match expected type + const labels: Record = {}; + for (const [key, value] of Object.entries(rawLabels)) { + if (value !== undefined && value !== null) { + labels[key] = value; + } + } + + // Check if this is a dev container + if (ContainerLabels.isDevContainer(labels)) { + try { + const info = ContainerStateManager.fromInspect(this.toContainerInspectInfo(container)); + devContainers.push(info); + } catch (error) { + logger.error(`Failed to process container ${container.Id}`, error); + } + } + } + + return devContainers; + } catch (error) { + logger.error('Failed to list dev containers', error); + return []; + } + } + + /** + * Start a stopped container + */ + async startContainer(containerId: string): Promise { + const logger = getLogger(); + logger.info(`Starting container: ${containerId}`); + + try { + const params = await this.createDockerParams(); + await dockerCLI(params, 'start', containerId); + logger.info(`Container started: ${containerId}`); + } catch (error) { + const errorMsg = formatError(error); + logger.error(`Failed to start container: ${errorMsg}`, error); + throw new Error(`Failed to start container: ${errorMsg}`); + } + } + + /** + * Stop a running container + */ + async stopContainer(containerId: string): Promise { + const logger = getLogger(); + logger.info(`Stopping container: ${containerId}`); + + try { + const params = await this.createDockerParams(); + await dockerCLI(params, 'stop', containerId); + logger.info(`Container stopped: ${containerId}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to stop container', error); + throw new Error(`Failed to stop container: ${errorMsg}`); + } + } + + /** + * Remove a container + */ + async removeContainer(containerId: string, force: boolean = true): Promise { + const logger = getLogger(); + logger.info(`Removing container: ${containerId}`); + + try { + const params = await this.createDockerParams(); + const args = ['rm']; + if (force) { + args.push('--force'); + } + args.push(containerId); + await dockerCLI(params, ...args); + logger.info(`Container removed: ${containerId}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to remove container', error); + throw new Error(`Failed to remove container: ${errorMsg}`); + } + } + + /** + * Get container logs + */ + async getContainerLogs(containerId: string, lines: number = 100): Promise { + const logger = getLogger(); + logger.debug(`Getting container logs: ${containerId}`); + + try { + const params = await this.createDockerParams(); + const result = await dockerCLI(params, 'logs', '--tail', lines.toString(), containerId); + return result.stdout.toString(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to get container logs', error); + throw new Error(`Failed to get container logs: ${errorMsg}`); + } + } + + /** + * Read dev container configuration + */ + async readConfiguration(workspaceFolder: string, configFilePath: string): Promise { + const logger = getLogger(); + logger.debug(`Reading config: ${configFilePath}`); + + try { + const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, false); + const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const configUri = URI.file(configFilePath); + + const output = makeLog({ + event: (e) => { + if (e.type === 'text') { + logger.debug(e.text); + } + }, + dimensions: { columns: 0, rows: 0 }, + }, LogLevel.Info); + + const config = await readDevContainerConfigFile( + cliHost, + workspace, + configUri, + false, // mountWorkspaceGitRoot + output + ); + + return config; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to read configuration', error); + throw new Error(`Failed to read configuration: ${errorMsg}`); + } + } + + /** + * Clean up stopped dev containers + */ + async cleanupStoppedContainers(): Promise { + const logger = getLogger(); + logger.info('Cleaning up stopped dev containers'); + + try { + const devContainers = await this.listDevContainers(); + const stoppedContainers = devContainers.filter(c => + ContainerStateManager.isStopped(c) + ); + + logger.info(`Found ${stoppedContainers.length} stopped dev containers`); + + for (const container of stoppedContainers) { + try { + await this.removeContainer(container.containerId); + logger.info(`Removed: ${container.containerName}`); + } catch (error) { + logger.error(`Failed to remove ${container.containerName}`, error); + } + } + + return stoppedContainers.length; + } catch (error) { + logger.error('Failed to cleanup containers', error); + return 0; + } + } + + /** + * Check if Docker is available + */ + async isDockerAvailable(): Promise { + const logger = getLogger(); + + // Docker is never available in remote context (UI extension running inside container) + if (vscode.env.remoteName) { + logger.debug(`Docker not available in remote context (${vscode.env.remoteName})`); + return false; + } + + try { + const config = getConfiguration(); + const dockerPath = config.getDockerPath(); + logger.debug(`Checking Docker availability with path: ${dockerPath}`); + const params = await this.createDockerParams(); + await dockerCLI(params, 'version'); + logger.debug('Docker is available'); + return true; + } catch (error) { + const errorMsg = formatError(error); + logger.warn(`Docker not available: ${errorMsg}`, error); + if (error instanceof Error && error.stack) { + logger.debug('Stack trace:', error.stack); + } + return false; + } + } + + /** + * Create Docker resolver parameters + */ + private async createDockerParams(): Promise { + const config = getConfiguration(); + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + + const disposables: (() => Promise | undefined)[] = []; + + const params = await createDockerParams({ + dockerPath: config.getDockerPath(), + dockerComposePath: config.getDockerComposePath(), + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + workspaceFolder: cwd, + workspaceMountConsistency: config.getWorkspaceMountConsistency(), + gpuAvailability: config.getGpuAvailability(), + mountWorkspaceGitRoot: false, + configFile: undefined, + overrideConfigFile: undefined, + logLevel: config.getLogLevel() as any, + logFormat: 'text', + log: (text: string) => getLogger().debug(text), + terminalDimensions: undefined, + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder: undefined, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: {}, + additionalCacheFroms: [], + useBuildKit: 'auto', + omitLoggerHeader: true, + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: true, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: {}, + }, disposables); + + return params; + } + + /** + * Inspect container by ID + */ + private async inspectContainerById(containerId: string): Promise { + const params = await this.createDockerParams(); + const details = await inspectContainer(params, containerId); + return this.toContainerInspectInfo(details); + } + + /** + * Convert ContainerDetails to ContainerInspectInfo + */ + private toContainerInspectInfo(details: ContainerDetails): ContainerInspectInfo { + // Filter out undefined values from labels + const rawLabels = details.Config.Labels || {}; + const labels: Record = {}; + for (const [key, value] of Object.entries(rawLabels)) { + if (value !== undefined && value !== null) { + labels[key] = value; + } + } + + return { + Id: details.Id, + Name: details.Name, + State: { + Status: details.State.Status, + Running: details.State.Status.toLowerCase() === 'running', + Paused: details.State.Status.toLowerCase() === 'paused', + Restarting: false, + OOMKilled: false, + Dead: details.State.Status.toLowerCase() === 'dead', + Pid: 0, + ExitCode: 0, + Error: '', + StartedAt: details.State.StartedAt, + FinishedAt: details.State.FinishedAt, + }, + Created: details.Created, + Config: { + Labels: Object.keys(labels).length > 0 ? labels : undefined, + Image: details.Config.Image, + Hostname: undefined, + User: details.Config.User, + WorkingDir: undefined, + Env: details.Config.Env || undefined, + }, + Image: details.Config.Image, + NetworkSettings: { + Ports: details.NetworkSettings.Ports, + IPAddress: undefined, + Networks: undefined, + }, + Mounts: details.Mounts, + }; + } +} + +/** + * Get the dev container manager instance + */ +export function getDevContainerManager(): DevContainerManager { + return DevContainerManager.getInstance(); +} + diff --git a/extensions/positron-dev-containers/src/container/featuresInstaller.ts b/extensions/positron-dev-containers/src/container/featuresInstaller.ts new file mode 100644 index 000000000000..4132bc4c81d4 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/featuresInstaller.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as os from 'os'; +import { URI } from 'vscode-uri'; +import { getLogger } from '../common/logger'; +import { Configuration } from '../common/configuration'; +import { getCLIHost, loadNativeModule } from '../spec/spec-common/commonUtils'; +import { createDockerParams } from '../spec/spec-node/devContainers'; +import { generateFeaturesConfig, FeaturesConfig } from '../spec/spec-configuration/containerFeaturesConfiguration'; +import { rmLocal } from '../spec/spec-utils/pfs'; +import { LogLevel } from '../spec/spec-utils/log'; + +/** + * Result from preparing features installation + */ +export interface FeaturesInstallInfo { + /** + * Whether any features need to be installed + */ + hasFeatures: boolean; + + /** + * Features configuration + */ + featuresConfig?: FeaturesConfig; + + /** + * Path to temporary directory containing features + */ + featuresDir?: string; +} + +/** + * Prepares features for installation in a container + * + * This function: + * 1. Reads features from devcontainer.json + * 2. Merges with default features from settings + * 3. Downloads/prepares features to a temporary directory using the reference implementation + * 4. Returns information needed to install features via terminal commands + */ +export async function prepareFeaturesInstallation( + workspaceFolder: string, + devContainerConfig: any +): Promise { + const logger = getLogger(); + const config = Configuration.getInstance(); + + logger.info('==> prepareFeaturesInstallation called'); + logger.debug(`Workspace folder: ${workspaceFolder}`); + + try { + // Merge features from devcontainer.json with default features + const defaultFeatures = config.getDefaultFeatures(); + const configFeatures = devContainerConfig.features || {}; + const allFeatures = { ...defaultFeatures, ...configFeatures }; + + logger.debug(`Default features: ${JSON.stringify(defaultFeatures)}`); + logger.debug(`Config features: ${JSON.stringify(configFeatures)}`); + logger.debug(`Merged features: ${JSON.stringify(allFeatures)}`); + + // Check if there are any features to install + if (Object.keys(allFeatures).length === 0) { + logger.debug('No features to install'); + return { hasFeatures: false }; + } + + logger.info(`Found ${Object.keys(allFeatures).length} feature(s) to install`); + + // Create temporary directory for features + const featuresDir = path.join(os.tmpdir(), `devcontainer-features-${Date.now()}`); + + // Create CLI host for feature processing + const cwd = workspaceFolder || process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, false); + await cliHost.mkdirp(featuresDir); + logger.debug(`Created features directory: ${featuresDir}`); + + // Create a temporary config file path in the features directory + // This is needed for lockfile operations to work correctly + const tempConfigPath = path.join(featuresDir, 'devcontainer.json'); + const configFileUri = URI.file(tempConfigPath); + + // Create docker params for feature configuration + const disposables: (() => Promise | undefined)[] = []; + const params = await createDockerParams( + { + dockerPath: config.getDockerPath(), + dockerComposePath: config.getDockerComposePath(), + workspaceFolder: workspaceFolder, + mountWorkspaceGitRoot: false, + configFile: undefined, + overrideConfigFile: undefined, + logLevel: LogLevel.Info, + logFormat: 'text', + log: (text) => logger.debug(text), + terminalDimensions: undefined, + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder: undefined, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: {}, + additionalCacheFroms: [], + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + omitConfigRemotEnvFromMetadata: false, + dotfiles: {}, + }, + disposables + ); + + // Generate features configuration using the reference implementation + // This will download and prepare all features + const cacheFolder = path.join(os.homedir(), '.devcontainer', 'cache'); + await cliHost.mkdirp(cacheFolder); + + logger.debug('Generating features configuration...'); + const featuresConfig = await generateFeaturesConfig( + { + ...params.common, + platform: cliHost.platform, + cacheFolder, + experimentalLockfile: false, + experimentalFrozenLockfile: false, + }, + featuresDir, + { + features: allFeatures, + configFilePath: configFileUri // Use temporary config file URI for lockfile operations + } as any, + {} + ); + + logger.debug(`Features config generated: ${featuresConfig ? 'yes' : 'no'}`); + if (featuresConfig) { + logger.debug(`Feature sets count: ${featuresConfig.featureSets.length}`); + for (const featureSet of featuresConfig.featureSets) { + logger.debug(`Feature set has ${featureSet.features.length} features`); + for (const feature of featureSet.features) { + logger.debug(` - Feature: ${feature.id}, included: ${feature.included}, cachePath: ${feature.cachePath}`); + } + } + } + + if (!featuresConfig || featuresConfig.featureSets.length === 0) { + logger.info('No features configuration generated'); + // Clean up empty directory + await rmLocal(featuresDir, { recursive: true, force: true }); + return { hasFeatures: false }; + } + + logger.info(`Prepared ${featuresConfig.featureSets.length} feature set(s) for installation`); + logger.info(`Features directory: ${featuresDir}`); + + return { + hasFeatures: true, + featuresConfig, + featuresDir + }; + } catch (error) { + logger.error('Failed to prepare features installation:', error); + return { hasFeatures: false }; + } +} + +/** + * Generates shell script commands to install features in a running container + * + * @param featuresConfig Features configuration from prepareFeaturesInstallation + * @param featuresDir Local directory containing downloaded features + * @param dockerPath Path to docker executable + * @param containerId Container ID (will be set from $CONTAINER_ID variable in script) + * @param isWindows Whether running on Windows (affects script syntax) + * @returns Shell script commands to install features + */ +export function generateFeatureInstallScript( + featuresConfig: FeaturesConfig, + _featuresDir: string, + dockerPath: string, + isWindows: boolean +): string { + const logger = getLogger(); + logger.info('==> generateFeatureInstallScript called'); + logger.debug(`Feature sets to install: ${featuresConfig.featureSets.length}`); + logger.debug(`Platform: ${isWindows ? 'Windows' : 'Unix'}`); + + let script = ''; + + // Platform-specific commands + const echoCmd = isWindows ? 'Write-Host' : 'echo'; + + script += `${echoCmd} "==> Installing dev container features..."\n\n`; + + // Process each feature set + for (const featureSet of featuresConfig.featureSets) { + logger.debug(`Processing feature set with internalVersion: ${featureSet.internalVersion}`); + logger.debug(`Feature set has ${featureSet.features.length} features`); + + for (const feature of featureSet.features) { + if (!feature.included || !feature.cachePath) { + logger.debug(`Skipping feature ${feature.id}: included=${feature.included}, cachePath=${feature.cachePath}`); + continue; + } + + const featureId = feature.id; + const featureName = feature.name || featureId; + const featureVersion = feature.version || 'latest'; + const consecutiveId = feature.consecutiveId || ''; + + // All features have install.sh as the main installation script + // The devcontainer-features-install.sh wrapper is only used during image build + const installScript = 'install.sh'; + + const localFeaturePath = feature.cachePath; + const containerFeaturePath = `/tmp/dev-container-features/${consecutiveId}`; + + logger.debug(`Generating install script for feature: ${featureId}`); + logger.debug(` - Internal version: ${featureSet.internalVersion}`); + logger.debug(` - Install script name: ${installScript}`); + logger.debug(` - Local path: ${localFeaturePath}`); + logger.debug(` - Container path: ${containerFeaturePath}`); + + if (isWindows) { + // PowerShell script + script += `Write-Host "==> Installing feature: ${featureName} (${featureVersion})"\n`; + + // Create directory in container + script += `& ${dockerPath} exec $CONTAINER_ID mkdir -p "${containerFeaturePath}"\n`; + + // Copy feature files into container + script += `& ${dockerPath} cp -a "${localFeaturePath.replace(/\\/g, '/')}/.\" "$CONTAINER_ID:${containerFeaturePath}/"\n`; + + // Set permissions and run install script + script += `& ${dockerPath} exec $CONTAINER_ID sh -c "cd ${containerFeaturePath} && chmod +x ./${installScript} && ./${installScript}"\n`; + script += 'if ($LASTEXITCODE -ne 0) {\n'; + script += ` Write-Host "Warning: Feature '${featureName}' installation had errors (exit code $LASTEXITCODE)" -ForegroundColor Yellow\n`; + script += '}\n\n'; + } else { + // Bash script + script += `echo "==> Installing feature: ${featureName} (${featureVersion})"\n`; + + // Create directory in container + script += `${dockerPath} exec $CONTAINER_ID mkdir -p "${containerFeaturePath}"\n`; + + // Copy feature files into container using tar (more reliable than docker cp for directories) + script += `tar -C "${localFeaturePath}" -cf - . | ${dockerPath} exec -i $CONTAINER_ID tar -C "${containerFeaturePath}" -xf -\n`; + + // Set permissions and run install script + script += `${dockerPath} exec $CONTAINER_ID sh -c "cd ${containerFeaturePath} && chmod +x ./${installScript} && ./${installScript}"\n`; + script += `if [ $? -ne 0 ]; then\n`; + script += ` echo "Warning: Feature '${featureName}' installation had errors"\n`; + script += `fi\n\n`; + } + } + } + + script += `${echoCmd} "==> Features installation complete"\n\n`; + + return script; +} + +/** + * Cleans up temporary features directory + */ +export async function cleanupFeaturesDir(featuresDir: string): Promise { + const logger = getLogger(); + try { + await rmLocal(featuresDir, { recursive: true, force: true }); + logger.debug(`Cleaned up features directory: ${featuresDir}`); + } catch (error) { + logger.warn('Failed to cleanup features directory:', error); + } +} diff --git a/extensions/positron-dev-containers/src/container/lifecycleHooksRunner.ts b/extensions/positron-dev-containers/src/container/lifecycleHooksRunner.ts new file mode 100644 index 000000000000..e52c9822d733 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/lifecycleHooksRunner.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as os from 'os'; +import { getLogger } from '../common/logger'; +import { Configuration } from '../common/configuration'; +import { DevContainerLifecycleHook, LifecycleCommand } from '../spec/spec-common/injectHeadless'; + +/** + * Configuration for lifecycle hooks execution + */ +export interface LifecycleHooksConfig { + /** + * Container ID + */ + containerId: string; + + /** + * Container name + */ + containerName: string; + + /** + * Workspace folder on host + */ + workspaceFolder: string; + + /** + * Remote workspace folder in container + */ + remoteWorkspaceFolder: string; + + /** + * Dev container configuration + */ + devContainerConfig: any; + + /** + * Terminal to show output in + */ + terminal: vscode.Terminal; +} + +/** + * Runs lifecycle hooks for a dev container by generating commands and sending them to the terminal. + * This provides clean output in the terminal with proper TTY support. + */ +export async function runDevContainerLifecycleHooks(config: LifecycleHooksConfig): Promise { + const logger = getLogger(); + const vsconfig = Configuration.getInstance(); + const dockerPath = vsconfig.getDockerPath(); + const isWindows = os.platform() === 'win32'; + + logger.info('==> Running lifecycle hooks...'); + + // Build map of hooks to run + const hooksToRun = buildLifecycleHooksMap(config.devContainerConfig); + + // Lifecycle hooks execution order (based on spec library) + const hookOrder: DevContainerLifecycleHook[] = [ + 'onCreateCommand', + 'updateContentCommand', + 'postCreateCommand', + 'postStartCommand', + 'postAttachCommand', + ]; + + // Generate and send commands to terminal + for (const hookName of hookOrder) { + const command = hooksToRun[hookName]; + if (!command) { + continue; + } + + logger.info(`Running ${hookName}...`); + + // Generate the docker exec command + const execCommand = buildDockerExecCommand( + dockerPath, + config.containerId, + config.remoteWorkspaceFolder, + command, + hookName, + isWindows + ); + + // Send to terminal for execution + config.terminal.sendText(execCommand); + } +} + +/** + * Builds a map of lifecycle hooks from the dev container configuration + */ +function buildLifecycleHooksMap(devContainerConfig: any): Partial> { + const map: Partial> = {}; + + const hooks: DevContainerLifecycleHook[] = [ + 'initializeCommand', + 'onCreateCommand', + 'updateContentCommand', + 'postCreateCommand', + 'postStartCommand', + 'postAttachCommand', + ]; + + for (const hook of hooks) { + if (devContainerConfig[hook]) { + map[hook] = devContainerConfig[hook]; + } + } + + return map; +} + +/** + * Builds a docker exec command to run a lifecycle hook + */ +function buildDockerExecCommand( + dockerPath: string, + containerId: string, + remoteCwd: string, + command: LifecycleCommand, + hookName: string, + isWindows: boolean +): string { + // Convert command to shell script + let shellCommand: string; + + if (typeof command === 'string') { + shellCommand = command; + } else if (Array.isArray(command)) { + // Array of commands - run them sequentially + shellCommand = command.join(' && '); + } else if (typeof command === 'object') { + // Object with named commands - run them sequentially + const commands = Object.entries(command).map(([name, cmd]) => { + const cmdStr = Array.isArray(cmd) ? cmd.join(' ') : cmd; + return `echo "Running ${name}..." && ${cmdStr}`; + }); + shellCommand = commands.join(' && '); + } else { + return ''; + } + + // Escape the command for the shell + const escapedCommand = isWindows + ? shellCommand.replace(/"/g, '`"') + : shellCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$'); + + // Build the full command + if (isWindows) { + return `Write-Host "==> Running ${hookName}..."; ${dockerPath} exec -w "${remoteCwd}" ${containerId} sh -c "${escapedCommand}"`; + } else { + return `echo "==> Running ${hookName}..."; ${dockerPath} exec -w "${remoteCwd}" ${containerId} sh -c "${escapedCommand}"`; + } +} diff --git a/extensions/positron-dev-containers/src/container/terminalBuilder.ts b/extensions/positron-dev-containers/src/container/terminalBuilder.ts new file mode 100644 index 000000000000..c9ca44fdbc38 --- /dev/null +++ b/extensions/positron-dev-containers/src/container/terminalBuilder.ts @@ -0,0 +1,517 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as jsonc from 'jsonc-parser'; +import { getLogger } from '../common/logger'; +import { Configuration } from '../common/configuration'; +import { generateDockerBuildCommand, generateDockerCreateCommand } from '../spec/spec-node/devContainersSpecCLI'; +import { formatCommandWithEcho, escapeShellArg } from '../spec/spec-node/commandGeneration'; +import { prepareFeaturesInstallation, generateFeatureInstallScript, cleanupFeaturesDir } from './featuresInstaller'; +import { Mount } from '../spec/spec-configuration/containerFeaturesConfiguration'; + +/** + * Result from terminal build + */ +export interface TerminalBuildResult { + containerId: string; + containerName: string; + remoteWorkspaceFolder: string; + terminal: vscode.Terminal; +} + +/** + * Escapes a PowerShell argument properly + */ +function escapePowerShellArg(arg: string): string { + // If the argument contains spaces or special characters, wrap in single quotes + // Single quotes in PowerShell are literal strings (no variable expansion) + if (/[\s"'`$(){}[\]&|<>^]/.test(arg)) { + // Escape single quotes by doubling them, then wrap in single quotes + return `'${arg.replace(/'/g, "''")}'`; + } + return arg; +} + +/** + * Generates lifecycle hooks script to be included in the build script + */ +function generateLifecycleHooksScript( + devContainerConfig: any, + dockerPath: string, + remoteCwd: string, + isWindows: boolean +): string { + let script = ''; + + // Build map of hooks to run + const hooksToRun: Record = {}; + const hooks = [ + 'onCreateCommand', + 'updateContentCommand', + 'postCreateCommand', + 'postStartCommand', + 'postAttachCommand', + ]; + + for (const hook of hooks) { + if (devContainerConfig[hook]) { + hooksToRun[hook] = devContainerConfig[hook]; + } + } + + // Generate commands for each hook + for (const [hookName, command] of Object.entries(hooksToRun)) { + // Convert command to shell script + let shellCommand: string; + + if (typeof command === 'string') { + shellCommand = command; + } else if (Array.isArray(command)) { + // Array of commands - run them sequentially + shellCommand = command.join(' && '); + } else if (typeof command === 'object') { + // Object with named commands - run them sequentially + const commands = Object.entries(command).map(([name, cmd]) => { + const cmdStr = Array.isArray(cmd) ? (cmd as string[]).join(' ') : cmd; + return `echo "Running ${name}..." && ${cmdStr}`; + }); + shellCommand = commands.join(' && '); + } else { + continue; + } + + // Escape the command for the shell + const escapedCommand = isWindows + ? shellCommand.replace(/"/g, '`"') + : shellCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$'); + + // Add to script + if (isWindows) { + script += `Write-Host "==> Running ${hookName}..."\n`; + script += `& ${escapePowerShellArg(dockerPath)} exec -w "${remoteCwd}" $CONTAINER_ID sh -c "${escapedCommand}"\n`; + script += 'if ($LASTEXITCODE -ne 0) {\n'; + script += ` Write-Host "WARNING: ${hookName} failed with exit code $LASTEXITCODE" -ForegroundColor Yellow\n`; + script += '}\n\n'; + } else { + script += `echo "==> Running ${hookName}..."\n`; + script += `${dockerPath} exec -w "${remoteCwd}" $CONTAINER_ID sh -c "${escapedCommand}"\n\n`; + } + } + + return script; +} + +/** + * Builds and runs dev containers using actual docker commands in a terminal + */ +export class TerminalBuilder { + /** + * Build and create a dev container in a terminal + */ + static async buildAndCreate( + workspaceFolder: string, + rebuild: boolean, + noCache: boolean + ): Promise { + const logger = getLogger(); + const config = Configuration.getInstance(); + + // Find the devcontainer.json file - check both standard locations + let devcontainerPath = path.join(workspaceFolder, '.devcontainer', 'devcontainer.json'); + if (!fs.existsSync(devcontainerPath)) { + // Try .devcontainer.json in workspace root + devcontainerPath = path.join(workspaceFolder, '.devcontainer.json'); + if (!fs.existsSync(devcontainerPath)) { + throw new Error(`Dev container configuration not found. Expected at ${path.join(workspaceFolder, '.devcontainer', 'devcontainer.json')} or ${path.join(workspaceFolder, '.devcontainer.json')}`); + } + } + + // Read and parse the devcontainer.json (supports comments via JSONC) + const devContainerConfig = jsonc.parse(fs.readFileSync(devcontainerPath, 'utf8')); + + // Determine the Dockerfile or image + const devcontainerDir = path.dirname(devcontainerPath); + let dockerfilePath: string | undefined; + let imageName: string | undefined; + + if (devContainerConfig.build && devContainerConfig.build.dockerfile) { + dockerfilePath = path.join(devcontainerDir, devContainerConfig.build.dockerfile); + } else if (devContainerConfig.dockerFile) { + dockerfilePath = path.join(devcontainerDir, devContainerConfig.dockerFile); + } else if (devContainerConfig.image) { + imageName = devContainerConfig.image; + } else { + // Default to Dockerfile in .devcontainer directory + const defaultDockerfile = path.join(devcontainerDir, 'Dockerfile'); + if (fs.existsSync(defaultDockerfile)) { + dockerfilePath = defaultDockerfile; + } + } + + // Setup folder paths + const folderName = path.basename(workspaceFolder); + const remoteWorkspaceFolder = `/workspaces/${folderName}`; + + // Build the image name + let builtImageName: string; + if (dockerfilePath) { + builtImageName = `vsc-${folderName}-${Date.now()}`.toLowerCase(); + } else if (imageName) { + builtImageName = imageName; + } else { + throw new Error('No Dockerfile or image specified in devcontainer.json'); + } + + // Get settings from VS Code configuration + const dockerPath = config.getDockerPath(); + const workspaceMountConsistency = config.getWorkspaceMountConsistency(); + + // Determine platform-specific settings + const isWindows = os.platform() === 'win32'; + const timestamp = Date.now(); + const scriptExt = isWindows ? '.ps1' : '.sh'; + const scriptPath = path.join(os.tmpdir(), `devcontainer-build-${timestamp}${scriptExt}`); + const markerPath = path.join(os.tmpdir(), `devcontainer-build-${timestamp}.done`); + const errorPath = path.join(os.tmpdir(), `devcontainer-build-${timestamp}.error`); + const containerIdPath = path.join(os.tmpdir(), `devcontainer-build-${timestamp}.id`); + const containerNamePath = path.join(os.tmpdir(), `devcontainer-build-${timestamp}.name`); + + // Helper function to generate script content + const generateScriptContent = () => { + if (isWindows) { + return generatePowerShellScript(); + } else { + return generateShellScript(); + } + }; + + const generateShellScript = () => { + let script = '#!/bin/sh\nset -e\n\n'; + script += '# Trap errors to keep terminal open so user can see what failed\n'; + script += '# Write error marker and exit 0 to avoid VS Code toast about exit code\n'; + script += `trap 'echo ""; echo "==> ERROR: Build failed! Press Enter to close this terminal..."; echo "failed" > "${errorPath}"; read dummy; exit 0' ERR\n\n`; + + if (rebuild) { + script += 'echo "==> Removing existing containers..."\n'; + script += `${dockerPath} ps -a -q --filter "label=devcontainer.local_folder=${escapeShellArg(workspaceFolder)}" | xargs ${dockerPath} rm -f 2>/dev/null || true\n\n`; + } + + return script; + }; + + const generatePowerShellScript = () => { + let script = '$ErrorActionPreference = "Stop"\n\n'; + script += '# Trap errors to keep terminal open so user can see what failed\n'; + script += '# Write error marker and exit 0 to avoid VS Code toast about exit code\n'; + script += 'trap {\n'; + script += ' Write-Host ""\n'; + script += ' Write-Host "==> ERROR: Build failed!"\n'; + script += ' Write-Host "Error: $_" -ForegroundColor Red\n'; + script += ' Write-Host ""\n'; + script += ` "failed" | Out-File -FilePath "${errorPath}" -Encoding utf8\n`; + script += ' Write-Host "Press Enter to close this terminal..."\n'; + script += ' Read-Host\n'; + script += ' exit 0\n'; + script += '}\n\n'; + + if (rebuild) { + script += 'Write-Host "==> Removing existing containers..."\n'; + const filterArg = `label=devcontainer.local_folder=${workspaceFolder.replace(/\\/g, '\\\\')}`; + script += `$containers = & ${escapePowerShellArg(dockerPath)} ps -a -q --filter "${filterArg}"\n`; + script += 'if ($containers) {\n'; + script += ` & ${escapePowerShellArg(dockerPath)} rm -f $containers 2>$null\n`; + script += '}\n\n'; + } + + return script; + }; + + let scriptContent = generateScriptContent(); + + // Generate build command using spec-node if we have a Dockerfile + if (dockerfilePath) { + const buildContext = devContainerConfig.build?.context ? path.join(devcontainerDir, devContainerConfig.build.context) : devcontainerDir; + const buildCmd = await generateDockerBuildCommand({ + dockerPath, + dockerfilePath, + contextPath: buildContext, + imageName: builtImageName, + buildArgs: devContainerConfig.build?.args, + target: devContainerConfig.build?.target, + noCache, + cacheFrom: devContainerConfig.build?.cacheFrom ? + (Array.isArray(devContainerConfig.build.cacheFrom) ? devContainerConfig.build.cacheFrom : [devContainerConfig.build.cacheFrom]) : + undefined, + buildKitEnabled: config.getConfiguration().workspaceMountConsistency !== 'consistent', // Simple heuristic + additionalArgs: devContainerConfig.build?.options, + }); + + logger.debug(`Docker command: ${buildCmd.command}`); + logger.debug(`Docker args: ${JSON.stringify(buildCmd.args)}`); + + // Format command for the appropriate platform + if (isWindows) { + scriptContent += 'Write-Host "==> ' + buildCmd.description + '"\n'; + logger.debug(`Docker command: ${buildCmd.command}`); + logger.debug(`Docker args: ${JSON.stringify(buildCmd.args)}`); + scriptContent += `Write-Host "Running: ${buildCmd.command.replace(/\\/g, '\\\\')} ${buildCmd.args.join(' ')}" -ForegroundColor Cyan\n`; + // Build the full command line and execute via cmd.exe to avoid window spawning + // Escape quotes in the command line for cmd.exe + const cmdLine = `"${buildCmd.command}" ${buildCmd.args.map(arg => { + // For cmd.exe, we need to escape quotes and wrap args with spaces in quotes + if (arg.includes(' ') || arg.includes('"')) { + return `"${arg.replace(/"/g, '""')}"`; + } + return arg; + }).join(' ')}`; + scriptContent += `cmd /c "${cmdLine.replace(/"/g, '""')}"\n`; + scriptContent += 'if ($LASTEXITCODE -ne 0) {\n'; + scriptContent += ' throw "Docker build failed with exit code $LASTEXITCODE"\n'; + scriptContent += '}\n\n'; + } else { + scriptContent += formatCommandWithEcho(buildCmd) + '\n\n'; + } + } else { + const echoCmd = isWindows ? 'Write-Host' : 'echo'; + scriptContent += `${echoCmd} "==> Using image: ${imageName}"\n\n`; + } + + // Prepare mounts array - handle both string and Mount object formats + const mounts: string[] = []; + if (devContainerConfig.mounts) { + for (const mount of devContainerConfig.mounts) { + if (typeof mount === 'string') { + mounts.push(mount); + } else { + // Convert Mount object to string format for --mount option + const mountObj = mount as Mount; + const type = `type=${mountObj.type}`; + const source = mountObj.source ? `,src=${mountObj.source}` : ''; + const target = `,dst=${mountObj.target}`; + mounts.push(`${type}${source}${target}`); + } + } + } + + // Add workspace mount consistency if specified + if (workspaceMountConsistency && workspaceMountConsistency !== 'consistent') { + // This is handled in the volume mount, not as a separate mount + } + + // Generate container create command using spec-node + const createCmd = await generateDockerCreateCommand({ + dockerPath, + imageName: builtImageName, + workspaceFolder, + remoteWorkspaceFolder, + containerUser: devContainerConfig.remoteUser, + env: devContainerConfig.remoteEnv, + mounts, + labels: { + 'devcontainer.local_folder': workspaceFolder, + 'devcontainer.config_file': devcontainerPath, + }, + runArgs: devContainerConfig.runArgs, + }); + + if (isWindows) { + scriptContent += 'Write-Host "==> Creating container..."\n'; + // Build command line and execute via cmd.exe to capture output and avoid window spawning + const createCmdLine = `"${dockerPath}" ${createCmd.args.map(arg => { + if (arg.includes(' ') || arg.includes('"')) { + return `"${arg.replace(/"/g, '""')}"`; + } + return arg; + }).join(' ')}`; + scriptContent += `$CONTAINER_ID = cmd /c "${createCmdLine.replace(/"/g, '""')}"\n`; + scriptContent += 'if ($LASTEXITCODE -ne 0) {\n'; + scriptContent += ' throw "Failed to create container with exit code $LASTEXITCODE"\n'; + scriptContent += '}\n'; + scriptContent += '$CONTAINER_ID = $CONTAINER_ID.Trim()\n'; + scriptContent += 'if (-not $CONTAINER_ID) {\n'; + scriptContent += ' throw "Failed to create container - no container ID returned"\n'; + scriptContent += '}\n'; + scriptContent += 'Write-Host "Container ID: $CONTAINER_ID"\n\n'; + + scriptContent += 'Write-Host "==> Starting container..."\n'; + // Use PowerShell's call operator (&) to execute docker directly + scriptContent += `& ${escapePowerShellArg(dockerPath)} start $CONTAINER_ID\n`; + scriptContent += 'if ($LASTEXITCODE -ne 0) {\n'; + scriptContent += ' throw "Failed to start container with exit code $LASTEXITCODE"\n'; + scriptContent += '}\n\n'; + } else { + scriptContent += 'echo "==> Creating container..."\n'; + scriptContent += `CONTAINER_ID=$(${dockerPath} ${createCmd.args.join(' ')})\n`; + scriptContent += 'echo "Container ID: $CONTAINER_ID"\n\n'; + + scriptContent += 'echo "==> Starting container..."\n'; + scriptContent += `${dockerPath} start $CONTAINER_ID\n\n`; + } + + // Prepare features installation (must happen before generating the script) + logger.info('==> Preparing features installation...'); + logger.debug(`DevContainer config keys: ${Object.keys(devContainerConfig).join(', ')}`); + logger.debug(`Features in config: ${JSON.stringify(devContainerConfig.features)}`); + const featuresInfo = await prepareFeaturesInstallation(workspaceFolder, devContainerConfig); + + logger.info(`Features info result: hasFeatures=${featuresInfo.hasFeatures}`); + if (featuresInfo.featuresConfig) { + logger.info(`Features config: ${featuresInfo.featuresConfig.featureSets.length} feature sets`); + } + if (featuresInfo.featuresDir) { + logger.info(`Features dir: ${featuresInfo.featuresDir}`); + } + + // Install features if any are configured + if (featuresInfo.hasFeatures && featuresInfo.featuresConfig && featuresInfo.featuresDir) { + logger.info('==> Adding features installation to build script'); + const featureScript = generateFeatureInstallScript( + featuresInfo.featuresConfig, + featuresInfo.featuresDir, + dockerPath, + isWindows + ); + logger.info(`Generated feature script length: ${featureScript.length} characters`); + logger.debug(`Feature script preview:\n${featureScript.substring(0, 500)}`); + scriptContent += featureScript; + } else { + logger.warn(`Skipping features installation: hasFeatures=${featuresInfo.hasFeatures}, hasConfig=${!!featuresInfo.featuresConfig}, hasDir=${!!featuresInfo.featuresDir}`); + } + + // Add lifecycle hooks to the build script + // This runs them as part of the script so there's no separate terminal output + const lifecycleScript = generateLifecycleHooksScript( + devContainerConfig, + dockerPath, + remoteWorkspaceFolder, + isWindows + ); + scriptContent += lifecycleScript; + + // Save container ID and name + if (isWindows) { + scriptContent += 'Write-Host "==> Saving container info..."\n'; + scriptContent += `$CONTAINER_ID | Out-File -FilePath "${containerIdPath}" -Encoding utf8 -NoNewline\n`; + const inspectCmdLine = `"${dockerPath}" inspect -f '{{.Name}}' $CONTAINER_ID`; + scriptContent += `$containerName = cmd /c "${inspectCmdLine.replace(/"/g, '""')}"\n`; + scriptContent += '$containerName = $containerName.Trim()\n'; + scriptContent += 'if (-not $containerName) {\n'; + scriptContent += ' throw "Failed to get container name"\n'; + scriptContent += '}\n'; + scriptContent += `$containerName.TrimStart('/') | Out-File -FilePath "${containerNamePath}" -Encoding utf8 -NoNewline\n\n`; + + scriptContent += 'Write-Host "==> Container ready!"\n'; + scriptContent += `"done" | Out-File -FilePath "${markerPath}" -Encoding utf8\n`; + scriptContent += '$ErrorActionPreference = "Continue"\n'; + scriptContent += 'exit 0\n'; + } else { + scriptContent += 'echo "==> Saving container info..."\n'; + scriptContent += `echo "$CONTAINER_ID" > "${containerIdPath}"\n`; + scriptContent += `${dockerPath} inspect -f '{{.Name}}' $CONTAINER_ID | sed 's/^\\///' > "${containerNamePath}"\n\n`; + + scriptContent += 'echo "==> Container ready!"\n'; + scriptContent += `echo "done" > "${markerPath}"\n`; + scriptContent += 'trap - ERR\n'; + scriptContent += 'exit 0\n'; + } + + // Write the script file + fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 }); + + // Also save to a debug location for inspection + const debugScriptPath = path.join(os.tmpdir(), `devcontainer-last-build${scriptExt}`); + try { + fs.writeFileSync(debugScriptPath, scriptContent, { mode: 0o755 }); + logger.info(`Debug copy saved to: ${debugScriptPath}`); + } catch (error) { + logger.debug(`Could not save debug copy: ${error}`); + } + + logger.info(`Created build script: ${scriptPath}`); + logger.debug(`Script content (first 1000 chars):\n${scriptContent.substring(0, 1000)}`); + + // Create terminal that executes the script directly without showing the command + const terminalOptions: vscode.TerminalOptions = { + name: 'Dev Container Build', + iconPath: new vscode.ThemeIcon('debug-console'), + shellPath: isWindows ? 'powershell.exe' : '/bin/sh', + shellArgs: isWindows ? ['-NoProfile', '-File', scriptPath] : [scriptPath], + }; + + const terminal = vscode.window.createTerminal(terminalOptions); + terminal.show(); + + // Wait for the marker file to appear + logger.debug('Waiting for container build to complete...'); + const startTime = Date.now(); + const timeout = 10 * 60 * 1000; // 10 minutes + + while (true) { + // Check for success marker + if (fs.existsSync(markerPath)) { + break; + } + + // Check for error marker (build failed but exited cleanly) + if (fs.existsSync(errorPath)) { + // Clean up + try { + fs.unlinkSync(scriptPath); + fs.unlinkSync(errorPath); + } catch { } + throw new Error('Container build failed. Check the terminal output for details.'); + } + + if (Date.now() - startTime > timeout) { + // Clean up + try { + fs.unlinkSync(scriptPath); + } catch { } + throw new Error('Container build timed out after 10 minutes'); + } + + // Wait a bit before checking again + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Read the container ID and name + let containerId: string; + let containerName: string; + try { + containerId = fs.readFileSync(containerIdPath, 'utf8').trim(); + containerName = fs.readFileSync(containerNamePath, 'utf8').trim(); + logger.info(`Container created: ${containerId} (${containerName})`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read container info: ${errorMessage}`); + } finally { + // Clean up temporary files + try { + fs.unlinkSync(scriptPath); + fs.unlinkSync(markerPath); + fs.unlinkSync(errorPath); + fs.unlinkSync(containerIdPath); + fs.unlinkSync(containerNamePath); + } catch { } + + // Clean up features directory if it was created + if (featuresInfo.hasFeatures && featuresInfo.featuresDir) { + logger.debug('Cleaning up features directory'); + await cleanupFeaturesDir(featuresInfo.featuresDir); + } + } + + return { + terminal, + containerId, + containerName, + remoteWorkspaceFolder + }; + } +} diff --git a/extensions/positron-dev-containers/src/extension.ts b/extensions/positron-dev-containers/src/extension.ts new file mode 100644 index 000000000000..c3e24e87a25a --- /dev/null +++ b/extensions/positron-dev-containers/src/extension.ts @@ -0,0 +1,594 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLogger } from './common/logger'; +import { getConfiguration } from './common/configuration'; +import { Workspace } from './common/workspace'; +import { PortForwardingManager } from './remote/portForwarding'; +import { ConnectionManager } from './remote/connectionManager'; +import { DevContainerAuthorityResolver } from './remote/authorityResolver'; +import { getDevContainerManager } from './container/devContainerManager'; +import { RebuildStateManager } from './common/rebuildState'; +import { WorkspaceMappingStorage } from './common/workspaceMappingStorage'; + +// Import command implementations +import * as ReopenCommands from './commands/reopen'; +import * as RebuildCommands from './commands/rebuild'; +import * as OpenCommands from './commands/open'; +import * as AttachCommands from './commands/attach'; + +// Import view providers +import { DevContainersTreeProvider, DevContainerTreeItem } from './views/devContainersTreeProvider'; + +// Import notifications +import { checkAndShowDevContainerNotification } from './notifications/devContainerDetection'; + +/** + * Tracks whether the core extension functionality is currently activated + */ +let isActivated = false; + +/** + * Stores disposables for dynamic deactivation + */ +let activationDisposables: vscode.Disposable[] = []; + +/** + * Extension activation entry point + */ +export async function activate(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + const config = getConfiguration(); + + // Initialize logger + logger.initialize(context, config.getLogLevel()); + logger.info('Activating positron-dev-containers extension'); + + // Check if extension is enabled + if (!config.getEnable()) { + logger.info('Dev Containers extension is disabled via settings. Skipping core activation.'); + // Set context keys to hide all UI elements + vscode.commands.executeCommand('setContext', 'dev.containers.enabled', false); + vscode.commands.executeCommand('setContext', 'isInDevContainer', false); + + // Set up listener to activate when setting is enabled + setupConfigurationListener(context); + return; + } + + // Activate the extension core functionality + await activateCore(context); + + // Set up listener for configuration changes (including disabling) + setupConfigurationListener(context); +} + +/** + * Set up configuration listener for dynamic enable/disable + */ +function setupConfigurationListener(context: vscode.ExtensionContext): void { + const logger = getLogger(); + const config = getConfiguration(); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('dev.containers.enable')) { + // Reload configuration to get the new value + config.reload(); + const isEnabled = config.getEnable(); + logger.info(`Dev Containers enable setting changed to: ${isEnabled}`); + + if (isEnabled && !isActivated) { + // Activate the extension + logger.info('Activating Dev Containers extension...'); + await activateCore(context); + } else if (!isEnabled && isActivated) { + // Deactivate the extension + logger.info('Deactivating Dev Containers extension...'); + deactivateCore(); + } + } + + // Handle other configuration changes when activated + if (isActivated && e.affectsConfiguration('dev.containers')) { + config.reload(); + + // Update log level if it changed + if (e.affectsConfiguration('dev.containers.logLevel')) { + logger.setLogLevel(config.getLogLevel()); + } + } + }) + ); +} + +/** + * Activate the core extension functionality + */ +async function activateCore(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + + // Prevent double activation + if (isActivated) { + logger.warn('Extension core already activated, skipping...'); + return; + } + + // Set context key to enable UI elements + vscode.commands.executeCommand('setContext', 'dev.containers.enabled', true); + + // Log workspace information + const hasDevContainer = Workspace.hasDevContainer(); + const isInDevContainer = Workspace.isInDevContainer(); + logger.debug(`Has dev container: ${hasDevContainer}`); + logger.debug(`Is in dev container: ${isInDevContainer}`); + logger.debug(`Remote name: ${Workspace.getRemoteName() || 'none'}`); + + // Check environment variables on activation (trace level for detailed diagnostics) + logger.trace(`LOCAL_WORKSPACE_FOLDER: ${process.env.LOCAL_WORKSPACE_FOLDER || 'NOT SET'}`); + logger.trace(`CONTAINER_WORKSPACE_FOLDER: ${process.env.CONTAINER_WORKSPACE_FOLDER || 'NOT SET'}`); + logger.trace(`POSITRON_CONTAINER_ID: ${process.env.POSITRON_CONTAINER_ID || 'NOT SET'}`); + logger.trace(`POSITRON_REMOTE_ENV: ${process.env.POSITRON_REMOTE_ENV || 'NOT SET'}`); + + // Initialize workspace mapping storage FIRST (before authority resolver) + // This must be loaded synchronously before any getCanonicalURI calls + const workspaceMappingStorage = WorkspaceMappingStorage.initialize(context, logger); + await workspaceMappingStorage.load(); + logger.info('Workspace mapping storage initialized'); + + // Optionally clean up stale mappings (older than 30 days) + await workspaceMappingStorage.cleanupStale(); + + // Initialize core managers for Phase 4: Remote Authority Resolver + + // Create PortForwardingManager for port forwarding + const portForwardingManager = new PortForwardingManager(logger); + + // Create ConnectionManager to manage container connections + const connectionManager = new ConnectionManager( + logger, + portForwardingManager + ); + + // Create and register the authority resolver + const authorityResolver = new DevContainerAuthorityResolver(logger, connectionManager); + + // Register resolver for dev-container and attached-container authorities + activationDisposables.push( + vscode.workspace.registerRemoteAuthorityResolver('dev-container', authorityResolver) + ); + activationDisposables.push( + vscode.workspace.registerRemoteAuthorityResolver('attached-container', authorityResolver) + ); + + logger.info('Remote authority resolver registered'); + // ResourceLabelFormatter is now registered dynamically in the authority resolver + + // Register tree view for dev containers (only when not in a dev container) + let devContainersTreeProvider: DevContainersTreeProvider | undefined; + if (!isInDevContainer) { + devContainersTreeProvider = new DevContainersTreeProvider(); + activationDisposables.push( + vscode.window.registerTreeDataProvider('targetsContainers', devContainersTreeProvider) + ); + logger.info('Dev containers tree view registered'); + } else { + logger.info('Skipping dev containers tree view registration (running in dev container)'); + } + + // Set context key for UI visibility + vscode.commands.executeCommand('setContext', 'isInDevContainer', isInDevContainer); + + // Store cleanup disposable for managers + activationDisposables.push({ + dispose: () => { + connectionManager.dispose(); + portForwardingManager.dispose(); + authorityResolver.dispose(); + } + }); + + // Register commands + registerCommands(context, devContainersTreeProvider, connectionManager); + + // Check for pending rebuilds (only on host, not in container) + // This must be done before showing the notification to avoid interrupting the rebuild + let hasPendingRebuild = false; + if (!isInDevContainer) { + const rebuildState = new RebuildStateManager(context); + hasPendingRebuild = !!rebuildState.getPendingRebuild(); + + if (hasPendingRebuild) { + logger.info('Pending rebuild detected, handling it now'); + await handlePendingRebuild(context); + } + } + + // Mark as activated + isActivated = true; + logger.info('Dev Containers extension core activated successfully'); + + // Show dev container detection notification after a delay (only if not rebuilding) + // This gives the UI time to fully activate and avoids interrupting the rebuild flow + if (!isInDevContainer && !hasPendingRebuild) { + setTimeout(() => { + checkAndShowDevContainerNotification(context).catch(err => { + logger.error('Failed to show dev container notification', err); + }); + }, 250); + } +} + +/** + * Deactivate the core extension functionality + */ +function deactivateCore(): void { + const logger = getLogger(); + + if (!isActivated) { + logger.warn('Extension core not activated, nothing to deactivate'); + return; + } + + logger.info('Deactivating Dev Containers extension core...'); + + // Dispose all activation-specific resources + activationDisposables.forEach(d => { + try { + d.dispose(); + } catch (error) { + logger.error('Error disposing resource during deactivation', error); + } + }); + activationDisposables = []; + + // Update context keys to hide UI elements + vscode.commands.executeCommand('setContext', 'dev.containers.enabled', false); + vscode.commands.executeCommand('setContext', 'isInDevContainer', false); + + isActivated = false; + logger.info('Dev Containers extension core deactivated'); +} + +/** + * Extension deactivation entry point + */ +export function deactivate(): void { + const logger = getLogger(); + logger.info('Deactivating positron-dev-containers extension'); + logger.dispose(); +} + +/** + * Handle pending rebuild requests from remote sessions + * This runs on the HOST when the extension activates after a window reload + */ +async function handlePendingRebuild(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + const rebuildState = new RebuildStateManager(context); + + const pending = rebuildState.getPendingRebuild(); + if (!pending) { + return; + } + + logger.info(`Found pending rebuild for: ${pending.workspaceFolder}`); + + // Clear the pending state immediately to prevent repeated attempts + await rebuildState.clearPendingRebuild(); + + try { + // Show notification that rebuild is starting + vscode.window.showInformationMessage( + `Rebuilding dev container${pending.noCache ? ' (no cache)' : ''}...` + ); + + // Execute the rebuild + const manager = getDevContainerManager(); + const result = await manager.createOrStartContainer({ + workspaceFolder: pending.workspaceFolder, + rebuild: true, + noCache: pending.noCache + }); + + logger.info(`Container rebuilt successfully: ${result.containerId}`); + + // Store workspace mapping BEFORE opening the window + // This ensures the mapping is available when the authority resolver runs + try { + const storage = WorkspaceMappingStorage.getInstance(); + + // Delete old container mapping if it exists (container ID changes on rebuild) + if (pending.containerId && pending.containerId !== result.containerId) { + logger.info(`Removing old container mapping: ${pending.containerId}`); + await storage.delete(pending.containerId); + } + + // Store new container mapping + await storage.set(result.containerId, pending.workspaceFolder, result.remoteWorkspaceFolder); + logger.info(`Stored workspace mapping: ${result.containerId} -> ${pending.workspaceFolder}`); + } catch (error) { + logger.error('Failed to store workspace mapping before window reload', error); + // Continue anyway - but this may cause issues with the reopen + } + + // Automatically reopen in the rebuilt container + const authority = `dev-container+${result.containerId}`; + const remoteUri = vscode.Uri.parse(`vscode-remote://${authority}${result.remoteWorkspaceFolder}`); + + logger.info(`Reopening in rebuilt container: ${authority}`); + await vscode.commands.executeCommand('vscode.openFolder', remoteUri); + } catch (error) { + logger.error('Failed to execute pending rebuild', error); + await vscode.window.showErrorMessage( + `Failed to rebuild container: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Register all commands + */ +function registerCommands(context: vscode.ExtensionContext, devContainersTreeProvider: DevContainersTreeProvider | undefined, connectionManager: ConnectionManager): void { + + // Core commands - Open/Reopen + registerCommand(context, 'remote-containers.reopenInContainer', ReopenCommands.reopenInContainer); + registerCommand(context, 'remote-containers.rebuildAndReopenInContainer', RebuildCommands.rebuildAndReopenInContainer); + registerCommand(context, 'remote-containers.rebuildNoCacheAndReopenInContainer', RebuildCommands.rebuildNoCacheAndReopenInContainer); + registerCommand(context, 'remote-containers.reopenLocally', ReopenCommands.reopenLocally); + registerCommand(context, 'remote-containers.openFolder', OpenCommands.openFolder); + registerCommand(context, 'remote-containers.openFolderInContainerInCurrentWindow', OpenCommands.openFolderInContainerInCurrentWindow); + registerCommand(context, 'remote-containers.openFolderInContainerInNewWindow', OpenCommands.openFolderInContainerInNewWindow); + registerCommand(context, 'remote-containers.openWorkspace', OpenCommands.openWorkspace); + + // Attach commands + registerCommand(context, 'remote-containers.attachToRunningContainer', AttachCommands.attachToRunningContainer); + registerCommand(context, 'remote-containers.attachToContainerInCurrentWindow', AttachCommands.attachToContainerInCurrentWindow); + registerCommand(context, 'remote-containers.attachToContainerInNewWindow', AttachCommands.attachToContainerInNewWindow); + + // Container management commands + registerCommand(context, 'remote-containers.rebuildContainer', () => RebuildCommands.rebuildContainer(context)); + registerCommand(context, 'remote-containers.rebuildContainerNoCache', () => RebuildCommands.rebuildContainerNoCache(context)); + registerCommand(context, 'remote-containers.stopContainer', AttachCommands.stopContainer); + registerCommand(context, 'remote-containers.startContainer', AttachCommands.startContainer); + registerCommand(context, 'remote-containers.removeContainer', AttachCommands.removeContainer); + registerCommand(context, 'remote-containers.showContainerLog', showContainerLog); + + // Configuration commands + registerCommand(context, 'remote-containers.openDevContainerFile', openDevContainerFile); + + // Settings and logs + registerCommand(context, 'remote-containers.settings', openSettings); + registerCommand(context, 'remote-containers.revealLogTerminal', revealLogTerminal); + registerCommand(context, 'remote-containers.openLogFile', openLogFile); + registerCommand(context, 'remote-containers.openLastLogFile', openLogFile); + registerCommand(context, 'remote-containers.testConnection', () => testConnection(connectionManager)); + + // View commands + registerCommand(context, 'remote-containers.explorerTargetsRefresh', async () => { + if (devContainersTreeProvider) { + await devContainersTreeProvider.refresh(); + } + }); +} + +/** + * Helper to register a command + */ +function registerCommand( + context: vscode.ExtensionContext, + command: string, + callback: (...args: any[]) => any +): void { + context.subscriptions.push(vscode.commands.registerCommand(command, callback)); +} + +// Command implementations (utility commands) + +/** + * Open the dev container configuration file + */ +async function openDevContainerFile(): Promise { + const logger = getLogger(); + logger.debug('Command: openDevContainerFile'); + + const currentFolder = Workspace.getCurrentWorkspaceFolder(); + if (!currentFolder) { + await vscode.window.showErrorMessage(vscode.l10n.t('No workspace folder is open')); + return; + } + + // Check for .devcontainer/devcontainer.json + let devContainerUri = vscode.Uri.joinPath(currentFolder.uri, '.devcontainer', 'devcontainer.json'); + + try { + await vscode.workspace.fs.stat(devContainerUri); + const document = await vscode.workspace.openTextDocument(devContainerUri); + await vscode.window.showTextDocument(document); + return; + } catch { + // File doesn't exist, try next location + } + + // Check for .devcontainer.json in workspace root + devContainerUri = vscode.Uri.joinPath(currentFolder.uri, '.devcontainer.json'); + + try { + await vscode.workspace.fs.stat(devContainerUri); + const document = await vscode.workspace.openTextDocument(devContainerUri); + await vscode.window.showTextDocument(document); + return; + } catch { + // File doesn't exist + } + + await vscode.window.showErrorMessage(vscode.l10n.t('No dev container configuration found')); +} + +/** + * Open settings + */ +async function openSettings(): Promise { + await vscode.commands.executeCommand('workbench.action.openSettings', 'dev.containers'); +} + +/** + * Reveal the log terminal + */ +async function revealLogTerminal(): Promise { + getLogger().show(); +} + +/** + * Open the log file + */ +async function openLogFile(): Promise { + const logger = getLogger(); + const logFilePath = logger.getLogFilePath(); + + if (!logFilePath) { + await vscode.window.showErrorMessage(vscode.l10n.t('No log file available')); + return; + } + + const document = await vscode.workspace.openTextDocument(logFilePath); + await vscode.window.showTextDocument(document); +} + +/** + * Show container log in an output channel + */ +async function showContainerLog(treeItem?: DevContainerTreeItem): Promise { + const logger = getLogger(); + logger.debug('Command: showContainerLog'); + + // Type check: ensure we have a tree item with container info + if (!treeItem || !treeItem.containerInfo) { + await vscode.window.showErrorMessage(vscode.l10n.t('No container selected')); + return; + } + + const containerInfo = treeItem.containerInfo; + + try { + // Show progress while fetching logs + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Fetching logs for {0}...', containerInfo.containerName), + cancellable: false + }, + async () => { + const manager = getDevContainerManager(); + const logs = await manager.getContainerLogs(containerInfo.containerId, 1000); + + // Create or get output channel for container logs + const outputChannel = vscode.window.createOutputChannel( + `Container Log: ${containerInfo.containerName}` + ); + + // Clear previous logs and show new ones + outputChannel.clear(); + outputChannel.appendLine(`Container: ${containerInfo.containerName}`); + outputChannel.appendLine(`ID: ${containerInfo.containerId}`); + outputChannel.appendLine(`State: ${containerInfo.state}`); + outputChannel.appendLine('='.repeat(80)); + outputChannel.appendLine(''); + outputChannel.append(logs); + + // Show the output channel + outputChannel.show(); + } + ); + } catch (error) { + logger.error('Failed to get container logs', error); + await vscode.window.showErrorMessage(vscode.l10n.t('Failed to get container logs: {0}', error)); + } +} + +/** + * Test connection to the current dev container + */ +async function testConnection(connectionManager: ConnectionManager): Promise { + const logger = getLogger(); + logger.debug('Command: testConnection'); + + // Check if we're in a dev container + if (!Workspace.isInDevContainer()) { + await vscode.window.showInformationMessage( + vscode.l10n.t('Not currently connected to a dev container. This command is only available when running inside a dev container.') + ); + return; + } + + try { + // Get container ID from workspace authority + const currentFolder = Workspace.getCurrentWorkspaceFolder(); + if (!currentFolder || !currentFolder.uri.authority) { + await vscode.window.showErrorMessage(vscode.l10n.t('Could not determine container ID from workspace')); + return; + } + + // Extract container ID from authority (format: dev-container+ or attached-container+) + const authority = currentFolder.uri.authority; + const match = authority.match(/^(?:dev-container|attached-container)\+(.+)$/); + const containerId = match?.[1]; + + if (!containerId) { + await vscode.window.showErrorMessage(vscode.l10n.t('Could not extract container ID from authority: {0}', authority)); + return; + } + + logger.info(`Testing connection to container: ${containerId}`); + + // Get connection info + const connection = connectionManager.getConnection(containerId); + + if (!connection) { + await vscode.window.showWarningMessage( + `No active connection found for container ${containerId}.\n\nThis may be normal if the connection was established in a different way.` + ); + return; + } + + // Build connection status message + const stateDisplay = connection.state.charAt(0).toUpperCase() + connection.state.slice(1); + + let message = `Connection Status: ${stateDisplay}\n\n`; + message += `Container ID: ${containerId}\n`; + message += `Host: ${connection.host}\n`; + message += `Port: ${connection.port}\n`; + message += `Remote Port: ${connection.remotePort}\n`; + + if (connection.connectedAt) { + const duration = Math.floor((Date.now() - connection.connectedAt.getTime()) / 1000); + message += `Connected: ${duration}s ago\n`; + } + + if (connection.localWorkspacePath && connection.remoteWorkspacePath) { + message += `\nWorkspace Mapping:\n`; + message += ` Local: ${connection.localWorkspacePath}\n`; + message += ` Remote: ${connection.remoteWorkspacePath}\n`; + } + + if (connection.lastError) { + message += `\nLast Error: ${connection.lastError}\n`; + } + + // Show the connection information + if (connection.state === 'connected') { + await vscode.window.showInformationMessage(message, { modal: true }); + } else { + await vscode.window.showWarningMessage(message, { modal: true }); + } + + logger.info(`Connection test completed: ${connection.state}`); + + } catch (error) { + logger.error('Failed to test connection', error); + await vscode.window.showErrorMessage(`Failed to test connection: ${error}`); + } +} diff --git a/extensions/positron-dev-containers/src/notifications/devContainerDetection.ts b/extensions/positron-dev-containers/src/notifications/devContainerDetection.ts new file mode 100644 index 000000000000..49d9a8b0278c --- /dev/null +++ b/extensions/positron-dev-containers/src/notifications/devContainerDetection.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Workspace } from '../common/workspace'; +import { getLogger } from '../common/logger'; + +/** + * Storage keys for dev container notification preferences + */ +const GLOBAL_DONT_SHOW_KEY = 'positron-dev-containers.dontShowDevContainerNotification'; +const WORKSPACE_DONT_SHOW_KEY = 'dontShowDevContainerNotification'; + +/** + * Check and show dev container detection notification if needed + */ +export async function checkAndShowDevContainerNotification(context: vscode.ExtensionContext): Promise { + const logger = getLogger(); + + // Don't show if we're already in a dev container + if (Workspace.isInDevContainer()) { + logger.debug('Already in dev container, skipping notification'); + return; + } + + // Check if workspace has dev container + if (!Workspace.hasDevContainer()) { + logger.debug('No dev container configuration found, skipping notification'); + return; + } + + // Check if user has opted out globally + const globalDontShow = context.globalState.get(GLOBAL_DONT_SHOW_KEY, false); + if (globalDontShow) { + logger.debug('User has opted out of dev container notifications globally'); + return; + } + + // Check if user has opted out for this workspace + const workspaceFolder = Workspace.getCurrentWorkspaceFolder(); + if (workspaceFolder) { + const workspaceDontShow = context.workspaceState.get( + `${WORKSPACE_DONT_SHOW_KEY}.${workspaceFolder.uri.toString()}`, + false + ); + if (workspaceDontShow) { + logger.debug(`User has opted out of dev container notifications for this workspace: ${workspaceFolder.name}`); + return; + } + } + + // Show the notification + await showDevContainerNotification(context, workspaceFolder); +} + +/** + * Show the dev container detection notification + */ +async function showDevContainerNotification( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder | undefined +): Promise { + const logger = getLogger(); + + logger.debug('Showing dev container detection notification'); + + const message = vscode.l10n.t('Folder contains a Dev Container configuration file. Reopen folder to develop in a container?'); + const reopenButton = vscode.l10n.t('Reopen in Container'); + const dontShowButton = vscode.l10n.t("Don't Show Again..."); + + const result = await vscode.window.showInformationMessage( + message, + reopenButton, + dontShowButton + ); + + if (result === reopenButton) { + logger.debug('User clicked "Reopen in Container"'); + await vscode.commands.executeCommand('remote-containers.reopenInContainer'); + } else if (result === dontShowButton) { + logger.debug('User clicked "Don\'t Show Again..."'); + await handleDontShowAgain(context, workspaceFolder); + } +} + +/** + * Handle "Don't Show Again" button click + */ +async function handleDontShowAgain( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder | undefined +): Promise { + const logger = getLogger(); + + // Ask user about the scope + const currentFolderOption = vscode.l10n.t('Current Folder Only'); + const allFoldersOption = vscode.l10n.t('All Folders'); + + const scope = await vscode.window.showQuickPick( + [currentFolderOption, allFoldersOption], + { + placeHolder: vscode.l10n.t("Don't show dev container notification for...") + } + ); + + if (!scope) { + logger.debug('User cancelled "Don\'t Show Again" scope selection'); + return; + } + + if (scope === allFoldersOption) { + // Store in global state + await context.globalState.update(GLOBAL_DONT_SHOW_KEY, true); + logger.debug('User opted out of dev container notifications globally'); + await vscode.window.showInformationMessage( + vscode.l10n.t('Dev container notifications will not be shown for any folder.') + ); + } else if (scope === currentFolderOption && workspaceFolder) { + // Store in workspace state + await context.workspaceState.update( + `${WORKSPACE_DONT_SHOW_KEY}.${workspaceFolder.uri.toString()}`, + true + ); + logger.debug(`User opted out of dev container notifications for workspace: ${workspaceFolder.name}`); + await vscode.window.showInformationMessage( + vscode.l10n.t("Dev container notifications will not be shown for {0}.", workspaceFolder.name) + ); + } else if (scope === currentFolderOption && !workspaceFolder) { + logger.error('Cannot store workspace preference: no workspace folder found'); + await vscode.window.showErrorMessage( + vscode.l10n.t('Cannot disable notifications for current folder: no workspace folder found.') + ); + } +} diff --git a/extensions/positron-dev-containers/src/remote/authorityResolver.ts b/extensions/positron-dev-containers/src/remote/authorityResolver.ts new file mode 100644 index 000000000000..f859183a2661 --- /dev/null +++ b/extensions/positron-dev-containers/src/remote/authorityResolver.ts @@ -0,0 +1,378 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Logger } from '../common/logger'; +import { ConnectionManager, ConnectionState } from './connectionManager'; +import { decodeDevContainerAuthority } from '../common/authorityEncoding'; +import { WorkspaceMappingStorage } from '../common/workspaceMappingStorage'; + +/** + * Authority types supported by the resolver + */ +export enum AuthorityType { + DevContainer = 'dev-container', + AttachedContainer = 'attached-container' +} + +/** + * Parsed authority information + */ +export interface ParsedAuthority { + type: AuthorityType; + containerId: string; + raw: string; +} + +/** + * Remote authority resolver for dev containers + * Implements vscode.RemoteAuthorityResolver to enable remote connections to containers + */ +export class DevContainerAuthorityResolver implements vscode.RemoteAuthorityResolver { + private logger: Logger; + private connectionManager: ConnectionManager; + private labelFormatterDisposable: vscode.Disposable | undefined; + + constructor(logger: Logger, connectionManager: ConnectionManager) { + this.logger = logger; + this.connectionManager = connectionManager; + } + + /** + * Resolve a remote authority + * Called by VS Code when connecting to a remote with our authority scheme + */ + async resolve(authority: string): Promise { + this.logger.debug(`Resolving authority: ${authority}`); + + // If we're already in a remote context, log it for debugging + // VS Code may call resolve() even when remote for verification purposes + if (vscode.env.remoteName) { + this.logger.debug(`Already in remote context: ${vscode.env.remoteName}`); + } + + try { + // Parse the authority + const parsed = this.parseAuthority(authority); + this.logger.debug(`Parsed authority: type=${parsed.type}, containerId=${parsed.containerId}`); + + // Check for existing connection + const existing = this.connectionManager.getConnection(parsed.containerId); + if (existing && existing.state === ConnectionState.Connected) { + this.logger.debug(`Using existing connection to ${parsed.containerId}`); + const resolvedAuthority = new vscode.ResolvedAuthority( + existing.host, + existing.port, + existing.connectionToken + ); + + // Register ResourceLabelFormatter for existing connection too + let workspaceName: string | undefined; + if (existing.localWorkspacePath) { + workspaceName = existing.localWorkspacePath.split(/[/\\]/).filter(s => s).pop(); + } + + if (workspaceName) { + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `dev-container+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: 'Dev Container' + } + }); + this.logger.debug(`Registered ResourceLabelFormatter with suffix: Dev Container`); + } + + // Return ResolverResult with environment variables + // Note: We don't set isTrusted here - we rely on getCanonicalURI mapping + // to let VS Code recognize that the remote workspace maps to a trusted local workspace + return Object.assign(resolvedAuthority, { + extensionHostEnv: existing.extensionHostEnv + }); + } + + // Establish new connection (pass full authority for path decoding) + this.logger.debug(`Establishing new connection to ${parsed.containerId}`); + const connection = await this.connectionManager.connect(parsed.containerId, authority); + + // Return resolved authority with environment variables + const resolvedAuthority = new vscode.ResolvedAuthority( + connection.host, + connection.port, + connection.connectionToken + ); + + this.logger.info(`Authority resolved: ${connection.host}:${connection.port}`); + this.logger.debug(`Extension host env: ${JSON.stringify(connection.extensionHostEnv, null, 2)}`); + + // Add workspace folder metadata to help VS Code display the correct path + const options: any = { + extensionHostEnv: connection.extensionHostEnv + }; + + // If we have workspace path mapping, include it in the resolver result + // This helps VS Code understand the workspace identity for MRU and trust + if (connection.localWorkspacePath && connection.remoteWorkspacePath) { + this.logger.debug(`Including workspace path mapping in resolver result: local=${connection.localWorkspacePath}, remote=${connection.remoteWorkspacePath}`); + } + + // Register ResourceLabelFormatter dynamically to show workspace name in remote indicator + // Extract workspace folder name from the local workspace path in the mapping + let workspaceName: string | undefined; + if (connection.localWorkspacePath) { + // Get the last component of the path (folder name) + workspaceName = connection.localWorkspacePath.split(/[/\\]/).filter(s => s).pop(); + } + + if (workspaceName) { + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `dev-container+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: 'Dev Container' + } + }); + this.logger.debug(`Registered ResourceLabelFormatter with suffix: Dev Container`); + } + + // Return ResolverResult with environment variables + // Note: We rely on getCanonicalURI mapping to let VS Code recognize + // that the remote workspace maps to the local workspace for trust and MRU + return Object.assign(resolvedAuthority, options); + + } catch (error) { + this.logger.error(`Failed to resolve authority: ${authority}`, error); + + // Create a descriptive error message for the user + const errorMessage = error instanceof Error ? error.message : String(error); + // Extract the first meaningful line + const shortMessage = errorMessage.split('\n')[0]; + + // Show a custom error message with action button to open logs + const fullMessage = `Failed to connect to container: ${shortMessage}`; + + // Show error message with action button to view extension logs + vscode.window.showErrorMessage( + fullMessage, + vscode.l10n.t('View Extension Logs') + ).then(selection => { + if (selection === vscode.l10n.t('View Extension Logs')) { + this.logger.show(); + } + }); + + // Throw with handled=true to suppress VS Code's default error dialog + // since we're already showing our own custom dialog above + throw vscode.RemoteAuthorityResolverError.NotAvailable( + shortMessage, + true // handled - suppresses the default error dialog + ); + } + } + + /** + * Get the canonical URI for a resource + * This allows remapping URIs between local and remote, and is critical for: + * - Workspace trust: Maps remote paths to local paths so trust is preserved + * - MRU entries: Ensures local paths are displayed instead of container paths + */ + getCanonicalURI(uri: vscode.Uri): vscode.ProviderResult { + // IMPORTANT: For workspace trust and MRU to work correctly, we need to return + // the LOCAL file:// URI as the canonical form of the remote URI. + // This tells VS Code that vscode-remote://dev-container+xxx/workspaces/foo + // and file:///Users/projects/foo are the SAME workspace. + + this.logger.debug(`getCanonicalURI called: scheme=${uri.scheme}, authority=${uri.authority}, path=${uri.path}`); + + if (uri.scheme === 'vscode-remote') { + // Remote -> Local mapping + // Look up workspace path from storage (synchronous from in-memory cache) + const decoded = decodeDevContainerAuthority(uri.authority); + if (!decoded?.containerId) { + this.logger.debug('Failed to decode container ID from authority'); + return uri; + } + + // Try to get workspace mapping from storage + try { + const storage = WorkspaceMappingStorage.getInstance(); + const mapping = storage.get(decoded.containerId); + + if (mapping?.localWorkspacePath && mapping?.remoteWorkspacePath) { + const normalizedRemote = mapping.remoteWorkspacePath.replace(/\\/g, '/'); + const normalizedUriPath = uri.path.replace(/\\/g, '/'); + + this.logger.debug(`Checking mapping: remote=${normalizedRemote}, uri=${normalizedUriPath}`); + + if (normalizedUriPath === normalizedRemote || normalizedUriPath.startsWith(normalizedRemote + '/')) { + // This URI points to the workspace folder or a file inside it + const relativePath = normalizedUriPath.substring(normalizedRemote.length); + const localPath = mapping.localWorkspacePath.replace(/\\/g, '/') + relativePath; + + const fileUri = vscode.Uri.file(localPath); + this.logger.debug(`Remapping remote to local (from storage): ${uri.toString()} -> ${fileUri.toString()}`); + + // Return file URI for workspace trust and MRU + // This is the KEY to making workspace trust and MRU work correctly + return fileUri; + } + } else { + this.logger.debug(`No workspace mapping found for container ${decoded.containerId}`); + } + } catch (error) { + this.logger.warn('Failed to get workspace mapping from storage', error); + // Fall through to return original URI + } + + // Fallback: try connection info (for backwards compatibility during transition) + const connection = this.connectionManager.getConnection(decoded.containerId); + if (connection?.localWorkspacePath && connection?.remoteWorkspacePath) { + const normalizedRemote = connection.remoteWorkspacePath.replace(/\\/g, '/'); + const normalizedUriPath = uri.path.replace(/\\/g, '/'); + + if (normalizedUriPath === normalizedRemote || normalizedUriPath.startsWith(normalizedRemote + '/')) { + const relativePath = normalizedUriPath.substring(normalizedRemote.length); + const localPath = connection.localWorkspacePath.replace(/\\/g, '/') + relativePath; + + this.logger.debug(`Remapping remote to local (from connection): ${uri.path} -> ${localPath}`); + return vscode.Uri.file(localPath); + } + } + + return uri; + } + + if (uri.scheme === 'file') { + // Local -> Remote mapping + // Check all active connections to see if this file is inside a mapped workspace + for (const connection of this.connectionManager.getAllConnections()) { + if (!connection.localWorkspacePath || !connection.remoteWorkspacePath) { + continue; + } + + const normalizedLocal = connection.localWorkspacePath.replace(/\\/g, '/'); + const normalizedUriPath = uri.path.replace(/\\/g, '/'); + + if (normalizedUriPath === normalizedLocal || normalizedUriPath.startsWith(normalizedLocal + '/')) { + // This file is inside a dev container workspace + const relativePath = normalizedUriPath.substring(normalizedLocal.length); + const remotePath = connection.remoteWorkspacePath + relativePath; + + this.logger.debug(`Remapping local to remote: ${uri.path} -> ${remotePath}`); + + // Return remote URI + const authority = `dev-container+${connection.containerId}`; + return vscode.Uri.parse(`vscode-remote://${authority}${remotePath}`); + } + } + + // No mapping found, return as-is + return uri; + } + + // Unknown scheme, no remapping + return uri; + } + + /** + * Parse an authority string into its components + */ + private parseAuthority(authority: string): ParsedAuthority { + // Expected format: dev-container+[+] or attached-container+ + + if (authority.startsWith('dev-container+')) { + // Use the decoding function to properly extract just the container ID + const decoded = decodeDevContainerAuthority(authority); + if (!decoded) { + throw new Error(`Invalid dev-container authority format: ${authority}`); + } + return { + type: AuthorityType.DevContainer, + containerId: decoded.containerId, + raw: authority + }; + } + + if (authority.startsWith('attached-container+')) { + return { + type: AuthorityType.AttachedContainer, + containerId: authority.substring('attached-container+'.length), + raw: authority + }; + } + + throw new Error(`Invalid authority format: ${authority}. Expected format: dev-container+ or attached-container+`); + } + + /** + * Dispose of resources + */ + dispose(): void { + this.labelFormatterDisposable?.dispose(); + // Cleanup handled by ConnectionManager + } +} + +/** + * Authority utilities + */ +export class AuthorityUtils { + /** + * Create a dev container authority string + */ + static createDevContainerAuthority(containerId: string): string { + return `dev-container+${containerId}`; + } + + /** + * Create an attached container authority string + */ + static createAttachedContainerAuthority(containerId: string): string { + return `attached-container+${containerId}`; + } + + /** + * Extract container ID from authority + */ + static extractContainerId(authority: string): string | undefined { + if (authority.startsWith('dev-container+')) { + const decoded = decodeDevContainerAuthority(authority); + return decoded?.containerId; + } + if (authority.startsWith('attached-container+')) { + return authority.substring('attached-container+'.length); + } + return undefined; + } + + /** + * Check if an authority is a dev container + */ + static isDevContainerAuthority(authority: string): boolean { + return authority.startsWith('dev-container+'); + } + + /** + * Check if an authority is an attached container + */ + static isAttachedContainerAuthority(authority: string): boolean { + return authority.startsWith('attached-container+'); + } + + /** + * Check if an authority is any container authority + */ + static isContainerAuthority(authority: string): boolean { + return this.isDevContainerAuthority(authority) || this.isAttachedContainerAuthority(authority); + } +} diff --git a/extensions/positron-dev-containers/src/remote/connectionManager.ts b/extensions/positron-dev-containers/src/remote/connectionManager.ts new file mode 100644 index 000000000000..1576c56f22aa --- /dev/null +++ b/extensions/positron-dev-containers/src/remote/connectionManager.ts @@ -0,0 +1,655 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Logger } from '../common/logger'; +import { PortForwardingManager } from './portForwarding'; +import { installAndStartServer } from '../server/serverInstaller'; +import { revokeConnectionToken } from '../server/connectionToken'; +import { getDevContainerManager } from '../container/devContainerManager'; +import { decodeDevContainerAuthority } from '../common/authorityEncoding'; +import { WorkspaceMappingStorage } from '../common/workspaceMappingStorage'; +import { getConfiguration } from '../common/configuration'; + +/** + * Connection state + */ +export enum ConnectionState { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed' +} + +/** + * Connection information + */ +export interface ConnectionInfo { + containerId: string; + state: ConnectionState; + host: string; + port: number; + connectionToken: string; + remotePort: number; + extensionHostEnv?: { [key: string]: string }; + connectedAt?: Date; + lastError?: string; + // Workspace path mapping for URI remapping + localWorkspacePath?: string; // e.g., /Users/jmcphers/git/cli + remoteWorkspacePath?: string; // e.g., /workspaces/cli +} + +/** + * Connection result from establishing a connection + */ +export interface ConnectionResult { + host: string; + port: number; + connectionToken: string; + extensionHostEnv: { [key: string]: string }; + localWorkspacePath?: string; + remoteWorkspacePath?: string; +} + +/** + * Manages connections to dev containers + */ +export class ConnectionManager { + private connections = new Map(); + private logger: Logger; + private portForwardingManager: PortForwardingManager; + + // Reconnection settings + private readonly maxReconnectAttempts = 3; + private readonly reconnectDelay = 2000; // ms + + constructor( + logger: Logger, + portForwardingManager: PortForwardingManager + ) { + this.logger = logger; + this.portForwardingManager = portForwardingManager; + } + + /** + * Establish a connection to a container + * @param containerIdOrWorkspace Container ID or workspace folder name + * @param authority Full authority string + */ + async connect(containerIdOrWorkspace: string, authority: string): Promise { + this.logger.info(`Establishing connection with identifier: ${containerIdOrWorkspace}`); + + // Resolve workspace name to container ID if needed + const containerId = await this.resolveContainerId(containerIdOrWorkspace); + this.logger.debug(`Resolved to container ID: ${containerId}`); + + // Update state + this.updateConnectionState(containerId, ConnectionState.Connecting); + + try { + // 1. Ensure container is running + await this.ensureContainerRunning(containerId); + + // 2. Get workspace path mapping from container + let localWorkspacePath: string | undefined; + let remoteWorkspacePath: string | undefined; + + // Try to get local workspace path from storage first (for MRU reopens) + try { + const storage = WorkspaceMappingStorage.getInstance(); + const mapping = storage.get(containerId); + if (mapping?.localWorkspacePath) { + localWorkspacePath = mapping.localWorkspacePath; + this.logger.debug(`Local workspace path from storage: ${localWorkspacePath}`); + } + } catch (error) { + this.logger.trace('WorkspaceMappingStorage not initialized yet, will try other methods'); + } + + // Fallback: Extract local workspace path from authority (for initial opens with encoded path) + if (!localWorkspacePath) { + const decoded = decodeDevContainerAuthority(authority); + if (decoded?.localWorkspacePath) { + localWorkspacePath = decoded.localWorkspacePath; + this.logger.debug(`Local workspace path from authority: ${localWorkspacePath}`); + } + } + + // Get remote workspace path - but only if Docker is available (not in remote context) + // In remote context, Docker won't be available and we don't need to inspect + try { + const containerManager = getDevContainerManager(); + const containerDetails = await containerManager.inspectContainerDetails(containerId); + + // Find the workspace mount to determine remote path + const workspaceMount = containerDetails.Mounts?.find(mount => + mount.Type === 'bind' && mount.Destination.startsWith('/workspaces/') + ); + if (workspaceMount) { + remoteWorkspacePath = workspaceMount.Destination; + this.logger.debug(`Remote workspace path from mount: ${remoteWorkspacePath}`); + } else if (localWorkspacePath) { + // Fallback: construct from local path + const folderName = localWorkspacePath.split(/[/\\]/).pop() || 'workspace'; + remoteWorkspacePath = `/workspaces/${folderName}`; + this.logger.debug(`No workspace mount found, using fallback: ${remoteWorkspacePath}`); + } + } catch (error) { + // If container inspection fails (e.g., in remote context), construct from local path + if (localWorkspacePath) { + const folderName = localWorkspacePath.split(/[/\\]/).pop() || 'workspace'; + remoteWorkspacePath = `/workspaces/${folderName}`; + this.logger.debug(`Container inspection failed, using fallback remote path: ${remoteWorkspacePath}`); + } + } + + // 3. Set up environment variables BEFORE starting server + const extensionHostEnv = this.createExtensionHostEnv(containerId); + + // Add workspace paths to environment + if (localWorkspacePath) { + extensionHostEnv.LOCAL_WORKSPACE_FOLDER = localWorkspacePath; + this.logger.debug(`Setting LOCAL_WORKSPACE_FOLDER: ${localWorkspacePath}`); + } else { + this.logger.debug('No localWorkspacePath available'); + } + if (remoteWorkspacePath) { + extensionHostEnv.CONTAINER_WORKSPACE_FOLDER = remoteWorkspacePath; + this.logger.debug(`Setting CONTAINER_WORKSPACE_FOLDER: ${remoteWorkspacePath}`); + } else { + this.logger.debug('No remoteWorkspacePath available'); + } + + // 3. Install Positron server with environment variables + this.logger.info('Installing Positron server in container...'); + + // Delay showing the notification to avoid flashing it when server is already installed + let notificationTimeout: NodeJS.Timeout | undefined; + + const serverInfoPromise = installAndStartServer({ + containerId, + port: 0, // Use 0 to let the OS pick a random available port + extensionHostEnv + }); + + // Set timeout to show notification after 5 seconds + const notificationPromise = new Promise((resolve) => { + notificationTimeout = setTimeout(() => { + resolve(); + }, 5000); + }); + + // Wait for either the server to be ready or the timeout + const serverInfo = await Promise.race([ + serverInfoPromise.then(result => { + // Installation completed - cancel notification if it hasn't shown yet + if (notificationTimeout) { + clearTimeout(notificationTimeout); + } + return result; + }), + notificationPromise.then(async () => { + // Timeout elapsed - show notification and wait for installation to complete + return await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Installing Positron server in container...'), + cancellable: false + }, async () => { + return await serverInfoPromise; + }); + }) + ]); + this.logger.info(`Server installed. Listening on ${serverInfo.isPort ? 'port ' + serverInfo.port : 'socket ' + serverInfo.socketPath}`); + // 3. Forward the port (if using port instead of socket) + let localPort: number; + if (serverInfo.isPort && serverInfo.port) { + this.logger.debug(`Forwarding port ${serverInfo.port} to localhost`); + localPort = await this.portForwardingManager.forwardPort( + containerId, + serverInfo.port + ); + } else { + // For socket-based connections, we'll use a default port + // The actual socket path will be used directly + this.logger.debug(`Using socket path: ${serverInfo.socketPath}`); + localPort = 0; // Socket-based connection + } + + // 4. Use the connection token from the server + // The server was started with this token, so we must use the same one + const connectionToken = serverInfo.connectionToken; + + // 5. Store connection info + const connectionInfo: ConnectionInfo = { + containerId, + state: ConnectionState.Connected, + host: '127.0.0.1', + port: localPort, + connectionToken, + remotePort: serverInfo.port || 0, + extensionHostEnv, + connectedAt: new Date(), + localWorkspacePath, + remoteWorkspacePath + }; + + this.connections.set(containerId, connectionInfo); + this.logger.info(`Connection established: ${connectionInfo.host}:${connectionInfo.port}`); + + // Store workspace mapping in global state for persistence + // This is idempotent and ensures mapping is always fresh even if state was cleared + if (localWorkspacePath) { + try { + const storage = WorkspaceMappingStorage.getInstance(); + await storage.set(containerId, localWorkspacePath, remoteWorkspacePath); + this.logger.debug(`Stored workspace mapping: ${containerId} -> ${localWorkspacePath}`); + } catch (error) { + this.logger.debug('Failed to store workspace mapping', error); + // Don't fail connection if storage fails + } + } + + this.logger.trace(`Extension host env keys: ${Object.keys(extensionHostEnv).join(', ')}`); + this.logger.trace(`Extension host env: ${JSON.stringify(extensionHostEnv, null, 2)}`); + + return { + host: connectionInfo.host, + port: connectionInfo.port, + connectionToken, + extensionHostEnv, + localWorkspacePath, + remoteWorkspacePath + }; + + } catch (error) { + this.logger.error(`Failed to establish connection to ${containerId}`, error); + this.updateConnectionState(containerId, ConnectionState.Failed, error); + + // Check if this is a server installation failure + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('Installation script failed') || errorMessage.includes('Failed to install server')) { + // Extract log file path and show helpful error message + await this.handleServerInstallationError(containerId, errorMessage); + } + + throw error; + } + } + + /** + * Reconnect to a container + */ + async reconnect(containerId: string, authority: string, attempt: number = 1): Promise { + this.logger.debug(`Reconnecting to container ${containerId} (attempt ${attempt}/${this.maxReconnectAttempts})`); + + this.updateConnectionState(containerId, ConnectionState.Reconnecting); + + try { + // Clean up old connection + await this.disconnect(containerId); + + // Wait before reconnecting + if (attempt > 1) { + await this.delay(this.reconnectDelay * attempt); + } + + // Attempt to reconnect + return await this.connect(containerId, authority); + + } catch (error) { + if (attempt < this.maxReconnectAttempts) { + this.logger.warn(`Reconnection attempt ${attempt} failed, retrying...`); + return this.reconnect(containerId, authority, attempt + 1); + } else { + this.logger.error(`Failed to reconnect after ${this.maxReconnectAttempts} attempts`); + this.updateConnectionState(containerId, ConnectionState.Failed, error); + throw error; + } + } + } + + /** + * Disconnect from a container + */ + async disconnect(containerId: string): Promise { + this.logger.debug(`Disconnecting from container ${containerId}`); + + const connection = this.connections.get(containerId); + if (!connection) { + this.logger.debug(`No active connection to ${containerId}`); + return; + } + + try { + // Stop port forwarding + await this.portForwardingManager.stopAllForContainer(containerId); + + // Note: Server stopping is not implemented yet in Phase 4 + // Will be added in later phases if needed + + // Revoke connection token + revokeConnectionToken(connection.connectionToken); + + // Update state + this.updateConnectionState(containerId, ConnectionState.Disconnected); + + // Remove connection + this.connections.delete(containerId); + + this.logger.debug(`Disconnected from container ${containerId}`); + + } catch (error) { + this.logger.error(`Error during disconnect from ${containerId}`, error); + } + } + + /** + * Get connection info for a container + * Supports container IDs, short ID prefixes, and workspace names + */ + getConnection(identifier: string): ConnectionInfo | undefined { + // Try exact match first (full container ID) + const exact = this.connections.get(identifier); + if (exact) { + return exact; + } + + // Try prefix match (for short IDs) + if (identifier.length === 8) { + for (const [fullId, info] of this.connections.entries()) { + if (fullId.startsWith(identifier)) { + return info; + } + } + } + + // Try workspace name match + // Check if any connection's remote workspace path ends with this identifier + for (const info of this.connections.values()) { + if (info.remoteWorkspacePath) { + const workspaceName = info.remoteWorkspacePath.split('/').filter(s => s).pop(); + if (workspaceName === identifier) { + return info; + } + } + } + + return undefined; + } + + /** + * Resolve a workspace name or container ID to the actual container ID + * @param identifier Workspace folder name or container ID + * @returns Full container ID + */ + private async resolveContainerId(identifier: string): Promise { + // If it looks like a container ID (long hash), use it directly + if (identifier.length > 12 && /^[a-f0-9]+$/.test(identifier)) { + return identifier; + } + + // Try to find a container with this workspace name + // If multiple containers match, use the most recent one (highest timestamp) + const storage = WorkspaceMappingStorage.getInstance(); + let bestMatch: { containerId: string; timestamp: number } | undefined; + + for (const [containerId, mapping] of storage.entries()) { + if (mapping.remoteWorkspacePath) { + const workspaceName = mapping.remoteWorkspacePath.split('/').filter(s => s).pop(); + if (workspaceName === identifier) { + // Found a match - check if it's more recent than the current best + if (!bestMatch || mapping.timestamp > bestMatch.timestamp) { + bestMatch = { containerId, timestamp: mapping.timestamp }; + } + } + } + } + + if (bestMatch) { + this.logger.debug(`Resolved workspace name "${identifier}" to container ${bestMatch.containerId}`); + return bestMatch.containerId; + } + + // Couldn't resolve - assume it's already a container ID + this.logger.warn(`Could not resolve "${identifier}" to a container ID, using as-is`); + return identifier; + } + + /** + * Check if connected to a container + */ + isConnected(containerId: string): boolean { + const connection = this.connections.get(containerId); + return connection?.state === ConnectionState.Connected; + } + + /** + * Get all active connections + */ + getAllConnections(): ConnectionInfo[] { + return Array.from(this.connections.values()); + } + + /** + * Disconnect all connections + */ + async disconnectAll(): Promise { + const containerIds = Array.from(this.connections.keys()); + for (const containerId of containerIds) { + await this.disconnect(containerId); + } + } + + /** + * Ensure container is running + */ + private async ensureContainerRunning(containerId: string): Promise { + // Skip docker operations when running in remote context (docker not available) + // We can detect this by checking if docker commands will fail + try { + this.logger.debug(`Checking if container ${containerId} is running`); + + const containerManager = getDevContainerManager(); + const containerInfo = await containerManager.getContainerInfo(containerId); + + if (containerInfo.state === 'running') { + this.logger.debug(`Container ${containerId} is running`); + return; + } + + if (containerInfo.state === 'stopped' || containerInfo.state === 'exited') { + this.logger.info(`Starting stopped container ${containerId}`); + await containerManager.startContainer(containerId); + return; + } + + throw new Error(`Container ${containerId} is not in a valid state: ${containerInfo.state || 'unknown'}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('ENOENT') || errorMsg.includes('spawn docker')) { + // Docker not available - we're probably running in remote context + // Assume container is running since we're being asked to connect to it + this.logger.debug(`Docker not available (remote context), assuming container ${containerId} is running`); + return; + } + // Re-throw other errors + throw error; + } + } + + /** + * Create environment variables for extension host + */ + private createExtensionHostEnv(containerId: string): { [key: string]: string } { + // These environment variables will be available in the extension host running in the container + return { + POSITRON_CONTAINER_ID: containerId, + POSITRON_REMOTE_ENV: 'devcontainer', + // Add other environment variables as needed + }; + } + + /** + * Update connection state + */ + private updateConnectionState( + containerId: string, + state: ConnectionState, + error?: any + ): void { + const connection = this.connections.get(containerId); + if (connection) { + connection.state = state; + if (error) { + connection.lastError = error.message || String(error); + } + } else { + // Create new connection entry + this.connections.set(containerId, { + containerId, + state, + host: '', + port: 0, + connectionToken: '', + remotePort: 0, + lastError: error ? (error.message || String(error)) : undefined + }); + } + } + + /** + * Delay helper + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Handle server installation errors by showing a toast with option to view logs + */ + private async handleServerInstallationError(containerId: string, errorMessage: string): Promise { + // Extract log file path from error message + const logPath = this.extractLogFilePath(errorMessage); + + if (logPath) { + this.logger.info(`Server installation failed. Log file: ${logPath}`); + + // Show error message with button to view log + const viewLogButton = vscode.l10n.t('View Log'); + const result = await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to install Positron server in container. Click "View Log" to see details.'), + viewLogButton + ); + + if (result === viewLogButton) { + await this.showServerLog(containerId, logPath); + } + } else { + // Fallback if we can't extract log path + this.logger.warn('Could not extract log file path from error message'); + } + } + + /** + * Extract log file path from error message + */ + private extractLogFilePath(errorMessage: string): string | null { + // Look for pattern: "Server output is being written to: /path/to/log" + const match = errorMessage.match(/Server output is being written to:\s*(\S+)/); + if (match && match[1]) { + return match[1]; + } + + // Fallback: look for common log path patterns + const fallbackMatch = errorMessage.match(/\/[^\s]+\/server\.log/); + if (fallbackMatch) { + return fallbackMatch[0]; + } + + return null; + } + + /** + * Show server log from container in an output channel + */ + private async showServerLog(containerId: string, logPath: string): Promise { + try { + this.logger.debug(`Reading server log from container: ${logPath}`); + + // Read log file from container + const logContent = await this.readLogFileFromContainer(containerId, logPath); + + // Create output channel and show log content + const outputChannel = vscode.window.createOutputChannel('Positron Server Installation Log'); + outputChannel.clear(); + outputChannel.appendLine(`Container: ${containerId}`); + outputChannel.appendLine(`Log file: ${logPath}`); + outputChannel.appendLine('='.repeat(80)); + outputChannel.appendLine(''); + outputChannel.append(logContent); + outputChannel.show(); + + this.logger.debug('Server log displayed successfully'); + } catch (error) { + this.logger.error(`Failed to read server log from container: ${error}`); + await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to read log file: {0}', error instanceof Error ? error.message : String(error)) + ); + } + } + + /** + * Read log file from container using docker exec + */ + private async readLogFileFromContainer(containerId: string, logPath: string): Promise { + const config = getConfiguration(); + const dockerPath = config.getDockerPath(); + + return new Promise((resolve, reject) => { + const { spawn } = require('child_process'); + + // Use cat to read the log file + const command = `cat ${logPath} 2>/dev/null || echo "[Log file not found or not readable]"`; + const args = ['exec', '-i', containerId, 'sh', '-c', command]; + + this.logger.debug(`Reading log: ${dockerPath} ${args.join(' ')}`); + + const proc = spawn(dockerPath, args); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('error', (error: Error) => { + this.logger.error(`Failed to read log file from container: ${error.message}`); + reject(error); + }); + + proc.on('close', (code: number) => { + if (code === 0) { + resolve(stdout); + } else { + const errorMsg = `Failed to read log file (exit code ${code})${stderr ? ': ' + stderr : ''}`; + reject(new Error(errorMsg)); + } + }); + }); + } + + /** + * Cleanup all resources + */ + dispose(): void { + this.disconnectAll(); + } +} diff --git a/extensions/positron-dev-containers/src/remote/portForwarding.ts b/extensions/positron-dev-containers/src/remote/portForwarding.ts new file mode 100644 index 000000000000..4a0a7f07fb5f --- /dev/null +++ b/extensions/positron-dev-containers/src/remote/portForwarding.ts @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import * as net from 'net'; +import { Logger } from '../common/logger'; + +/** + * Represents an active port forward + */ +export interface PortForward { + containerId: string; + remotePort: number; + localPort: number; + process?: cp.ChildProcess; + server?: net.Server; +} + +/** + * Manages port forwarding between containers and localhost + */ +export class PortForwardingManager { + private activeForwards = new Map(); + private logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + + /** + * Forward a port from a container to localhost + * @param containerId Container ID to forward from + * @param remotePort Port in the container + * @returns Local port number that is forwarding to the remote port + */ + async forwardPort(containerId: string, remotePort: number): Promise { + const forwardKey = `${containerId}:${remotePort}`; + + // Check if already forwarding + const existing = this.activeForwards.get(forwardKey); + if (existing) { + this.logger.debug(`Port forward already exists: ${existing.localPort} -> ${containerId}:${remotePort}`); + return existing.localPort; + } + + try { + // Find available local port + const localPort = await this.findAvailablePort(); + + this.logger.info(`Setting up port forward: ${localPort} -> ${containerId}:${remotePort}`); + + // Create port forward using socat + // socat is more reliable than `docker port` command for this use case + const forward = await this.createPortForward(containerId, remotePort, localPort); + + this.activeForwards.set(forwardKey, forward); + this.logger.info(`Port forward established: ${localPort} -> ${containerId}:${remotePort}`); + + return localPort; + } catch (error) { + this.logger.error(`Failed to forward port ${remotePort} from container ${containerId}`, error); + throw error; + } + } + + /** + * Create a port forward using Docker's port forwarding capability + */ + private async createPortForward( + containerId: string, + remotePort: number, + localPort: number + ): Promise { + return new Promise((resolve, reject) => { + // Create a TCP server that relays to the container + // Try multiple approaches for maximum container compatibility + + const server = net.createServer((clientSocket) => { + this.logger.debug(`New connection to forwarded port ${localPort}`); + + // Create connection to container using a fallback chain: + // 1. Try bash with /dev/tcp (most efficient if bash is available) + // 2. Try netcat (nc) if bash is not available + // 3. Try socat as a last resort + // Note: positron-server listens on ::1 (IPv6), so we use that instead of 127.0.0.1 + const portForwardCommand = ` + if command -v bash >/dev/null 2>&1; then + exec bash -c 'exec 3<>/dev/tcp/::1/${remotePort}; cat <&3 & cat >&3; kill %1' + elif command -v nc >/dev/null 2>&1; then + exec nc ::1 ${remotePort} + elif command -v socat >/dev/null 2>&1; then + exec socat - TCP6:[::1]:${remotePort} + else + echo "ERROR: No suitable tool found for port forwarding (need bash, nc, or socat)" >&2 + exit 1 + fi + `.trim(); + + const dockerExec = cp.spawn('docker', [ + 'exec', + '-i', + containerId, + 'sh', + '-c', + portForwardCommand + ]); + + // Pipe data bidirectionally + clientSocket.pipe(dockerExec.stdin); + dockerExec.stdout.pipe(clientSocket); + + dockerExec.stderr.on('data', (data) => { + const errorText = data.toString(); + // Log errors for debugging + if (errorText.trim().length > 0) { + this.logger.debug(`Port forward stderr: ${errorText}`); + } + }); + + clientSocket.on('error', (err) => { + this.logger.debug(`Client socket error: ${err.message}`); + dockerExec.kill(); + }); + + clientSocket.on('close', () => { + this.logger.debug(`Client disconnected from port ${localPort}`); + dockerExec.kill(); + }); + + dockerExec.on('exit', (code) => { + if (code !== 0 && code !== null) { + this.logger.debug(`Port forward process exited with code ${code}`); + } + clientSocket.end(); + }); + }); + + server.on('error', (err) => { + reject(err); + }); + + server.listen(localPort, '127.0.0.1', () => { + this.logger.debug(`Port forward server listening on ${localPort}`); + resolve({ + containerId, + remotePort, + localPort, + process: undefined, + server: server // Track the server so we can close it later + }); + }); + }); + } + + /** + * Stop a port forward + */ + async stopPortForward(containerId: string, remotePort: number): Promise { + const forwardKey = `${containerId}:${remotePort}`; + const forward = this.activeForwards.get(forwardKey); + + if (!forward) { + this.logger.debug(`No port forward found for ${forwardKey}`); + return; + } + + this.logger.info(`Stopping port forward: ${forward.localPort} -> ${containerId}:${remotePort}`); + + if (forward.process) { + forward.process.kill(); + } + + if (forward.server) { + forward.server.close(); + } + + this.activeForwards.delete(forwardKey); + } + + /** + * Stop all port forwards for a container + */ + async stopAllForContainer(containerId: string): Promise { + const forwards = Array.from(this.activeForwards.values()) + .filter(f => f.containerId === containerId); + + for (const forward of forwards) { + await this.stopPortForward(forward.containerId, forward.remotePort); + } + } + + /** + * Stop all port forwards + */ + async stopAll(): Promise { + const forwards = Array.from(this.activeForwards.values()); + for (const forward of forwards) { + await this.stopPortForward(forward.containerId, forward.remotePort); + } + } + + /** + * Get all active port forwards + */ + getActiveForwards(): PortForward[] { + return Array.from(this.activeForwards.values()); + } + + /** + * Find an available local port + */ + private async findAvailablePort(startPort: number = 10000): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + // Port in use, try next one + resolve(this.findAvailablePort(startPort + 1)); + } else { + reject(err); + } + }); + + server.listen(startPort, '127.0.0.1', () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => { + resolve(port); + }); + }); + }); + } + + /** + * Check if a port forward is active + */ + isPortForwarded(containerId: string, remotePort: number): boolean { + const forwardKey = `${containerId}:${remotePort}`; + return this.activeForwards.has(forwardKey); + } + + /** + * Get the local port for a forwarded remote port + */ + getLocalPort(containerId: string, remotePort: number): number | undefined { + const forwardKey = `${containerId}:${remotePort}`; + return this.activeForwards.get(forwardKey)?.localPort; + } + + /** + * Cleanup all resources + */ + dispose(): void { + this.stopAll(); + } +} diff --git a/extensions/positron-dev-containers/src/server/connectionToken.ts b/extensions/positron-dev-containers/src/server/connectionToken.ts new file mode 100644 index 000000000000..7b38412d9ba6 --- /dev/null +++ b/extensions/positron-dev-containers/src/server/connectionToken.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import { getLogger } from '../common/logger'; + +/** + * Connection token manager + * Handles generation and validation of secure connection tokens + */ +export class ConnectionTokenManager { + private static instance: ConnectionTokenManager; + private logger = getLogger(); + + // Store active tokens (in-memory, per session) + private activeTokens: Map = new Map(); + + private constructor() { } + + /** + * Get the singleton instance + */ + public static getInstance(): ConnectionTokenManager { + if (!ConnectionTokenManager.instance) { + ConnectionTokenManager.instance = new ConnectionTokenManager(); + } + return ConnectionTokenManager.instance; + } + + /** + * Generate a new secure connection token + * @param containerId Container ID this token is for + * @returns The generated token string + */ + public generateToken(containerId: string): string { + // Generate a cryptographically secure random token + // Using 32 bytes (256 bits) for strong security + const tokenBytes = crypto.randomBytes(32); + const token = tokenBytes.toString('hex'); + + // Store token info + this.activeTokens.set(token, { + containerId, + createdAt: new Date(), + lastUsed: new Date() + }); + + this.logger.debug(`Generated connection token for container ${containerId}`); + + return token; + } + + /** + * Validate a connection token + * @param token Token to validate + * @param containerId Container ID to validate against + * @returns True if token is valid, false otherwise + */ + public validateToken(token: string, containerId: string): boolean { + const tokenInfo = this.activeTokens.get(token); + + if (!tokenInfo) { + this.logger.warn(`Token validation failed: token not found`); + return false; + } + + if (tokenInfo.containerId !== containerId) { + this.logger.warn(`Token validation failed: container ID mismatch`); + return false; + } + + // Update last used time + tokenInfo.lastUsed = new Date(); + + this.logger.debug(`Token validated successfully for container ${containerId}`); + return true; + } + + /** + * Get token info + * @param token Token to get info for + * @returns Token info or undefined if not found + */ + public getTokenInfo(token: string): TokenInfo | undefined { + return this.activeTokens.get(token); + } + + /** + * Revoke a token + * @param token Token to revoke + */ + public revokeToken(token: string): void { + const tokenInfo = this.activeTokens.get(token); + if (tokenInfo) { + this.activeTokens.delete(token); + this.logger.debug(`Revoked token for container ${tokenInfo.containerId}`); + } + } + + /** + * Revoke all tokens for a container + * @param containerId Container ID + */ + public revokeTokensForContainer(containerId: string): void { + const tokensToRevoke: string[] = []; + + // Convert to array to avoid iterator issues + const entries = Array.from(this.activeTokens.entries()); + for (const [token, info] of entries) { + if (info.containerId === containerId) { + tokensToRevoke.push(token); + } + } + + for (const token of tokensToRevoke) { + this.activeTokens.delete(token); + } + + if (tokensToRevoke.length > 0) { + this.logger.debug(`Revoked ${tokensToRevoke.length} token(s) for container ${containerId}`); + } + } + + /** + * Clean up expired tokens + * Removes tokens that haven't been used in the specified time period + * @param maxAgeMs Maximum age in milliseconds (default: 24 hours) + */ + public cleanupExpiredTokens(maxAgeMs: number = 24 * 60 * 60 * 1000): void { + const now = new Date(); + const tokensToRevoke: string[] = []; + + // Convert to array to avoid iterator issues + const entries = Array.from(this.activeTokens.entries()); + for (const [token, info] of entries) { + const ageMs = now.getTime() - info.lastUsed.getTime(); + if (ageMs > maxAgeMs) { + tokensToRevoke.push(token); + } + } + + for (const token of tokensToRevoke) { + this.activeTokens.delete(token); + } + + if (tokensToRevoke.length > 0) { + this.logger.debug(`Cleaned up ${tokensToRevoke.length} expired token(s)`); + } + } + + /** + * Get all active tokens (for debugging) + */ + public getActiveTokenCount(): number { + return this.activeTokens.size; + } + + /** + * Clear all tokens (for testing/cleanup) + */ + public clearAllTokens(): void { + const count = this.activeTokens.size; + this.activeTokens.clear(); + this.logger.debug(`Cleared all ${count} token(s)`); + } +} + +/** + * Token information + */ +interface TokenInfo { + /** + * Container ID this token is for + */ + containerId: string; + + /** + * When the token was created + */ + createdAt: Date; + + /** + * When the token was last used + */ + lastUsed: Date; +} + +/** + * Get connection token manager instance + */ +export function getConnectionTokenManager(): ConnectionTokenManager { + return ConnectionTokenManager.getInstance(); +} + +/** + * Generate a new connection token for a container + * @param containerId Container ID + * @returns Generated token + */ +export function generateConnectionToken(containerId: string): string { + return getConnectionTokenManager().generateToken(containerId); +} + +/** + * Validate a connection token + * @param token Token to validate + * @param containerId Container ID + * @returns True if valid, false otherwise + */ +export function validateConnectionToken(token: string, containerId: string): boolean { + return getConnectionTokenManager().validateToken(token, containerId); +} + +/** + * Revoke a connection token + * @param token Token to revoke + */ +export function revokeConnectionToken(token: string): void { + getConnectionTokenManager().revokeToken(token); +} diff --git a/extensions/positron-dev-containers/src/server/installScript.ts b/extensions/positron-dev-containers/src/server/installScript.ts new file mode 100644 index 000000000000..312ab01a2d63 --- /dev/null +++ b/extensions/positron-dev-containers/src/server/installScript.ts @@ -0,0 +1,472 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServerConfig } from './serverConfig'; + +/** + * Options for generating the installation script + */ +export interface InstallScriptOptions { + /** + * Server configuration + */ + serverConfig: ServerConfig; + + /** + * Connection token for authentication + */ + connectionToken: string; + + /** + * Port to listen on (0 for random port) + */ + port?: number; + + /** + * Use socket instead of port + */ + useSocket?: boolean; + + /** + * Socket path (if useSocket is true) + */ + socketPath?: string; + + /** + * Additional extensions to install + */ + extensions?: string[]; + + /** + * Additional server arguments + */ + additionalArgs?: string[]; + + /** + * Skip server start (just install) + */ + skipStart?: boolean; + + /** + * Environment variables to set for the server process + */ + extensionHostEnv?: { [key: string]: string }; +} + +/** + * Generate installation script for the Positron server + * This script will be executed inside the container to: + * 1. Download the Positron server tarball + * 2. Extract it to the appropriate location + * 3. Start the server with the correct arguments + * 4. Output connection information + * + * @param options Installation options + * @returns Bash script as a string + */ +export function generateInstallScript(options: InstallScriptOptions): string { + const { + serverConfig, + skipStart = false + } = options; + + const installDir = `~/.positron-server/${serverConfig.serverDirName}`; + const dataDir = '~/.positron-server/data'; + const extensionsDir = '~/.positron-server/extensions'; + + // Build the script + const script = `#!/bin/sh +set -e + +# Positron Server Installation Script +# Generated for commit: ${serverConfig.commit} +# Platform: ${serverConfig.platform.platformString} + +# Output format markers +MARKER_START="__POSITRON_SERVER_START__" +MARKER_END="__POSITRON_SERVER_END__" + +# Log function +log() { + echo "[positron-server] $*" >&2 +} + +# Error function +error() { + echo "[positron-server] ERROR: $*" >&2 + exit 1 +} + +log "Starting Positron server installation..." + +# Define installation paths +INSTALL_DIR="${installDir.replace(/^~/, '$HOME')}" +DATA_DIR="${dataDir.replace(/^~/, '$HOME')}" +EXTENSIONS_DIR="${extensionsDir.replace(/^~/, '$HOME')}" +SERVER_BINARY="\${INSTALL_DIR}/bin/positron-server" + +# Check if server is already installed +if [ -f "\${SERVER_BINARY}" ]; then + log "Server already installed at \${INSTALL_DIR}" +else + log "Installing server to \${INSTALL_DIR}" + + # Create installation directory + mkdir -p "\${INSTALL_DIR}" + cd "\${INSTALL_DIR}" + + # Detect download tool + if command -v wget >/dev/null 2>&1; then + DOWNLOAD_TOOL="wget" + DOWNLOAD_CMD="wget -q -O -" + elif command -v curl >/dev/null 2>&1; then + DOWNLOAD_TOOL="curl" + DOWNLOAD_CMD="curl -fsSL" + else + error "Neither wget nor curl found. Please install one of them." + fi + + log "Downloading and installing server..." + + # Download and extract server + DOWNLOAD_URL="${serverConfig.downloadUrl}" + + log "Attempting to download from: \${DOWNLOAD_URL}" + + # Try to download and extract, capturing detailed error information + if [ "\${DOWNLOAD_TOOL}" = "wget" ]; then + # wget provides better error messages with -S (show headers) + # Redirect stderr to log file while keeping stdout clean for tar + if ! wget -S -O - "\${DOWNLOAD_URL}" 2>/tmp/download.log | tar -xz -C "\${INSTALL_DIR}" --strip-components=1 2>&1; then + # Extract HTTP status from wget output + HTTP_STATUS=\$(grep -i "HTTP/" /tmp/download.log | tail -n1 | sed 's/.*HTTP\\/[^ ]* \\([0-9]*\\).*/\\1/' || echo "unknown") + case "\${HTTP_STATUS}" in + 404) + error "File not found (HTTP 404): \${DOWNLOAD_URL}. This version may not be available yet or the URL may be incorrect." + ;; + 403) + error "Access forbidden (HTTP 403): \${DOWNLOAD_URL}. Check if the URL requires authentication or if access is restricted." + ;; + 5*) + error "Server error (HTTP \${HTTP_STATUS}): \${DOWNLOAD_URL}. The download server may be experiencing issues. Try again later." + ;; + *) + log "Download or extraction failed. Last wget output:" + tail -10 /tmp/download.log >&2 || true + error "Failed to download or extract server from \${DOWNLOAD_URL} (HTTP status: \${HTTP_STATUS})" + ;; + esac + fi + elif [ "\${DOWNLOAD_TOOL}" = "curl" ]; then + # curl with -v for verbose error information + HTTP_CODE=\$(curl -w "%{http_code}" -fsSL "\${DOWNLOAD_URL}" 2>/tmp/curl.err | tee /tmp/download.tar.gz | tar -xz -C "\${INSTALL_DIR}" --strip-components=1 2>&1 || echo "000") + if [ "\${HTTP_CODE}" != "200" ] && [ "\${HTTP_CODE}" != "000" ]; then + case "\${HTTP_CODE}" in + 404) + error "File not found (HTTP 404): \${DOWNLOAD_URL}. This version may not be available yet or the URL may be incorrect." + ;; + 403) + error "Access forbidden (HTTP 403): \${DOWNLOAD_URL}. Check if the URL requires authentication or if access is restricted." + ;; + 5*) + error "Server error (HTTP \${HTTP_CODE}): \${DOWNLOAD_URL}. The download server may be experiencing issues. Try again later." + ;; + 000) + log "Curl error output:" + cat /tmp/curl.err >&2 || true + error "Failed to connect or download from \${DOWNLOAD_URL}. Check network connectivity and URL." + ;; + *) + log "Unexpected HTTP status. Curl error output:" + cat /tmp/curl.err >&2 || true + error "Failed to download server from \${DOWNLOAD_URL} (HTTP status: \${HTTP_CODE})" + ;; + esac + fi + fi + + # Verify installation + if [ ! -f "\${SERVER_BINARY}" ]; then + error "Server binary not found after extraction: \${SERVER_BINARY}" + fi + + log "Server installed successfully" +fi + +# Create data and extensions directories +mkdir -p "\${DATA_DIR}" +mkdir -p "\${EXTENSIONS_DIR}" + +# Make server binary executable +chmod +x "\${SERVER_BINARY}" + +# Verify Node.js binary compatibility +NODE_BINARY="\${INSTALL_DIR}/node" +if [ ! -f "\${NODE_BINARY}" ]; then + error "Node.js binary not found: \${NODE_BINARY}. The server archive may be incomplete." +fi + +# Check if the Node.js binary can execute +if ! "\${NODE_BINARY}" --version >/dev/null 2>&1; then + log "WARNING: Node.js binary cannot execute. This may indicate a libc compatibility issue." + + # Try to detect Alpine/musl + if [ -f /etc/alpine-release ]; then + log "Alpine Linux detected. Attempting to install glibc compatibility layer..." + + # Try to install gcompat (glibc compatibility for musl) + if command -v apk >/dev/null 2>&1; then + log "Installing gcompat package..." + + # Try with different permission escalation methods + INSTALL_CMD="" + if apk add --no-cache gcompat >/dev/null 2>&1; then + INSTALL_CMD="success" + elif command -v sudo >/dev/null 2>&1 && sudo -n apk add --no-cache gcompat >/dev/null 2>&1; then + INSTALL_CMD="success" + elif command -v doas >/dev/null 2>&1 && doas apk add --no-cache gcompat >/dev/null 2>&1; then + INSTALL_CMD="success" + fi + + if [ "\${INSTALL_CMD}" = "success" ]; then + log "gcompat installed successfully. Retrying Node.js binary..." + if "\${NODE_BINARY}" --version >/dev/null 2>&1; then + log "Node.js binary now works with gcompat!" + else + error "Node.js binary still cannot execute even with gcompat. You may need to use a Debian/Ubuntu-based container image instead of Alpine." + fi + else + error "Failed to install gcompat (permission denied or package unavailable). Try: 1) Run 'apk add gcompat' as root in your container, or 2) Add 'gcompat' to your Dockerfile, or 3) Use a Debian/Ubuntu-based container instead of Alpine." + fi + else + error "Alpine Linux detected but apk not available. Cannot install gcompat automatically. Please use a glibc-based container (e.g., Debian, Ubuntu)." + fi + else + # Check what libc is being used + if command -v ldd >/dev/null 2>&1; then + log "Checking Node.js binary dependencies:" + ldd "\${NODE_BINARY}" >&2 || true + fi + + error "Node.js binary is incompatible with this container. This usually means the container uses musl libc instead of glibc. Please use a glibc-based container image (e.g., Debian, Ubuntu)." + fi +fi + +log "Node.js binary verified: \$(\${NODE_BINARY} --version)" + +${skipStart ? '# Skipping server start as requested' : generateServerStartScript(options, extensionsDir)} + +log "Installation script completed" +`; + + return script; +} + +/** + * Generate the server start portion of the script + */ +function generateServerStartScript(options: InstallScriptOptions, extensionsDir: string): string { + const { + connectionToken, + port, + useSocket, + socketPath, + extensions = [], + additionalArgs = [], + extensionHostEnv = {} + } = options; + + const expandedExtensionsDir = extensionsDir.replace(/^~/, '$HOME'); + + // Determine socket path or port + const defaultSocketPath = '$DATA_DIR/positron-server.sock'; + const actualSocketPath = socketPath || defaultSocketPath; + const actualPort = port !== undefined ? port : 0; + + const serverArgs = [ + '--accept-server-license-terms', + `--connection-token="${connectionToken}"`, + `--user-data-dir="\${DATA_DIR}"`, + `--extensions-dir="${expandedExtensionsDir}"` + ]; + + // Add listen configuration + if (useSocket) { + serverArgs.push(`--socket-path="${actualSocketPath}"`); + } else { + // Use port 0 if not specified (OS will pick a random available port) + serverArgs.push(`--port=${actualPort}`); + } + + // Add extension installation commands + const extensionInstallCommands = extensions.map(ext => + ` log "Installing extension: ${ext}"\n` + + ` "\${SERVER_BINARY}" --install-extension "${ext}" --extensions-dir="${expandedExtensionsDir}" || log "Warning: Failed to install extension ${ext}"` + ).join('\n'); + + // Add additional arguments + if (additionalArgs.length > 0) { + serverArgs.push(...additionalArgs.map(arg => `"${arg}"`)); + } + + return `# Install extensions if requested +${extensionInstallCommands ? extensionInstallCommands + '\n' : ''} +# Start the server +log "Starting Positron server..." +log "Server binary: \${SERVER_BINARY}" +log "Connection token: [REDACTED]" +${useSocket ? `log "Socket path: ${actualSocketPath}"` : `log "Port: ${actualPort} (0 = random port)"`} + +# Output marker for parsing +echo "\${MARKER_START}" + +# Create a log file for server output +SERVER_LOG="\${DATA_DIR}/server.log" +touch "\${SERVER_LOG}" + +# Start server and capture output to log file +# The server will print its listening information +# Set environment variables for the server process +${Object.entries(extensionHostEnv).map(([key, value]) => `${key}="${value}"`).join(' ')} "\${SERVER_BINARY}" ${serverArgs.join(' ')} > "\${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +log "Server started with PID: \${SERVER_PID}" +log "Server output is being written to: \${SERVER_LOG}" + +# Wait for server to output its listening information +# The server typically outputs a line like: +# "Extension host agent listening on port 12345" +# or "Extension host agent listening on /tmp/socket.sock" + +# Give the server time to start and output its info +sleep 2 + +# Check if server is still running +if ! kill -0 \${SERVER_PID} 2>/dev/null; then + log "ERROR: Server process terminated unexpectedly" + log "Server log contents:" + cat "\${SERVER_LOG}" >&2 + error "Server process terminated unexpectedly. Check the logs above for details." +fi + +# Determine the actual listening address +${useSocket ? ` +ACTUAL_LISTENING="${actualSocketPath}" +` : ` +# Parse the server log to find the actual port (in case we used port 0) +# Use sed instead of grep -P for better portability (works with busybox) +ACTUAL_PORT=\$(sed -n 's/.*Extension host agent listening on \\([0-9][0-9]*\\).*/\\1/p' "\${SERVER_LOG}" | head -n1) +if [ -z "\${ACTUAL_PORT}" ]; then + # Fallback: try other patterns + ACTUAL_PORT=\$(sed -n 's/.*listening on.*port \\([0-9][0-9]*\\).*/\\1/p' "\${SERVER_LOG}" | head -n1) +fi +if [ -z "\${ACTUAL_PORT}" ]; then + # Last resort: use the original port value + ACTUAL_PORT="${actualPort}" +fi +ACTUAL_LISTENING="\${ACTUAL_PORT}" +`} + +log "Server is listening on: \${ACTUAL_LISTENING}" + +# Output connection information +echo "listeningOn=\${ACTUAL_LISTENING}" +echo "connectionToken=${connectionToken}" +echo "serverPid=\${SERVER_PID}" +echo "exitCode=0" + +echo "\${MARKER_END}" + +# Don't wait for the server - let it run in the background +# The docker exec will exit and the server will continue running in the container +# The server process is now running independently +`; +} + +/** + * Parse the output from the installation script + * Extracts connection information from the script output + * + * @param output Script output + * @returns Parsed connection information + */ +export interface InstallScriptOutput { + /** + * Port or socket path the server is listening on + */ + listeningOn: string; + + /** + * Connection token + */ + connectionToken: string; + + /** + * Server process ID + */ + serverPid: string; + + /** + * Exit code + */ + exitCode: number; + + /** + * Full output + */ + fullOutput: string; +} + +/** + * Parse installation script output + */ +export function parseInstallScriptOutput(output: string): InstallScriptOutput | undefined { + // Look for the marker section + const markerStart = '__POSITRON_SERVER_START__'; + const markerEnd = '__POSITRON_SERVER_END__'; + + const startIndex = output.indexOf(markerStart); + const endIndex = output.indexOf(markerEnd); + + if (startIndex === -1 || endIndex === -1) { + return undefined; + } + + // Extract the section between markers + const markedOutput = output.substring(startIndex + markerStart.length, endIndex).trim(); + + // Parse key-value pairs + const result: Partial = { + fullOutput: output + }; + + const lines = markedOutput.split('\n'); + for (const line of lines) { + const [key, value] = line.split('=', 2); + if (key && value) { + switch (key.trim()) { + case 'listeningOn': + result.listeningOn = value.trim(); + break; + case 'connectionToken': + result.connectionToken = value.trim(); + break; + case 'serverPid': + result.serverPid = value.trim(); + break; + case 'exitCode': + result.exitCode = parseInt(value.trim(), 10); + break; + } + } + } + + // Validate required fields + if (result.listeningOn && result.connectionToken && result.exitCode !== undefined) { + return result as InstallScriptOutput; + } + + return undefined; +} diff --git a/extensions/positron-dev-containers/src/server/serverConfig.ts b/extensions/positron-dev-containers/src/server/serverConfig.ts new file mode 100644 index 000000000000..5121e0a163b2 --- /dev/null +++ b/extensions/positron-dev-containers/src/server/serverConfig.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import { getLogger } from '../common/logger'; + +/** + * Server platform information + */ +export interface ServerPlatform { + /** + * Platform name (linux, darwin, win32) + */ + platform: string; + + /** + * Architecture (x64, arm64) + */ + arch: string; + + /** + * Combined platform string (e.g., "linux-x64", "darwin-arm64") + */ + platformString: string; +} + +/** + * Server configuration + */ +export interface ServerConfig { + /** + * Positron version (e.g., "2024.12.0") + */ + version: string; + + /** + * Positron commit SHA + */ + commit: string; + + /** + * Quality (stable, insider) + */ + quality: string; + + /** + * Server download URL + */ + downloadUrl: string; + + /** + * Server install directory name + */ + serverDirName: string; + + /** + * Platform information + */ + platform: ServerPlatform; +} + +// Default download URL template - matches the format used by open-remote-ssh +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://cdn.posit.co/positron/dailies/reh/${arch-long}/positron-reh-${os}-${arch}-${version}.tar.gz'; + +/** + * Server configuration provider + */ +export class ServerConfigProvider { + private static instance: ServerConfigProvider; + private logger = getLogger(); + + private constructor() { } + + /** + * Get the singleton instance + */ + public static getInstance(): ServerConfigProvider { + if (!ServerConfigProvider.instance) { + ServerConfigProvider.instance = new ServerConfigProvider(); + } + return ServerConfigProvider.instance; + } + + /** + * Get server configuration for the host platform + */ + public getServerConfig(): ServerConfig { + const version = this.getVersion(); + const commit = this.getCommitId(); + const quality = this.getQuality(); + const platform = this.getPlatformInfo(); + + const serverDirName = `positron-server-${version}`; + const downloadUrl = this.getDownloadUrl(version, commit, quality, platform); + + this.logger.debug(`Server config: version=${version}, commit=${commit}, quality=${quality}, platform=${platform.platformString}`); + + return { + version, + commit, + quality, + downloadUrl, + serverDirName, + platform + }; + } + + /** + * Get server configuration for a specific container platform + * @param containerPlatform Platform detected in container (linux, darwin) + * @param containerArch Architecture detected in container (x64, arm64, aarch64) + */ + public getServerConfigForContainer(containerPlatform: string, containerArch: string): ServerConfig { + const version = this.getVersion(); + const commit = this.getCommitId(); + const quality = this.getQuality(); + + // Normalize architecture names + const normalizedArch = this.normalizeArch(containerArch); + + const platform: ServerPlatform = { + platform: containerPlatform, + arch: normalizedArch, + platformString: `${containerPlatform}-${normalizedArch}` + }; + + const serverDirName = `positron-server-${version}`; + const downloadUrl = this.getDownloadUrl(version, commit, quality, platform); + + this.logger.debug(`Container server config: version=${version}, commit=${commit}, quality=${quality}, platform=${platform.platformString}`); + + return { + version, + commit, + quality, + downloadUrl, + serverDirName, + platform + }; + } + + /** + * Get the version string in the format used by Positron (version-buildNumber) + */ + private getVersion(): string { + return `${positron.version}-${positron.buildNumber}`; + } + + /** + * Get the commit ID from the environment or from VS Code API + */ + private getCommitId(): string { + // Try to get from environment variable first (set by Positron build) + const envCommit = process.env.VSCODE_COMMIT; + if (envCommit) { + return envCommit; + } + + // Fallback to extracting from app root + // The commit is typically stored in product.json + try { + // Get the app root + const appRoot = vscode.env.appRoot; + const productPath = path.join(appRoot, 'product.json'); + + const productJson = fs.readFileSync(productPath, 'utf8'); + const product = JSON.parse(productJson); + if (product.commit) { + return product.commit; + } + } catch (error) { + this.logger.error(`Failed to read commit from product.json: ${error}`); + } + + // If all else fails, use a placeholder + // This should not happen in production builds + this.logger.warn('Could not determine commit ID, using placeholder'); + return 'unknown'; + } + + /** + * Get the quality (stable, insider) from the environment + */ + private getQuality(): string { + // Check if this is an insider build + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes('insider') || appName.includes('insiders')) { + return 'insider'; + } + + // Check product.json for quality + try { + const appRoot = vscode.env.appRoot; + const productPath = path.join(appRoot, 'product.json'); + + const productJson = fs.readFileSync(productPath, 'utf8'); + const product = JSON.parse(productJson); + if (product.quality) { + return product.quality; + } + } catch (error) { + this.logger.error(`Failed to read quality from product.json: ${error}`); + } + + // Default to stable + return 'stable'; + } + + /** + * Get platform information for the host + */ + private getPlatformInfo(): ServerPlatform { + const platform = os.platform(); + const arch = os.arch(); + + const normalizedPlatform = this.normalizePlatform(platform); + const normalizedArch = this.normalizeArch(arch); + + return { + platform: normalizedPlatform, + arch: normalizedArch, + platformString: `${normalizedPlatform}-${normalizedArch}` + }; + } + + /** + * Normalize platform name + */ + private normalizePlatform(platform: string): string { + switch (platform) { + case 'darwin': + return 'darwin'; + case 'linux': + return 'linux'; + case 'win32': + return 'win32'; + default: + this.logger.warn(`Unknown platform: ${platform}, defaulting to linux`); + return 'linux'; + } + } + + /** + * Normalize architecture name + */ + private normalizeArch(arch: string): string { + switch (arch) { + case 'x64': + case 'x86_64': + case 'amd64': + return 'x64'; + case 'arm64': + case 'aarch64': + return 'arm64'; + case 'arm': + case 'armv7l': + return 'armhf'; + default: + this.logger.warn(`Unknown architecture: ${arch}, defaulting to x64`); + return 'x64'; + } + } + + /** + * Get download URL for the server + * @param version Version string (e.g., 2024.10.0-123) + * @param commit Commit SHA + * @param quality Build quality (stable, insider) + * @param platform Platform information + */ + private getDownloadUrl(version: string, commit: string, quality: string, platform: ServerPlatform): string { + // Get the URL template from configuration or use default + const config = vscode.workspace.getConfiguration('dev.containers'); + const urlTemplate = config.get('serverDownloadUrlTemplate') || DEFAULT_DOWNLOAD_URL_TEMPLATE; + + // Get the long-form architecture name (e.g., x86_64 instead of x64) + const archLong = this.getArchLong(platform.arch); + + // Replace variables in the template + return urlTemplate + .replace(/\$\{quality\}/g, quality) + .replace(/\$\{version\}/g, version) + .replace(/\$\{commit\}/g, commit) + .replace(/\$\{os\}/g, platform.platform) + .replace(/\$\{arch-long\}/g, archLong) + .replace(/\$\{arch\}/g, platform.arch); + } + + /** + * Get the long-form architecture name + * @param arch Short architecture name (e.g., x64, arm64) + * @returns Long architecture name (e.g., x86_64, arm64) + */ + private getArchLong(arch: string): string { + switch (arch) { + case 'x64': + return 'x86_64'; + case 'arm64': + return 'arm64'; + case 'armhf': + return 'armv7l'; + default: + return arch; + } + } + + /** + * Get the server installation path in the container + * @param serverConfig Server configuration + */ + public getServerInstallPath(serverConfig: ServerConfig): string { + // Install in the user's home directory under .positron-server + return `~/.positron-server/${serverConfig.serverDirName}`; + } + + /** + * Get the server data path in the container + */ + public getServerDataPath(): string { + // Data directory for the server + return '~/.positron-server/data'; + } + + /** + * Get the server extensions path in the container + */ + public getServerExtensionsPath(): string { + // Extensions directory for the server + return '~/.positron-server/extensions'; + } +} + +/** + * Get server configuration provider instance + */ +export function getServerConfigProvider(): ServerConfigProvider { + return ServerConfigProvider.getInstance(); +} diff --git a/extensions/positron-dev-containers/src/server/serverInstaller.ts b/extensions/positron-dev-containers/src/server/serverInstaller.ts new file mode 100644 index 000000000000..eb6214b3e5e6 --- /dev/null +++ b/extensions/positron-dev-containers/src/server/serverInstaller.ts @@ -0,0 +1,532 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLogger } from '../common/logger'; +import { getConfiguration } from '../common/configuration'; +import { ServerConfig, ServerConfigProvider, getServerConfigProvider } from './serverConfig'; +import { generateConnectionToken, revokeConnectionToken } from './connectionToken'; +import { generateInstallScript, parseInstallScriptOutput, InstallScriptOptions } from './installScript'; + +/** + * Server installation options + */ +export interface ServerInstallOptions { + /** + * Container ID to install server in + */ + containerId: string; + + /** + * Whether to force reinstallation even if already installed + */ + forceReinstall?: boolean; + + /** + * Port to listen on (0 for random port) + */ + port?: number; + + /** + * Use socket instead of port + */ + useSocket?: boolean; + + /** + * Socket path (if useSocket is true) + */ + socketPath?: string; + + /** + * Extensions to install + */ + extensions?: string[]; + + /** + * Additional server arguments + */ + additionalArgs?: string[]; + + /** + * Progress reporter + */ + progress?: vscode.Progress<{ message?: string; increment?: number }>; + + /** + * Environment variables to set for the server process + */ + extensionHostEnv?: { [key: string]: string }; +} + +/** + * Server installation result + */ +export interface ServerInstallResult { + /** + * Container ID + */ + containerId: string; + + /** + * Connection token + */ + connectionToken: string; + + /** + * Port or socket path server is listening on + */ + listeningOn: string; + + /** + * Whether listening on a port (vs socket) + */ + isPort: boolean; + + /** + * Port number (if isPort is true) + */ + port?: number; + + /** + * Socket path (if isPort is false) + */ + socketPath?: string; + + /** + * Server process ID in container + */ + serverPid: string; + + /** + * Server configuration used + */ + serverConfig: ServerConfig; +} + +/** + * Container platform information + */ +interface ContainerPlatformInfo { + platform: string; + arch: string; + osRelease?: string; +} + +/** + * Server installer + * Handles installation and startup of the Positron server in containers + */ +export class ServerInstaller { + private static instance: ServerInstaller; + private logger = getLogger(); + private serverConfigProvider: ServerConfigProvider; + + private constructor() { + this.serverConfigProvider = getServerConfigProvider(); + } + + /** + * Get the singleton instance + */ + public static getInstance(): ServerInstaller { + if (!ServerInstaller.instance) { + ServerInstaller.instance = new ServerInstaller(); + } + return ServerInstaller.instance; + } + + /** + * Install and start the Positron server in a container + * @param options Installation options + * @returns Server installation result + */ + public async installAndStartServer(options: ServerInstallOptions): Promise { + const { containerId, progress } = options; + + this.logger.info(`Installing Positron server in container ${containerId}`); + + try { + // Step 1: Detect container platform + progress?.report({ message: vscode.l10n.t('Detecting container platform...'), increment: 10 }); + const platformInfo = await this.detectContainerPlatform(containerId); + this.logger.debug(`Container platform: ${platformInfo.platform}-${platformInfo.arch}`); + + // Step 2: Get server configuration for container platform + progress?.report({ message: vscode.l10n.t('Preparing server configuration...'), increment: 10 }); + const serverConfig = this.serverConfigProvider.getServerConfigForContainer( + platformInfo.platform, + platformInfo.arch + ); + + // Step 3: Generate connection token + const connectionToken = generateConnectionToken(containerId); + + // Step 4: Get extensions to install + const config = getConfiguration(); + const extensions = options.extensions || config.getDefaultExtensions(); + + // Step 5: Generate installation script + progress?.report({ message: vscode.l10n.t('Generating installation script...'), increment: 10 }); + const scriptOptions: InstallScriptOptions = { + serverConfig, + connectionToken, + port: options.port, + useSocket: options.useSocket, + socketPath: options.socketPath, + extensions, + additionalArgs: options.additionalArgs, + skipStart: false, + extensionHostEnv: options.extensionHostEnv + }; + + const installScript = generateInstallScript(scriptOptions); + this.logger.debug('Installation script generated'); + + // Step 6: Execute installation script in container + progress?.report({ message: vscode.l10n.t('Installing server in container...'), increment: 30 }); + const scriptOutput = await this.executeInstallScript(containerId, installScript); + + // Step 7: Parse output + progress?.report({ message: vscode.l10n.t('Verifying installation...'), increment: 20 }); + const parsedOutput = parseInstallScriptOutput(scriptOutput); + + if (!parsedOutput) { + throw new Error('Failed to parse installation script output. Server may not have started correctly.'); + } + + if (parsedOutput.exitCode !== 0) { + throw new Error(`Server installation failed with exit code ${parsedOutput.exitCode}`); + } + + // Step 8: Build result + progress?.report({ message: vscode.l10n.t('Server installed successfully'), increment: 20 }); + + const isPort = !options.useSocket; + const result: ServerInstallResult = { + containerId, + connectionToken: parsedOutput.connectionToken, + listeningOn: parsedOutput.listeningOn, + isPort, + port: isPort ? parseInt(parsedOutput.listeningOn, 10) : undefined, + socketPath: isPort ? undefined : parsedOutput.listeningOn, + serverPid: parsedOutput.serverPid, + serverConfig + }; + + this.logger.info(`Server installed successfully in container ${containerId}, listening on ${result.listeningOn}`); + + return result; + + } catch (error) { + this.logger.error(`Failed to install server in container ${containerId}: ${error}`); + // Revoke the token if installation failed + try { + revokeConnectionToken(containerId); + } catch (revokeError) { + this.logger.warn(`Failed to revoke connection token: ${revokeError}`); + } + throw error; + } + } + + /** + * Check if server is already installed in a container + * @param containerId Container ID + * @returns True if server is installed, false otherwise + */ + public async isServerInstalled(containerId: string): Promise { + try { + const serverConfig = this.serverConfigProvider.getServerConfig(); + const installPath = this.serverConfigProvider.getServerInstallPath(serverConfig); + const serverBinary = `${installPath}/bin/positron-server`; + + // Check if server binary exists + const checkCommand = `test -f ${serverBinary} && echo "exists" || echo "not-exists"`; + const output = await this.executeCommandInContainer(containerId, checkCommand); + + return output.trim() === 'exists'; + } catch (error) { + this.logger.warn(`Failed to check if server is installed in container ${containerId}: ${error}`); + return false; + } + } + + /** + * Detect the platform and architecture of a container + * @param containerId Container ID + * @returns Platform information + */ + private async detectContainerPlatform(containerId: string): Promise { + // Run uname commands to detect platform + const detectScript = ` + echo "platform=$(uname -s | tr '[:upper:]' '[:lower:]')" + echo "arch=$(uname -m)" + if [ -f /etc/os-release ]; then + echo "osRelease=$(cat /etc/os-release | grep '^ID=' | cut -d= -f2 | tr -d '"')" + fi + `; + + const output = await this.executeCommandInContainer(containerId, detectScript); + + // Parse output + const platformInfo: Partial = {}; + + const lines = output.split('\n'); + for (const line of lines) { + const [key, value] = line.split('=', 2); + if (key && value) { + switch (key.trim()) { + case 'platform': + // Normalize platform name + const platform = value.trim(); + if (platform === 'linux') { + platformInfo.platform = 'linux'; + } else if (platform === 'darwin') { + platformInfo.platform = 'darwin'; + } else { + // Default to linux for unknown platforms + platformInfo.platform = 'linux'; + } + break; + case 'arch': + platformInfo.arch = value.trim(); + break; + case 'osRelease': + platformInfo.osRelease = value.trim(); + break; + } + } + } + + if (!platformInfo.platform || !platformInfo.arch) { + throw new Error(`Failed to detect container platform. Output: ${output}`); + } + + return platformInfo as ContainerPlatformInfo; + } + + /** + * Execute the installation script in a container + * @param containerId Container ID + * @param script Script content + * @returns Script output + */ + private async executeInstallScript(containerId: string, script: string): Promise { + // Write script to a temp file in container and execute it + // We use a multi-step approach: + // 1. Write script content to stdin + // 2. Pipe to sh (more compatible than bash) + + const command = `sh -c ${this.escapeShellArg(script)}`; + + this.logger.debug(`Executing installation script in container ${containerId}`); + + try { + const output = await this.executeCommandInContainer(containerId, command); + return output; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + this.logger.error(`Installation script execution failed: ${errorMsg}`); + // Preserve the detailed error message from executeCommandInContainer + throw new Error(`Installation script failed: ${errorMsg}`); + } + } + + /** + * Execute a command in a container using docker exec + * This is a simplified version that directly uses docker CLI + * @param containerId Container ID + * @param command Command to execute + * @returns Command output + */ + private async executeCommandInContainer(containerId: string, command: string): Promise { + const config = getConfiguration(); + const dockerPath = config.getDockerPath(); + + return new Promise((resolve, reject) => { + const { spawn } = require('child_process'); + + // Build the command args + const args = ['exec', '-i', containerId, 'sh', '-c', command]; + this.logger.debug(`Executing: ${dockerPath} ${args.join(' ')}`); + + const proc = spawn(dockerPath, args); + + let stdout = ''; + let stderr = ''; + let lastProgressLog = 0; + const PROGRESS_LOG_INTERVAL = 1000; // Log progress at most once per second + + // Stream stdout to logger in real-time + proc.stdout.on('data', (data: Buffer) => { + const text = data.toString(); + stdout += text; + // Stream to output channel - split by lines for cleaner output + text.split('\n').filter(line => line.trim()).forEach(line => { + // Filter out download progress lines that contain percentage or are very repetitive + const isProgressLine = /\d+%|####|===|\.\.\./.test(line) || line.length > 200; + + if (isProgressLine) { + // Only log progress lines occasionally to avoid spam + const now = Date.now(); + if (now - lastProgressLog > PROGRESS_LOG_INTERVAL) { + this.logger.debug(`[Container] ${line.substring(0, 100)}...`); + lastProgressLog = now; + } + } else { + // Log non-progress lines normally + this.logger.debug(`[Container] ${line}`); + } + }); + }); + + // Stream stderr to logger in real-time + proc.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + stderr += text; + // Stream to output channel + text.split('\n').filter(line => line.trim()).forEach(line => { + // Use info level for script output (scripts use stderr for normal logging) + // Actual errors will be caught by the exit code and error handling + this.logger.debug(`[Container] ${line}`); + }); + }); + + proc.on('error', (error: Error) => { + this.logger.error(`Failed to execute command in container ${containerId}: ${error.message}`); + reject(error); + }); + + proc.on('close', (code: number) => { + if (code === 0) { + resolve(stdout); + } else { + const errorMsg = `Command exited with code ${code}`; + this.logger.error(errorMsg); + + // Always log the full stderr for debugging + if (stderr) { + this.logger.error('--- Installation Script Output (stderr) ---'); + this.logger.error(stderr); + this.logger.error('--- End Installation Script Output ---'); + } + + // Also log stdout if it has content + if (stdout && stdout.trim()) { + this.logger.error('--- Installation Script Output (stdout) ---'); + this.logger.error(stdout); + this.logger.error('--- End Installation Script Output ---'); + } + + // Create a more descriptive error by extracting context from stderr/stdout + const errorContext = this.extractErrorContext(stderr, stdout); + reject(new Error(`${errorMsg}${errorContext ? ': ' + errorContext : ''}`)); + } + }); + }); + } + + /** + * Extract meaningful error context from command output + */ + private extractErrorContext(stderr: string, stdout: string): string { + // Look for ERROR: lines in stdout/stderr + const errorLines = (stdout + '\n' + stderr) + .split('\n') + .filter(line => line.includes('ERROR:')) + .map(line => line.replace(/^.*ERROR:\s*/, '').trim()) + .filter(line => line.length > 0); + + if (errorLines.length > 0) { + return errorLines[0]; + } + + // Look for common error patterns + const lastLines = (stderr || stdout).split('\n').filter(l => l.trim()).slice(-5); + if (lastLines.length > 0) { + return lastLines[lastLines.length - 1].substring(0, 200); + } + + return ''; + } + + /** + * Escape a string for use as a shell argument + * @param arg Argument to escape + * @returns Escaped argument + */ + private escapeShellArg(arg: string): string { + // Escape single quotes by replacing them with '\'' + return `'${arg.replace(/'/g, `'\\''`)}'`; + } + + /** + * Stop the server in a container + * @param containerId Container ID + * @param serverPid Server process ID + */ + public async stopServer(containerId: string, serverPid: string): Promise { + try { + this.logger.info(`Stopping server (PID ${serverPid}) in container ${containerId}`); + + // Send SIGTERM to the server process + const killCommand = `kill -TERM ${serverPid}`; + await this.executeCommandInContainer(containerId, killCommand); + + // Wait a bit for graceful shutdown + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check if process is still running + const checkCommand = `kill -0 ${serverPid} 2>/dev/null && echo "running" || echo "stopped"`; + const status = await this.executeCommandInContainer(containerId, checkCommand); + + if (status.trim() === 'running') { + // Force kill if still running + this.logger.warn(`Server did not stop gracefully, force killing...`); + const forceKillCommand = `kill -KILL ${serverPid}`; + await this.executeCommandInContainer(containerId, forceKillCommand); + } + + this.logger.info(`Server stopped successfully in container ${containerId}`); + } catch (error) { + this.logger.error(`Failed to stop server in container ${containerId}: ${error}`); + throw error; + } + } +} + +/** + * Get server installer instance + */ +export function getServerInstaller(): ServerInstaller { + return ServerInstaller.getInstance(); +} + +/** + * Install and start server in a container + * @param options Installation options + * @returns Installation result + */ +export async function installAndStartServer(options: ServerInstallOptions): Promise { + return getServerInstaller().installAndStartServer(options); +} + +/** + * Check if server is installed in a container + * @param containerId Container ID + * @returns True if installed + */ +export async function isServerInstalled(containerId: string): Promise { + return getServerInstaller().isServerInstalled(containerId); +} + +/** + * Stop server in a container + * @param containerId Container ID + * @param serverPid Server process ID + */ +export async function stopServer(containerId: string, serverPid: string): Promise { + return getServerInstaller().stopServer(containerId, serverPid); +} diff --git a/extensions/positron-dev-containers/src/spec/LICENSE.txt b/extensions/positron-dev-containers/src/spec/LICENSE.txt new file mode 100644 index 000000000000..4b1d0b148025 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/LICENSE.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/positron-dev-containers/src/spec/README.md b/extensions/positron-dev-containers/src/spec/README.md new file mode 100644 index 000000000000..854e5ff74c8a --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/README.md @@ -0,0 +1,12 @@ +# Dev Containers Reference Implementation + +This folder contains a reference implementation of the MIT-licensed Dev +Containers spec, adapted from: + +https://github.com/devcontainers/cli + +The reference implementation has been lightly adapted to work in Positron; the +primary change is the adaptation of the Docker/Podman commands to run in +Positron terminals instead of being run directly as child processes. + + diff --git a/extensions/positron-dev-containers/src/spec/global.d.ts b/extensions/positron-dev-containers/src/spec/global.d.ts new file mode 100644 index 000000000000..bdeab3591c60 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/global.d.ts @@ -0,0 +1,20 @@ +// Type definitions to allow compilation of spec directory code + +// Augment catch clause error types to allow property access +declare global { + interface Error { + code?: string | number; + signal?: string; + cmdOutput?: string; + stderr?: string; + Message?: string; + } + + // Allow PlatformSwitch to be used as both an object and callable + interface PlatformSwitch extends Function { + posix: T; + win32: T; + } +} + +export { }; diff --git a/extensions/positron-dev-containers/src/spec/platformSwitch.d.ts b/extensions/positron-dev-containers/src/spec/platformSwitch.d.ts new file mode 100644 index 000000000000..ebfe4bd6ed39 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/platformSwitch.d.ts @@ -0,0 +1,16 @@ +// Type augmentations for PlatformSwitch to handle union types properly + +import type { PlatformSwitch } from './spec-common/commonUtils'; + +declare module './spec-common/commonUtils' { + // Allow PlatformSwitch functions to be callable + export function platformDispatch any>( + platform: NodeJS.Platform, + platformSwitch: PlatformSwitch + ): T; + + export function platformDispatch( + platform: NodeJS.Platform, + platformSwitch: PlatformSwitch + ): T; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/async.ts b/extensions/positron-dev-containers/src/spec/spec-common/async.ts new file mode 100644 index 000000000000..baff3891a8ee --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/async.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export async function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/cliHost.ts b/extensions/positron-dev-containers/src/spec/spec-common/cliHost.ts new file mode 100644 index 000000000000..294f8be4a8ae --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/cliHost.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as net from 'net'; +import * as os from 'os'; + +import { readLocalFile, writeLocalFile, mkdirpLocal, isLocalFile, renameLocal, readLocalDir, isLocalFolder } from '../spec-utils/pfs'; +import { URI } from 'vscode-uri'; +import { ExecFunction, getLocalUsername, plainExec, plainPtyExec, PtyExecFunction } from './commonUtils'; +import { Abort, Duplex, Sink, Source, SourceCallback } from 'pull-stream'; + +const toPull = require('stream-to-pull-stream'); + + +export type CLIHostType = 'local' | 'wsl' | 'container' | 'ssh'; + +export interface CLIHost { + type: CLIHostType; + platform: NodeJS.Platform; + arch: NodeJS.Architecture; + exec: ExecFunction; + ptyExec: PtyExecFunction; + cwd: string; + env: NodeJS.ProcessEnv; + path: typeof path.posix | typeof path.win32; + homedir(): Promise; + tmpdir(): Promise; + isFile(filepath: string): Promise; + isFolder(filepath: string): Promise; + readFile(filepath: string): Promise; + writeFile(filepath: string, content: Buffer): Promise; + rename(oldPath: string, newPath: string): Promise; + mkdirp(dirpath: string): Promise; + readDir(dirpath: string): Promise; + readDirWithTypes?(dirpath: string): Promise<[string, FileTypeBitmask][]>; + getUsername(): Promise; + getuid?: () => Promise; + getgid?: () => Promise; + toCommonURI(filePath: string): Promise; + connect: ConnectFunction; + reconnect?(): Promise; + terminate?(): Promise; +} + +export type ConnectFunction = (socketPath: string) => Duplex; + +export enum FileTypeBitmask { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64 +} + +export async function getCLIHost(localCwd: string, loadNativeModule: (moduleName: string) => Promise, allowInheritTTY: boolean): Promise { + const exec = plainExec(localCwd); + const ptyExec = await plainPtyExec(localCwd, loadNativeModule, allowInheritTTY); + return createLocalCLIHostFromExecFunctions(localCwd, exec, ptyExec, connectLocal); +} + +function createLocalCLIHostFromExecFunctions(localCwd: string, exec: ExecFunction, ptyExec: PtyExecFunction, connect: ConnectFunction): CLIHost { + return { + type: 'local', + platform: process.platform, + arch: process.arch, + exec, + ptyExec, + cwd: localCwd, + env: process.env, + path: path, + homedir: async () => os.homedir(), + tmpdir: async () => os.tmpdir(), + isFile: isLocalFile, + isFolder: isLocalFolder, + readFile: readLocalFile, + writeFile: writeLocalFile, + rename: renameLocal, + mkdirp: async (dirpath) => { + await mkdirpLocal(dirpath); + }, + readDir: readLocalDir, + getUsername: getLocalUsername, + getuid: process.platform === 'linux' || process.platform === 'darwin' ? async () => process.getuid!() : undefined, + getgid: process.platform === 'linux' || process.platform === 'darwin' ? async () => process.getgid!() : undefined, + toCommonURI: async (filePath) => URI.file(filePath), + connect, + }; +} + +// Parse a Cygwin socket cookie string to a raw Buffer +function cygwinUnixSocketCookieToBuffer(cookie: string) { + let bytes: number[] = []; + + cookie.split('-').map((number: string) => { + const bytesInChar = number.match(/.{2}/g); + if (bytesInChar !== null) { + bytesInChar.reverse().map((byte) => { + bytes.push(parseInt(byte, 16)); + }); + } + }); + return Buffer.from(bytes); +} + +// The cygwin/git bash ssh-agent server will reply us with the cookie back (16 bytes) +// + identifiers (12 bytes), skip them while forwarding data from ssh-agent to the client +function skipHeader(headerSize: number, err: Abort, data?: Buffer) { + if (err || data === undefined) { + return { headerSize, err }; + } + + if (headerSize === 0) { + // Fast path avoiding data buffer manipulation + // We don't need to modify the received data (handshake header + // already removed) + return { headerSize, data }; + } else if (data.length > headerSize) { + // We need to remove part of the data to forward + data = data.slice(headerSize, data.length); + headerSize = 0; + return { headerSize, data }; + } else { + // We need to remove all forwarded data + headerSize = headerSize - data.length; + return { headerSize }; + } +} + +// Function to handle the Cygwin/Gpg4win socket filtering +// These sockets need an handshake before forwarding client and server data +function handleUnixSocketOnWindows(socket: net.Socket, socketPath: string): Duplex { + let headerSize = 0; + let pendingSourceCallbacks: { abort: Abort; cb: SourceCallback }[] = []; + let pendingSinkCalls: Source[] = []; + let connectionDuplex: Duplex | undefined = undefined; + + let handleError = (err: Abort) => { + if (err instanceof Error) { + console.error(err); + } + socket.destroy(); + + // Notify pending callbacks with the error + for (let callback of pendingSourceCallbacks) { + callback.cb(err, undefined); + } + pendingSourceCallbacks = []; + + for (let callback of pendingSinkCalls) { + callback(err, (_abort, _data) => { }); + } + pendingSinkCalls = []; + }; + + function doSource(abort: Abort, cb: SourceCallback) { + (connectionDuplex as Duplex).source(abort, function (err, data) { + const res = skipHeader(headerSize, err, data); + headerSize = res.headerSize; + if (res.err || res.data) { + cb(res.err || null, res.data); + } else { + doSource(abort, cb); + } + }); + } + + (async () => { + const buf = await readLocalFile(socketPath); + const str = buf.toString(); + + // Try to parse cygwin socket data + const cygwinSocketParameters = str.match(/!(\d+)( s)? ((([A-Fa-f0-9]{2}){4}-?){4})/); + + let port: number; + let handshake: Buffer; + + if (cygwinSocketParameters !== null) { + // Cygwin / MSYS / Git Bash unix socket on Windows + const portStr = cygwinSocketParameters[1]; + const guidStr = cygwinSocketParameters[3]; + port = parseInt(portStr, 10); + const guid = cygwinUnixSocketCookieToBuffer(guidStr); + + let identifierData = Buffer.alloc(12); + identifierData.writeUInt32LE(process.pid, 0); + + handshake = Buffer.concat([guid, identifierData]); + + // Recv header size = GUID (16 bytes) + identifiers (3 * 4 bytes) + headerSize = 16 + 3 * 4; + } else { + // Gpg4Win unix socket + const i = buf.indexOf(0xa); + port = parseInt(buf.slice(0, i).toString(), 10); + handshake = buf.slice(i + 1); + + // No header will be received from Gpg4Win agent + headerSize = 0; + } + + // Handle connection errors and resets + socket.on('error', err => { + handleError(err); + }); + + socket.connect(port, '127.0.0.1', () => { + // Write handshake data to the ssh-agent/gpg-agent server + socket.write(handshake, err => { + if (err) { + // Error will be handled via the 'error' event + return; + } + + connectionDuplex = toPull.duplex(socket); + + // Call pending source calls, if the pull-stream connection was + // pull-ed before we got connected to the ssh-agent/gpg-agent + // server. + // The received data from ssh-agent/gpg-agent server is filtered + // to skip the handshake header. + for (let callback of pendingSourceCallbacks) { + doSource(callback.abort, callback.cb); + } + pendingSourceCallbacks = []; + + // Call pending sink calls after the handshake is completed + // to send what the client sent to us + for (let callback of pendingSinkCalls) { + (connectionDuplex as Duplex).sink(callback); + } + pendingSinkCalls = []; + }); + }); + })() + .catch(err => { + handleError(err); + }); + + // pull-stream source that remove the first bytes + let source: Source = function (abort: Abort, cb: SourceCallback) { + if (connectionDuplex !== undefined) { + doSource(abort, cb); + } else { + pendingSourceCallbacks.push({ abort: abort, cb: cb }); + } + }; + + // pull-stream sink. No filtering done, but we need to store calls in case + // the connection to the upstram ssh-agent/gpg-agent is not yet connected + let sink: Sink = function (source: Source) { + if (connectionDuplex !== undefined) { + connectionDuplex.sink(source); + } else { + pendingSinkCalls.push(source); + } + }; + + return { + source: source, + sink: sink + }; +} + +// Connect to a ssh-agent or gpg-agent, supporting multiple platforms +function connectLocal(socketPath: string) { + if (process.platform !== 'win32' || socketPath.startsWith('\\\\.\\pipe\\')) { + // Simple case: direct forwarding + return toPull.duplex(net.connect(socketPath)); + } + + // More complex case: we need to do an handshake to support Cygwin / Git Bash + // sockets or Gpg4Win sockets + + const socket = new net.Socket(); + + return handleUnixSocketOnWindows(socket, socketPath); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/commonUtils.ts b/extensions/positron-dev-containers/src/spec/spec-common/commonUtils.ts new file mode 100644 index 000000000000..1470d7b43e5c --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/commonUtils.ts @@ -0,0 +1,609 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Writable, Readable } from 'stream'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as cp from 'child_process'; +import * as ptyType from 'node-pty'; +import { StringDecoder } from 'string_decoder'; +import { createRequire } from 'module'; + +import { toErrorText } from './errors'; +import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; +import { isLocalFile } from '../spec-utils/pfs'; +import { escapeRegExCharacters } from '../spec-utils/strings'; +import { Log, nullLog } from '../spec-utils/log'; +import { ShellServer } from './shellServer'; + +export { CLIHost, getCLIHost } from './cliHost'; + +export interface Exec { + stdin: Writable; + stdout: Readable; + stderr: Readable; + exit: Promise<{ code: number | null; signal: string | null }>; + terminate(): Promise; +} + +export interface ExecParameters { + env?: NodeJS.ProcessEnv; + cwd?: string; + cmd: string; + args?: string[]; + stdio?: [cp.StdioNull | cp.StdioPipe, cp.StdioNull | cp.StdioPipe, cp.StdioNull | cp.StdioPipe]; + output: Log; +} + +export interface ExecFunction { + (params: ExecParameters): Promise; +} + +export type GoOS = { [OS in NodeJS.Platform]: OS extends 'win32' ? 'windows' : OS; }[NodeJS.Platform]; +export type GoARCH = { [ARCH in NodeJS.Architecture]: ARCH extends 'x64' ? 'amd64' : ARCH; }[NodeJS.Architecture]; + +export interface PlatformInfo { + os: GoOS; + arch: GoARCH; + variant?: string; +} + +export interface PtyExec { + onData: Event; + write?(data: string): void; + resize(cols: number, rows: number): void; + exit: Promise<{ code: number | undefined; signal: number | undefined }>; + terminate(): Promise; +} + +export interface PtyExecParameters { + env?: NodeJS.ProcessEnv; + cwd?: string; + cmd: string; + args?: string[]; + cols?: number; + rows?: number; + output: Log; +} + +export interface PtyExecFunction { + (params: PtyExecParameters): Promise; +} + +export function equalPaths(platform: NodeJS.Platform, a: string, b: string) { + if (platform === 'linux') { + return a === b; + } + return a.toLowerCase() === b.toLowerCase(); +} + +export async function runCommandNoPty(options: { + exec: ExecFunction; + cmd: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + stdin?: Buffer | fs.ReadStream | Event; + output: Log; + print?: boolean | 'continuous' | 'onerror'; +}) { + const { exec, cmd, args, cwd, env, stdin, output, print } = options; + + const p = await exec({ + cmd, + args, + cwd, + env, + output, + }); + + return new Promise<{ stdout: Buffer; stderr: Buffer }>((resolve, reject) => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + const stdoutDecoder = print === 'continuous' ? new StringDecoder() : undefined; + p.stdout.on('data', (chunk: Buffer) => { + stdout.push(chunk); + if (print === 'continuous') { + output.write(stdoutDecoder!.write(chunk)); + } + }); + p.stdout.on('error', (err: any) => { + // ENOTCONN seen with missing executable in addition to ENOENT on child_process. + if (err?.code !== 'ENOTCONN') { + throw err; + } + }); + const stderrDecoder = print === 'continuous' ? new StringDecoder() : undefined; + p.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk); + if (print === 'continuous') { + output.write(toErrorText(stderrDecoder!.write(chunk))); + } + }); + p.stderr.on('error', (err: any) => { + // ENOTCONN seen with missing executable in addition to ENOENT on child_process. + if (err?.code !== 'ENOTCONN') { + throw err; + } + }); + const subs: Disposable[] = []; + p.exit.then(({ code, signal }) => { + try { + const failed = !!code || !!signal; + subs.forEach(sub => sub.dispose()); + const stdoutBuf = Buffer.concat(stdout); + const stderrBuf = Buffer.concat(stderr); + if (print === true || (failed && print === 'onerror')) { + output.write(stdoutBuf.toString().replace(/\r?\n/g, '\r\n')); + output.write(toErrorText(stderrBuf.toString())); + } + if (print && code) { + output.write(`Exit code ${code}`); + } + if (print && signal) { + output.write(`Process signal ${signal}`); + } + if (failed) { + reject({ + message: `Command failed: ${cmd} ${(args || []).join(' ')}`, + stdout: stdoutBuf, + stderr: stderrBuf, + code, + signal, + }); + } else { + resolve({ + stdout: stdoutBuf, + stderr: stderrBuf, + }); + } + } catch (e) { + reject(e); + } + }, reject); + if (stdin instanceof Buffer) { + p.stdin.write(stdin, err => { + if (err) { + reject(err); + } + }); + p.stdin.end(); + } else if (stdin instanceof fs.ReadStream) { + stdin.pipe(p.stdin); + } else if (typeof stdin === 'function') { + subs.push(stdin(buf => p.stdin.write(buf))); + } + }); +} + +export async function runCommand(options: { + ptyExec: PtyExecFunction; + cmd: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + output: Log; + resolveOn?: RegExp; + onDidInput?: Event; + stdin?: string; + print?: 'off' | 'continuous' | 'end'; +}) { + const { ptyExec, cmd, args, cwd, env, output, resolveOn, onDidInput, stdin } = options; + const print = options.print || 'continuous'; + + const p = await ptyExec({ + cmd, + args, + cwd, + env, + output: output, + }); + + return new Promise<{ cmdOutput: string }>((resolve, reject) => { + let cmdOutput = ''; + + const subs: Disposable[] = []; + if (p.write) { + if (stdin) { + p.write(stdin); + } + if (onDidInput) { + subs.push(onDidInput(data => p.write!(data))); + } + } + + p.onData(chunk => { + cmdOutput += chunk; + if (print === 'continuous') { + output.raw(chunk); + } + if (resolveOn && resolveOn.exec(cmdOutput)) { + resolve({ cmdOutput }); + } + }); + p.exit.then(({ code, signal }) => { + try { + if (print === 'end') { + output.raw(cmdOutput); + } + subs.forEach(sub => sub?.dispose()); + if (code || signal) { + reject({ + message: `Command failed: ${cmd} ${(args || []).join(' ')}`, + cmdOutput, + code, + signal, + }); + } else { + resolve({ cmdOutput }); + } + } catch (e) { + reject(e); + } + }, e => { + subs.forEach(sub => sub?.dispose()); + reject(e); + }); + }); +} + +// From https://man7.org/linux/man-pages/man7/signal.7.html: +export const processSignals: Record = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGILL: 4, + SIGTRAP: 5, + SIGABRT: 6, + SIGIOT: 6, + SIGBUS: 7, + SIGEMT: undefined, + SIGFPE: 8, + SIGKILL: 9, + SIGUSR1: 10, + SIGSEGV: 11, + SIGUSR2: 12, + SIGPIPE: 13, + SIGALRM: 14, + SIGTERM: 15, + SIGSTKFLT: 16, + SIGCHLD: 17, + SIGCLD: undefined, + SIGCONT: 18, + SIGSTOP: 19, + SIGTSTP: 20, + SIGTTIN: 21, + SIGTTOU: 22, + SIGURG: 23, + SIGXCPU: 24, + SIGXFSZ: 25, + SIGVTALRM: 26, + SIGPROF: 27, + SIGWINCH: 28, + SIGIO: 29, + SIGPOLL: 29, + SIGPWR: 30, + SIGINFO: undefined, + SIGLOST: undefined, + SIGSYS: 31, + SIGUNUSED: 31, +}; + +export function plainExec(defaultCwd: string | undefined): ExecFunction { + return async function (params: ExecParameters): Promise { + const { cmd, args, stdio, output } = params; + + const text = `Run: ${cmd} ${(args || []).join(' ').replace(/\n.*/g, '')}`; + const start = output.start(text); + + const cwd = params.cwd || defaultCwd; + const env = params.env ? { ...process.env, ...params.env } : process.env; + const exec = await findLocalWindowsExecutable(cmd, cwd, env, output); + // --- Start Positron --- + // On Windows, when exec contains spaces (like "C:\Program Files\..."), we need to quote it + // Shell is needed for PATH resolution, but we must handle spaces in the executable path + const needsShell = process.platform === 'win32' && !path.isAbsolute(exec); + const spawnArgs = needsShell ? args : args; + const spawnCmd = needsShell ? exec : exec; + const p = cp.spawn(spawnCmd, spawnArgs, { cwd, env, stdio: stdio as any, windowsHide: true, shell: needsShell }); + // --- End Positron --- + + return { + stdin: p.stdin, + stdout: p.stdout, + stderr: p.stderr, + exit: new Promise((resolve, reject) => { + p.once('error', err => { + output.stop(text, start); + reject(err); + }); + p.once('close', (code, signal) => { + output.stop(text, start); + resolve({ code, signal }); + }); + }), + async terminate() { + p.kill('SIGKILL'); + } + }; + }; +} + +export async function plainPtyExec(defaultCwd: string | undefined, loadNativeModule: (moduleName: string) => Promise, allowInheritTTY: boolean): Promise { + const pty = await loadNativeModule('node-pty'); + if (!pty) { + const plain = plainExec(defaultCwd); + return plainExecAsPtyExec(plain, allowInheritTTY); + } + + return async function (params: PtyExecParameters): Promise { + const { cmd, args, output } = params; + + const text = `Run: ${cmd} ${(args || []).join(' ').replace(/\n.*/g, '')}`; + const start = output.start(text); + + const useConpty = false; // TODO: Investigate using a shell with ConPTY. https://github.com/Microsoft/vscode-remote/issues/1234#issuecomment-485501275 + const cwd = params.cwd || defaultCwd; + const env = params.env ? { ...process.env, ...params.env } : process.env; + const exec = await findLocalWindowsExecutable(cmd, cwd, env, output); + const p = pty.spawn(exec, args || [], { + cwd, + env: env as any, + cols: output.dimensions?.columns, + rows: output.dimensions?.rows, + useConpty, + }); + const subs = [ + output.onDidChangeDimensions && output.onDidChangeDimensions(e => p.resize(e.columns, e.rows)) + ]; + + return { + onData: p.onData.bind(p), + write: p.write.bind(p), + resize: p.resize.bind(p), + exit: new Promise(resolve => { + p.onExit(({ exitCode, signal }) => { + subs.forEach(sub => sub?.dispose()); + output.stop(text, start); + resolve({ code: exitCode, signal }); + if (process.platform === 'win32') { + try { + // In some cases the process hasn't cleanly exited on Windows and the winpty-agent gets left around + // https://github.com/microsoft/node-pty/issues/333 + p.kill(); + } catch { + } + } + }); + }), + async terminate() { + p.kill('SIGKILL'); + } + }; + }; +} + +export function plainExecAsPtyExec(plain: ExecFunction, allowInheritTTY: boolean): PtyExecFunction { + return async function (params: PtyExecParameters): Promise { + const p = await plain({ + ...params, + stdio: allowInheritTTY && params.output !== nullLog ? [ + process.stdin.isTTY ? 'inherit' : 'pipe', + process.stdout.isTTY ? 'inherit' : 'pipe', + process.stderr.isTTY ? 'inherit' : 'pipe', + ] : undefined, + }); + const onDataEmitter = new NodeEventEmitter(); + if (p.stdout) { + const stdoutDecoder = new StringDecoder(); + p.stdout.on('data', data => onDataEmitter.fire(stdoutDecoder.write(data))); + p.stdout.on('close', () => { + const end = stdoutDecoder.end(); + if (end) { + onDataEmitter.fire(end); + } + }); + } + if (p.stderr) { + const stderrDecoder = new StringDecoder(); + p.stderr.on('data', data => onDataEmitter.fire(stderrDecoder.write(data))); + p.stderr.on('close', () => { + const end = stderrDecoder.end(); + if (end) { + onDataEmitter.fire(end); + } + }); + } + return { + onData: onDataEmitter.event, + write: p.stdin ? p.stdin.write.bind(p.stdin) : undefined, + resize: () => { }, + exit: p.exit.then(({ code, signal }) => ({ + code: typeof code === 'number' ? code : undefined, + signal: typeof signal === 'string' ? processSignals[signal] : undefined, + })), + terminate: p.terminate.bind(p), + }; + }; +} + +async function findLocalWindowsExecutable(command: string, cwd = process.cwd(), env: Record, output: Log): Promise { + if (process.platform !== 'win32') { + return command; + } + + // From terminalTaskSystem.ts. + + // If we have an absolute path then we take it. + if (path.isAbsolute(command)) { + return await findLocalWindowsExecutableWithExtension(command) || command; + } + if (/[/\\]/.test(command)) { + // We have a directory and the directory is relative (see above). Make the path absolute + // to the current working directory. + const fullPath = path.join(cwd, command); + return await findLocalWindowsExecutableWithExtension(fullPath) || fullPath; + } + let pathValue: string | undefined = undefined; + let paths: string[] | undefined = undefined; + // The options can override the PATH. So consider that PATH if present. + if (env) { + // Path can be named in many different ways and for the execution it doesn't matter + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') { + const value = env[key]; + if (typeof value === 'string') { + pathValue = value; + paths = value.split(path.delimiter) + .filter(Boolean); + paths.push(path.join(env.ProgramW6432 || 'C:\\Program Files', 'Docker\\Docker\\resources\\bin')); // Fall back when newly installed. + } + break; + } + } + } + // No PATH environment. Bail out. + if (paths === void 0 || paths.length === 0) { + output.write(`findLocalWindowsExecutable: No PATH to look up executable '${command}'.`); + const err = new Error(`No PATH to look up executable '${command}'.`); + (err as any).code = 'ENOENT'; + throw err; + } + // We have a simple file name. We get the path variable from the env + // and try to find the executable on the path. + for (const pathEntry of paths) { + // The path entry is absolute. + let fullPath: string; + if (path.isAbsolute(pathEntry)) { + fullPath = path.join(pathEntry, command); + } else { + fullPath = path.join(cwd, pathEntry, command); + } + const withExtension = await findLocalWindowsExecutableWithExtension(fullPath); + if (withExtension) { + return withExtension; + } + } + // Not found in PATH. Bail out. + output.write(`findLocalWindowsExecutable: Exectuable '${command}' not found on PATH '${pathValue}'.`); + const err = new Error(`Exectuable '${command}' not found on PATH '${pathValue}'.`); + (err as any).code = 'ENOENT'; + throw err; +} + +const pathext = process.env.PATHEXT; +const executableExtensions = pathext ? pathext.toLowerCase().split(';') : ['.com', '.exe', '.bat', '.cmd']; + +async function findLocalWindowsExecutableWithExtension(fullPath: string) { + if (executableExtensions.indexOf(path.extname(fullPath)) !== -1) { + return await isLocalFile(fullPath) ? fullPath : undefined; + } + for (const ext of executableExtensions) { + const withExtension = fullPath + ext; + if (await isLocalFile(withExtension)) { + return withExtension; + } + } + return undefined; +} + +export function parseVersion(str: string) { + const m = /^'?v?(\d+(\.\d+)*)/.exec(str); + if (!m) { + return undefined; + } + return m[1].split('.') + .map(i => parseInt(i, 10)); +} + +export function isEarlierVersion(left: number[], right: number[]) { + for (let i = 0, n = Math.max(left.length, right.length); i < n; i++) { + const l = left[i] || 0; + const r = right[i] || 0; + if (l !== r) { + return l < r; + } + } + return false; // Equal. +} + +export async function loadNativeModule(moduleName: string): Promise { + // Create a require function for dynamic module loading + const dynamicRequire = createRequire(__filename); + + // Check NODE_PATH for Electron. Do this first to avoid loading a binary-incompatible version from the local node_modules during development. + if (process.env.NODE_PATH) { + for (const nodePath of process.env.NODE_PATH.split(path.delimiter)) { + if (nodePath) { + try { + return dynamicRequire(`${nodePath}/${moduleName}`); + } catch (err) { + // Not available. + } + } + } + } + try { + return dynamicRequire(moduleName); + } catch (err) { + // Not available. + } + return undefined; +} + +export type PlatformSwitch = T | { posix: T; win32: T }; + +export function platformDispatch(platform: NodeJS.Platform, platformSwitch: PlatformSwitch): T { + if (platformSwitch && typeof platformSwitch === 'object' && 'win32' in platformSwitch) { + return platform === 'win32' ? platformSwitch.win32 : platformSwitch.posix; + } + return platformSwitch as T; +} + +export async function isFile(shellServer: ShellServer, location: string) { + return platformDispatch(shellServer.platform, { + posix: async () => { + try { + await shellServer.exec(`test -f '${location}'`); + return true; + } catch (err) { + return false; + } + }, + win32: async () => { + return (await shellServer.exec(`Test-Path '${location}' -PathType Leaf`)) + .stdout.trim() === 'True'; + } + })(); +} + +let localUsername: Promise; +export async function getLocalUsername() { + if (localUsername === undefined) { + localUsername = (async () => { + try { + return os.userInfo().username; + } catch (err) { + if (process.platform !== 'linux') { + throw err; + } + // os.userInfo() fails with VS Code snap install: https://github.com/microsoft/vscode-remote-release/issues/6913 + const result = await runCommandNoPty({ exec: plainExec(undefined), cmd: 'id', args: ['-u', '-n'], output: nullLog }); + return result.stdout.toString().trim(); + } + })(); + } + return localUsername; +} + +export function getEntPasswdShellCommand(userNameOrId: string) { + const escapedForShell = userNameOrId.replace(/['\\]/g, '\\$&'); + const escapedForRexExp = escapeRegExCharacters(userNameOrId) + .replace(/'/g, '\\\''); + // Leading space makes sure we don't concatenate to arithmetic expansion (https://tldp.org/LDP/abs/html/dblparens.html). + return ` (command -v getent >/dev/null 2>&1 && getent passwd '${escapedForShell}' || grep -E '^${escapedForRexExp}|^[^:]*:[^:]*:${escapedForRexExp}:' /etc/passwd || true)`; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/dotfiles.ts b/extensions/positron-dev-containers/src/spec/spec-common/dotfiles.ts new file mode 100644 index 000000000000..d055763dbe11 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/dotfiles.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { LogLevel } from '../spec-utils/log'; + +import { ResolverParameters, ContainerProperties, createFileCommand } from './injectHeadless'; + +const installCommands = [ + 'install.sh', + 'install', + 'bootstrap.sh', + 'bootstrap', + 'script/bootstrap', + 'setup.sh', + 'setup', + 'script/setup', +]; + +export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise>, secretsP: Promise>) { + let { repository, installCommand, targetPath } = params.dotfilesConfiguration; + if (!repository) { + return; + } + if (repository.indexOf(':') === -1 && !/^\.{0,2}\//.test(repository)) { + repository = `https://github.com/${repository}.git`; + } + const shellServer = properties.shellServer; + const markerFile = getDotfilesMarkerFile(properties); + const dockerEnvAndSecrets = { ...await dockerEnvP, ...await secretsP }; + const allEnv = Object.keys(dockerEnvAndSecrets) + .filter(key => !(key.startsWith('BASH_FUNC_') && key.endsWith('%%'))) + .reduce((env, key) => `${env}${key}=${quoteValue(dockerEnvAndSecrets[key])} `, ''); + try { + params.output.event({ + type: 'progress', + name: 'Installing Dotfiles', + status: 'running', + }); + if (installCommand) { + await shellServer.exec(`# Clone & install dotfiles via '${installCommand}' +${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0 +command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0 +[ -e ${targetPath} ] || ${allEnv}git clone --depth 1 ${repository} ${targetPath} || exit $? +echo Setting current directory to '${targetPath}' +cd ${targetPath} + +if [ -f "./${installCommand}" ] +then + if [ ! -x "./${installCommand}" ] + then + echo Setting './${installCommand}' as executable + chmod +x "./${installCommand}" + fi + echo Executing command './${installCommand}'..\n + ${allEnv}"./${installCommand}" +elif [ -f "${installCommand}" ] +then + if [ ! -x "${installCommand}" ] + then + echo Setting '${installCommand}' as executable + chmod +x "${installCommand}" + fi + echo Executing command '${installCommand}'...\n + ${allEnv}"${installCommand}" +else + echo Could not locate '${installCommand}'...\n + exit 126 +fi +`, { logOutput: 'continuous', logLevel: LogLevel.Info }); + } else { + await shellServer.exec(`# Clone & install dotfiles +${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0 +command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0 +[ -e ${targetPath} ] || ${allEnv}git clone --depth 1 ${repository} ${targetPath} || exit $? +echo Setting current directory to ${targetPath} +cd ${targetPath} +for f in ${installCommands.join(' ')} +do + if [ -e $f ] + then + installCommand=$f + break + fi +done +if [ -z "$installCommand" ] +then + dotfiles=$(ls -d ${targetPath}/.* 2>/dev/null | grep -v -E '/(.|..|.git)$') + if [ ! -z "$dotfiles" ] + then + echo Linking dotfiles: $dotfiles + ln -sf $dotfiles ~ 2>/dev/null + else + echo No dotfiles found. + fi +else + if [ ! -x "$installCommand" ] + then + echo Setting '${targetPath}'/"$installCommand" as executable + chmod +x "$installCommand" + fi + + echo Executing command '${targetPath}'/"$installCommand"...\n + ${allEnv}./"$installCommand" +fi +`, { logOutput: 'continuous', logLevel: LogLevel.Info }); + } + params.output.event({ + type: 'progress', + name: 'Installing Dotfiles', + status: 'succeeded', + }); + } catch (err) { + params.output.event({ + type: 'progress', + name: 'Installing Dotfiles', + status: 'failed', + }); + } +} + +function quoteValue(value: string | undefined) { + return `'${(value || '').replace(/'+/g, '\'"$&"\'')}'`; +} + +function getDotfilesMarkerFile(properties: ContainerProperties) { + return path.posix.join(properties.userDataFolder, '.dotfilesMarker'); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/errors.ts b/extensions/positron-dev-containers/src/spec/spec-common/errors.ts new file mode 100644 index 000000000000..33ea26eae819 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/errors.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ContainerProperties, CommonDevContainerConfig, ResolverParameters } from './injectHeadless'; + +export { toErrorText, toWarningText } from '../spec-utils/log'; + +export interface ContainerErrorAction { + readonly id: string; + readonly title: string; + readonly isCloseAffordance?: boolean; + readonly isLastAction: boolean; + applicable: (err: ContainerError, primary: boolean) => boolean | Promise; + execute: (err: ContainerError) => Promise; +} + +interface ContainerErrorData { + reload?: boolean; + start?: boolean; + attach?: boolean; + fileWithError?: string; + disallowedFeatureId?: string; + didStopContainer?: boolean; + learnMoreUrl?: string; +} + +interface ContainerErrorInfo { + description: string; + originalError?: any; + manageContainer?: boolean; + params?: ResolverParameters; + containerId?: string; + dockerParams?: any; // TODO + containerProperties?: ContainerProperties; + actions?: ContainerErrorAction[]; + data?: ContainerErrorData; +} + +export class ContainerError extends Error implements ContainerErrorInfo { + description!: string; + originalError?: any; + manageContainer = false; + params?: ResolverParameters; + containerId?: string; // TODO + dockerParams?: any; // TODO + volumeName?: string; + repositoryPath?: string; + folderPath?: string; + containerProperties?: ContainerProperties; + config?: CommonDevContainerConfig; + actions: ContainerErrorAction[] = []; + data: ContainerErrorData = {}; + + constructor(info: ContainerErrorInfo) { + super(info.originalError && info.originalError.message || info.description); + Object.assign(this, info); + if (this.originalError?.stack) { + this.stack = this.originalError.stack; + } + } +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/git.ts b/extensions/positron-dev-containers/src/spec/spec-common/git.ts new file mode 100644 index 000000000000..4bbf5073043c --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/git.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { runCommandNoPty, CLIHost } from './commonUtils'; +import { Log } from '../spec-utils/log'; +import { FileHost } from '../spec-utils/pfs'; + +export async function findGitRootFolder(cliHost: FileHost | CLIHost, folderPath: string, output: Log) { + if (!('exec' in cliHost)) { + for (let current = folderPath, previous = ''; current !== previous; previous = current, current = cliHost.path.dirname(current)) { + if (await cliHost.isFile(cliHost.path.join(current, '.git', 'config'))) { + return current; + } + } + return undefined; + } + try { + // Preserves symlinked paths (unlike --show-toplevel). + const { stdout } = await runCommandNoPty({ + exec: cliHost.exec, + cmd: 'git', + args: ['rev-parse', '--show-cdup'], + cwd: folderPath, + output, + }); + const cdup = stdout.toString().trim(); + return cliHost.path.resolve(folderPath, cdup); + } catch { + return undefined; + } +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/injectHeadless.ts b/extensions/positron-dev-containers/src/spec/spec-common/injectHeadless.ts new file mode 100644 index 000000000000..e7770d8f0625 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/injectHeadless.ts @@ -0,0 +1,963 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as fs from 'fs'; +import { StringDecoder } from 'string_decoder'; +import * as crypto from 'crypto'; + +import { ContainerError, toErrorText, toWarningText } from './errors'; +import { launch, ShellServer } from './shellServer'; +import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils'; +import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; +import { PackageConfiguration } from '../spec-utils/product'; +import { URI } from 'vscode-uri'; +import { containerSubstitute } from './variableSubstitution'; +import { delay } from './async'; +import { Log, LogEvent, LogLevel, makeLog, nullLog } from '../spec-utils/log'; +import { buildProcessTrees, findProcesses, Process, processTreeToString } from './proc'; +import { installDotfiles } from './dotfiles'; + +export enum ResolverProgress { + Begin, + CloningRepository, + BuildingImage, + StartingContainer, + InstallingServer, + StartingServer, + End, +} + +export interface ResolverParameters { + prebuild?: boolean; + computeExtensionHostEnv: boolean; + package: PackageConfiguration; + containerDataFolder: string | undefined; + containerSystemDataFolder: string | undefined; + appRoot: string | undefined; + extensionPath: string; + sessionId: string; + sessionStart: Date; + cliHost: CLIHost; + env: NodeJS.ProcessEnv; + cwd: string; + isLocalContainer: boolean; + dotfilesConfiguration: DotfilesConfiguration; + progress: (current: ResolverProgress) => void; + output: Log; + allowSystemConfigChange: boolean; + defaultUserEnvProbe: UserEnvProbe; + lifecycleHook: LifecycleHook; + getLogLevel: () => LogLevel; + onDidChangeLogLevel: Event; + loadNativeModule: (moduleName: string) => Promise; + allowInheritTTY: boolean; + shutdowns: (() => Promise)[]; + backgroundTasks: (Promise | (() => Promise))[]; + persistedFolder: string; // A path where config can be persisted and restored at a later time. Should default to tmpdir() folder if not provided. + remoteEnv: Record; + buildxPlatform: string | undefined; + buildxPush: boolean; + buildxOutput: string | undefined; + buildxCacheTo: string | undefined; + skipFeatureAutoMapping: boolean; + skipPostAttach: boolean; + containerSessionDataFolder?: string; + skipPersistingCustomizationsFromFeatures: boolean; + omitConfigRemotEnvFromMetadata?: boolean; + secretsP?: Promise>; + omitSyntaxDirective?: boolean; +} + +export interface LifecycleHook { + enabled: boolean; + skipNonBlocking: boolean; + output: Log; + onDidInput: Event; + done: () => void; +} + +export type LifecycleHooksInstallMap = { + [lifecycleHook in DevContainerLifecycleHook]: { + command: LifecycleCommand; + origin: string; + }[]; // In installation order. +}; + +export function createNullLifecycleHook(enabled: boolean, skipNonBlocking: boolean, output: Log): LifecycleHook { + function listener(data: Buffer) { + emitter.fire(data.toString()); + } + const emitter = new NodeEventEmitter({ + on: () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.on('data', listener); + }, + off: () => process.stdin.off('data', listener), + }); + return { + enabled, + skipNonBlocking, + output: makeLog({ + ...output, + get dimensions() { + return output.dimensions; + }, + event: e => output.event({ + ...e, + channel: 'postCreate', + }), + }), + onDidInput: emitter.event, + done: () => { }, + }; +} + +export interface PortAttributes { + label: string | undefined; + onAutoForward: string | undefined; + elevateIfNeeded: boolean | undefined; +} + +export type UserEnvProbe = 'none' | 'loginInteractiveShell' | 'interactiveShell' | 'loginShell'; + +export type DevContainerLifecycleHook = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand'; + +const defaultWaitFor: DevContainerLifecycleHook = 'updateContentCommand'; + +export type LifecycleCommand = string | string[] | { [key: string]: string | string[] }; + +export interface CommonDevContainerConfig { + configFilePath?: URI; + remoteEnv?: Record; + forwardPorts?: (number | string)[]; + portsAttributes?: Record; + otherPortsAttributes?: PortAttributes; + features?: Record>; + onCreateCommand?: LifecycleCommand | Record; + updateContentCommand?: LifecycleCommand | Record; + postCreateCommand?: LifecycleCommand | Record; + postStartCommand?: LifecycleCommand | Record; + postAttachCommand?: LifecycleCommand | Record; + waitFor?: DevContainerLifecycleHook; + userEnvProbe?: UserEnvProbe; +} + +export interface CommonContainerMetadata { + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; + waitFor?: DevContainerLifecycleHook; + remoteEnv?: Record; + userEnvProbe?: UserEnvProbe; +} + +export type CommonMergedDevContainerConfig = MergedConfig; + +type MergedConfig = Omit & UpdatedConfigProperties; + +const replaceProperties = [ + 'onCreateCommand', + 'updateContentCommand', + 'postCreateCommand', + 'postStartCommand', + 'postAttachCommand', +] as const; + +interface UpdatedConfigProperties { + onCreateCommands?: LifecycleCommand[]; + updateContentCommands?: LifecycleCommand[]; + postCreateCommands?: LifecycleCommand[]; + postStartCommands?: LifecycleCommand[]; + postAttachCommands?: LifecycleCommand[]; +} + +export interface OSRelease { + hardware: string; + id: string; + version: string; +} + +export interface ContainerProperties { + createdAt: string | undefined; + startedAt: string | undefined; + osRelease: OSRelease; + user: string; + gid: string | undefined; + env: NodeJS.ProcessEnv; + shell: string; + homeFolder: string; + userDataFolder: string; + remoteWorkspaceFolder?: string; + remoteExec: ExecFunction; + remotePtyExec: PtyExecFunction; + remoteExecAsRoot?: ExecFunction; + shellServer: ShellServer; + launchRootShellServer?: () => Promise; +} + +export interface DotfilesConfiguration { + repository: string | undefined; + installCommand: string | undefined; + targetPath: string; +} + +export async function getContainerProperties(options: { + params: ResolverParameters; + createdAt: string | undefined; + startedAt: string | undefined; + remoteWorkspaceFolder: string | undefined; + containerUser: string | undefined; + containerGroup: string | undefined; + containerEnv: NodeJS.ProcessEnv | undefined; + remoteExec: ExecFunction; + remotePtyExec: PtyExecFunction; + remoteExecAsRoot: ExecFunction | undefined; + rootShellServer: ShellServer | undefined; +}) { + let { params, createdAt, startedAt, remoteWorkspaceFolder, containerUser, containerGroup, containerEnv, remoteExec, remotePtyExec, remoteExecAsRoot, rootShellServer } = options; + let shellServer: ShellServer; + if (rootShellServer && containerUser === 'root') { + shellServer = rootShellServer; + } else { + shellServer = await launch(remoteExec, params.output, params.sessionId); + } + if (!containerEnv) { + const PATH = (await shellServer.exec('echo $PATH')).stdout.trim(); + containerEnv = PATH ? { PATH } : {}; + } + if (!containerUser) { + containerUser = await getUser(shellServer); + } + if (!remoteExecAsRoot && containerUser === 'root') { + remoteExecAsRoot = remoteExec; + } + const osRelease = await getOSRelease(shellServer); + const passwdUser = await getUserFromPasswdDB(shellServer, containerUser); + if (!passwdUser) { + params.output.write(toWarningText(`User ${containerUser} not found with 'getent passwd'.`)); + } + const shell = await getUserShell(containerEnv, passwdUser); + const homeFolder = await getHomeFolder(shellServer, containerEnv, passwdUser); + const userDataFolder = getUserDataFolder(homeFolder, params); + let rootShellServerP: Promise | undefined; + if (rootShellServer) { + rootShellServerP = Promise.resolve(rootShellServer); + } else if (containerUser === 'root') { + rootShellServerP = Promise.resolve(shellServer); + } + const containerProperties: ContainerProperties = { + createdAt, + startedAt, + osRelease, + user: containerUser, + gid: containerGroup || passwdUser?.gid, + env: containerEnv, + shell, + homeFolder, + userDataFolder, + remoteWorkspaceFolder, + remoteExec, + remotePtyExec, + remoteExecAsRoot, + shellServer, + }; + if (rootShellServerP || remoteExecAsRoot) { + containerProperties.launchRootShellServer = () => rootShellServerP || (rootShellServerP = launch(remoteExecAsRoot!, params.output)); + } + return containerProperties; +} + +export async function getUser(shellServer: ShellServer) { + return (await shellServer.exec('id -un')).stdout.trim(); +} + +export async function getHomeFolder(shellServer: ShellServer, containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdUser | undefined) { + if (containerEnv.HOME) { + if (containerEnv.HOME === passwdUser?.home || passwdUser?.uid === '0') { + return containerEnv.HOME; + } + try { + await shellServer.exec(`[ ! -e '${containerEnv.HOME}' ] || [ -w '${containerEnv.HOME}' ]`); + return containerEnv.HOME; + } catch { + // Exists but not writable. + } + } + return passwdUser?.home || '/root'; +} + +async function getUserShell(containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdUser | undefined) { + return containerEnv.SHELL || (passwdUser && passwdUser.shell) || '/bin/sh'; +} + +export async function getUserFromPasswdDB(shellServer: ShellServer, userNameOrId: string) { + const { stdout } = await shellServer.exec(getEntPasswdShellCommand(userNameOrId), { logOutput: false }); + if (!stdout.trim()) { + return undefined; + } + return parseUserInPasswdDB(stdout); +} + +export interface PasswdUser { + name: string; + uid: string; + gid: string; + home: string; + shell: string; +} + +function parseUserInPasswdDB(etcPasswdLine: string): PasswdUser | undefined { + const row = etcPasswdLine + .replace(/\n$/, '') + .split(':'); + return { + name: row[0], + uid: row[2], + gid: row[3], + home: row[5], + shell: row[6] + }; +} + +export function getUserDataFolder(homeFolder: string, params: ResolverParameters) { + return path.posix.resolve(homeFolder, params.containerDataFolder || '.devcontainer'); +} + +export function getSystemVarFolder(params: ResolverParameters): string { + return params.containerSystemDataFolder || '/var/devcontainer'; +} + +export async function setupInContainer(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonDevContainerConfig, mergedConfig: CommonMergedDevContainerConfig, lifecycleCommandOriginMap: LifecycleHooksInstallMap) { + await patchEtcEnvironment(params, containerProperties); + await patchEtcProfile(params, containerProperties); + const computeRemoteEnv = params.computeExtensionHostEnv || params.lifecycleHook.enabled; + const updatedConfig = containerSubstitute(params.cliHost.platform, config.configFilePath, containerProperties.env, config); + const updatedMergedConfig = containerSubstitute(params.cliHost.platform, mergedConfig.configFilePath, containerProperties.env, mergedConfig); + const remoteEnv = computeRemoteEnv ? probeRemoteEnv(params, containerProperties, updatedMergedConfig) : Promise.resolve({}); + const secretsP = params.secretsP || Promise.resolve({}); + if (params.lifecycleHook.enabled) { + await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedMergedConfig, remoteEnv, secretsP, false); + } + return { + remoteEnv: params.computeExtensionHostEnv ? await remoteEnv : {}, + updatedConfig, + updatedMergedConfig, + }; +} + +export function probeRemoteEnv(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig) { + return probeUserEnv(params, containerProperties, config) + .then>(shellEnv => ({ + ...shellEnv, + ...params.remoteEnv, + ...config.remoteEnv, + } as Record)); +} + +export async function runLifecycleHooks(params: ResolverParameters, lifecycleHooksInstallMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>, secrets: Promise>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> { + const skipNonBlocking = params.lifecycleHook.skipNonBlocking; + const waitFor = config.waitFor || defaultWaitFor; + if (skipNonBlocking && waitFor === 'initializeCommand') { + return 'skipNonBlocking'; + } + + params.output.write('LifecycleCommandExecutionMap: ' + JSON.stringify(lifecycleHooksInstallMap, undefined, 4), LogLevel.Trace); + + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'onCreateCommand', remoteEnv, secrets, false); + if (skipNonBlocking && waitFor === 'onCreateCommand') { + return 'skipNonBlocking'; + } + + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'updateContentCommand', remoteEnv, secrets, !!params.prebuild); + if (skipNonBlocking && waitFor === 'updateContentCommand') { + return 'skipNonBlocking'; + } + + if (params.prebuild) { + return 'prebuild'; + } + + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'postCreateCommand', remoteEnv, secrets, false); + if (skipNonBlocking && waitFor === 'postCreateCommand') { + return 'skipNonBlocking'; + } + + if (params.dotfilesConfiguration) { + await installDotfiles(params, containerProperties, remoteEnv, secrets); + } + + if (stopForPersonalization) { + return 'stopForPersonalization'; + } + + await runPostStartCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv, secrets); + if (skipNonBlocking && waitFor === 'postStartCommand') { + return 'skipNonBlocking'; + } + + if (!params.skipPostAttach) { + await runPostAttachCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv, secrets); + } + return 'done'; +} + +export async function getOSRelease(shellServer: ShellServer) { + let hardware = 'unknown'; + let id = 'unknown'; + let version = 'unknown'; + try { + hardware = (await shellServer.exec('uname -m')).stdout.trim(); + const { stdout } = await shellServer.exec('(cat /etc/os-release || cat /usr/lib/os-release) 2>/dev/null'); + id = (stdout.match(/^ID=([^\u001b\r\n]*)/m) || [])[1] || 'notfound'; + version = (stdout.match(/^VERSION_ID=([^\u001b\r\n]*)/m) || [])[1] || 'notfound'; + } catch (err) { + console.error(err); + // Optimistically continue. + } + return { hardware, id, version }; +} + +async function runPostCreateCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise>, secrets: Promise>, rerun: boolean) { + const markerFile = path.posix.join(containerProperties.userDataFolder, `.${postCommandName}Marker`); + const doRun = !!containerProperties.createdAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.createdAt) || rerun; + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, postCommandName, remoteEnv, secrets, doRun); +} + +async function runPostStartCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>, secrets: Promise>) { + const markerFile = path.posix.join(containerProperties.userDataFolder, '.postStartCommandMarker'); + const doRun = !!containerProperties.startedAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.startedAt); + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postStartCommand', remoteEnv, secrets, doRun); +} + +async function updateMarkerFile(shellServer: ShellServer, location: string, content: string) { + try { + await shellServer.exec(`mkdir -p '${path.posix.dirname(location)}' && CONTENT="$(cat '${location}' 2>/dev/null || echo ENOENT)" && [ "\${CONTENT:-${content}}" != '${content}' ] && echo '${content}' > '${location}'`); + return true; + } catch (err) { + return false; + } +} + +async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>, secrets: Promise>) { + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postAttachCommand', remoteEnv, secrets, true); +} + + +async function runLifecycleCommands(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, secrets: Promise>, doRun: boolean) { + const commandsForHook = lifecycleCommandOriginMap[lifecycleHookName]; + if (commandsForHook.length === 0) { + return; + } + + for (const { command, origin } of commandsForHook) { + const displayOrigin = origin ? (origin === 'devcontainer.json' ? origin : `Feature '${origin}'`) : '???'; /// '???' should never happen. + await runLifecycleCommand(params, containerProperties, command, displayOrigin, lifecycleHookName, remoteEnv, secrets, doRun); + } +} + +async function runLifecycleCommand({ lifecycleHook }: ResolverParameters, containerProperties: ContainerProperties, userCommand: LifecycleCommand, userCommandOrigin: string, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, secrets: Promise>, doRun: boolean) { + let hasCommand = false; + if (typeof userCommand === 'string') { + hasCommand = userCommand.trim().length > 0; + } else if (Array.isArray(userCommand)) { + hasCommand = userCommand.length > 0; + } else if (typeof userCommand === 'object') { + hasCommand = Object.keys(userCommand).length > 0; + } + if (doRun && userCommand && hasCommand) { + const progressName = `Running ${lifecycleHookName}...`; + const infoOutput = makeLog({ + event(e: LogEvent) { + lifecycleHook.output.event(e); + if (e.type === 'raw' && e.text.includes('::endstep::')) { + lifecycleHook.output.event({ + type: 'progress', + name: progressName, + status: 'running', + stepDetail: '' + }); + } + if (e.type === 'raw' && e.text.includes('::step::')) { + lifecycleHook.output.event({ + type: 'progress', + name: progressName, + status: 'running', + stepDetail: `${e.text.split('::step::')[1].split('\r\n')[0]}` + }); + } + }, + get dimensions() { + return lifecycleHook.output.dimensions; + }, + onDidChangeDimensions: lifecycleHook.output.onDidChangeDimensions, + }, LogLevel.Info); + const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder; + async function runSingleCommand(postCommand: string | string[], name?: string) { + const progressDetails = typeof postCommand === 'string' ? postCommand : postCommand.join(' '); + infoOutput.event({ + type: 'progress', + name: progressName, + status: 'running', + stepDetail: progressDetails + }); + // If we have a command name then the command is running in parallel and + // we need to hold output until the command is done so that the output + // doesn't get interleaved with the output of other commands. + const printMode = name ? 'off' : 'continuous'; + const env = { ...(await remoteEnv), ...(await secrets) }; + try { + const { cmdOutput } = await runRemoteCommand({ ...lifecycleHook, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: env, pty: true, print: printMode }); + + // 'name' is set when parallel execution syntax is used. + if (name) { + infoOutput.raw(`\x1b[1mRunning ${name} of ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`); + } + } catch (err) { + if (printMode === 'off' && err?.cmdOutput) { + infoOutput.raw(`\r\n\x1b[1m${err.cmdOutput}\x1b[0m\r\n\r\n`); + } + if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2. + infoOutput.raw(`\r\n\x1b[1m${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} interrupted.\x1b[0m\r\n\r\n`); + } else { + if (err?.code) { + infoOutput.write(toErrorText(`${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed with exit code ${err.code}. Skipping any further user-provided commands.`)); + } + throw new ContainerError({ + description: `${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed.`, + originalError: err + }); + } + } + } + + infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`); + + try { + let commands; + if (typeof userCommand === 'string' || Array.isArray(userCommand)) { + commands = [runSingleCommand(userCommand)]; + } else { + commands = Object.keys(userCommand).map(name => { + const command = userCommand[name]; + return runSingleCommand(command, name); + }); + } + + const results = await Promise.allSettled(commands); // Wait for all commands to finish (successfully or not) before continuing. + const rejection = results.find(p => p.status === 'rejected'); + if (rejection) { + throw (rejection as PromiseRejectedResult).reason; + } + infoOutput.event({ + type: 'progress', + name: progressName, + status: 'succeeded', + }); + } catch (err) { + infoOutput.event({ + type: 'progress', + name: progressName, + status: 'failed', + }); + throw err; + } + } +} + +async function createFile(shellServer: ShellServer, location: string) { + try { + await shellServer.exec(createFileCommand(location)); + return true; + } catch (err) { + return false; + } +} + +export function createFileCommand(location: string) { + return `test ! -f '${location}' && set -o noclobber && mkdir -p '${path.posix.dirname(location)}' && { > '${location}' ; } 2> /dev/null`; +} + +export async function runRemoteCommand(params: { output: Log; onDidInput?: Event; stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream }, { remoteExec, remotePtyExec }: ContainerProperties, cmd: string[], cwd?: string, options: { remoteEnv?: NodeJS.ProcessEnv; pty?: boolean; print?: 'off' | 'continuous' | 'end' } = {}) { + const print = options.print || 'end'; + let sub: Disposable | undefined; + let pp: Exec | PtyExec; + let cmdOutput = ''; + if (options.pty) { + const p = pp = await remotePtyExec({ + env: options.remoteEnv, + cwd, + cmd: cmd[0], + args: cmd.slice(1), + output: params.output, + }); + p.onData(chunk => { + cmdOutput += chunk; + if (print === 'continuous') { + if (params.stdout) { + params.stdout.write(chunk); + } else { + params.output.raw(chunk); + } + } + }); + if (p.write && params.onDidInput) { + params.onDidInput(data => p.write!(data)); + } else if (p.write && params.stdin) { + const listener = (data: Buffer): void => p.write!(data.toString()); + const stdin = params.stdin; + if (stdin.isTTY) { + stdin.setRawMode(true); + } + stdin.on('data', listener); + sub = { dispose: () => stdin.off('data', listener) }; + } + } else { + const p = pp = await remoteExec({ + env: options.remoteEnv, + cwd, + cmd: cmd[0], + args: cmd.slice(1), + output: params.output, + }); + const stdout: Buffer[] = []; + if (print === 'continuous' && params.stdout) { + p.stdout.pipe(params.stdout); + } else { + p.stdout.on('data', chunk => { + stdout.push(chunk); + if (print === 'continuous') { + params.output.raw(chunk.toString()); + } + }); + } + const stderr: Buffer[] = []; + if (print === 'continuous' && params.stderr) { + p.stderr.pipe(params.stderr); + } else { + p.stderr.on('data', chunk => { + stderr.push(chunk); + if (print === 'continuous') { + params.output.raw(chunk.toString()); + } + }); + } + if (params.onDidInput) { + params.onDidInput(data => p.stdin.write(data)); + } else if (params.stdin) { + params.stdin.pipe(p.stdin); + } + await pp.exit; + cmdOutput = `${Buffer.concat(stdout)}\n${Buffer.concat(stderr)}`; + } + const exit = await pp.exit; + if (sub) { + sub.dispose(); + } + if (print === 'end') { + params.output.raw(cmdOutput); + } + if (exit.code || exit.signal) { + return Promise.reject({ + message: `Command failed: ${cmd.join(' ')}`, + cmdOutput, + code: exit.code, + signal: exit.signal, + }); + } + return { + cmdOutput, + }; +} + +async function runRemoteCommandNoPty(params: { output: Log }, { remoteExec }: { remoteExec: ExecFunction }, cmd: string[], cwd?: string, options: { remoteEnv?: NodeJS.ProcessEnv; stdin?: Buffer | fs.ReadStream; silent?: boolean; print?: 'off' | 'continuous' | 'end'; resolveOn?: RegExp } = {}) { + const print = options.print || (options.silent ? 'off' : 'end'); + const p = await remoteExec({ + env: options.remoteEnv, + cwd, + cmd: cmd[0], + args: cmd.slice(1), + output: options.silent ? nullLog : params.output, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const stdoutDecoder = new StringDecoder(); + const stderrDecoder = new StringDecoder(); + let stdoutStr = ''; + let stderrStr = ''; + let doResolveEarly: () => void; + let doRejectEarly: (err: any) => void; + const resolveEarly = new Promise((resolve, reject) => { + doResolveEarly = resolve; + doRejectEarly = reject; + }); + p.stdout.on('data', (chunk: Buffer) => { + stdout.push(chunk); + const str = stdoutDecoder.write(chunk); + if (print === 'continuous') { + params.output.write(str.replace(/\r?\n/g, '\r\n')); + } + stdoutStr += str; + if (options.resolveOn && options.resolveOn.exec(stdoutStr)) { + doResolveEarly(); + } + }); + p.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk); + stderrStr += stderrDecoder.write(chunk); + }); + if (options.stdin instanceof Buffer) { + p.stdin.write(options.stdin, err => { + if (err) { + doRejectEarly(err); + } + }); + p.stdin.end(); + } else if (options.stdin instanceof fs.ReadStream) { + options.stdin.pipe(p.stdin); + } + const exit = await Promise.race([p.exit, resolveEarly]); + const stdoutBuf = Buffer.concat(stdout); + const stderrBuf = Buffer.concat(stderr); + if (print === 'end') { + params.output.write(stdoutStr.replace(/\r?\n/g, '\r\n')); + params.output.write(toErrorText(stderrStr)); + } + const cmdOutput = `${stdoutStr}\n${stderrStr}`; + if (exit && (exit.code || exit.signal)) { + return Promise.reject({ + message: `Command failed: ${cmd.join(' ')}`, + cmdOutput, + stdout: stdoutBuf, + stderr: stderrBuf, + code: exit.code, + signal: exit.signal, + }); + } + return { + cmdOutput, + stdout: stdoutBuf, + stderr: stderrBuf, + }; +} + +async function patchEtcEnvironment(params: ResolverParameters, containerProperties: ContainerProperties) { + const markerFile = path.posix.join(getSystemVarFolder(params), `.patchEtcEnvironmentMarker`); + if (params.allowSystemConfigChange && containerProperties.launchRootShellServer && !(await isFile(containerProperties.shellServer, markerFile))) { + const rootShellServer = await containerProperties.launchRootShellServer(); + if (await createFile(rootShellServer, markerFile)) { + await rootShellServer.exec(`cat >> /etc/environment <<'etcEnvironmentEOF' +${Object.keys(containerProperties.env).map(k => `\n${k}="${containerProperties.env[k]}"`).join('')} +etcEnvironmentEOF +`); + } + } +} + +async function patchEtcProfile(params: ResolverParameters, containerProperties: ContainerProperties) { + const markerFile = path.posix.join(getSystemVarFolder(params), `.patchEtcProfileMarker`); + if (params.allowSystemConfigChange && containerProperties.launchRootShellServer && !(await isFile(containerProperties.shellServer, markerFile))) { + const rootShellServer = await containerProperties.launchRootShellServer(); + if (await createFile(rootShellServer, markerFile)) { + await rootShellServer.exec(`sed -i -E 's/((^|\\s)PATH=)([^\\$]*)$/\\1\${PATH:-\\3}/g' /etc/profile || true`); + } + } +} + +async function probeUserEnv(params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log; containerSessionDataFolder?: string }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise); user?: string }, config?: CommonMergedDevContainerConfig) { + let userEnvProbe = getUserEnvProb(config, params); + if (!userEnvProbe || userEnvProbe === 'none') { + return {}; + } + + let env = await readUserEnvFromCache(userEnvProbe, params, containerProperties.shellServer); + if (env) { + return env; + } + + params.output.write('userEnvProbe: not found in cache'); + env = await runUserEnvProbe(userEnvProbe, params, containerProperties, 'cat /proc/self/environ', '\0'); + if (!env) { + params.output.write('userEnvProbe: falling back to printenv'); + env = await runUserEnvProbe(userEnvProbe, params, containerProperties, 'printenv', '\n'); + } + + if (env) { + await updateUserEnvCache(env, userEnvProbe, params, containerProperties.shellServer); + } + + return env || {}; +} + +async function readUserEnvFromCache(userEnvProbe: UserEnvProbe, params: { output: Log; containerSessionDataFolder?: string }, shellServer?: ShellServer) { + if (!shellServer || !params.containerSessionDataFolder) { + return undefined; + } + + const cacheFile = getUserEnvCacheFilePath(userEnvProbe, params.containerSessionDataFolder); + try { + if (await isFile(shellServer, cacheFile)) { + const { stdout } = await shellServer.exec(`cat '${cacheFile}'`); + return JSON.parse(stdout); + } + } + catch (e) { + params.output.write(`Failed to read/parse user env cache: ${e}`, LogLevel.Error); + } + + return undefined; +} + +async function updateUserEnvCache(env: Record, userEnvProbe: UserEnvProbe, params: { output: Log; containerSessionDataFolder?: string }, shellServer?: ShellServer) { + if (!shellServer || !params.containerSessionDataFolder) { + return; + } + + const cacheFile = getUserEnvCacheFilePath(userEnvProbe, params.containerSessionDataFolder); + try { + await shellServer.exec(`mkdir -p '${path.posix.dirname(cacheFile)}' && cat > '${cacheFile}' << 'envJSON' +${JSON.stringify(env, null, '\t')} +envJSON +`); + } + catch (e) { + params.output.write(`Failed to cache user env: ${e}`, LogLevel.Error); + } +} + +function getUserEnvCacheFilePath(userEnvProbe: UserEnvProbe, cacheFolder: string): string { + return path.posix.join(cacheFolder, `env-${userEnvProbe}.json`); +} + +async function runUserEnvProbe(userEnvProbe: UserEnvProbe, params: { allowSystemConfigChange: boolean; output: Log }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise); user?: string }, cmd: string, sep: string) { + if (userEnvProbe === 'none') { + return {}; + } + try { + // From VS Code's shellEnv.ts + + const mark = crypto.randomUUID(); + const regex = new RegExp(mark + '([^]*)' + mark); + const systemShellUnix = containerProperties.shell; + params.output.write(`userEnvProbe shell: ${systemShellUnix}`); + + // handle popular non-POSIX shells + const name = path.posix.basename(systemShellUnix); + const command = `echo -n ${mark}; ${cmd}; echo -n ${mark}`; + let shellArgs: string[]; + if (/^pwsh(-preview)?$/.test(name)) { + shellArgs = userEnvProbe === 'loginInteractiveShell' || userEnvProbe === 'loginShell' ? + ['-Login', '-Command'] : // -Login must be the first option. + ['-Command']; + } else { + shellArgs = [ + userEnvProbe === 'loginInteractiveShell' ? '-lic' : + userEnvProbe === 'loginShell' ? '-lc' : + userEnvProbe === 'interactiveShell' ? '-ic' : + '-c' + ]; + } + + const traceOutput = makeLog(params.output, LogLevel.Trace); + const resultP = runRemoteCommandNoPty({ output: traceOutput }, { remoteExec: containerProperties.remoteExec }, [systemShellUnix, ...shellArgs, command], containerProperties.installFolder); + Promise.race([resultP, delay(2000)]) + .then(async result => { + if (!result) { + let processes: Process[]; + const shellServer = containerProperties.shellServer || await launch(containerProperties.remoteExec, params.output); + try { + ({ processes } = await findProcesses(shellServer)); + } finally { + if (!containerProperties.shellServer) { + await shellServer.process.terminate(); + } + } + const shell = processes.find(p => p.cmd.startsWith(systemShellUnix) && p.cmd.indexOf(mark) !== -1); + if (shell) { + const index = buildProcessTrees(processes); + const tree = index[shell.pid]; + params.output.write(`userEnvProbe is taking longer than 2 seconds. Process tree: +${processTreeToString(tree)}`); + } else { + params.output.write(`userEnvProbe is taking longer than 2 seconds. Process not found.`); + } + } + }, () => undefined) + .catch(err => params.output.write(toErrorText(err && (err.stack || err.message) || 'Error reading process tree.'))); + const result = await Promise.race([resultP, delay(10000)]); + if (!result) { + params.output.write(toErrorText(`userEnvProbe is taking longer than 10 seconds. Avoid waiting for user input in your shell's startup scripts. Continuing.`)); + return {}; + } + const raw = result.stdout.toString(); + const match = regex.exec(raw); + const rawStripped = match ? match[1] : ''; + if (!rawStripped) { + return undefined; // assume error + } + const env = rawStripped.split(sep) + .reduce((env, e) => { + const i = e.indexOf('='); + if (i !== -1) { + env[e.substring(0, i)] = e.substring(i + 1); + } + return env; + }, {} as Record); + params.output.write(`userEnvProbe parsed: ${JSON.stringify(env, undefined, ' ')}`, LogLevel.Trace); + delete env.PWD; + + const shellPath = env.PATH; + const containerPath = containerProperties.env?.PATH; + const doMergePaths = !(params.allowSystemConfigChange && containerProperties.launchRootShellServer) && shellPath && containerPath; + if (doMergePaths) { + const user = containerProperties.user; + env.PATH = mergePaths(shellPath, containerPath!, user === 'root' || user === '0'); + } + params.output.write(`userEnvProbe PATHs: +Probe: ${typeof shellPath === 'string' ? `'${shellPath}'` : 'None'} +Container: ${typeof containerPath === 'string' ? `'${containerPath}'` : 'None'}${doMergePaths ? ` +Merged: ${typeof env.PATH === 'string' ? `'${env.PATH}'` : 'None'}` : ''}`); + + return env; + } catch (err) { + params.output.write(toErrorText(err && (err.stack || err.message) || 'Error reading shell environment.')); + return {}; + } +} + +function getUserEnvProb(config: CommonMergedDevContainerConfig | undefined, params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log }) { + let userEnvProbe = config?.userEnvProbe; + params.output.write(`userEnvProbe: ${userEnvProbe || params.defaultUserEnvProbe}${userEnvProbe ? '' : ' (default)'}`); + if (!userEnvProbe) { + userEnvProbe = params.defaultUserEnvProbe; + } + return userEnvProbe; +} + +function mergePaths(shellPath: string, containerPath: string, rootUser: boolean) { + const result = shellPath.split(':'); + let insertAt = 0; + for (const entry of containerPath.split(':')) { + const i = result.indexOf(entry); + if (i === -1) { + if (rootUser || !/\/sbin(\/|$)/.test(entry)) { + result.splice(insertAt++, 0, entry); + } + } else { + insertAt = i + 1; + } + } + return result.join(':'); +} + +export async function finishBackgroundTasks(tasks: (Promise | (() => Promise))[]) { + for (const task of tasks) { + await (typeof task === 'function' ? task() : task); + } +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/proc.ts b/extensions/positron-dev-containers/src/spec/spec-common/proc.ts new file mode 100644 index 000000000000..b6a14d52b826 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/proc.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ShellServer } from './shellServer'; + +export interface Process { + pid: string; + ppid: string | undefined; + pgrp: string | undefined; + cwd: string; + mntNS: string; + cmd: string; + env: Record; +} + +export async function findProcesses(shellServer: ShellServer) { + const ps = 'for pid in `cd /proc && ls -d [0-9]*`; do { echo $pid ; readlink /proc/$pid/cwd ; readlink /proc/$pid/ns/mnt ; cat /proc/$pid/stat | tr "\n" " " ; echo ; xargs -0 < /proc/$pid/environ ; xargs -0 < /proc/$pid/cmdline ; } ; echo --- ; done ; readlink /proc/self/ns/mnt 2>/dev/null'; + const { stdout } = await shellServer.exec(ps, { logOutput: false }); + + const n = 6; + const sections = stdout.split('\n---\n'); + const mntNS = sections.pop()!.trim(); + const processes: Process[] = sections + .map(line => line.split('\n')) + .filter(parts => parts.length >= n) + .map(([pid, cwd, mntNS, stat, env, cmd]) => { + const statM: (string | undefined)[] = /.*\) [^ ]* ([^ ]*) ([^ ]*)/.exec(stat) || []; + return { + pid, + ppid: statM[1], + pgrp: statM[2], + cwd, + mntNS, + cmd, + env: env.split(' ') + .reduce((env, current) => { + const i = current.indexOf('='); + if (i !== -1) { + env[current.substr(0, i)] = current.substr(i + 1); + } + return env; + }, {} as Record), + }; + }); + return { + processes, + mntNS, + }; +} + +export interface ProcessTree { + process: Process; + childProcesses: ProcessTree[]; +} + +export function buildProcessTrees(processes: Process[]) { + const index: Record = {}; + processes.forEach(process => index[process.pid] = { process, childProcesses: [] }); + processes.filter(p => p.ppid) + .forEach(p => index[p.ppid!]?.childProcesses.push(index[p.pid])); + return index; +} + +export function processTreeToString(tree: ProcessTree, singleIndent = ' ', currentIndent = ' '): string { + return `${currentIndent}${tree.process.pid}: ${tree.process.cmd} +${tree.childProcesses.map(p => processTreeToString(p, singleIndent, currentIndent + singleIndent))}`; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/shellServer.ts b/extensions/positron-dev-containers/src/spec/spec-common/shellServer.ts new file mode 100644 index 000000000000..8cc1f5aa018d --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/shellServer.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +import { StringDecoder } from 'string_decoder'; +import { ExecFunction, Exec, PlatformSwitch, platformDispatch } from './commonUtils'; +import { Log, LogLevel } from '../spec-utils/log'; + +export interface ShellServer { + exec(cmd: PlatformSwitch, options?: { logLevel?: LogLevel; logOutput?: boolean | 'continuous' | 'silent'; stdin?: Buffer }): Promise<{ stdout: string; stderr: string }>; + process: Exec; + platform: NodeJS.Platform; + path: typeof path.posix | typeof path.win32; +} + +export const EOT = '\u2404'; + +export async function launch(remoteExec: ExecFunction | Exec, output: Log, agentSessionId?: string, platform: NodeJS.Platform = 'linux', hostName: 'Host' | 'Container' = 'Container'): Promise { + const isExecFunction = typeof remoteExec === 'function'; + const isWindows = platform === 'win32'; + const p = isExecFunction ? await remoteExec({ + env: agentSessionId ? { VSCODE_REMOTE_CONTAINERS_SESSION: agentSessionId } : {}, + cmd: isWindows ? 'powershell' : '/bin/sh', + args: isWindows ? ['-NoProfile', '-Command', '-'] : [], + output, + }) : remoteExec; + if (!isExecFunction) { + // TODO: Pass in agentSessionId. + const stdinText = isWindows + ? `powershell -NoProfile -Command "powershell -NoProfile -Command -"\n` // Nested PowerShell (for some reason) avoids the echo of stdin on stdout. + : `/bin/sh -c 'echo ${EOT}; /bin/sh'\n`; + p.stdin.write(stdinText); + const eot = new Promise(resolve => { + let stdout = ''; + const stdoutDecoder = new StringDecoder(); + p.stdout.on('data', function eotListener(chunk: Buffer) { + stdout += stdoutDecoder.write(chunk); + if (stdout.includes(stdinText)) { + p.stdout.off('data', eotListener); + resolve(); + } + }); + }); + await eot; + } + + const monitor = monitorProcess(p); + + let lastExec: Promise | undefined; + async function exec(cmd: PlatformSwitch, options?: { logLevel?: LogLevel; logOutput?: boolean | 'continuous' | 'silent'; stdin?: Buffer }) { + const currentExec = lastExec = (async () => { + try { + await lastExec; + } catch (err) { + // ignore + } + return _exec(platformDispatch(platform, cmd), options); + })(); + try { + return await Promise.race([currentExec, monitor.unexpectedExit]); + } finally { + monitor.disposeStdioListeners(); + if (lastExec === currentExec) { + lastExec = undefined; + } + } + } + + async function _exec(cmd: string, options?: { logLevel?: LogLevel; logOutput?: boolean | 'continuous' | 'silent'; stdin?: Buffer }) { + const text = `Run in ${hostName.toLowerCase()}: ${cmd.replace(/\n.*/g, '')}`; + let start: number; + if (options?.logOutput !== 'silent') { + start = output.start(text, options?.logLevel); + } + if (p.stdin.destroyed) { + output.write('Stdin closed!'); + const { code, signal } = await p.exit; + return Promise.reject({ message: `Shell server terminated (code: ${code}, signal: ${signal})`, code, signal }); + } + if (platform === 'win32') { + p.stdin.write(`[Console]::Write('${EOT}'); ( ${cmd} ); [Console]::Write("${EOT}$LastExitCode ${EOT}"); [Console]::Error.Write('${EOT}')\n`); + } else { + p.stdin.write(`echo -n ${EOT}; ( ${cmd} ); echo -n ${EOT}$?${EOT}; echo -n ${EOT} >&2\n`); + } + const [stdoutP0, stdoutP] = read(p.stdout, [1, 2], options?.logOutput === 'continuous' ? (str, i, j) => { + if (i === 1 && j === 0) { + output.write(str, options?.logLevel); + } + } : () => undefined); + const stderrP = read(p.stderr, [1], options?.logOutput === 'continuous' ? (str, i, j) => { + if (i === 0 && j === 0) { + output.write(str, options?.logLevel); // TODO + } + } : () => undefined)[0]; + if (options?.stdin) { + await stdoutP0; // Wait so `cmd` has its stdin set up. + p.stdin.write(options?.stdin); + } + const [stdout, codeStr] = await stdoutP; + const [stderr] = await stderrP; + const code = parseInt(codeStr, 10) || 0; + if (options?.logOutput === undefined || options?.logOutput === true) { + output.write(stdout, options?.logLevel); + output.write(stderr, options?.logLevel); // TODO + if (code) { + output.write(`Exit code ${code}`, options?.logLevel); + } + } + if (options?.logOutput === 'continuous' && code) { + output.write(`Exit code ${code}`, options?.logLevel); + } + if (options?.logOutput !== 'silent') { + output.stop(text, start!, options?.logLevel); + } + if (code) { + return Promise.reject({ message: `Command in ${hostName.toLowerCase()} failed: ${cmd}`, code, stdout, stderr }); + } + return { stdout, stderr }; + } + + return { exec, process: p, platform, path: platformDispatch(platform, path) }; +} + +function read(stream: NodeJS.ReadableStream, numberOfResults: number[], log: (str: string, i: number, j: number) => void) { + const promises = numberOfResults.map(() => { + let cbs: { resolve: (value: string[]) => void; reject: () => void }; + const promise = new Promise((resolve, reject) => cbs = { resolve, reject }); + return { promise, ...cbs! }; + }); + const decoder = new StringDecoder('utf8'); + const strings: string[] = []; + + let j = 0; + let results: string[] = []; + function data(chunk: Buffer) { + const str = decoder.write(chunk); + consume(str); + } + function consume(str: string) { + // console.log(`consume ${numberOfResults}: '${str}'`); + const i = str.indexOf(EOT); + if (i !== -1) { + const s = str.substr(0, i); + strings.push(s); + log(s, j, results.length); + // console.log(`result ${numberOfResults}: '${strings.join('')}'`); + results.push(strings.join('')); + strings.length = 0; + if (results.length === numberOfResults[j]) { + promises[j].resolve(results); + j++; + results = []; + if (j === numberOfResults.length) { + stream.off('data', data); + } + } + if (i + 1 < str.length) { + consume(str.substr(i + 1)); + } + } else { + strings.push(str); + log(str, j, results.length); + } + } + stream.on('data', data); + + return promises.map(p => p.promise); +} + +function monitorProcess(p: Exec) { + let processExited: (err: any) => void; + const unexpectedExit = new Promise((_resolve, reject) => processExited = reject); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const stdoutListener = (chunk: Buffer) => stdout.push(chunk); + const stderrListener = (chunk: Buffer) => stderr.push(chunk); + p.stdout.on('data', stdoutListener); + p.stderr.on('data', stderrListener); + p.exit.then(({ code, signal }) => { + processExited(`Shell server terminated (code: ${code}, signal: ${signal}) +${Buffer.concat(stdout).toString()} +${Buffer.concat(stderr).toString()}`); + }, err => { + processExited(`Shell server failed: ${err && (err.stack || err.message)}`); + }); + const disposeStdioListeners = () => { + p.stdout.off('data', stdoutListener); + p.stderr.off('data', stderrListener); + stdout.length = 0; + stderr.length = 0; + }; + return { + unexpectedExit, + disposeStdioListeners, + }; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-common/tsconfig.json b/extensions/positron-dev-containers/src/spec/spec-common/tsconfig.json new file mode 100644 index 000000000000..eff319378d71 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { + "path": "../spec-utils" + } + ] +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-common/variableSubstitution.ts b/extensions/positron-dev-containers/src/spec/spec-common/variableSubstitution.ts new file mode 100644 index 000000000000..d973f0cd636a --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-common/variableSubstitution.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as crypto from 'crypto'; + +import { ContainerError } from './errors'; +import { URI } from 'vscode-uri'; + +export interface SubstitutionContext { + platform: NodeJS.Platform; + configFile?: URI; + localWorkspaceFolder?: string; + containerWorkspaceFolder?: string; + env: NodeJS.ProcessEnv; +} + +export function substitute(context: SubstitutionContext, value: T): T { + let env: NodeJS.ProcessEnv | undefined; + const isWindows = context.platform === 'win32'; + const updatedContext = { + ...context, + get env() { + return env || (env = normalizeEnv(isWindows, context.env)); + } + }; + const replace = replaceWithContext.bind(undefined, isWindows, updatedContext); + if (context.containerWorkspaceFolder) { + updatedContext.containerWorkspaceFolder = resolveString(replace, context.containerWorkspaceFolder); + } + return substitute0(replace, value); +} + +export function beforeContainerSubstitute(idLabels: Record | undefined, value: T): T { + let devcontainerId: string | undefined; + return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (idLabels && (devcontainerId = devcontainerIdForLabels(idLabels)))), value); +} + +export function containerSubstitute(platform: NodeJS.Platform, configFile: URI | undefined, containerEnv: NodeJS.ProcessEnv, value: T): T { + const isWindows = platform === 'win32'; + return substitute0(replaceContainerEnv.bind(undefined, isWindows, configFile, normalizeEnv(isWindows, containerEnv)), value); +} + +type Replace = (match: string, variable: string, args: string[]) => string; + +function substitute0(replace: Replace, value: any): any { + if (typeof value === 'string') { + return resolveString(replace, value); + } else if (Array.isArray(value)) { + return value.map(s => substitute0(replace, s)); + } else if (value && typeof value === 'object' && !URI.isUri(value)) { + const result: any = Object.create(null); + Object.keys(value).forEach(key => { + result[key] = substitute0(replace, value[key]); + }); + return result; + } + return value; +} + +const VARIABLE_REGEXP = /\$\{(.*?)\}/g; + +function normalizeEnv(isWindows: boolean, originalEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (isWindows) { + const env = Object.create(null); + Object.keys(originalEnv).forEach(key => { + env[key.toLowerCase()] = originalEnv[key]; + }); + return env; + } + return originalEnv; +} + +function resolveString(replace: Replace, value: string): string { + // loop through all variables occurrences in 'value' + return value.replace(VARIABLE_REGEXP, evaluateSingleVariable.bind(undefined, replace)); +} + +function evaluateSingleVariable(replace: Replace, match: string, variable: string): string { + + // try to separate variable arguments from variable name + let args: string[] = []; + const parts = variable.split(':'); + if (parts.length > 1) { + variable = parts[0]; + args = parts.slice(1); + } + + return replace(match, variable, args); +} + +function replaceWithContext(isWindows: boolean, context: SubstitutionContext, match: string, variable: string, args: string[]) { + switch (variable) { + case 'env': + case 'localEnv': + return lookupValue(isWindows, context.env, args, match, context.configFile); + + case 'localWorkspaceFolder': + return context.localWorkspaceFolder !== undefined ? context.localWorkspaceFolder : match; + + case 'localWorkspaceFolderBasename': + return context.localWorkspaceFolder !== undefined ? (isWindows ? path.win32 : path.posix).basename(context.localWorkspaceFolder) : match; + + case 'containerWorkspaceFolder': + return context.containerWorkspaceFolder !== undefined ? context.containerWorkspaceFolder : match; + + case 'containerWorkspaceFolderBasename': + return context.containerWorkspaceFolder !== undefined ? path.posix.basename(context.containerWorkspaceFolder) : match; + + default: + return match; + } +} + +function replaceContainerEnv(isWindows: boolean, configFile: URI | undefined, containerEnvObj: NodeJS.ProcessEnv, match: string, variable: string, args: string[]) { + switch (variable) { + case 'containerEnv': + return lookupValue(isWindows, containerEnvObj, args, match, configFile); + + default: + return match; + } +} + +function replaceDevContainerId(getDevContainerId: () => string | undefined, match: string, variable: string) { + switch (variable) { + case 'devcontainerId': + return getDevContainerId() || match; + + default: + return match; + } +} + +function lookupValue(isWindows: boolean, envObj: NodeJS.ProcessEnv, args: string[], match: string, configFile: URI | undefined) { + if (args.length > 0) { + let envVariableName = args[0]; + if (isWindows) { + envVariableName = envVariableName.toLowerCase(); + } + const env = envObj[envVariableName]; + if (typeof env === 'string') { + return env; + } + + if (args.length > 1) { + const defaultValue = args[1]; + return defaultValue; + } + + // For `env` we should do the same as a normal shell does - evaluates missing envs to an empty string #46436 + return ''; + } + throw new ContainerError({ + description: `'${match}'${configFile ? ` in ${path.posix.basename(configFile.path)}` : ''} can not be resolved because no environment variable name is given.` + }); +} + +function devcontainerIdForLabels(idLabels: Record): string { + const stringInput = JSON.stringify(idLabels, Object.keys(idLabels).sort()); // sort properties + const bufferInput = Buffer.from(stringInput, 'utf-8'); + const hash = crypto.createHash('sha256') + .update(bufferInput) + .digest(); + const uniqueId = BigInt(`0x${hash.toString('hex')}`) + .toString(32) + .padStart(52, '0'); + return uniqueId; +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/configuration.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/configuration.ts new file mode 100644 index 000000000000..5995e7e2ba92 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/configuration.ts @@ -0,0 +1,269 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { URI } from 'vscode-uri'; +import { FileHost, parentURI, uriToFsPath } from './configurationCommonUtils'; +import { Mount } from './containerFeaturesConfiguration'; +import { RemoteDocuments } from './editableFiles'; + +export type DevContainerConfig = DevContainerFromImageConfig | DevContainerFromDockerfileConfig | DevContainerFromDockerComposeConfig; + +export interface PortAttributes { + label: string | undefined; + onAutoForward: string | undefined; + elevateIfNeeded: boolean | undefined; +} + +export type UserEnvProbe = 'none' | 'loginInteractiveShell' | 'interactiveShell' | 'loginShell'; + +export type DevContainerConfigCommand = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand'; + +export interface HostGPURequirements { + cores?: number; + memory?: string; +} + +export interface HostRequirements { + cpus?: number; + memory?: string; + storage?: string; + gpu?: boolean | 'optional' | HostGPURequirements; +} + +export interface DevContainerFeature { + userFeatureId: string; + options: boolean | string | Record; +} + +export interface DevContainerFromImageConfig { + configFilePath?: URI; + image?: string; // Only optional when setting up an existing container as a dev container. + name?: string; + forwardPorts?: (number | string)[]; + appPort?: number | string | (number | string)[]; + portsAttributes?: Record; + otherPortsAttributes?: PortAttributes; + runArgs?: string[]; + shutdownAction?: 'none' | 'stopContainer'; + overrideCommand?: boolean; + initializeCommand?: string | string[]; + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; + waitFor?: DevContainerConfigCommand; + /** remote path to folder or workspace */ + workspaceFolder?: string; + workspaceMount?: string; + mounts?: (Mount | string)[]; + containerEnv?: Record; + containerUser?: string; + init?: boolean; + privileged?: boolean; + capAdd?: string[]; + securityOpt?: string[]; + remoteEnv?: Record; + remoteUser?: string; + updateRemoteUserUID?: boolean; + userEnvProbe?: UserEnvProbe; + features?: Record>; + overrideFeatureInstallOrder?: string[]; + hostRequirements?: HostRequirements; + customizations?: Record; +} + +export type DevContainerFromDockerfileConfig = { + configFilePath: URI; + name?: string; + forwardPorts?: (number | string)[]; + appPort?: number | string | (number | string)[]; + portsAttributes?: Record; + otherPortsAttributes?: PortAttributes; + runArgs?: string[]; + shutdownAction?: 'none' | 'stopContainer'; + overrideCommand?: boolean; + initializeCommand?: string | string[]; + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; + waitFor?: DevContainerConfigCommand; + /** remote path to folder or workspace */ + workspaceFolder?: string; + workspaceMount?: string; + mounts?: (Mount | string)[]; + containerEnv?: Record; + containerUser?: string; + init?: boolean; + privileged?: boolean; + capAdd?: string[]; + securityOpt?: string[]; + remoteEnv?: Record; + remoteUser?: string; + updateRemoteUserUID?: boolean; + userEnvProbe?: UserEnvProbe; + features?: Record>; + overrideFeatureInstallOrder?: string[]; + hostRequirements?: HostRequirements; + customizations?: Record; +} & ( + { + dockerFile: string; + context?: string; + build?: { + target?: string; + args?: Record; + cacheFrom?: string | string[]; + options?: string[]; + }; + } + | + { + build: { + dockerfile: string; + context?: string; + target?: string; + args?: Record; + cacheFrom?: string | string[]; + options?: string[]; + }; + } + ); + +export interface DevContainerFromDockerComposeConfig { + configFilePath: URI; + dockerComposeFile: string | string[]; + service: string; + workspaceFolder: string; + name?: string; + forwardPorts?: (number | string)[]; + portsAttributes?: Record; + otherPortsAttributes?: PortAttributes; + shutdownAction?: 'none' | 'stopCompose'; + overrideCommand?: boolean; + initializeCommand?: string | string[]; + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; + waitFor?: DevContainerConfigCommand; + runServices?: string[]; + mounts?: (Mount | string)[]; + containerEnv?: Record; + containerUser?: string; + init?: boolean; + privileged?: boolean; + capAdd?: string[]; + securityOpt?: string[]; + remoteEnv?: Record; + remoteUser?: string; + updateRemoteUserUID?: boolean; + userEnvProbe?: UserEnvProbe; + features?: Record>; + overrideFeatureInstallOrder?: string[]; + hostRequirements?: HostRequirements; + customizations?: Record; +} + +interface DevContainerVSCodeConfig { + extensions?: string[]; + settings?: object; + devPort?: number; +} + +export interface VSCodeCustomizations { + vscode?: DevContainerVSCodeConfig; +} + +export function updateFromOldProperties(original: T): T { + // https://github.com/microsoft/dev-container-spec/issues/1 + if (!(original.extensions || original.settings || original.devPort !== undefined)) { + return original; + } + const copy = { ...original }; + const customizations = copy.customizations || (copy.customizations = {}); + const vscode = customizations.vscode || (customizations.vscode = {}); + if (copy.extensions) { + vscode.extensions = (vscode.extensions || []).concat(copy.extensions); + delete copy.extensions; + } + if (copy.settings) { + vscode.settings = { + ...copy.settings, + ...(vscode.settings || {}), + }; + delete copy.settings; + } + if (copy.devPort !== undefined && vscode.devPort === undefined) { + vscode.devPort = copy.devPort; + delete copy.devPort; + } + return copy; +} + +export function getConfigFilePath(cliHost: { platform: NodeJS.Platform }, config: { configFilePath: URI }, relativeConfigFilePath: string) { + return resolveConfigFilePath(cliHost, config.configFilePath, relativeConfigFilePath); +} + +export function resolveConfigFilePath(cliHost: { platform: NodeJS.Platform }, configFilePath: URI, relativeConfigFilePath: string) { + const folder = parentURI(configFilePath); + return configFilePath.with({ + path: path.posix.resolve(folder.path, (cliHost.platform === 'win32' && configFilePath.scheme !== RemoteDocuments.scheme) ? (path.win32.isAbsolute(relativeConfigFilePath) ? '/' : '') + relativeConfigFilePath.replace(/\\/g, '/') : relativeConfigFilePath) + }); +} + +export function isDockerFileConfig(config: DevContainerConfig): config is DevContainerFromDockerfileConfig { + return 'dockerFile' in config || ('build' in config && 'dockerfile' in config.build); +} + +export function getDockerfilePath(cliHost: { platform: NodeJS.Platform }, config: DevContainerFromDockerfileConfig) { + return getConfigFilePath(cliHost, config, getDockerfile(config)); +} + +export function getDockerfile(config: DevContainerFromDockerfileConfig) { + return 'dockerFile' in config ? config.dockerFile : config.build.dockerfile; +} + +export async function getDockerComposeFilePaths(cliHost: FileHost, config: DevContainerFromDockerComposeConfig, envForComposeFile: NodeJS.ProcessEnv, cwdForDefaultFiles: string) { + if (Array.isArray(config.dockerComposeFile)) { + if (config.dockerComposeFile.length) { + return config.dockerComposeFile.map(composeFile => uriToFsPath(getConfigFilePath(cliHost, config, composeFile), cliHost.platform)); + } + } else if (typeof config.dockerComposeFile === 'string') { + return [uriToFsPath(getConfigFilePath(cliHost, config, config.dockerComposeFile), cliHost.platform)]; + } + + const envComposeFile = envForComposeFile?.COMPOSE_FILE; + if (envComposeFile) { + return envComposeFile.split(cliHost.path.delimiter) + .map(composeFile => cliHost.path.resolve(cwdForDefaultFiles, composeFile)); + } + + try { + const envPath = cliHost.path.join(cwdForDefaultFiles, '.env'); + const buffer = await cliHost.readFile(envPath); + const match = /^COMPOSE_FILE=(.+)$/m.exec(buffer.toString()); + const envFileComposeFile = match && match[1].trim(); + if (envFileComposeFile) { + return envFileComposeFile.split(cliHost.path.delimiter) + .map(composeFile => cliHost.path.resolve(cwdForDefaultFiles, composeFile)); + } + } catch (err) { + if (!(err && (err.code === 'ENOENT' || err.code === 'EISDIR'))) { + throw err; + } + } + + const defaultFiles = [cliHost.path.resolve(cwdForDefaultFiles, 'docker-compose.yml')]; + const override = cliHost.path.resolve(cwdForDefaultFiles, 'docker-compose.override.yml'); + if (await cliHost.isFile(override)) { + defaultFiles.push(override); + } + return defaultFiles; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/configurationCommonUtils.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/configurationCommonUtils.ts new file mode 100644 index 000000000000..371125f52f43 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/configurationCommonUtils.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +import { URI } from 'vscode-uri'; + +import { CLIHostDocuments } from './editableFiles'; +import { FileHost } from '../spec-utils/pfs'; + +export { FileHost } from '../spec-utils/pfs'; + +const enum CharCode { + Slash = 47, + Colon = 58, + A = 65, + Z = 90, + a = 97, + z = 122, +} + +export function uriToFsPath(uri: URI, platform: NodeJS.Platform): string { + + let value: string; + if (uri.authority && uri.path.length > 1 && (uri.scheme === 'file' || uri.scheme === CLIHostDocuments.scheme)) { + // unc path: file://shares/c$/far/boo + value = `//${uri.authority}${uri.path}`; + } else if ( + uri.path.charCodeAt(0) === CharCode.Slash + && (uri.path.charCodeAt(1) >= CharCode.A && uri.path.charCodeAt(1) <= CharCode.Z || uri.path.charCodeAt(1) >= CharCode.a && uri.path.charCodeAt(1) <= CharCode.z) + && uri.path.charCodeAt(2) === CharCode.Colon + ) { + // windows drive letter: file:///c:/far/boo + value = uri.path[1].toLowerCase() + uri.path.substr(2); + } else { + // other path + value = uri.path; + } + if (platform === 'win32') { + value = value.replace(/\//g, '\\'); + } + return value; +} + +export function getWellKnownDevContainerPaths(path_: typeof path.posix | typeof path.win32, folderPath: string): string[] { + return [ + path_.join(folderPath, '.devcontainer', 'devcontainer.json'), + path_.join(folderPath, '.devcontainer.json'), + ]; +} + +export function getDefaultDevContainerConfigPath(fileHost: FileHost, configFolderPath: string) { + return URI.file(fileHost.path.join(configFolderPath, '.devcontainer', 'devcontainer.json')) + .with({ scheme: CLIHostDocuments.scheme }); +} + +export async function getDevContainerConfigPathIn(fileHost: FileHost, configFolderPath: string) { + const possiblePaths = getWellKnownDevContainerPaths(fileHost.path, configFolderPath); + + for (let possiblePath of possiblePaths) { + if (await fileHost.isFile(possiblePath)) { + return URI.file(possiblePath) + .with({ scheme: CLIHostDocuments.scheme }); + } + } + + return undefined; +} + +export function parentURI(uri: URI) { + const parent = path.posix.dirname(uri.path); + return uri.with({ path: parent }); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCI.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCI.ts new file mode 100644 index 000000000000..082bb0daa335 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCI.ts @@ -0,0 +1,615 @@ +import path from 'path'; +import * as semver from 'semver'; +import * as tar from 'tar'; +import * as jsonc from 'jsonc-parser'; +import * as crypto from 'crypto'; + +import { Log, LogLevel } from '../spec-utils/log'; +import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; +import { requestEnsureAuthenticated } from './httpOCIRegistry'; +import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils'; + +export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers'; +export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar'; +export const DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE = 'application/vnd.devcontainers.collection.layer.v1+json'; + + +export interface CommonParams { + env: NodeJS.ProcessEnv; + output: Log; + cachedAuthHeader?: Record; // +} + +// Represents the unique OCI identifier for a Feature or Template. +// eg: ghcr.io/devcontainers/features/go:1.0.0 +// eg: ghcr.io/devcontainers/features/go@sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3 +// Constructed by 'getRef()' +export interface OCIRef { + registry: string; // 'ghcr.io' + owner: string; // 'devcontainers' + namespace: string; // 'devcontainers/features' + path: string; // 'devcontainers/features/go' + resource: string; // 'ghcr.io/devcontainers/features/go' + id: string; // 'go' + + version: string; // (Either the contents of 'tag' or 'digest') + tag?: string; // '1.0.0' + digest?: string; // 'sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3' +} + +// Represents the unique OCI identifier for a Collection's Metadata artifact. +// eg: ghcr.io/devcontainers/features:latest +// Constructed by 'getCollectionRef()' +export interface OCICollectionRef { + registry: string; // 'ghcr.io' + path: string; // 'devcontainers/features' + resource: string; // 'ghcr.io/devcontainers/features' + tag: 'latest'; // 'latest' (always) + version: 'latest'; // 'latest' (always) +} + +export interface OCILayer { + mediaType: string; + digest: string; + size: number; + annotations: { + 'org.opencontainers.image.title': string; + }; +} + +export interface OCIManifest { + digest?: string; + schemaVersion: number; + mediaType: string; + config: { + digest: string; + mediaType: string; + size: number; + }; + layers: OCILayer[]; + annotations?: { + 'dev.containers.metadata'?: string; + 'com.github.package.type'?: string; + }; +} + +export interface ManifestContainer { + manifestObj: OCIManifest; + manifestBuffer: Buffer; + contentDigest: string; + canonicalId: string; +} + + +interface OCITagList { + name: string; + tags: string[]; +} + +interface OCIImageIndexEntry { + mediaType: string; + size: number; + digest: string; + platform: { + architecture: string; + variant?: string; + os: string; + }; +} + +// https://github.com/opencontainers/image-spec/blob/main/manifest.md#example-image-manifest +interface OCIImageIndex { + schemaVersion: number; + mediaType: string; + manifests: OCIImageIndexEntry[]; +} + +// Following Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests +// Alternative Spec: https://docs.docker.com/registry/spec/api/#overview +// +// The path: +// 'namespace' in spec terminology for the given repository +// (eg: devcontainers/features/go) +const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/; +// The reference: +// MUST be either (a) the digest of the manifest or (b) a tag +// MUST be at most 128 characters in length and MUST match the following regular expression: +const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; + +// https://go.dev/doc/install/source#environment +// Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions +export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH { + switch (arch) { + case 'x64': + return 'amd64'; + default: + return arch; + } +} + +// https://go.dev/doc/install/source#environment +// Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions +export function mapNodeOSToGOOS(os: NodeJS.Platform): GoOS { + switch (os) { + case 'win32': + return 'windows'; + default: + return os; + } +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests +// Attempts to parse the given string into an OCIRef +export function getRef(output: Log, input: string): OCIRef | undefined { + // Normalize input by downcasing entire string + input = input.toLowerCase(); + + // Invalid if first character is a dot + if (input.startsWith('.')) { + output.write(`Input '${input}' failed validation. Expected input to not start with '.'`, LogLevel.Error); + return; + } + + const indexOfLastColon = input.lastIndexOf(':'); + const indexOfLastAtCharacter = input.lastIndexOf('@'); + + let resource = ''; + let tag: string | undefined = undefined; + let digest: string | undefined = undefined; + + // -- Resolve version + if (indexOfLastAtCharacter !== -1) { + // The version is specified by digest + // eg: ghcr.io/codspace/features/ruby@sha256:abcdefgh + resource = input.substring(0, indexOfLastAtCharacter); + const digestWithHashingAlgorithm = input.substring(indexOfLastAtCharacter + 1); + const splitOnColon = digestWithHashingAlgorithm.split(':'); + if (splitOnColon.length !== 2) { + output.write(`Failed to parse digest '${digestWithHashingAlgorithm}'. Expected format: 'sha256:abcdefghijk'`, LogLevel.Error); + return; + } + + if (splitOnColon[0] !== 'sha256') { + output.write(`Digest algorithm for input '${input}' failed validation. Expected hashing algorithm to be 'sha256'.`, LogLevel.Error); + return; + } + + if (!regexForVersionOrDigest.test(splitOnColon[1])) { + output.write(`Digest for input '${input}' failed validation. Expected digest to match regex '${regexForVersionOrDigest}'.`, LogLevel.Error); + } + + digest = digestWithHashingAlgorithm; + } else if (indexOfLastColon !== -1 && indexOfLastColon > input.lastIndexOf('/')) { + // The version is specified by tag + // eg: ghcr.io/codspace/features/ruby:1.0.0 + + // 1. The last colon is before the first slash (a port) + // eg: ghcr.io:8081/codspace/features/ruby + // 2. There is no tag at all + // eg: ghcr.io/codspace/features/ruby + resource = input.substring(0, indexOfLastColon); + tag = input.substring(indexOfLastColon + 1); + } else { + // There is no tag or digest, so assume 'latest' + resource = input; + tag = 'latest'; + } + + + if (tag && !regexForVersionOrDigest.test(tag)) { + output.write(`Tag '${tag}' for input '${input}' failed validation. Expected digest to match regex '${regexForVersionOrDigest}'.`, LogLevel.Error); + return; + } + + const splitOnSlash = resource.split('/'); + + if (splitOnSlash[1] === 'devcontainers-contrib') { + output.write(`Redirecting 'devcontainers-contrib' to 'devcontainers-extra'.`); + splitOnSlash[1] = 'devcontainers-extra'; + } + + const id = splitOnSlash[splitOnSlash.length - 1]; // Aka 'featureName' - Eg: 'ruby' + const owner = splitOnSlash[1]; + const registry = splitOnSlash[0]; + const namespace = splitOnSlash.slice(1, -1).join('/'); + + const path = `${namespace}/${id}`; + + if (!regexForPath.exec(path)) { + output.write(`Path '${path}' for input '${input}' failed validation. Expected path to match regex '${regexForPath}'.`, LogLevel.Error); + return; + } + + const version = digest || tag || 'latest'; // The most specific version. + + output.write(`> input: ${input}`, LogLevel.Trace); + output.write(`>`, LogLevel.Trace); + output.write(`> resource: ${resource}`, LogLevel.Trace); + output.write(`> id: ${id}`, LogLevel.Trace); + output.write(`> owner: ${owner}`, LogLevel.Trace); + output.write(`> namespace: ${namespace}`, LogLevel.Trace); // TODO: We assume 'namespace' includes at least one slash (eg: 'devcontainers/features') + output.write(`> registry: ${registry}`, LogLevel.Trace); + output.write(`> path: ${path}`, LogLevel.Trace); + output.write(`>`, LogLevel.Trace); + output.write(`> version: ${version}`, LogLevel.Trace); + output.write(`> tag?: ${tag}`, LogLevel.Trace); + output.write(`> digest?: ${digest}`, LogLevel.Trace); + + return { + id, + owner, + namespace, + registry, + resource, + path, + version, + tag, + digest, + }; +} + +export function getCollectionRef(output: Log, registry: string, namespace: string): OCICollectionRef | undefined { + // Normalize input by downcasing entire string + registry = registry.toLowerCase(); + namespace = namespace.toLowerCase(); + + const path = namespace; + const resource = `${registry}/${path}`; + + output.write(`> Inputs: registry='${registry}' namespace='${namespace}'`, LogLevel.Trace); + output.write(`>`, LogLevel.Trace); + output.write(`> resource: ${resource}`, LogLevel.Trace); + + if (!regexForPath.exec(path)) { + output.write(`Parsed path '${path}' from input failed validation.`, LogLevel.Error); + return undefined; + } + + return { + registry, + path, + resource, + version: 'latest', + tag: 'latest', + }; +} + +// Validate if a manifest exists and is reachable about the declared feature/template. +// Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#pulling-manifests +export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef | OCICollectionRef, manifestDigest?: string): Promise { + const { output } = params; + + // Simple mechanism to avoid making a DNS request for + // something that is not a domain name. + if (ref.registry.indexOf('.') < 0 && !ref.registry.startsWith('localhost')) { + return; + } + + // TODO: Always use the manifest digest (the canonical digest) + // instead of the `ref.version` by referencing some lock file (if available). + let reference = ref.version; + if (manifestDigest) { + reference = manifestDigest; + } + const manifestUrl = `https://${ref.registry}/v2/${ref.path}/manifests/${reference}`; + output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); + const expectedDigest = manifestDigest || ('digest' in ref ? ref.digest : undefined); + const manifestContainer = await getManifest(params, manifestUrl, ref, undefined, expectedDigest); + + if (!manifestContainer || !manifestContainer.manifestObj) { + return; + } + + const { manifestObj } = manifestContainer; + + if (manifestObj.config.mediaType !== DEVCONTAINER_MANIFEST_MEDIATYPE) { + output.write(`(!) Unexpected manifest media type: ${manifestObj.config.mediaType}`, LogLevel.Error); + return undefined; + } + + return manifestContainer; +} + +export async function getManifest(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, mimeType?: string, expectedDigest?: string): Promise { + const { output } = params; + const res = await getBufferWithMimeType(params, url, ref, mimeType || 'application/vnd.oci.image.manifest.v1+json'); + if (!res) { + return undefined; + } + + const { body, headers } = res; + + // Per the specification: + // https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#pulling-manifests + // The registry server SHOULD return the canonical content digest in a header, but it's not required to. + // That is useful to have, so if the server doesn't provide it, recalculate it outselves. + // Headers are always automatically downcased by node. + let contentDigest = headers['docker-content-digest']; + if (!contentDigest || expectedDigest) { + if (!contentDigest) { + output.write('Registry did not send a \'docker-content-digest\' header. Recalculating...', LogLevel.Trace); + } + contentDigest = `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`; + } + + if (expectedDigest && contentDigest !== expectedDigest) { + throw new Error(`Digest did not match for ${ref.resource}.`); + } + + return { + contentDigest, + manifestObj: JSON.parse(body.toString()), + manifestBuffer: body, + canonicalId: `${ref.resource}@${contentDigest}`, + }; +} + +// https://github.com/opencontainers/image-spec/blob/main/manifest.md +export async function getImageIndexEntryForPlatform(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, platformInfo: PlatformInfo, mimeType?: string): Promise { + const { output } = params; + const response = await getJsonWithMimeType(params, url, ref, mimeType || 'application/vnd.oci.image.index.v1+json'); + if (!response) { + return undefined; + } + + const { body: imageIndex } = response; + if (!imageIndex) { + output.write(`Unwrapped response for image index is undefined.`, LogLevel.Error); + return undefined; + } + + // Find a manifest for the current architecture and OS. + return imageIndex.manifests.find(m => { + if (m.platform?.architecture === platformInfo.arch && m.platform?.os === platformInfo.os) { + if (!platformInfo.variant || m.platform?.variant === platformInfo.variant) { + return m; + } + } + return undefined; + }); +} + +async function getBufferWithMimeType(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, mimeType: string): Promise<{ body: Buffer; headers: Record } | undefined> { + const { output } = params; + const headers = { + 'user-agent': 'devcontainer', + 'accept': mimeType, + }; + + const httpOptions = { + type: 'GET', + url: url, + headers: headers + }; + + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + output.write(`Request '${url}' failed`, LogLevel.Error); + return; + } + + // NOTE: A 404 is expected here if the manifest does not exist on the remote. + if (res.statusCode > 299) { + // Get the error out. + const errorMsg = res?.resBody?.toString(); + output.write(`Did not fetch target with expected mimetype '${mimeType}': ${errorMsg}`, LogLevel.Trace); + return; + } + + return { + body: res.resBody, + headers: res.resHeaders, + }; +} + +async function getJsonWithMimeType(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, mimeType: string): Promise<{ body: T; headers: Record } | undefined> { + const { output } = params; + let body: string = ''; + try { + const headers = { + 'user-agent': 'devcontainer', + 'accept': mimeType, + }; + + const httpOptions = { + type: 'GET', + url: url, + headers: headers + }; + + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + output.write(`Request '${url}' failed`, LogLevel.Error); + return; + } + + const { resBody, statusCode, resHeaders } = res; + body = resBody.toString(); + + // NOTE: A 404 is expected here if the manifest does not exist on the remote. + if (statusCode > 299) { + output.write(`Did not fetch target with expected mimetype '${mimeType}': ${body}`, LogLevel.Trace); + return; + } + const parsedBody: T = JSON.parse(body); + output.write(`Fetched: ${JSON.stringify(parsedBody, undefined, 4)}`, LogLevel.Trace); + return { + body: parsedBody, + headers: resHeaders, + }; + } catch (e) { + output.write(`Failed to parse JSON with mimeType '${mimeType}': ${body}`, LogLevel.Error); + return; + } +} + +// Gets published tags and sorts them by ascending semantic version. +// Omits any tags (eg: 'latest', or major/minor tags '1','1.0') that are not semantic versions. +export async function getVersionsStrictSorted(params: CommonParams, ref: OCIRef): Promise { + const { output } = params; + + const publishedTags = await getPublishedTags(params, ref); + if (!publishedTags) { + return; + } + + const sortedVersions = publishedTags + .filter(f => semver.valid(f)) // Remove all major,minor,latest tags + .sort((a, b) => semver.compare(a, b)); + + output.write(`Published versions (sorted) for '${ref.id}': ${JSON.stringify(sortedVersions, undefined, 2)}`, LogLevel.Trace); + + return sortedVersions; +} + +// Lists published tags of a Feature/Template +// Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#content-discovery +export async function getPublishedTags(params: CommonParams, ref: OCIRef): Promise { + const { output } = params; + try { + const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; + + const headers = { + 'Accept': 'application/json', + }; + + const httpOptions = { + type: 'GET', + url: url, + headers: headers + }; + + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody } = res; + const body = resBody.toString(); + + // Expected when publishing for the first time + if (statusCode === 404) { + return []; + // Unexpected Error + } else if (statusCode > 299) { + output.write(`(!) ERR: Could not fetch published tags for '${ref.namespace}/${ref.id}' : ${resBody ?? ''} `, LogLevel.Error); + return; + } + + const publishedVersionsResponse: OCITagList = JSON.parse(body); + + // Return published tags from the registry as-is, meaning: + // - Not necessarily sorted + // - *Including* major/minor/latest tags + return publishedVersionsResponse.tags; + } catch (e) { + output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); + return; + } +} + +export async function getBlob(params: CommonParams, url: string, ociCacheDir: string, destCachePath: string, ociRef: OCIRef, expectedDigest: string, omitDuringExtraction: string[] = [], metadataFile?: string): Promise<{ files: string[]; metadata: {} | undefined } | undefined> { + // TODO: Parallelize if multiple layers (not likely). + // TODO: Seeking might be needed if the size is too large. + + const { output } = params; + try { + await mkdirpLocal(ociCacheDir); + const tempTarballPath = path.join(ociCacheDir, 'blob.tar'); + + const headers = { + 'Accept': 'application/vnd.oci.image.manifest.v1+json', + }; + + const httpOptions = { + type: 'GET', + url: url, + headers: headers + }; + + const res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody } = res; + if (statusCode > 299) { + output.write(`Failed to fetch blob (${url}): ${resBody}`, LogLevel.Error); + return; + } + + const actualDigest = `sha256:${crypto.createHash('sha256').update(resBody).digest('hex')}`; + if (actualDigest !== expectedDigest) { + throw new Error(`Digest did not match for ${ociRef.resource}.`); + } + + await mkdirpLocal(destCachePath); + await writeLocalFile(tempTarballPath, resBody); + + // https://github.com/devcontainers/spec/blob/main/docs/specs/devcontainer-templates.md#the-optionalpaths-property + const directoriesToOmit = omitDuringExtraction.filter(f => f.endsWith('/*')).map(f => f.slice(0, -1)); + const filesToOmit = omitDuringExtraction.filter(f => !f.endsWith('/*')); + + output.write(`omitDuringExtraction: '${omitDuringExtraction.join(', ')}`, LogLevel.Trace); + output.write(`Files to omit: '${filesToOmit.join(', ')}'`, LogLevel.Info); + if (directoriesToOmit.length) { + output.write(`Dirs to omit : '${directoriesToOmit.join(', ')}'`, LogLevel.Info); + } + + const files: string[] = []; + await tar.x( + { + file: tempTarballPath, + cwd: destCachePath, + filter: (tPath: string, stat: tar.FileStat) => { + output.write(`Testing '${tPath}'(${stat.type})`, LogLevel.Trace); + const cleanedPath = tPath + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + + if (filesToOmit.includes(cleanedPath) || directoriesToOmit.some(d => cleanedPath.startsWith(d))) { + output.write(` Omitting '${tPath}'`, LogLevel.Trace); + return false; // Skip + } + + if (stat.type.toString() === 'File') { + files.push(tPath); + } + + return true; // Keep + } + } + ); + output.write('Files extracted from blob: ' + files.join(', '), LogLevel.Trace); + + // No 'metadataFile' to look for. + if (!metadataFile) { + return { files, metadata: undefined }; + } + + // Attempt to extract 'metadataFile' + await tar.x( + { + file: tempTarballPath, + cwd: ociCacheDir, + filter: (tPath: string, _: tar.FileStat) => { + return tPath === `./${metadataFile}`; + } + }); + const pathToMetadataFile = path.join(ociCacheDir, metadataFile); + let metadata = undefined; + if (await isLocalFile(pathToMetadataFile)) { + output.write(`Found metadata file '${metadataFile}' in blob`, LogLevel.Trace); + metadata = jsonc.parse((await readLocalFile(pathToMetadataFile)).toString()); + } + + return { + files, metadata + }; + } catch (e) { + output.write(`Error getting blob: ${e}`, LogLevel.Error); + return; + } +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCIPush.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCIPush.ts new file mode 100644 index 000000000000..24f811663d4d --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerCollectionsOCIPush.ts @@ -0,0 +1,416 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import { delay } from '../spec-common/async'; +import { Log, LogLevel } from '../spec-utils/log'; +import { isLocalFile } from '../spec-utils/pfs'; +import { DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE, DEVCONTAINER_TAR_LAYER_MEDIATYPE, fetchOCIManifestIfExists, OCICollectionRef, OCILayer, OCIManifest, OCIRef, CommonParams, ManifestContainer } from './containerCollectionsOCI'; +import { requestEnsureAuthenticated } from './httpOCIRegistry'; + +// (!) Entrypoint function to push a single feature/template to a registry. +// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry +// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry +// OCI Spec : https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push +export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCIRef, pathToTgz: string, tags: string[], collectionType: string, annotations: { [key: string]: string } = {}): Promise { + const { output } = params; + + output.write(`-- Starting push of ${collectionType} '${ociRef.id}' to '${ociRef.resource}' with tags '${tags.join(', ')}'`); + output.write(`${JSON.stringify(ociRef, null, 2)}`, LogLevel.Trace); + + if (!(await isLocalFile(pathToTgz))) { + output.write(`Blob ${pathToTgz} does not exist.`, LogLevel.Error); + return; + } + + const dataBytes = fs.readFileSync(pathToTgz); + + // Generate Manifest for given feature/template artifact. + const manifest = await generateCompleteManifestForIndividualFeatureOrTemplate(output, dataBytes, pathToTgz, ociRef, collectionType, annotations); + if (!manifest) { + output.write(`Failed to generate manifest for ${ociRef.id}`, LogLevel.Error); + return; + } + + output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace); + + // If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!) + const existingManifest = await fetchOCIManifestIfExists(params, ociRef, manifest.contentDigest); + if (manifest.contentDigest && existingManifest) { + output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace); + return await putManifestWithTags(params, manifest, ociRef, tags); + } + + const blobsToPush = [ + { + name: 'configLayer', + digest: manifest.manifestObj.config.digest, + size: manifest.manifestObj.config.size, + contents: Buffer.from('{}'), + }, + { + name: 'tgzLayer', + digest: manifest.manifestObj.layers[0].digest, + size: manifest.manifestObj.layers[0].size, + contents: dataBytes, + } + ]; + + + for await (const blob of blobsToPush) { + const { name, digest } = blob; + const blobExistsConfigLayer = await checkIfBlobExists(params, ociRef, digest); + output.write(`blob: '${name}' ${blobExistsConfigLayer ? 'DOES exists' : 'DOES NOT exist'} in registry.`, LogLevel.Trace); + + // PUT blobs + if (!blobExistsConfigLayer) { + + // Obtain session ID with `/v2//blobs/uploads/` + const blobPutLocationUriPath = await postUploadSessionId(params, ociRef); + if (!blobPutLocationUriPath) { + output.write(`Failed to get upload session ID`, LogLevel.Error); + return; + } + + if (!(await putBlob(params, blobPutLocationUriPath, ociRef, blob))) { + output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error); + return; + } + } + } + + // Send a final PUT to combine blobs and tag manifest properly. + return await putManifestWithTags(params, manifest, ociRef, tags); +} + +// (!) Entrypoint function to push a collection metadata/overview file for a set of features/templates to a registry. +// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry (see 'devcontainer-collection.json') +// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry (see 'devcontainer-collection.json') +// OCI Spec : https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push +export async function pushCollectionMetadata(params: CommonParams, collectionRef: OCICollectionRef, pathToCollectionJson: string, collectionType: string): Promise { + const { output } = params; + + output.write(`Starting push of latest ${collectionType} collection for namespace '${collectionRef.path}' to '${collectionRef.registry}'`); + output.write(`${JSON.stringify(collectionRef, null, 2)}`, LogLevel.Trace); + + if (!(await isLocalFile(pathToCollectionJson))) { + output.write(`Collection Metadata was not found at expected location: ${pathToCollectionJson}`, LogLevel.Error); + return; + } + + const dataBytes = fs.readFileSync(pathToCollectionJson); + + // Generate Manifest for collection artifact. + const manifest = await generateCompleteManifestForCollectionFile(output, dataBytes, collectionRef); + if (!manifest) { + output.write(`Failed to generate manifest for ${collectionRef.path}`, LogLevel.Error); + return; + } + output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace); + + // If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!) + const existingManifest = await fetchOCIManifestIfExists(params, collectionRef, manifest.contentDigest); + if (manifest.contentDigest && existingManifest) { + output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace); + return await putManifestWithTags(params, manifest, collectionRef, ['latest']); + } + + const blobsToPush = [ + { + name: 'configLayer', + digest: manifest.manifestObj.config.digest, + size: manifest.manifestObj.config.size, + contents: Buffer.from('{}'), + }, + { + name: 'collectionLayer', + digest: manifest.manifestObj.layers[0].digest, + size: manifest.manifestObj.layers[0].size, + contents: dataBytes, + } + ]; + + for await (const blob of blobsToPush) { + const { name, digest } = blob; + const blobExistsConfigLayer = await checkIfBlobExists(params, collectionRef, digest); + output.write(`blob: '${name}' with digest '${digest}' ${blobExistsConfigLayer ? 'already exists' : 'does not exist'} in registry.`, LogLevel.Trace); + + // PUT blobs + if (!blobExistsConfigLayer) { + + // Obtain session ID with `/v2//blobs/uploads/` + const blobPutLocationUriPath = await postUploadSessionId(params, collectionRef); + if (!blobPutLocationUriPath) { + output.write(`Failed to get upload session ID`, LogLevel.Error); + return; + } + + if (!(await putBlob(params, blobPutLocationUriPath, collectionRef, blob))) { + output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error); + return; + } + } + } + + // Send a final PUT to combine blobs and tag manifest properly. + // Collections are always tagged 'latest' + return await putManifestWithTags(params, manifest, collectionRef, ['latest']); +} + +// --- Helper Functions + +// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests (PUT /manifests/) +async function putManifestWithTags(params: CommonParams, manifest: ManifestContainer, ociRef: OCIRef | OCICollectionRef, tags: string[]): Promise { + const { output } = params; + + output.write(`Tagging manifest with tags: ${tags.join(', ')}`, LogLevel.Trace); + + const { manifestBuffer, contentDigest } = manifest; + + for await (const tag of tags) { + const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; + output.write(`PUT -> '${url}'`, LogLevel.Trace); + + const httpOptions = { + type: 'PUT', + url, + headers: { + 'content-type': 'application/vnd.oci.image.manifest.v1+json', + }, + data: manifestBuffer, + }; + + let res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + // Retry logic: when request fails with HTTP 429: too many requests + // TODO: Wrap into `requestEnsureAuthenticated`? + if (res.statusCode === 429) { + output.write(`Failed to PUT manifest for tag ${tag} due to too many requests. Retrying...`, LogLevel.Warning); + await delay(2000); + + res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + } + + const { statusCode, resBody, resHeaders } = res; + + if (statusCode !== 201) { + const parsed = JSON.parse(resBody?.toString() || '{}'); + output.write(`Failed to PUT manifest for tag ${tag}\n${JSON.stringify(parsed, undefined, 4)}`, LogLevel.Error); + return; + } + + const dockerContentDigestResponseHeader = resHeaders['docker-content-digest']; + const locationResponseHeader = resHeaders['location'] || resHeaders['Location']; + output.write(`Tagged: ${tag} -> ${locationResponseHeader}`, LogLevel.Info); + output.write(`Returned Content-Digest: ${dockerContentDigestResponseHeader}`, LogLevel.Trace); + } + return contentDigest; +} + +// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put (PUT ?digest=) +async function putBlob(params: CommonParams, blobPutLocationUriPath: string, ociRef: OCIRef | OCICollectionRef, blob: { name: string; digest: string; size: number; contents: Buffer }): Promise { + + const { output } = params; + const { name, digest, size, contents } = blob; + + output.write(`Starting PUT of ${name} blob '${digest}' (size=${size})`, LogLevel.Info); + + const headers = { + 'content-type': 'application/octet-stream', + 'content-length': `${size}` + }; + + // OCI distribution spec is ambiguous on whether we get back an absolute or relative path. + let url = ''; + if (blobPutLocationUriPath.startsWith('https://') || blobPutLocationUriPath.startsWith('http://')) { + url = blobPutLocationUriPath; + } else { + url = `https://${ociRef.registry}${blobPutLocationUriPath}`; + } + + // The MAY contain critical query parameters. + // Additionally, it SHOULD match exactly the obtained from the POST request. + // It SHOULD NOT be assembled manually by clients except where absolute/relative conversion is necessary. + const queryParamsStart = url.indexOf('?'); + if (queryParamsStart === -1) { + // Just append digest to the end. + url += `?digest=${digest}`; + } else { + url = url.substring(0, queryParamsStart) + `?digest=${digest}` + '&' + url.substring(queryParamsStart + 1); + } + + output.write(`PUT blob to -> ${url}`, LogLevel.Trace); + + const res = await requestEnsureAuthenticated(params, { type: 'PUT', url, headers, data: contents }, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return false; + } + + const { statusCode, resBody } = res; + + if (statusCode !== 201) { + const parsed = JSON.parse(resBody?.toString() || '{}'); + output.write(`${statusCode}: Failed to upload blob '${digest}' to '${url}' \n${JSON.stringify(parsed, undefined, 4)}`, LogLevel.Error); + return false; + } + + return true; +} + +// Generate a layer that follows the `application/vnd.devcontainers.layer.v1+tar` mediaType as defined in +// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry +// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry +async function generateCompleteManifestForIndividualFeatureOrTemplate(output: Log, dataBytes: Buffer, pathToTgz: string, ociRef: OCIRef, collectionType: string, annotations: { [key: string]: string } = {}): Promise { + const tgzLayer = await calculateDataLayer(output, dataBytes, path.basename(pathToTgz), DEVCONTAINER_TAR_LAYER_MEDIATYPE); + if (!tgzLayer) { + output.write(`Failed to calculate tgz layer.`, LogLevel.Error); + return undefined; + } + + // Specific registries look for certain optional metadata + // in the manifest, in this case for UI presentation. + if (ociRef.registry === 'ghcr.io') { + annotations = { + ...annotations, + 'com.github.package.type': `devcontainer_${collectionType}`, + }; + } + + return await calculateManifestAndContentDigest(output, ociRef, tgzLayer, annotations); +} + +// Generate a layer that follows the `application/vnd.devcontainers.collection.layer.v1+json` mediaType as defined in +// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry +// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry +async function generateCompleteManifestForCollectionFile(output: Log, dataBytes: Buffer, collectionRef: OCICollectionRef): Promise { + const collectionMetadataLayer = await calculateDataLayer(output, dataBytes, 'devcontainer-collection.json', DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE); + if (!collectionMetadataLayer) { + output.write(`Failed to calculate collection file layer.`, LogLevel.Error); + return undefined; + } + + let annotations: { [key: string]: string } | undefined = undefined; + // Specific registries look for certain optional metadata + // in the manifest, in this case for UI presentation. + if (collectionRef.registry === 'ghcr.io') { + annotations = { + 'com.github.package.type': 'devcontainer_collection', + }; + } + return await calculateManifestAndContentDigest(output, collectionRef, collectionMetadataLayer, annotations); +} + +// Generic construction of a layer in the manifest and digest for the generated layer. +export async function calculateDataLayer(output: Log, data: Buffer, basename: string, mediaType: string): Promise { + output.write(`Creating manifest from data`, LogLevel.Trace); + + const algorithm = 'sha256'; + const tarSha256 = crypto.createHash(algorithm).update(data).digest('hex'); + const digest = `${algorithm}:${tarSha256}`; + output.write(`Data layer digest: ${digest} (archive size: ${data.byteLength})`, LogLevel.Info); + + return { + mediaType, + digest, + size: data.byteLength, + annotations: { + 'org.opencontainers.image.title': basename, + } + }; +} + +// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry +// Requires registry auth token. +export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | OCICollectionRef, digest: string): Promise { + const { output } = params; + + const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; + const res = await requestEnsureAuthenticated(params, { type: 'HEAD', url, headers: {} }, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return false; + } + + const statusCode = res.statusCode; + output.write(`checkIfBlobExists: ${url}: ${statusCode}`, LogLevel.Trace); + return statusCode === 200; +} + +// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put +// Requires registry auth token. +async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise { + const { output } = params; + + const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; + output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace); + const res = await requestEnsureAuthenticated(params, { type: 'POST', url, headers: {} }, ociRef); + + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody, resHeaders } = res; + + output.write(`${url}: ${statusCode}`, LogLevel.Trace); + if (statusCode === 202) { + const locationHeader = resHeaders['location'] || resHeaders['Location']; + if (!locationHeader) { + output.write(`${url}: Got 202 status code, but no location header found.`, LogLevel.Error); + return undefined; + } + output.write(`Generated Upload URL: ${locationHeader}`, LogLevel.Trace); + return locationHeader; + } else { + // Any other statusCode besides 202 is unexpected + // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes + const parsed = JSON.parse(resBody?.toString() || '{}'); + output.write(`${url}: Unexpected status code '${statusCode}' \n${JSON.stringify(parsed, undefined, 4)}`, LogLevel.Error); + return undefined; + } +} + +export async function calculateManifestAndContentDigest(output: Log, ociRef: OCIRef | OCICollectionRef, dataLayer: OCILayer, annotations: { [key: string]: string } | undefined): Promise { + // A canonical manifest digest is the sha256 hash of the JSON representation of the manifest, without the signature content. + // See: https://docs.docker.com/registry/spec/api/#content-digests + // Below is an example of a serialized manifest that should resolve to 'dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc' + // {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/vnd.devcontainers.layer.v1+tar","digest":"sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1","size":3584,"annotations":{"org.opencontainers.image.title":"devcontainer-feature-color.tgz"}}],"annotations":{"dev.containers.metadata":"{\"id\":\"color\",\"version\":\"1.0.0\",\"name\":\"A feature to remind you of your favorite color\",\"options\":{\"favorite\":{\"type\":\"string\",\"enum\":[\"red\",\"gold\",\"green\"],\"default\":\"red\",\"description\":\"Choose your favorite color.\"}}}","com.github.package.type":"devcontainer_feature"}} + + let manifest: OCIManifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.devcontainers', + digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a', // A empty json byte digest for the devcontainer mediaType. + size: 2 + }, + layers: [ + dataLayer + ], + }; + + if (annotations) { + manifest.annotations = annotations; + } + + const manifestBuffer = Buffer.from(JSON.stringify(manifest)); + const algorithm = 'sha256'; + const manifestHash = crypto.createHash(algorithm).update(manifestBuffer).digest('hex'); + const contentDigest = `${algorithm}:${manifestHash}`; + output.write(`Computed content digest from manifest: ${contentDigest}`, LogLevel.Info); + + return { + manifestBuffer, + manifestObj: manifest, + contentDigest, + canonicalId: `${ociRef.resource}@sha256:${manifestHash}` + }; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesConfiguration.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesConfiguration.ts new file mode 100644 index 000000000000..662ed3b65ac5 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesConfiguration.ts @@ -0,0 +1,1260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as jsonc from 'jsonc-parser'; +import * as path from 'path'; +import * as URL from 'url'; +import * as tar from 'tar'; +import * as crypto from 'crypto'; +import * as semver from 'semver'; +import * as os from 'os'; + +import { DevContainerConfig, DevContainerFeature, VSCodeCustomizations } from './configuration'; +import { mkdirpLocal, readLocalFile, rmLocal, writeLocalFile, cpDirectoryLocal, isLocalFile } from '../spec-utils/pfs'; +import { Log, LogLevel, nullLog } from '../spec-utils/log'; +import { request } from '../spec-utils/httpRequest'; +import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI'; +import { uriToFsPath } from './configurationCommonUtils'; +import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI'; +import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; +import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; +import { logFeatureAdvisories } from './featureAdvisories'; +import { getEntPasswdShellCommand } from '../spec-common/commonUtils'; +import { ContainerError } from '../spec-common/errors'; + +// v1 +const V1_ASSET_NAME = 'devcontainer-features.tgz'; +export const V1_DEVCONTAINER_FEATURES_FILE_NAME = 'devcontainer-features.json'; + +// v2 +export const DEVCONTAINER_FEATURE_FILE_NAME = 'devcontainer-feature.json'; + +export type Feature = SchemaFeatureBaseProperties & SchemaFeatureLifecycleHooks & DeprecatedSchemaFeatureProperties & InternalFeatureProperties; + +export const FEATURES_CONTAINER_TEMP_DEST_FOLDER = '/tmp/dev-container-features'; + +export interface SchemaFeatureLifecycleHooks { + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; +} + +// Properties who are members of the schema +export interface SchemaFeatureBaseProperties { + id: string; + version?: string; + name?: string; + description?: string; + documentationURL?: string; + licenseURL?: string; + options?: Record; + containerEnv?: Record; + mounts?: Mount[]; + init?: boolean; + privileged?: boolean; + capAdd?: string[]; + securityOpt?: string[]; + entrypoint?: string; + customizations?: VSCodeCustomizations; + installsAfter?: string[]; + deprecated?: boolean; + legacyIds?: string[]; + dependsOn?: Record>; +} + +// Properties that are set programmatically for book-keeping purposes +export interface InternalFeatureProperties { + cachePath?: string; + internalVersion?: string; + consecutiveId?: string; + value: boolean | string | Record; + currentId?: string; + included: boolean; +} + +// Old or deprecated properties maintained for backwards compatibility +export interface DeprecatedSchemaFeatureProperties { + buildArg?: string; + include?: string[]; + exclude?: string[]; +} + +export type FeatureOption = { + type: 'boolean'; + default?: boolean; + description?: string; +} | { + type: 'string'; + enum?: string[]; + default?: string; + description?: string; +} | { + type: 'string'; + proposals?: string[]; + default?: string; + description?: string; +}; +export interface Mount { + type: 'bind' | 'volume'; + source?: string; + target: string; + external?: boolean; +} + +const normalizedMountKeys: Record = { + src: 'source', + destination: 'target', + dst: 'target', +}; + +export function parseMount(str: string): Mount { + return str.split(',') + .map(s => s.split('=')) + .reduce((acc, [key, value]) => ({ ...acc, [(normalizedMountKeys[key] || key)]: value }), {}) as Mount; +} + +export type SourceInformation = GithubSourceInformation | DirectTarballSourceInformation | FilePathSourceInformation | OCISourceInformation; + +interface BaseSourceInformation { + type: string; + userFeatureId: string; // Dictates how a supporting tool will locate and download a given feature. See https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features.md#referencing-a-feature + userFeatureIdWithoutVersion?: string; +} + +export interface OCISourceInformation extends BaseSourceInformation { + type: 'oci'; + featureRef: OCIRef; + manifest: OCIManifest; + manifestDigest: string; + userFeatureIdWithoutVersion: string; +} + +export interface DirectTarballSourceInformation extends BaseSourceInformation { + type: 'direct-tarball'; + tarballUri: string; +} + +export interface FilePathSourceInformation extends BaseSourceInformation { + type: 'file-path'; + resolvedFilePath: string; // Resolved, absolute file path +} + +// deprecated +export interface GithubSourceInformation extends BaseSourceInformation { + type: 'github-repo'; + apiUri: string; + unauthenticatedUri: string; + owner: string; + repo: string; + isLatest: boolean; // 'true' indicates user didn't supply a version tag, thus we implicitly pull latest. + tag?: string; + ref?: string; + sha?: string; + userFeatureIdWithoutVersion: string; +} + +export interface FeatureSet { + features: Feature[]; + internalVersion?: string; + sourceInformation: SourceInformation; + computedDigest?: string; +} + +export interface FeaturesConfig { + featureSets: FeatureSet[]; + dstFolder?: string; // set programatically +} + +export interface GitHubApiReleaseInfo { + assets: GithubApiReleaseAsset[]; + name: string; + tag_name: string; +} + +export interface GithubApiReleaseAsset { + url: string; + name: string; + content_type: string; + size: number; + download_count: number; + updated_at: string; +} + +export interface ContainerFeatureInternalParams { + extensionPath: string; + cacheFolder: string; + cwd: string; + output: Log; + env: NodeJS.ProcessEnv; + skipFeatureAutoMapping: boolean; + platform: NodeJS.Platform; + experimentalLockfile?: boolean; + experimentalFrozenLockfile?: boolean; +} + +// TODO: Move to node layer. +export function getContainerFeaturesBaseDockerFile(contentSourceRootPath: string) { + return ` + +#{nonBuildKitFeatureContentFallback} + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source ${path.posix.join(contentSourceRootPath, 'devcontainer-features.builtin.env')} /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} + +#{featureLayer} + +#{containerEnv} + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER + +#{devcontainerMetadata} + +#{containerEnvMetadata} +`; +} + +export function getFeatureInstallWrapperScript(feature: Feature, featureSet: FeatureSet, options: string[]): string { + const id = escapeQuotesForShell(featureSet.sourceInformation.userFeatureIdWithoutVersion ?? 'Unknown'); + const name = escapeQuotesForShell(feature.name ?? 'Unknown'); + const description = escapeQuotesForShell(feature.description ?? ''); + const version = escapeQuotesForShell(feature.version ?? ''); + const documentation = escapeQuotesForShell(feature.documentationURL ?? ''); + const optionsIndented = escapeQuotesForShell(options.map(x => ` ${x}`).join('\n')); + + let warningHeader = ''; + if (feature.deprecated) { + warningHeader += `(!) WARNING: Using the deprecated Feature "${escapeQuotesForShell(feature.id)}". This Feature will no longer receive any further updates/support.\n`; + } + + if (feature?.legacyIds && feature.legacyIds.length > 0 && feature.currentId && feature.id !== feature.currentId) { + warningHeader += `(!) WARNING: This feature has been renamed. Please update the reference in devcontainer.json to "${escapeQuotesForShell(feature.currentId)}".`; + } + + const echoWarning = warningHeader ? `echo '${warningHeader}'` : ''; + const errorMessage = `ERROR: Feature "${name}" (${id}) failed to install!`; + const troubleshootingMessage = documentation + ? ` Look at the documentation at ${documentation} for help troubleshooting this error.` + : ''; + + return `#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo '${errorMessage}${troubleshootingMessage}' +} + +trap on_exit EXIT + +echo =========================================================================== +${echoWarning} +echo 'Feature : ${name}' +echo 'Description : ${description}' +echo 'Id : ${id}' +echo 'Version : ${version}' +echo 'Documentation : ${documentation}' +echo 'Options :' +echo '${optionsIndented}' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh +`; +} + +function escapeQuotesForShell(input: string) { + // The `input` is expected to be a string which will be printed inside single quotes + // by the caller. This means we need to escape any nested single quotes within the string. + // We can do this by ending the first string with a single quote ('), printing an escaped + // single quote (\'), and then opening a new string ('). + return input.replace(new RegExp(`'`, 'g'), `'\\''`); +} + +export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features') { + + const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`; + let result = `RUN \\ +echo "_CONTAINER_USER_HOME=$(${getEntPasswdShellCommand(containerUser)} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ +echo "_REMOTE_USER_HOME=$(${getEntPasswdShellCommand(remoteUser)} | cut -d: -f6)" >> ${builtinsEnvFile} + +`; + + // Features version 1 + const folders = (featuresConfig.featureSets || []).filter(y => y.internalVersion !== '2').map(x => x.features[0].consecutiveId); + folders.forEach(folder => { + const source = path.posix.join(contentSourceRootPath, folder!); + const dest = path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, folder!); + if (!useBuildKitBuildContexts) { + result += `COPY --chown=root:root --from=dev_containers_feature_content_source ${source} ${dest} +RUN chmod -R 0755 ${dest} \\ +&& cd ${dest} \\ +&& chmod +x ./install.sh \\ +&& ./install.sh + +`; + } else { + result += `RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\ + cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ + && chmod -R 0755 ${dest} \\ + && cd ${dest} \\ + && chmod +x ./install.sh \\ + && ./install.sh \\ + && rm -rf ${dest} + +`; + } + }); + // Features version 2 + featuresConfig.featureSets.filter(y => y.internalVersion === '2').forEach(featureSet => { + featureSet.features.forEach(feature => { + result += generateContainerEnvs(feature.containerEnv); + const source = path.posix.join(contentSourceRootPath, feature.consecutiveId!); + const dest = path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, feature.consecutiveId!); + if (!useBuildKitBuildContexts) { + result += ` +COPY --chown=root:root --from=dev_containers_feature_content_source ${source} ${dest} +RUN chmod -R 0755 ${dest} \\ +&& cd ${dest} \\ +&& chmod +x ./devcontainer-features-install.sh \\ +&& ./devcontainer-features-install.sh + +`; + } else { + result += ` +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\ + cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ + && chmod -R 0755 ${dest} \\ + && cd ${dest} \\ + && chmod +x ./devcontainer-features-install.sh \\ + && ./devcontainer-features-install.sh \\ + && rm -rf ${dest} + +`; + } + }); + }); + return result; +} + +// Features version two export their environment variables as part of the Dockerfile to make them available to subsequent features. +export function generateContainerEnvs(containerEnv: Record | undefined, escapeDollar = false): string { + if (!containerEnv) { + return ''; + } + const keys = Object.keys(containerEnv); + // https://docs.docker.com/engine/reference/builder/#envs + const r = escapeDollar ? /(?=["\\$])/g : /(?=["\\])/g; // escape double quotes, back slash, and optionally dollar sign + return keys.map(k => `ENV ${k}="${containerEnv[k] + .replace(r, '\\') + }"`).join('\n'); +} + +const allowedFeatureIdRegex = new RegExp('^[a-zA-Z0-9_-]*$'); + +// Parses a declared feature in user's devcontainer file into +// a usable URI to download remote features. +// RETURNS +// { +// "id", <----- The ID of the feature in the feature set. +// sourceInformation <----- Source information (is this locally cached, a GitHub remote feature, etc..), including tarballUri if applicable. +// } +// + +const cleanupIterationFetchAndMerge = async (tempTarballPath: string, output: Log) => { + // Non-fatal, will just get overwritten if we don't do the cleaned up. + try { + await rmLocal(tempTarballPath, { force: true }); + } catch (e) { + output.write(`Didn't remove temporary tarball from disk with caught exception: ${e?.Message} `, LogLevel.Trace); + } +}; + +function getRequestHeaders(params: CommonParams, sourceInformation: SourceInformation) { + const { env, output } = params; + let headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string } = { + 'user-agent': 'devcontainer' + }; + + const isGitHubUri = (srcInfo: DirectTarballSourceInformation) => { + const uri = srcInfo.tarballUri; + return uri.startsWith('https://github.com') || uri.startsWith('https://api.github.com'); + }; + + if (sourceInformation.type === 'github-repo' || (sourceInformation.type === 'direct-tarball' && isGitHubUri(sourceInformation))) { + const githubToken = env['GITHUB_TOKEN']; + if (githubToken) { + output.write('Using environment GITHUB_TOKEN.'); + headers.Authorization = `Bearer ${githubToken}`; + } else { + output.write('No environment GITHUB_TOKEN available.'); + } + } + return headers; +} + +async function askGitHubApiForTarballUri(sourceInformation: GithubSourceInformation, feature: Feature, headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string }, output: Log) { + const options = { + type: 'GET', + url: sourceInformation.apiUri, + headers + }; + + const apiInfo: GitHubApiReleaseInfo = JSON.parse(((await request(options, output)).toString())); + if (apiInfo) { + const asset = + apiInfo.assets.find(a => a.name === `${feature.id}.tgz`) // v2 + || apiInfo.assets.find(a => a.name === V1_ASSET_NAME) // v1 + || undefined; + + if (asset && asset.url) { + output.write(`Found url to fetch release artifact '${asset.name}'. Asset of size ${asset.size} has been downloaded ${asset.download_count} times and was last updated at ${asset.updated_at}`); + return asset.url; + } else { + output.write('Unable to fetch release artifact URI from GitHub API', LogLevel.Error); + return undefined; + } + } + return undefined; +} + +function updateFromOldProperties(original: T): T { + // https://github.com/microsoft/dev-container-spec/issues/1 + if (!original.features.find(f => f.extensions || f.settings)) { + return original; + } + return { + ...original, + features: original.features.map(f => { + if (!(f.extensions || f.settings)) { + return f; + } + const copy = { ...f }; + const customizations = copy.customizations || (copy.customizations = {}); + const vscode = customizations.vscode || (customizations.vscode = {}); + if (copy.extensions) { + vscode.extensions = (vscode.extensions || []).concat(copy.extensions); + delete copy.extensions; + } + if (copy.settings) { + vscode.settings = { + ...copy.settings, + ...(vscode.settings || {}), + }; + delete copy.settings; + } + return copy; + }), + }; +} + +// Generate a base featuresConfig object with the set of locally-cached features, +// as well as downloading and merging in remote feature definitions. +export async function generateFeaturesConfig(params: ContainerFeatureInternalParams, dstFolder: string, config: DevContainerConfig, additionalFeatures: Record>) { + const { output } = params; + + const workspaceRoot = params.cwd; + output.write(`workspace root: ${workspaceRoot}`, LogLevel.Trace); + + const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(config, additionalFeatures), output); + if (!userFeatures) { + return undefined; + } + + let configPath = config.configFilePath && uriToFsPath(config.configFilePath, params.platform); + output.write(`configPath: ${configPath}`, LogLevel.Trace); + + const ociCacheDir = await prepareOCICache(dstFolder); + + const { lockfile, initLockfile } = await readLockfile(config); + + const processFeature = async (_userFeature: DevContainerFeature) => { + return await processFeatureIdentifier(params, configPath, workspaceRoot, _userFeature, lockfile); + }; + + output.write('--- Processing User Features ----', LogLevel.Trace); + const featureSets = await computeDependsOnInstallationOrder(params, processFeature, userFeatures, config, lockfile); + if (!featureSets) { + throw new Error('Failed to compute Feature installation order!'); + } + + // Create the featuresConfig object. + const featuresConfig: FeaturesConfig = { + featureSets, + dstFolder + }; + + // Fetch features, stage into the appropriate build folder, and read the feature's devcontainer-feature.json + output.write('--- Fetching User Features ----', LogLevel.Trace); + await fetchFeatures(params, featuresConfig, dstFolder, ociCacheDir, lockfile); + + await logFeatureAdvisories(params, featuresConfig); + await writeLockfile(params, config, await generateLockfile(featuresConfig), initLockfile); + return featuresConfig; +} + +export async function loadVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { + const userFeatures = userFeaturesToArray(config); + if (!userFeatures) { + return { features: {} }; + } + + const { lockfile } = await readLockfile(config); + + const resolved: Record = {}; + + await Promise.all(userFeatures.map(async userFeature => { + const userFeatureId = userFeature.userFeatureId; + const featureRef = getRef(nullLog, userFeatureId); // Filters out Feature identifiers that cannot be versioned (e.g. local paths, deprecated, etc..) + if (featureRef) { + const versions = (await getVersionsStrictSorted(params, featureRef)) + ?.reverse() || []; + if (versions) { + const lockfileVersion = lockfile?.features[userFeatureId]?.version; + let wanted = lockfileVersion; + const tag = featureRef.tag; + if (tag) { + if (tag === 'latest') { + wanted = versions[0]; + } else { + wanted = versions.find(version => semver.satisfies(version, tag)); + } + } else if (featureRef.digest && !wanted) { + const { type, manifest } = await getFeatureIdType(params, userFeatureId, undefined); + if (type === 'oci' && manifest) { + const wantedFeature = await findOCIFeatureMetadata(params, manifest); + wanted = wantedFeature?.version; + } + } + resolved[userFeatureId] = { + current: lockfileVersion || wanted, + wanted, + wantedMajor: wanted && semver.major(wanted)?.toString(), + latest: versions[0], + latestMajor: versions[0] && semver.major(versions[0])?.toString(), + }; + } + } + })); + + // Reorder Features to match the order in which they were specified in config + return { + features: userFeatures.reduce((acc, userFeature) => { + const r = resolved[userFeature.userFeatureId]; + if (r) { + acc[userFeature.userFeatureId] = r; + } + return acc; + }, {} as Record) + }; +} + +async function findOCIFeatureMetadata(params: ContainerFeatureInternalParams, manifest: ManifestContainer) { + const annotation = manifest.manifestObj.annotations?.['dev.containers.metadata']; + if (annotation) { + return jsonc.parse(annotation) as Feature; + } + + // Backwards compatibility. + const featureSet = tryGetOCIFeatureSet(params.output, manifest.canonicalId, {}, manifest, manifest.canonicalId); + if (!featureSet) { + return undefined; + } + + const tmp = path.join(os.tmpdir(), crypto.randomUUID()); + const f = await fetchOCIFeature(params, featureSet, tmp, tmp, DEVCONTAINER_FEATURE_FILE_NAME); + return f.metadata as Feature | undefined; +} + +async function prepareOCICache(dstFolder: string) { + const ociCacheDir = path.join(dstFolder, 'ociCache'); + await mkdirpLocal(ociCacheDir); + + return ociCacheDir; +} + +export function userFeaturesToArray(config: DevContainerConfig, additionalFeatures?: Record>): DevContainerFeature[] | undefined { + if (!Object.keys(config.features || {}).length && !Object.keys(additionalFeatures || {}).length) { + return undefined; + } + + const userFeatures: DevContainerFeature[] = []; + const userFeatureKeys = new Set(); + + if (config.features) { + for (const userFeatureKey of Object.keys(config.features)) { + const userFeatureValue = config.features[userFeatureKey]; + const feature: DevContainerFeature = { + userFeatureId: userFeatureKey, + options: userFeatureValue + }; + userFeatures.push(feature); + userFeatureKeys.add(userFeatureKey); + } + } + + if (additionalFeatures) { + for (const userFeatureKey of Object.keys(additionalFeatures)) { + // add the additional feature if it hasn't already been added from the config features + if (!userFeatureKeys.has(userFeatureKey)) { + const userFeatureValue = additionalFeatures[userFeatureKey]; + const feature: DevContainerFeature = { + userFeatureId: userFeatureKey, + options: userFeatureValue + }; + userFeatures.push(feature); + } + } + } + + return userFeatures; +} + +const deprecatedFeaturesIntoOptions: Record = { + gradle: { + mapTo: 'java', + withOptions: { + installGradle: true + } + }, + maven: { + mapTo: 'java', + withOptions: { + installMaven: true + } + }, + jupyterlab: { + mapTo: 'python', + withOptions: { + installJupyterlab: true + } + }, +}; + +export function updateDeprecatedFeaturesIntoOptions(userFeatures: DevContainerFeature[] | undefined, output: Log) { + if (!userFeatures) { + output.write('No user features to update', LogLevel.Trace); + return; + } + + const newFeaturePath = 'ghcr.io/devcontainers/features'; + const versionBackwardComp = '1'; + for (const update of userFeatures.filter(feature => deprecatedFeaturesIntoOptions[feature.userFeatureId])) { + const { mapTo, withOptions } = deprecatedFeaturesIntoOptions[update.userFeatureId]; + output.write(`(!) WARNING: Using the deprecated '${update.userFeatureId}' Feature. It is now part of the '${mapTo}' Feature. See https://github.com/devcontainers/features/tree/main/src/${mapTo}#options for the updated Feature.`, LogLevel.Warning); + const qualifiedMapToId = `${newFeaturePath}/${mapTo}`; + let userFeature = userFeatures.find(feature => feature.userFeatureId === mapTo || feature.userFeatureId === qualifiedMapToId || feature.userFeatureId.startsWith(`${qualifiedMapToId}:`)); + if (userFeature) { + userFeature.options = { + ...( + typeof userFeature.options === 'object' ? userFeature.options : + typeof userFeature.options === 'string' ? { version: userFeature.options } : + {} + ), + ...withOptions, + }; + } else { + userFeature = { + userFeatureId: `${qualifiedMapToId}:${versionBackwardComp}`, + options: withOptions + }; + userFeatures.push(userFeature); + } + } + const updatedUserFeatures = userFeatures.filter(feature => !deprecatedFeaturesIntoOptions[feature.userFeatureId]); + return updatedUserFeatures; +} + +export async function getFeatureIdType(params: CommonParams, userFeatureId: string, lockfile: Lockfile | undefined) { + const { output } = params; + // See the specification for valid feature identifiers: + // > https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features.md#referencing-a-feature + // + // Additionally, we support the following deprecated syntaxes for backwards compatibility: + // (0) A 'local feature' packaged with the CLI. + // Syntax: + // + // (1) A feature backed by a GitHub Release + // Syntax: //[@version] + + // Legacy feature-set ID + if (!userFeatureId.includes('/') && !userFeatureId.includes('\\')) { + const errorMessage = `Legacy feature '${userFeatureId}' not supported. Please check https://containers.dev/features for replacements. +If you were hoping to use local Features, remember to prepend your Feature name with "./". Please check https://containers.dev/implementors/features-distribution/#addendum-locally-referenced for more information.`; + output.write(errorMessage, LogLevel.Error); + throw new ContainerError({ + description: errorMessage + }); + } + + // Direct tarball reference + if (userFeatureId.startsWith('https://')) { + return { type: 'direct-tarball', manifest: undefined }; + } + + // Local feature on disk + // !! NOTE: The ability for paths outside the project file tree will soon be removed. + if (userFeatureId.startsWith('./') || userFeatureId.startsWith('../') || userFeatureId.startsWith('/')) { + return { type: 'file-path', manifest: undefined }; + } + + const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier(params, userFeatureId, lockfile?.features[userFeatureId]?.integrity); + if (manifest) { + return { type: 'oci', manifest: manifest }; + } else { + output.write(`Could not resolve Feature manifest for '${userFeatureId}'. If necessary, provide registry credentials with 'docker login '.`, LogLevel.Warning); + output.write(`Falling back to legacy GitHub Releases mode to acquire Feature.`, LogLevel.Trace); + + // DEPRECATED: This is a legacy feature-set ID + return { type: 'github-repo', manifest: undefined }; + } +} + +export function getBackwardCompatibleFeatureId(output: Log, id: string) { + const migratedfeatures = ['aws-cli', 'azure-cli', 'desktop-lite', 'docker-in-docker', 'docker-from-docker', 'dotnet', 'git', 'git-lfs', 'github-cli', 'java', 'kubectl-helm-minikube', 'node', 'powershell', 'python', 'ruby', 'rust', 'sshd', 'terraform']; + const renamedFeatures = new Map(); + renamedFeatures.set('golang', 'go'); + renamedFeatures.set('common', 'common-utils'); + + const deprecatedFeaturesIntoOptions = new Map(); + deprecatedFeaturesIntoOptions.set('gradle', 'java'); + deprecatedFeaturesIntoOptions.set('maven', 'java'); + deprecatedFeaturesIntoOptions.set('jupyterlab', 'python'); + + const newFeaturePath = 'ghcr.io/devcontainers/features'; + // Note: Pin the versionBackwardComp to '1' to avoid breaking changes. + const versionBackwardComp = '1'; + + // Mapping feature references (old shorthand syntax) from "microsoft/vscode-dev-containers" to "ghcr.io/devcontainers/features" + if (migratedfeatures.includes(id)) { + output.write(`(!) WARNING: Using the deprecated '${id}' Feature. See https://github.com/devcontainers/features/tree/main/src/${id}#example-usage for the updated Feature.`, LogLevel.Warning); + return `${newFeaturePath}/${id}:${versionBackwardComp}`; + } + + // Mapping feature references (renamed old shorthand syntax) from "microsoft/vscode-dev-containers" to "ghcr.io/devcontainers/features" + if (renamedFeatures.get(id) !== undefined) { + output.write(`(!) WARNING: Using the deprecated '${id}' Feature. See https://github.com/devcontainers/features/tree/main/src/${renamedFeatures.get(id)}#example-usage for the updated Feature.`, LogLevel.Warning); + return `${newFeaturePath}/${renamedFeatures.get(id)}:${versionBackwardComp}`; + } + + if (deprecatedFeaturesIntoOptions.get(id) !== undefined) { + output.write(`(!) WARNING: Falling back to the deprecated '${id}' Feature. It is now part of the '${deprecatedFeaturesIntoOptions.get(id)}' Feature. See https://github.com/devcontainers/features/tree/main/src/${deprecatedFeaturesIntoOptions.get(id)}#options for the updated Feature.`, LogLevel.Warning); + } + + // Deprecated and all other features references (eg. fish, ghcr.io/devcontainers/features/go, ghcr.io/owner/repo/id etc) + return id; +} + +// Strictly processes the user provided feature identifier to determine sourceInformation type. +// Returns a featureSet per feature. +export async function processFeatureIdentifier(params: CommonParams, configPath: string | undefined, _workspaceRoot: string, userFeature: DevContainerFeature, lockfile?: Lockfile, skipFeatureAutoMapping?: boolean): Promise { + const { output } = params; + + output.write(`* Processing feature: ${userFeature.userFeatureId}`); + + // id referenced by the user before the automapping from old shorthand syntax to "ghcr.io/devcontainers/features" + const originalUserFeatureId = userFeature.userFeatureId; + // Adding backward compatibility + if (!skipFeatureAutoMapping) { + userFeature.userFeatureId = getBackwardCompatibleFeatureId(output, userFeature.userFeatureId); + } + + const { type, manifest } = await getFeatureIdType(params, userFeature.userFeatureId, lockfile); + + // remote tar file + if (type === 'direct-tarball') { + output.write(`Remote tar file found.`); + const tarballUri = new URL.URL(userFeature.userFeatureId); + + const fullPath = tarballUri.pathname; + const tarballName = fullPath.substring(fullPath.lastIndexOf('/') + 1); + output.write(`tarballName = ${tarballName}`, LogLevel.Trace); + + const regex = new RegExp('devcontainer-feature-(.*).tgz'); + const matches = regex.exec(tarballName); + + if (!matches || matches.length !== 2) { + output.write(`Expected tarball name to follow 'devcontainer-feature-.tgz' format. Received '${tarballName}'`, LogLevel.Error); + return undefined; + } + const id = matches[1]; + + if (id === '' || !allowedFeatureIdRegex.test(id)) { + output.write(`Parse error. Specify a feature id with alphanumeric, dash, or underscore characters. Received ${id}.`, LogLevel.Error); + return undefined; + } + + let feat: Feature = { + id: id, + name: userFeature.userFeatureId, + value: userFeature.options, + included: true, + }; + + let newFeaturesSet: FeatureSet = { + sourceInformation: { + type: 'direct-tarball', + tarballUri: tarballUri.toString(), + userFeatureId: originalUserFeatureId + }, + features: [feat], + }; + + return newFeaturesSet; + } + + // Spec: https://containers.dev/implementors/features-distribution/#addendum-locally-referenced + if (type === 'file-path') { + output.write(`Local disk feature.`); + + const id = path.basename(userFeature.userFeatureId); + + // Fail on Absolute paths. + if (path.isAbsolute(userFeature.userFeatureId)) { + output.write('An Absolute path to a local feature is not allowed.', LogLevel.Error); + return undefined; + } + + // Local-path features are expected to be a sub-folder of the '$WORKSPACE_ROOT/.devcontainer' folder. + if (!configPath) { + output.write('A local feature requires a configuration path.', LogLevel.Error); + return undefined; + } + const featureFolderPath = path.join(path.dirname(configPath), userFeature.userFeatureId); + + // Ensure we aren't escaping .devcontainer folder + const parent = path.join(_workspaceRoot, '.devcontainer'); + const child = featureFolderPath; + const relative = path.relative(parent, child); + output.write(`${parent} -> ${child}: Relative Distance = '${relative}'`, LogLevel.Trace); + if (relative.indexOf('..') !== -1) { + output.write(`Local file path parse error. Resolved path must be a child of the .devcontainer/ folder. Parsed: ${featureFolderPath}`, LogLevel.Error); + return undefined; + } + + output.write(`Resolved: ${userFeature.userFeatureId} -> ${featureFolderPath}`, LogLevel.Trace); + + // -- All parsing and validation steps complete at this point. + + output.write(`Parsed feature id: ${id}`, LogLevel.Trace); + let feat: Feature = { + id, + name: userFeature.userFeatureId, + value: userFeature.options, + included: true, + }; + + let newFeaturesSet: FeatureSet = { + sourceInformation: { + type: 'file-path', + resolvedFilePath: featureFolderPath, + userFeatureId: originalUserFeatureId + }, + features: [feat], + }; + + return newFeaturesSet; + } + + // (6) Oci Identifier + if (type === 'oci' && manifest) { + return tryGetOCIFeatureSet(output, userFeature.userFeatureId, userFeature.options, manifest, originalUserFeatureId); + } + + output.write(`Github feature.`); + // Github repository source. + let version = 'latest'; + let splitOnAt = userFeature.userFeatureId.split('@'); + if (splitOnAt.length > 2) { + output.write(`Parse error. Use the '@' symbol only to designate a version tag.`, LogLevel.Error); + return undefined; + } + if (splitOnAt.length === 2) { + output.write(`[${userFeature.userFeatureId}] has version ${splitOnAt[1]}`, LogLevel.Trace); + version = splitOnAt[1]; + } + + // Remaining info must be in the first part of the split. + const featureBlob = splitOnAt[0]; + const splitOnSlash = featureBlob.split('/'); + // We expect all GitHub/registry features to follow the triple slash pattern at this point + // eg: // + if (splitOnSlash.length !== 3 || splitOnSlash.some(x => x === '') || !allowedFeatureIdRegex.test(splitOnSlash[2])) { + // This is the final fallback. If we end up here, we weren't able to resolve the Feature + output.write(`Could not resolve Feature '${userFeature.userFeatureId}'. Ensure the Feature is published and accessible from your current environment.`, LogLevel.Error); + return undefined; + } + const owner = splitOnSlash[0]; + const repo = splitOnSlash[1]; + const id = splitOnSlash[2]; + + let feat: Feature = { + id: id, + name: userFeature.userFeatureId, + value: userFeature.options, + included: true, + }; + + const userFeatureIdWithoutVersion = originalUserFeatureId.split('@')[0]; + if (version === 'latest') { + let newFeaturesSet: FeatureSet = { + sourceInformation: { + type: 'github-repo', + apiUri: `https://api.github.com/repos/${owner}/${repo}/releases/latest`, + unauthenticatedUri: `https://github.com/${owner}/${repo}/releases/latest/download`, // v1/v2 implementations append name of relevant asset + owner, + repo, + isLatest: true, + userFeatureId: originalUserFeatureId, + userFeatureIdWithoutVersion + }, + features: [feat], + }; + return newFeaturesSet; + } else { + // We must have a tag, return a tarball URI for the tagged version. + let newFeaturesSet: FeatureSet = { + sourceInformation: { + type: 'github-repo', + apiUri: `https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`, + unauthenticatedUri: `https://github.com/${owner}/${repo}/releases/download/${version}`, // v1/v2 implementations append name of relevant asset + owner, + repo, + tag: version, + isLatest: false, + userFeatureId: originalUserFeatureId, + userFeatureIdWithoutVersion + }, + features: [feat], + }; + return newFeaturesSet; + } + + // TODO: Handle invalid source types better by refactoring this function. + // throw new Error(`Unsupported feature source type: ${type}`); +} + +async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { + const featureSets = featuresConfig.featureSets; + for (let idx = 0; idx < featureSets.length; idx++) { // Index represents the previously computed installation order. + const featureSet = featureSets[idx]; + try { + if (!featureSet || !featureSet.features || !featureSet.sourceInformation) { + continue; + } + + const { output } = params; + + const feature = featureSet.features[0]; + const consecutiveId = `${feature.id}_${idx}`; + // Calculate some predictable caching paths. + const featCachePath = path.join(dstFolder, consecutiveId); + const sourceInfoType = featureSet.sourceInformation?.type; + + feature.cachePath = featCachePath; + feature.consecutiveId = consecutiveId; + + if (!feature.consecutiveId || !feature.id || !featureSet?.sourceInformation || !featureSet.sourceInformation.userFeatureId) { + const err = 'Internal Features error. Missing required attribute(s).'; + throw new Error(err); + } + + const featureDebugId = `${feature.consecutiveId}_${sourceInfoType}`; + output.write(`* Fetching feature: ${featureDebugId}`); + + if (sourceInfoType === 'oci') { + output.write(`Fetching from OCI`, LogLevel.Trace); + await mkdirpLocal(featCachePath); + const res = await fetchOCIFeature(params, featureSet, ociCacheDir, featCachePath); + if (!res) { + const err = `Could not download OCI feature: ${featureSet.sourceInformation.featureRef.id}`; + throw new Error(err); + } + + if (!(await applyFeatureConfigToFeature(output, featureSet, feature, featCachePath, featureSet.sourceInformation.manifestDigest))) { + const err = `Failed to parse feature '${featureDebugId}'. Please check your devcontainer.json 'features' attribute.`; + throw new Error(err); + } + output.write(`* Fetched feature: ${featureDebugId} version ${feature.version}`); + + continue; + } + + if (sourceInfoType === 'file-path') { + output.write(`Detected local file path`, LogLevel.Trace); + await mkdirpLocal(featCachePath); + const executionPath = featureSet.sourceInformation.resolvedFilePath; + await cpDirectoryLocal(executionPath, featCachePath); + + if (!(await applyFeatureConfigToFeature(output, featureSet, feature, featCachePath, undefined))) { + const err = `Failed to parse feature '${featureDebugId}'. Please check your devcontainer.json 'features' attribute.`; + throw new Error(err); + } + continue; + } + + output.write(`Detected tarball`, LogLevel.Trace); + const headers = getRequestHeaders(params, featureSet.sourceInformation); + + // Ordered list of tarballUris to attempt to fetch from. + let tarballUris: (string | { uri: string; digest?: string })[] = []; + + if (sourceInfoType === 'github-repo') { + output.write('Determining tarball URI for provided github repo.', LogLevel.Trace); + if (headers.Authorization && headers.Authorization !== '') { + output.write('GITHUB_TOKEN available. Attempting to fetch via GH API.', LogLevel.Info); + const authenticatedGithubTarballUri = await askGitHubApiForTarballUri(featureSet.sourceInformation, feature, headers, output); + + if (authenticatedGithubTarballUri) { + tarballUris.push(authenticatedGithubTarballUri); + } else { + output.write('Failed to generate autenticated tarball URI for provided feature, despite a GitHub token present', LogLevel.Warning); + } + headers.Accept = 'Accept: application/octet-stream'; + } + + // Always add the unauthenticated URIs as fallback options. + output.write('Appending unauthenticated URIs for v2 and then v1', LogLevel.Trace); + tarballUris.push(`${featureSet.sourceInformation.unauthenticatedUri}/${feature.id}.tgz`); + tarballUris.push(`${featureSet.sourceInformation.unauthenticatedUri}/${V1_ASSET_NAME}`); + + } else { + // We have a plain ol' tarball URI, since we aren't in the github-repo case. + const uri = featureSet.sourceInformation.tarballUri; + const digest = lockfile?.features[uri]?.integrity; + tarballUris.push({ uri, digest }); + } + + // Attempt to fetch from 'tarballUris' in order, until one succeeds. + let res: { computedDigest: string } | undefined; + for (const tarballUri of tarballUris) { + const uri = typeof tarballUri === 'string' ? tarballUri : tarballUri.uri; + const digest = typeof tarballUri === 'string' ? undefined : tarballUri.digest; + res = await fetchContentsAtTarballUri(params, uri, digest, featCachePath, headers, dstFolder); + + if (res) { + output.write(`Succeeded fetching ${uri}`, LogLevel.Trace); + if (!(await applyFeatureConfigToFeature(output, featureSet, feature, featCachePath, res.computedDigest))) { + const err = `Failed to parse feature '${featureDebugId}'. Please check your devcontainer.json 'features' attribute.`; + throw new Error(err); + } + break; + } + } + + if (!res) { + const msg = `(!) Failed to fetch tarball for ${featureDebugId} after attempting ${tarballUris.length} possibilities.`; + throw new Error(msg); + } + } + catch (e) { + params.output.write(`(!) ERR: Failed to fetch feature: ${e?.message ?? ''} `, LogLevel.Error); + throw e; + } + } +} + +export async function fetchContentsAtTarballUri(params: { output: Log; env: NodeJS.ProcessEnv }, tarballUri: string, expectedDigest: string | undefined, featCachePath: string, headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string } | undefined, dstFolder: string, metadataFile?: string): Promise<{ computedDigest: string; metadata: {} | undefined } | undefined> { + const { output } = params; + const tempTarballPath = path.join(dstFolder, 'temp.tgz'); + try { + const options = { + type: 'GET', + url: tarballUri, + headers: headers ?? getRequestHeaders(params, { tarballUri, userFeatureId: tarballUri, type: 'direct-tarball' }) + }; + + output.write(`Fetching tarball at ${options.url}`); + output.write(`Headers: ${JSON.stringify(options)}`, LogLevel.Trace); + const tarball = await request(options, output); + + if (!tarball || tarball.length === 0) { + output.write(`Did not receive a response from tarball download URI: ${tarballUri}`, LogLevel.Trace); + return undefined; + } + + const computedDigest = `sha256:${crypto.createHash('sha256').update(tarball).digest('hex')}`; + if (expectedDigest && computedDigest !== expectedDigest) { + throw new Error(`Digest did not match for ${tarballUri}.`); + } + + // Filter what gets emitted from the tar.extract(). + const filter = (file: string, _: tar.FileStat) => { + // Don't include .dotfiles or the archive itself. + if (file.startsWith('./.') || file === `./${V1_ASSET_NAME}` || file === './.') { + return false; + } + return true; + }; + + output.write(`Preparing to unarchive received tgz from ${tempTarballPath} -> ${featCachePath}.`, LogLevel.Trace); + // Create the directory to cache this feature-set in. + await mkdirpLocal(featCachePath); + await writeLocalFile(tempTarballPath, tarball); + await tar.x( + { + file: tempTarballPath, + cwd: featCachePath, + filter + } + ); + + // No 'metadataFile' to look for. + if (!metadataFile) { + await cleanupIterationFetchAndMerge(tempTarballPath, output); + return { computedDigest, metadata: undefined }; + } + + // Attempt to extract 'metadataFile' + await tar.x( + { + file: tempTarballPath, + cwd: featCachePath, + filter: (path: string, _: tar.FileStat) => { + return path === `./${metadataFile}`; + } + }); + const pathToMetadataFile = path.join(featCachePath, metadataFile); + let metadata = undefined; + if (await isLocalFile(pathToMetadataFile)) { + output.write(`Found metadata file '${metadataFile}' in tgz`, LogLevel.Trace); + metadata = jsonc.parse((await readLocalFile(pathToMetadataFile)).toString()); + } + + await cleanupIterationFetchAndMerge(tempTarballPath, output); + return { computedDigest, metadata }; + } catch (e) { + output.write(`Caught failure when fetching from URI '${tarballUri}': ${e}`, LogLevel.Trace); + await cleanupIterationFetchAndMerge(tempTarballPath, output); + return undefined; + } +} + +// Reads the feature's 'devcontainer-feature.json` and applies any attributes to the in-memory Feature object. +// NOTE: +// Implements the latest ('internalVersion' = '2') parsing logic, +// Falls back to earlier implementation(s) if requirements not present. +// Returns a boolean indicating whether the feature was successfully parsed. +async function applyFeatureConfigToFeature(output: Log, featureSet: FeatureSet, feature: Feature, featCachePath: string, computedDigest: string | undefined): Promise { + const innerJsonPath = path.join(featCachePath, DEVCONTAINER_FEATURE_FILE_NAME); + + if (!(await isLocalFile(innerJsonPath))) { + output.write(`Feature ${feature.id} is not a 'v2' feature. Attempting fallback to 'v1' implementation.`, LogLevel.Trace); + output.write(`For v2, expected devcontainer-feature.json at ${innerJsonPath}`, LogLevel.Trace); + return await parseDevContainerFeature_v1Impl(output, featureSet, feature, featCachePath); + } + + featureSet.internalVersion = '2'; + featureSet.computedDigest = computedDigest; + feature.cachePath = featCachePath; + const jsonString: Buffer = await readLocalFile(innerJsonPath); + const featureJson = jsonc.parse(jsonString.toString()); + + + feature = { + ...featureJson, + ...feature + }; + + featureSet.features[0] = updateFromOldProperties({ features: [feature] }).features[0]; + + return true; +} + +async function parseDevContainerFeature_v1Impl(output: Log, featureSet: FeatureSet, feature: Feature, featCachePath: string): Promise { + + const pathToV1DevContainerFeatureJson = path.join(featCachePath, V1_DEVCONTAINER_FEATURES_FILE_NAME); + + if (!(await isLocalFile(pathToV1DevContainerFeatureJson))) { + output.write(`Failed to find ${V1_DEVCONTAINER_FEATURES_FILE_NAME} metadata file (v1)`, LogLevel.Error); + return false; + } + featureSet.internalVersion = '1'; + feature.cachePath = featCachePath; + const jsonString: Buffer = await readLocalFile(pathToV1DevContainerFeatureJson); + const featureJson: FeatureSet = jsonc.parse(jsonString.toString()); + + const seekedFeature = featureJson?.features.find(f => f.id === feature.id); + if (!seekedFeature) { + output.write(`Failed to find feature '${feature.id}' in provided v1 metadata file`, LogLevel.Error); + return false; + } + + feature = { + ...seekedFeature, + ...feature + }; + + featureSet.features[0] = updateFromOldProperties({ features: [feature] }).features[0]; + + + return true; +} + +export function getFeatureMainProperty(feature: Feature) { + return feature.options?.version ? 'version' : undefined; +} + +export function getFeatureMainValue(feature: Feature) { + const defaultProperty = getFeatureMainProperty(feature); + if (!defaultProperty) { + return !!feature.value; + } + if (typeof feature.value === 'object') { + const value = feature.value[defaultProperty]; + if (value === undefined && feature.options) { + return feature.options[defaultProperty]?.default; + } + return value; + } + if (feature.value === undefined && feature.options) { + return feature.options[defaultProperty]?.default; + } + return feature.value; +} + +export function getFeatureValueObject(feature: Feature) { + if (typeof feature.value === 'object') { + return { + ...getFeatureValueDefaults(feature), + ...feature.value + }; + } + const mainProperty = getFeatureMainProperty(feature); + if (!mainProperty) { + return getFeatureValueDefaults(feature); + } + return { + ...getFeatureValueDefaults(feature), + [mainProperty]: feature.value, + }; +} + +function getFeatureValueDefaults(feature: Feature) { + const options = feature.options || {}; + return Object.keys(options) + .reduce((defaults, key) => { + if ('default' in options[key]) { + defaults[key] = options[key].default; + } + return defaults; + }, {} as Record); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOCI.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOCI.ts new file mode 100644 index 000000000000..84fb869cf26d --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOCI.ts @@ -0,0 +1,74 @@ +import { Log, LogLevel } from '../spec-utils/log'; +import { Feature, FeatureSet } from './containerFeaturesConfiguration'; +import { CommonParams, fetchOCIManifestIfExists, getBlob, getRef, ManifestContainer } from './containerCollectionsOCI'; + +export function tryGetOCIFeatureSet(output: Log, identifier: string, options: boolean | string | Record, manifest: ManifestContainer, originalUserFeatureId: string): FeatureSet | undefined { + const featureRef = getRef(output, identifier); + if (!featureRef) { + output.write(`Unable to parse '${identifier}'`, LogLevel.Error); + return undefined; + } + + const feat: Feature = { + id: featureRef.id, + included: true, + value: options + }; + + const userFeatureIdWithoutVersion = getFeatureIdWithoutVersion(originalUserFeatureId); + let featureSet: FeatureSet = { + sourceInformation: { + type: 'oci', + manifest: manifest.manifestObj, + manifestDigest: manifest.contentDigest, + featureRef: featureRef, + userFeatureId: originalUserFeatureId, + userFeatureIdWithoutVersion + + }, + features: [feat], + }; + + return featureSet; +} + +const lastDelimiter = /[:@][^/]*$/; +export function getFeatureIdWithoutVersion(featureId: string) { + const m = lastDelimiter.exec(featureId); + return m ? featureId.substring(0, m.index) : featureId; +} + +export async function fetchOCIFeatureManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string): Promise { + const { output } = params; + + const featureRef = getRef(output, identifier); + if (!featureRef) { + return undefined; + } + return await fetchOCIManifestIfExists(params, featureRef, manifestDigest); +} + +// Download a feature from which a manifest was previously downloaded. +// Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#pulling-blobs +export async function fetchOCIFeature(params: CommonParams, featureSet: FeatureSet, ociCacheDir: string, featCachePath: string, metadataFile?: string) { + const { output } = params; + + if (featureSet.sourceInformation.type !== 'oci') { + output.write(`FeatureSet is not an OCI featureSet.`, LogLevel.Error); + throw new Error('FeatureSet is not an OCI featureSet.'); + } + + const { featureRef } = featureSet.sourceInformation; + + const layerDigest = featureSet.sourceInformation.manifest?.layers[0].digest; + const blobUrl = `https://${featureSet.sourceInformation.featureRef.registry}/v2/${featureSet.sourceInformation.featureRef.path}/blobs/${layerDigest}`; + output.write(`blob url: ${blobUrl}`, LogLevel.Trace); + + const blobResult = await getBlob(params, blobUrl, ociCacheDir, featCachePath, featureRef, layerDigest, undefined, metadataFile); + + if (!blobResult) { + throw new Error(`Failed to download package for ${featureSet.sourceInformation.featureRef.resource}`); + } + + return blobResult; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOrder.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOrder.ts new file mode 100644 index 000000000000..18f449a0a1f9 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerFeaturesOrder.ts @@ -0,0 +1,706 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; +import * as os from 'os'; +import * as crypto from 'crypto'; + +import { DEVCONTAINER_FEATURE_FILE_NAME, DirectTarballSourceInformation, Feature, FeatureSet, FilePathSourceInformation, OCISourceInformation, fetchContentsAtTarballUri } from '../spec-configuration/containerFeaturesConfiguration'; +import { LogLevel } from '../spec-utils/log'; +import { DevContainerFeature } from './configuration'; +import { CommonParams, OCIRef } from './containerCollectionsOCI'; +import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; +import { fetchOCIFeature } from './containerFeaturesOCI'; +import { Lockfile } from './lockfile'; + +interface FNode { + type: 'user-provided' | 'override' | 'resolved'; + userFeatureId: string; + options: string | boolean | Record; + + // FeatureSet contains 'sourceInformation', useful for: + // Providing information on if Feature is an OCI Feature, Direct HTTPS Feature, or Local Feature. + // Additionally, contains 'ref' and 'manifestDigest' for OCI Features - useful for sorting. + // Property set programatically when discovering all the nodes in the graph. + featureSet?: FeatureSet; + + // Graph directed adjacency lists. + dependsOn: FNode[]; + installsAfter: FNode[]; + + // If a Feature was renamed, this property will contain: + // [, <...allPreviousIds>] + // See: https://containers.dev/implementors/features/#steps-to-rename-a-feature + // Eg: ['node', 'nodejs', 'nodejs-feature'] + featureIdAliases?: string[]; + + // Round Order Priority + // Effective value is always the max + roundPriority: number; +} + +interface DependencyGraph { + worklist: FNode[]; +} + +function equals(params: CommonParams, a: FNode, b: FNode): boolean { + const { output } = params; + + const aSourceInfo = a.featureSet?.sourceInformation; + let bSourceInfo = b.featureSet?.sourceInformation; // Mutable only for type-casting. + + if (!aSourceInfo || !bSourceInfo) { + output.write(`Missing sourceInfo: equals(${aSourceInfo?.userFeatureId}, ${bSourceInfo?.userFeatureId})`, LogLevel.Trace); + throw new Error('ERR: Failure resolving Features.'); + } + + if (aSourceInfo.type !== bSourceInfo.type) { + return false; + } + + return compareTo(params, a, b) === 0; +} + +function satisfiesSoftDependency(params: CommonParams, node: FNode, softDep: FNode): boolean { + const { output } = params; + + const nodeSourceInfo = node.featureSet?.sourceInformation; + let softDepSourceInfo = softDep.featureSet?.sourceInformation; // Mutable only for type-casting. + + if (!nodeSourceInfo || !softDepSourceInfo) { + output.write(`Missing sourceInfo: satisifiesSoftDependency(${nodeSourceInfo?.userFeatureId}, ${softDepSourceInfo?.userFeatureId})`, LogLevel.Trace); + throw new Error('ERR: Failure resolving Features.'); + } + + if (nodeSourceInfo.type !== softDepSourceInfo.type) { + return false; + } + + switch (nodeSourceInfo.type) { + case 'oci': + softDepSourceInfo = softDepSourceInfo as OCISourceInformation; + const nodeFeatureRef = nodeSourceInfo.featureRef; + const softDepFeatureRef = softDepSourceInfo.featureRef; + const softDepFeatureRefPrefix = `${softDepFeatureRef.registry}/${softDepFeatureRef.namespace}`; + + return nodeFeatureRef.resource === softDepFeatureRef.resource // Same resource + || softDep.featureIdAliases?.some(legacyId => `${softDepFeatureRefPrefix}/${legacyId}` === nodeFeatureRef.resource) // Handle 'legacyIds' + || false; + + case 'file-path': + softDepSourceInfo = softDepSourceInfo as FilePathSourceInformation; + return nodeSourceInfo.resolvedFilePath === softDepSourceInfo.resolvedFilePath; + + case 'direct-tarball': + softDepSourceInfo = softDepSourceInfo as DirectTarballSourceInformation; + return nodeSourceInfo.tarballUri === softDepSourceInfo.tarballUri; + + default: + // Legacy + const softDepId = softDepSourceInfo.userFeatureIdWithoutVersion || softDepSourceInfo.userFeatureId; + const nodeId = nodeSourceInfo.userFeatureIdWithoutVersion || nodeSourceInfo.userFeatureId; + return softDepId === nodeId; + + } +} + +function optionsCompareTo(a: string | boolean | Record, b: string | boolean | Record): number { + if (typeof a === 'string' && typeof b === 'string') { + return a.localeCompare(b); + } + + if (typeof a === 'boolean' && typeof b === 'boolean') { + return a === b ? 0 : a ? 1 : -1; + } + + if (typeof a === 'object' && typeof b === 'object') { + // Compare lengths + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return aKeys.length - bKeys.length; + } + + aKeys.sort(); + bKeys.sort(); + + for (let i = 0; i < aKeys.length; i++) { + // Compare keys + if (aKeys[i] !== bKeys[i]) { + return aKeys[i].localeCompare(bKeys[i]); + } + // Compare values + const aVal = a[aKeys[i]]; + const bVal = b[bKeys[i]]; + if (typeof aVal === 'string' && typeof bVal === 'string') { + const v = aVal.localeCompare(bVal); + if (v !== 0) { + return v; + } + } + if (typeof aVal === 'boolean' && typeof bVal === 'boolean') { + const v = aVal === bVal ? 0 : aVal ? 1 : -1; + if (v !== 0) { + return v; + } + } + if (typeof aVal === 'undefined' || typeof bVal === 'undefined') { + const v = aVal === bVal ? 0 : (aVal === undefined) ? 1 : -1; + if (v !== 0) { + return v; + } + } + } + // Object is piece-wise equal + return 0; + } + return (typeof a).localeCompare(typeof b); +} + +function ociResourceCompareTo(a: { featureRef: OCIRef; aliases?: string[] }, b: { featureRef: OCIRef; aliases?: string[] }): number { + + // Left Side + const aFeatureRef = a.featureRef; + const aRegistryAndNamespace = `${aFeatureRef.registry}/${aFeatureRef.namespace}`; + + // Right Side + const bFeatureRef = b.featureRef; + const bRegistryAndNamespace = `${bFeatureRef.registry}/${bFeatureRef.namespace}`; + + // If the registry+namespace are different, sort by them + if (aRegistryAndNamespace !== bRegistryAndNamespace) { + return aRegistryAndNamespace.localeCompare(bRegistryAndNamespace); + } + + let commonId: string | undefined = undefined; + // Determine if any permutation of the set of valid Ids are equal + // Prefer the the canonical/non-legacy Id. + // https://containers.dev/implementors/features/#steps-to-rename-a-feature + for (const aId of a.aliases || [aFeatureRef.id]) { + if (commonId) { + break; + } + for (const bId of b.aliases || [bFeatureRef.id]) { + if (aId === bId) { + commonId = aId; + break; + } + } + } + + if (!commonId) { + // Sort by canonical id + return aFeatureRef.id.localeCompare(bFeatureRef.id); + } + + // The (registry + namespace + id) are equal. + return 0; +} + +// If the two features are equal, return 0. +// If the sorting algorithm should place A _before_ B, return negative number. +// If the sorting algorithm should place A _after_ B, return positive number. +function compareTo(params: CommonParams, a: FNode, b: FNode): number { + const { output } = params; + + const aSourceInfo = a.featureSet?.sourceInformation; + let bSourceInfo = b.featureSet?.sourceInformation; // Mutable only for type-casting. + + if (!aSourceInfo || !bSourceInfo) { + output.write(`Missing sourceInfo: compareTo(${aSourceInfo?.userFeatureId}, ${bSourceInfo?.userFeatureId})`, LogLevel.Trace); + throw new Error('ERR: Failure resolving Features.'); + } + + if (aSourceInfo.type !== bSourceInfo.type) { + return aSourceInfo.userFeatureId.localeCompare(bSourceInfo.userFeatureId); + } + + switch (aSourceInfo.type) { + case 'oci': + bSourceInfo = bSourceInfo as OCISourceInformation; + + const aDigest = aSourceInfo.manifestDigest; + const bDigest = bSourceInfo.manifestDigest; + + // Short circuit if the digests and options are equal + if (aDigest === bDigest && optionsCompareTo(a.options, b.options) === 0) { + return 0; + } + + // Compare two OCI Features by their + // resource accounting for legacy id aliases + const ociResourceVal = ociResourceCompareTo( + { featureRef: aSourceInfo.featureRef, aliases: a.featureIdAliases }, + { featureRef: bSourceInfo.featureRef, aliases: b.featureIdAliases } + ); + + if (ociResourceVal !== 0) { + return ociResourceVal; + } + + const aTag = aSourceInfo.featureRef.tag; + const bTag = bSourceInfo.featureRef.tag; + // Sort by tags (if both have tags) + // Eg: 1.9.9, 2.0.0, 2.0.1, 3, latest + if ((aTag && bTag) && (aTag !== bTag)) { + return aTag.localeCompare(bTag); + } + + // Sort by options + const optionsVal = optionsCompareTo(a.options, b.options); + if (optionsVal !== 0) { + return optionsVal; + } + + // Sort by manifest digest hash + if (aDigest !== bDigest) { + return aDigest.localeCompare(bDigest); + } + + // Consider these two OCI Features equal. + return 0; + + case 'file-path': + bSourceInfo = bSourceInfo as FilePathSourceInformation; + const pathCompare = aSourceInfo.resolvedFilePath.localeCompare(bSourceInfo.resolvedFilePath); + if (pathCompare !== 0) { + return pathCompare; + } + return optionsCompareTo(a.options, b.options); + + case 'direct-tarball': + bSourceInfo = bSourceInfo as DirectTarballSourceInformation; + const urlCompare = aSourceInfo.tarballUri.localeCompare(bSourceInfo.tarballUri); + if (urlCompare !== 0) { + return urlCompare; + } + return optionsCompareTo(a.options, b.options); + + default: + // Legacy + const aId = aSourceInfo.userFeatureIdWithoutVersion || aSourceInfo.userFeatureId; + const bId = bSourceInfo.userFeatureIdWithoutVersion || bSourceInfo.userFeatureId; + const userIdCompare = aId.localeCompare(bId); + if (userIdCompare !== 0) { + return userIdCompare; + } + return optionsCompareTo(a.options, b.options); + } +} + +async function applyOverrideFeatureInstallOrder( + params: CommonParams, + processFeature: (userFeature: DevContainerFeature) => Promise, + worklist: FNode[], + config: { overrideFeatureInstallOrder?: string[] }, +) { + const { output } = params; + + if (!config.overrideFeatureInstallOrder) { + return worklist; + } + + // Create an override node for each Feature in the override property. + const originalLength = config.overrideFeatureInstallOrder.length; + for (let i = config.overrideFeatureInstallOrder.length - 1; i >= 0; i--) { + const overrideFeatureId = config.overrideFeatureInstallOrder[i]; + + // First element == N, last element == 1 + const roundPriority = originalLength - i; + + const tmpOverrideNode: FNode = { + type: 'override', + userFeatureId: overrideFeatureId, + options: {}, + roundPriority, + installsAfter: [], + dependsOn: [], + featureSet: undefined, + }; + + const processed = await processFeature(tmpOverrideNode); + if (!processed) { + throw new Error(`Feature '${tmpOverrideNode.userFeatureId}' in 'overrideFeatureInstallOrder' could not be processed.`); + } + + tmpOverrideNode.featureSet = processed; + + // Scan the worklist, incrementing the priority of each Feature that matches the override. + for (const node of worklist) { + if (satisfiesSoftDependency(params, node, tmpOverrideNode)) { + // Increase the priority of this node to install it sooner. + output.write(`[override]: '${node.userFeatureId}' has override priority of ${roundPriority}`, LogLevel.Trace); + node.roundPriority = Math.max(node.roundPriority, roundPriority); + } + } + } + + // Return the modified worklist. + return worklist; +} + +async function _buildDependencyGraph( + params: CommonParams, + processFeature: (userFeature: DevContainerFeature) => Promise, + worklist: FNode[], + acc: FNode[], + lockfile: Lockfile | undefined): Promise { + const { output } = params; + + while (worklist.length > 0) { + const current = worklist.shift()!; + + output.write(`Resolving Feature dependencies for '${current.userFeatureId}'...`, LogLevel.Info); + + const processedFeature = await processFeature(current); + if (!processedFeature) { + throw new Error(`ERR: Feature '${current.userFeatureId}' could not be processed. You may not have permission to access this Feature, or may not be logged in. If the issue persists, report this to the Feature author.`); + } + + // Set the processed FeatureSet object onto Node. + current.featureSet = processedFeature; + + // If the current Feature is already in the accumulator, skip it. + // This stops cycles but doesn't report them. + // Cycles/inconsistencies are thrown as errors in the next stage (rounds). + if (acc.some(f => equals(params, f, current))) { + continue; + } + + const type = processedFeature.sourceInformation.type; + let metadata: Feature | undefined; + // Switch on the source type of the provided Feature. + // Retrieving the metadata for the Feature (the contents of 'devcontainer-feature.json') + switch (type) { + case 'oci': + metadata = await getOCIFeatureMetadata(params, current); + break; + + case 'file-path': + const filePath = (current.featureSet.sourceInformation as FilePathSourceInformation).resolvedFilePath; + const metadataFilePath = path.join(filePath, DEVCONTAINER_FEATURE_FILE_NAME); + if (!isLocalFile(filePath)) { + throw new Error(`Metadata file '${metadataFilePath}' cannot be read for Feature '${current.userFeatureId}'.`); + } + const serialized = (await readLocalFile(metadataFilePath)).toString(); + if (serialized) { + metadata = jsonc.parse(serialized) as Feature; + } + break; + + case 'direct-tarball': + const tarballUri = (processedFeature.sourceInformation as DirectTarballSourceInformation).tarballUri; + const expectedDigest = lockfile?.features[tarballUri]?.integrity; + metadata = await getTgzFeatureMetadata(params, current, expectedDigest); + break; + + default: + // Legacy + // No dependency metadata to retrieve. + break; + } + + // Resolve dependencies given the current Feature's metadata. + if (metadata) { + current.featureSet.features[0] = { + ...current.featureSet.features[0], + ...metadata, + }; + + // Dependency-related properties + const dependsOn = metadata.dependsOn || {}; + const installsAfter = metadata.installsAfter || []; + + // Remember legacyIds + const legacyIds = (metadata.legacyIds || []); + const currentId = metadata.currentId || metadata.id; + current.featureIdAliases = [currentId, ...legacyIds]; + + // Add a new node for each 'dependsOn' dependency onto the 'current' node. + // **Add this new node to the worklist to process recursively** + for (const [userFeatureId, options] of Object.entries(dependsOn)) { + const dependency: FNode = { + type: 'resolved', + userFeatureId, + options, + featureSet: undefined, + dependsOn: [], + installsAfter: [], + roundPriority: 0, + }; + current.dependsOn.push(dependency); + worklist.push(dependency); + } + + // Add a new node for each 'installsAfter' soft-dependency onto the 'current' node. + // Soft-dependencies are NOT recursively processed - do *not* add to worklist. + for (const userFeatureId of installsAfter) { + const dependency: FNode = { + type: 'resolved', + userFeatureId, + options: {}, + featureSet: undefined, + dependsOn: [], + installsAfter: [], + roundPriority: 0, + }; + const processedFeatureSet = await processFeature(dependency); + if (!processedFeatureSet) { + throw new Error(`installsAfter dependency '${userFeatureId}' of Feature '${current.userFeatureId}' could not be processed.`); + } + + dependency.featureSet = processedFeatureSet; + + // Resolve and add all 'legacyIds' as aliases for the soft dependency relationship. + // https://containers.dev/implementors/features/#steps-to-rename-a-feature + const softDepMetadata = await getOCIFeatureMetadata(params, dependency); + if (softDepMetadata) { + const legacyIds = softDepMetadata.legacyIds || []; + const currentId = softDepMetadata.currentId || softDepMetadata.id; + dependency.featureIdAliases = [currentId, ...legacyIds]; + } + + current.installsAfter.push(dependency); + } + } + + acc.push(current); + } + + // Return the accumulated collection of dependencies. + return { + worklist: acc, + }; +} + +async function getOCIFeatureMetadata(params: CommonParams, node: FNode): Promise { + const { output } = params; + + // TODO: Implement a caching layer here! + // This can be optimized to share work done here + // with the 'fetchFeatures()` stage later on. + const srcInfo = node?.featureSet?.sourceInformation; + if (!node.featureSet || !srcInfo || srcInfo.type !== 'oci') { + return; + } + + const manifest = srcInfo.manifest; + const annotation = manifest?.annotations?.['dev.containers.metadata']; + + if (annotation) { + return jsonc.parse(annotation) as Feature; + } else { + // For backwards compatibility, + // If the metadata is not present on the manifest, we have to fetch the entire blob + // to extract the 'installsAfter' property. + // TODO: Cache this smarter to reuse later! + const tmp = path.join(os.tmpdir(), crypto.randomUUID()); + const f = await fetchOCIFeature(params, node.featureSet, tmp, tmp, DEVCONTAINER_FEATURE_FILE_NAME); + + if (f && f.metadata) { + return f.metadata as Feature; + } + } + output.write('No metadata found for Feature', LogLevel.Trace); + return; +} + +async function getTgzFeatureMetadata(params: CommonParams, node: FNode, expectedDigest: string | undefined): Promise { + const { output } = params; + + // TODO: Implement a caching layer here! + // This can be optimized to share work done here + // with the 'fetchFeatures()` stage later on. + const srcInfo = node?.featureSet?.sourceInformation; + if (!node.featureSet || !srcInfo || srcInfo.type !== 'direct-tarball') { + return; + } + + const tmp = path.join(os.tmpdir(), crypto.randomUUID()); + const result = await fetchContentsAtTarballUri(params, srcInfo.tarballUri, expectedDigest, tmp, undefined, tmp, DEVCONTAINER_FEATURE_FILE_NAME); + if (!result || !result.metadata) { + output.write(`No metadata for Feature '${node.userFeatureId}' from '${srcInfo.tarballUri}'`, LogLevel.Trace); + return; + } + + const metadata = result.metadata as Feature; + return metadata; + +} + +// Creates the directed acyclic graph (DAG) of Features and their dependencies. +export async function buildDependencyGraph( + params: CommonParams, + processFeature: (userFeature: DevContainerFeature) => Promise, + userFeatures: DevContainerFeature[], + config: { overrideFeatureInstallOrder?: string[] }, + lockfile: Lockfile | undefined): Promise { + + const { output } = params; + + const rootNodes = + userFeatures.map(f => { + return { + type: 'user-provided', // This Feature was provided by the user in the 'features' object of devcontainer.json. + userFeatureId: f.userFeatureId, + options: f.options, + dependsOn: [], + installsAfter: [], + roundPriority: 0, + }; + }); + + output.write(`[* user-provided] ${rootNodes.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + + const { worklist } = await _buildDependencyGraph(params, processFeature, rootNodes, [], lockfile); + + output.write(`[* resolved worklist] ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + + // Apply the 'overrideFeatureInstallOrder' to the worklist. + if (config?.overrideFeatureInstallOrder) { + await applyOverrideFeatureInstallOrder(params, processFeature, worklist, config); + } + + return { worklist }; +} + +// Returns the ordered list of FeatureSets to fetch and install, or undefined on error. +export async function computeDependsOnInstallationOrder( + params: CommonParams, + processFeature: (userFeature: DevContainerFeature) => Promise, + userFeatures: DevContainerFeature[], + config: { overrideFeatureInstallOrder?: string[] }, + lockfile?: Lockfile, + precomputedGraph?: DependencyGraph): Promise { + + const { output } = params; + + // Build dependency graph and resolves all to FeatureSets. + const graph = precomputedGraph ?? await buildDependencyGraph(params, processFeature, userFeatures, config, lockfile); + if (!graph) { + return; + } + + const { worklist } = graph; + + if (worklist.length === 0) { + output.write('Zero length or undefined worklist.', LogLevel.Error); + return; + } + + output.write(`${JSON.stringify(worklist, null, 2)}`, LogLevel.Trace); + + // Sanity check + if (worklist.some(node => !node.featureSet)) { + output.write('Feature dependency worklist contains one or more undefined entries.', LogLevel.Error); + throw new Error(`ERR: Failure resolving Features.`); + } + + output.write(`[raw worklist]: ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + + // For each node in the worklist, remove all 'soft-dependency' graph edges that are irrelevant + // i.e. the node is not a 'soft match' for any node in the worklist itself + for (let i = 0; i < worklist.length; i++) { + const node = worklist[i]; + // reverse iterate + for (let j = node.installsAfter.length - 1; j >= 0; j--) { + const softDep = node.installsAfter[j]; + if (!worklist.some(n => satisfiesSoftDependency(params, n, softDep))) { + output.write(`Soft-dependency '${softDep.userFeatureId}' is not required. Removing from installation order...`, LogLevel.Info); + // Delete that soft-dependency + node.installsAfter.splice(j, 1); + } + } + } + + output.write(`[worklist-without-dangling-soft-deps]: ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write('Starting round-based Feature install order calculation from worklist...', LogLevel.Trace); + + const installationOrder: FNode[] = []; + while (worklist.length > 0) { + const round = worklist.filter(node => + // If the node has no hard/soft dependencies, the node can always be installed. + (node.dependsOn.length === 0 && node.installsAfter.length === 0) + // OR, every hard-dependency (dependsOn) AND soft-dependency (installsAfter) has been satified in prior rounds + || node.dependsOn.every(dep => + installationOrder.some(installed => equals(params, installed, dep))) + && node.installsAfter.every(dep => + installationOrder.some(installed => satisfiesSoftDependency(params, installed, dep)))); + + output.write(`\n[round] ${round.map(r => r.userFeatureId).join(', ')}`, LogLevel.Trace); + if (round.length === 0) { + output.write('Circular dependency detected!', LogLevel.Error); + output.write(`Nodes remaining: ${worklist.map(n => n.userFeatureId!).join(', ')}`, LogLevel.Error); + return; + } + + output.write(`[round-candidates] ${round.map(r => `${r.userFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); + + // Given the set of eligible nodes to install this round, + // determine the highest 'roundPriority' present of the nodes in this + // round, and exclude nodes from this round with a lower priority. + // This ensures that both: + // - The pre-computed graph derived from dependOn/installsAfter is honored + // - The overrideFeatureInstallOrder property (more generically, 'roundPriority') is honored + const maxRoundPriority = Math.max(...round.map(r => r.roundPriority)); + round.splice(0, round.length, ...round.filter(node => node.roundPriority === maxRoundPriority)); + output.write(`[round-after-filter-priority] (maxPriority=${maxRoundPriority}) ${round.map(r => `${r.userFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); + + // Delete all nodes present in this round from the worklist. + worklist.splice(0, worklist.length, ...worklist.filter(node => !round.some(r => equals(params, r, node)))); + + // Sort rounds lexicographically by id. + round.sort((a, b) => compareTo(params, a, b)); + output.write(`[round-after-comparesTo] ${round.map(r => r.userFeatureId).join(', ')}`, LogLevel.Trace); + + // Commit round + installationOrder.push(...round); + } + + return installationOrder.map(n => n.featureSet!); +} + +// Pretty-print the calculated graph in the mermaid flowchart format. +// Viewable by copy-pasting the output string to a live editor, i.e: https://mermaid.live/ +export function generateMermaidDiagram(params: CommonParams, graph: FNode[]) { + // Output dependency graph in a mermaid flowchart format + const roots = graph?.filter(f => f.type === 'user-provided')!; + let str = 'flowchart\n'; + for (const root of roots) { + str += `${generateMermaidNode(root)}\n`; + str += `${generateMermaidSubtree(params, root, graph).reduce((p, c) => p + c + '\n', '')}`; + } + return str; +} + +function generateMermaidSubtree(params: CommonParams, current: FNode, worklist: FNode[], acc: string[] = []) { + for (const child of current.dependsOn) { + // For each corresponding member of the worklist that satisfies this hard-dependency + for (const w of worklist) { + if (equals(params, w, child)) { + acc.push(`${generateMermaidNode(current)} --> ${generateMermaidNode(w)}`); + } + } + generateMermaidSubtree(params, child, worklist, acc); + } + for (const softDep of current.installsAfter) { + // For each corresponding member of the worklist that satisfies this soft-dependency + for (const w of worklist) { + if (satisfiesSoftDependency(params, w, softDep)) { + acc.push(`${generateMermaidNode(current)} -.-> ${generateMermaidNode(w)}`); + } + } + generateMermaidSubtree(params, softDep, worklist, acc); + } + return acc; +} + +function generateMermaidNode(node: FNode) { + const hasher = crypto.createHash('sha256', { encoding: 'hex' }); + const hash = hasher.update(JSON.stringify(node)).digest('hex').slice(0, 6); + const aliases = node.featureIdAliases && node.featureIdAliases.length > 0 ? `
aliases: ${node.featureIdAliases.join(', ')}` : ''; + return `${hash}[${node.userFeatureId}
<${node.roundPriority}>${aliases}]`; +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesConfiguration.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesConfiguration.ts new file mode 100644 index 000000000000..2020bac6543b --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesConfiguration.ts @@ -0,0 +1,33 @@ +export interface Template { + id: string; + version?: string; + name?: string; + description?: string; + documentationURL?: string; + licenseURL?: string; + type?: string; // Added programatically during packaging + fileCount?: number; // Added programatically during packaging + featureIds?: string[]; + options?: Record; + platforms?: string[]; + publisher?: string; + keywords?: string[]; + optionalPaths?: string[]; + files: string[]; // Added programatically during packaging +} + +export type TemplateOption = { + type: 'boolean'; + default?: boolean; + description?: string; +} | { + type: 'string'; + enum?: string[]; + default?: string; + description?: string; +} | { + type: 'string'; + default?: string; + proposals?: string[]; + description?: string; +}; diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesOCI.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesOCI.ts new file mode 100644 index 000000000000..4c5c277552c0 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/containerTemplatesOCI.ts @@ -0,0 +1,177 @@ +import { Log, LogLevel } from '../spec-utils/log'; +import * as os from 'os'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; +import { CommonParams, fetchOCIManifestIfExists, getBlob, getRef, ManifestContainer } from './containerCollectionsOCI'; +import { isLocalFile, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; +import { DevContainerConfig } from './configuration'; +import { Template } from './containerTemplatesConfiguration'; + +export interface TemplateOptions { + [name: string]: string; +} +export interface TemplateFeatureOption { + id: string; + options: Record; +} + +export interface SelectedTemplate { + id: string; + options: TemplateOptions; + features: TemplateFeatureOption[]; + omitPaths: string[]; +} + +export async function fetchTemplate(params: CommonParams, selectedTemplate: SelectedTemplate, templateDestPath: string, userProvidedTmpDir?: string): Promise { + const { output } = params; + + let { id: userSelectedId, options: userSelectedOptions, omitPaths } = selectedTemplate; + const templateRef = getRef(output, userSelectedId); + if (!templateRef) { + output.write(`Failed to parse template ref for ${userSelectedId}`, LogLevel.Error); + return; + } + + const ociManifest = await fetchOCITemplateManifestIfExistsFromUserIdentifier(params, userSelectedId); + if (!ociManifest) { + output.write(`Failed to fetch template manifest for ${userSelectedId}`, LogLevel.Error); + return; + } + const blobDigest = ociManifest?.manifestObj?.layers[0]?.digest; + if (!blobDigest) { + output.write(`Failed to fetch template manifest for ${userSelectedId}`, LogLevel.Error); + return; + } + + const blobUrl = `https://${templateRef.registry}/v2/${templateRef.path}/blobs/${blobDigest}`; + output.write(`blob url: ${blobUrl}`, LogLevel.Trace); + + const tmpDir = userProvidedTmpDir || path.join(os.tmpdir(), 'vsch-template-temp', `${Date.now()}`); + const blobResult = await getBlob(params, blobUrl, tmpDir, templateDestPath, templateRef, blobDigest, [...omitPaths, 'devcontainer-template.json', 'README.md', 'NOTES.md'], 'devcontainer-template.json'); + + if (!blobResult) { + output.write(`Failed to download package for ${templateRef.resource}`, LogLevel.Error); + return; + } + + const { files, metadata } = blobResult; + + // Auto-replace default values for values not provided by user. + if (metadata) { + const templateMetadata = metadata as Template; + if (templateMetadata.options) { + const templateOptions = templateMetadata.options; + for (const templateOptionKey of Object.keys(templateOptions)) { + if (userSelectedOptions[templateOptionKey] === undefined) { + // If the user didn't provide a value for this option, use the default if there is one in the extracted metadata. + const templateOption = templateOptions[templateOptionKey]; + + if (templateOption.type === 'string') { + const _default = templateOption.default; + if (_default) { + output.write(`Using default value for ${templateOptionKey} --> ${_default}`, LogLevel.Trace); + userSelectedOptions[templateOptionKey] = _default; + } + } + else if (templateOption.type === 'boolean') { + const _default = templateOption.default; + if (_default) { + output.write(`Using default value for ${templateOptionKey} --> ${_default}`, LogLevel.Trace); + userSelectedOptions[templateOptionKey] = _default.toString(); + } + } + } + } + } + } + + // Scan all template files and replace any templated values. + for (const f of files) { + output.write(`Scanning file '${f}'`, LogLevel.Trace); + const filePath = path.join(templateDestPath, f); + if (await isLocalFile(filePath)) { + const fileContents = await readLocalFile(filePath); + const fileContentsReplaced = replaceTemplatedValues(output, fileContents.toString(), userSelectedOptions); + await writeLocalFile(filePath, Buffer.from(fileContentsReplaced)); + } else { + output.write(`Could not find templated file '${f}'.`, LogLevel.Error); + } + } + + // Get the config. A template should not have more than one devcontainer.json. + const config = async (files: string[]) => { + const p = files.find(f => f.endsWith('devcontainer.json')); + if (p) { + const configPath = path.join(templateDestPath, p); + if (await isLocalFile(configPath)) { + const configContents = await readLocalFile(configPath); + return { + configPath, + configText: configContents.toString(), + configObject: jsonc.parse(configContents.toString()) as DevContainerConfig, + }; + } + } + return undefined; + }; + + if (selectedTemplate.features.length !== 0) { + const configResult = await config(files); + if (configResult) { + await addFeatures(output, selectedTemplate.features, configResult); + } else { + output.write(`Could not find a devcontainer.json to apply selected Features onto.`, LogLevel.Error); + } + } + + return files; +} + + +async function fetchOCITemplateManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string): Promise { + const { output } = params; + + const templateRef = getRef(output, identifier); + if (!templateRef) { + return undefined; + } + return await fetchOCIManifestIfExists(params, templateRef, manifestDigest); +} + +function replaceTemplatedValues(output: Log, template: string, options: TemplateOptions) { + const pattern = /\${templateOption:\s*(\w+?)\s*}/g; // ${templateOption:XXXX} + return template.replace(pattern, (_, token) => { + output.write(`Replacing ${token} with ${options[token]}`); + return options[token] || ''; + }); +} + +async function addFeatures(output: Log, newFeatures: TemplateFeatureOption[], configResult: { configPath: string; configText: string; configObject: DevContainerConfig }) { + const { configPath, configText, configObject } = configResult; + if (newFeatures) { + let previousText = configText; + let updatedText = configText; + + // Add the features property if it doesn't exist. + if (!configObject.features) { + const edits = jsonc.modify(updatedText, ['features'], {}, { formattingOptions: {} }); + updatedText = jsonc.applyEdits(updatedText, edits); + } + + for (const newFeature of newFeatures) { + let edits: jsonc.Edit[] = []; + const propertyPath = ['features', newFeature.id]; + + edits = edits.concat( + jsonc.modify(updatedText, propertyPath, newFeature.options ?? {}, { formattingOptions: {} } + )); + + updatedText = jsonc.applyEdits(updatedText, edits); + } + + if (previousText !== updatedText) { + output.write(`Updating ${configPath} with ${newFeatures.length} Features`, LogLevel.Trace); + await writeLocalFile(configPath, Buffer.from(updatedText)); + } + } +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/controlManifest.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/controlManifest.ts new file mode 100644 index 000000000000..a2d4b7a1304e --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/controlManifest.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; + +import { request } from '../spec-utils/httpRequest'; +import * as crypto from 'crypto'; +import { Log, LogLevel } from '../spec-utils/log'; + +export interface DisallowedFeature { + featureIdPrefix: string; + documentationURL?: string; +} + +export interface FeatureAdvisory { + featureId: string; + introducedInVersion: string; + fixedInVersion: string; + description: string; + documentationURL?: string; + +} + +export interface DevContainerControlManifest { + disallowedFeatures: DisallowedFeature[]; + featureAdvisories: FeatureAdvisory[]; +} + +const controlManifestFilename = 'control-manifest.json'; + +const emptyControlManifest: DevContainerControlManifest = { + disallowedFeatures: [], + featureAdvisories: [], +}; + +const cacheTimeoutMillis = 5 * 60 * 1000; // 5 minutes + +export async function getControlManifest(cacheFolder: string, output: Log): Promise { + const controlManifestPath = path.join(cacheFolder, controlManifestFilename); + const cacheStat = await fs.stat(controlManifestPath) + .catch(err => { + if (err?.code !== 'ENOENT') { + throw err; + } + }); + const cacheBuffer = (cacheStat && cacheStat.isFile()) ? await fs.readFile(controlManifestPath) + .catch(err => { + if (err?.code !== 'ENOENT') { + throw err; + } + }) : undefined; + const cachedManifest = cacheBuffer ? sanitizeControlManifest(jsonc.parse(cacheBuffer.toString())) : undefined; + if (cacheStat && cachedManifest && cacheStat.mtimeMs + cacheTimeoutMillis > Date.now()) { + return cachedManifest; + } + return updateControlManifest(controlManifestPath, cachedManifest, output); +} + +async function updateControlManifest(controlManifestPath: string, oldManifest: DevContainerControlManifest | undefined, output: Log): Promise { + let manifestBuffer: Buffer; + try { + manifestBuffer = await fetchControlManifest(output); + } catch (error) { + output.write(`Failed to fetch control manifest: ${error.message}`, LogLevel.Error); + if (oldManifest) { + // Keep old manifest to not lose existing information and update timestamp to avoid flooding the server. + const now = new Date(); + await fs.utimes(controlManifestPath, now, now); + return oldManifest; + } + manifestBuffer = Buffer.from(JSON.stringify(emptyControlManifest, undefined, 2)); + } + + const controlManifestTmpPath = `${controlManifestPath}-${crypto.randomUUID()}`; + await fs.mkdir(path.dirname(controlManifestPath), { recursive: true }); + await fs.writeFile(controlManifestTmpPath, manifestBuffer); + await fs.rename(controlManifestTmpPath, controlManifestPath); + return sanitizeControlManifest(jsonc.parse(manifestBuffer.toString())); +} + +async function fetchControlManifest(output: Log) { + return request({ + type: 'GET', + url: 'https://containers.dev/static/devcontainer-control-manifest.json', + headers: { + 'user-agent': 'devcontainers-vscode', + 'accept': 'application/json', + }, + }, output); +} + +function sanitizeControlManifest(manifest: any): DevContainerControlManifest { + if (!manifest || typeof manifest !== 'object') { + return emptyControlManifest; + } + const disallowedFeatures = manifest.disallowedFeatures as DisallowedFeature[] | undefined; + const featureAdvisories = manifest.featureAdvisories as FeatureAdvisory[] | undefined; + return { + disallowedFeatures: Array.isArray(disallowedFeatures) ? disallowedFeatures.filter(f => typeof f.featureIdPrefix === 'string') : [], + featureAdvisories: Array.isArray(featureAdvisories) ? featureAdvisories.filter(f => + typeof f.featureId === 'string' && + typeof f.introducedInVersion === 'string' && + typeof f.fixedInVersion === 'string' && + typeof f.description === 'string' + ) : [], + }; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/editableFiles.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/editableFiles.ts new file mode 100644 index 000000000000..d233a15fc444 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/editableFiles.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import * as jsonc from 'jsonc-parser'; +import { URI } from 'vscode-uri'; +import { uriToFsPath, FileHost } from './configurationCommonUtils'; +import { readLocalFile, writeLocalFile } from '../spec-utils/pfs'; + +export type Edit = jsonc.Edit; + +export interface Documents { + readDocument(uri: URI): Promise; + applyEdits(uri: URI, edits: Edit[], content: string): Promise; +} + +export const fileDocuments: Documents = { + + async readDocument(uri: URI) { + switch (uri.scheme) { + case 'file': + try { + const buffer = await readLocalFile(uri.fsPath); + return buffer.toString(); + } catch (err) { + if (err && err.code === 'ENOENT') { + return undefined; + } + throw err; + } + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + }, + + async applyEdits(uri: URI, edits: Edit[], content: string) { + switch (uri.scheme) { + case 'file': + const result = jsonc.applyEdits(content, edits); + await writeLocalFile(uri.fsPath, result); + break; + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + } +}; + +export class CLIHostDocuments implements Documents { + + static scheme = 'vscode-fileHost'; + + constructor(private fileHost: FileHost) { + } + + async readDocument(uri: URI) { + switch (uri.scheme) { + case CLIHostDocuments.scheme: + try { + return (await this.fileHost.readFile(uriToFsPath(uri, this.fileHost.platform))).toString(); + } catch (err) { + return undefined; + } + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + } + + async applyEdits(uri: URI, edits: Edit[], content: string) { + switch (uri.scheme) { + case CLIHostDocuments.scheme: + const result = jsonc.applyEdits(content, edits); + await this.fileHost.writeFile(uriToFsPath(uri, this.fileHost.platform), Buffer.from(result)); + break; + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + } +} + +export class RemoteDocuments implements Documents { + + static scheme = 'vscode-remote'; + + private static nonce: string | undefined; + + constructor(private shellServer: ShellServer) { + } + + async readDocument(uri: URI) { + switch (uri.scheme) { + case RemoteDocuments.scheme: + try { + const { stdout } = await this.shellServer.exec(`cat ${uri.path}`); + return stdout; + } catch (err) { + return undefined; + } + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + } + + async applyEdits(uri: URI, edits: Edit[], content: string) { + switch (uri.scheme) { + case RemoteDocuments.scheme: + try { + if (!RemoteDocuments.nonce) { + RemoteDocuments.nonce = crypto.randomUUID(); + } + const result = jsonc.applyEdits(content, edits); + const eof = `EOF-${RemoteDocuments.nonce}`; + await this.shellServer.exec(`cat <<'${eof}' >${uri.path} +${result} +${eof} +`); + } catch (err) { + console.log(err); // XXX + } + break; + default: + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + } +} + +export class AllDocuments implements Documents { + + constructor(private documents: Record) { + } + + async readDocument(uri: URI) { + const documents = this.documents[uri.scheme]; + if (!documents) { + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + return documents.readDocument(uri); + } + + async applyEdits(uri: URI, edits: Edit[], content: string) { + const documents = this.documents[uri.scheme]; + if (!documents) { + throw new Error(`Unsupported scheme: ${uri.toString()}`); + } + return documents.applyEdits(uri, edits, content); + } +} + +export function createDocuments(fileHost: FileHost, shellServer?: ShellServer): Documents { + const documents: Record = { + file: fileDocuments, + [CLIHostDocuments.scheme]: new CLIHostDocuments(fileHost), + }; + if (shellServer) { + documents[RemoteDocuments.scheme] = new RemoteDocuments(shellServer); + } + return new AllDocuments(documents); +} + +export interface ShellServer { + exec(cmd: string, options?: { logOutput?: boolean; stdin?: Buffer }): Promise<{ stdout: string; stderr: string }>; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/featureAdvisories.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/featureAdvisories.ts new file mode 100644 index 000000000000..3eafa984a59f --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/featureAdvisories.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FeatureSet, FeaturesConfig, OCISourceInformation } from './containerFeaturesConfiguration'; +import { FeatureAdvisory, getControlManifest } from './controlManifest'; +import { parseVersion, isEarlierVersion } from '../spec-common/commonUtils'; +import { Log, LogLevel } from '../spec-utils/log'; + +export async function fetchFeatureAdvisories(params: { cacheFolder: string; output: Log }, featuresConfig: FeaturesConfig) { + + const features = featuresConfig.featureSets + .map(f => [f, f.sourceInformation] as const) + .filter((tup): tup is [FeatureSet, OCISourceInformation] => tup[1].type === 'oci') + .map(([set, source]) => ({ + id: `${source.featureRef.registry}/${source.featureRef.path}`, + version: set.features[0].version!, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + if (!features.length) { + return []; + } + + const controlManifest = await getControlManifest(params.cacheFolder, params.output); + if (!controlManifest.featureAdvisories.length) { + return []; + } + + const featureAdvisories = controlManifest.featureAdvisories.reduce((acc, cur) => { + const list = acc.get(cur.featureId); + if (list) { + list.push(cur); + } else { + acc.set(cur.featureId, [cur]); + } + return acc; + }, new Map()); + + const parsedVersions = new Map(); + function lookupParsedVersion(version: string) { + if (!parsedVersions.has(version)) { + parsedVersions.set(version, parseVersion(version)); + } + return parsedVersions.get(version); + } + const featuresWithAdvisories = features.map(feature => { + const advisories = featureAdvisories.get(feature.id); + const featureVersion = lookupParsedVersion(feature.version); + if (!featureVersion) { + params.output.write(`Unable to parse version for feature ${feature.id}: ${feature.version}`, LogLevel.Warning); + return { + feature, + advisories: [], + }; + } + return { + feature, + advisories: advisories?.filter(advisory => { + const introducedInVersion = lookupParsedVersion(advisory.introducedInVersion); + const fixedInVersion = lookupParsedVersion(advisory.fixedInVersion); + if (!introducedInVersion || !fixedInVersion) { + return false; + } + return !isEarlierVersion(featureVersion, introducedInVersion) && isEarlierVersion(featureVersion, fixedInVersion); + }) || [], + }; + }).filter(f => f.advisories.length); + + return featuresWithAdvisories; +} + +export async function logFeatureAdvisories(params: { cacheFolder: string; output: Log }, featuresConfig: FeaturesConfig) { + + const featuresWithAdvisories = await fetchFeatureAdvisories(params, featuresConfig); + if (!featuresWithAdvisories.length) { + return; + } + + params.output.write(` + +----------------------------------------------------------------------------------------------------------- +FEATURE ADVISORIES:${featuresWithAdvisories.map(f => ` +- ${f.feature.id}:${f.feature.version}:${f.advisories.map(a => ` + - ${a.description} (introduced in ${a.introducedInVersion}, fixed in ${a.fixedInVersion}${a.documentationURL ? `, see ${a.documentationURL}` : ''})`) + .join('')}`) +.join('')} + +It is recommended that you update your configuration to versions of these features with the fixes applied. +----------------------------------------------------------------------------------------------------------- + +`, LogLevel.Warning); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/httpOCIRegistry.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/httpOCIRegistry.ts new file mode 100644 index 000000000000..2bebba82e62f --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/httpOCIRegistry.ts @@ -0,0 +1,431 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; + +import { runCommandNoPty, plainExec } from '../spec-common/commonUtils'; +import { requestResolveHeaders } from '../spec-utils/httpRequest'; +import { LogLevel } from '../spec-utils/log'; +import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; +import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; + +export type HEADERS = { 'authorization'?: string; 'user-agent'?: string; 'content-type'?: string; 'Accept'?: string; 'content-length'?: string }; + +interface DockerConfigFile { + auths: { + [registry: string]: { + auth: string; + identitytoken?: string; // Used by Azure Container Registry + }; + }; + credHelpers: { + [registry: string]: string; + }; + credsStore: string; +} + +interface CredentialHelperResult { + Username: string; + Secret: string; +} + +// WWW-Authenticate Regex +// realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push" +// realm="https://ghcr.io/token",service="ghcr.io",scope="repository:devcontainers/features:pull" +const realmRegex = /realm="([^"]+)"/; +const serviceRegex = /service="([^"]+)"/; +const scopeRegex = /scope="([^"]+)"/; + +// https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate +export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { + // If needed, Initialize the Authorization header cache. + if (!params.cachedAuthHeader) { + params.cachedAuthHeader = {}; + } + const { output, cachedAuthHeader } = params; + + // -- Update headers + httpOptions.headers['user-agent'] = 'devcontainer'; + // If the user has a cached auth token, attempt to use that first. + const maybeCachedAuthHeader = cachedAuthHeader[ociRef.registry]; + if (maybeCachedAuthHeader) { + output.write(`[httpOci] Applying cachedAuthHeader for registry ${ociRef.registry}...`, LogLevel.Trace); + httpOptions.headers.authorization = maybeCachedAuthHeader; + } + + const initialAttemptRes = await requestResolveHeaders(httpOptions, output); + + // For anything except a 401 (invalid/no token) or 403 (insufficient scope) + // response simply return the original response to the caller. + if (initialAttemptRes.statusCode !== 401 && initialAttemptRes.statusCode !== 403) { + output.write(`[httpOci] ${initialAttemptRes.statusCode} (${maybeCachedAuthHeader ? 'Cached' : 'NoAuth'}): ${httpOptions.url}`, LogLevel.Trace); + return initialAttemptRes; + } + + // -- 'responseAttempt' status code was 401 or 403 at this point. + + // Attempt to authenticate via WWW-Authenticate Header. + const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate']; + if (!wwwAuthenticate) { + output.write(`[httpOci] ERR: Server did not provide instructions to authentiate! (Required: A 'WWW-Authenticate' Header)`, LogLevel.Error); + return; + } + + const authenticationMethod = wwwAuthenticate.split(' ')[0]; + switch (authenticationMethod.toLowerCase()) { + // Basic realm="localhost" + case 'basic': + + output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); + + const credential = await getCredential(params, ociRef); + const basicAuthCredential = credential?.base64EncodedCredential; + if (!basicAuthCredential) { + output.write(`[httpOci] ERR: No basic auth credentials to send for registry service '${ociRef.registry}'`, LogLevel.Error); + return; + } + + httpOptions.headers.authorization = `Basic ${basicAuthCredential}`; + break; + + // Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push" + case 'bearer': + + output.write(`[httpOci] Attempting to authenticate via 'Bearer' auth.`, LogLevel.Trace); + + const realmGroup = realmRegex.exec(wwwAuthenticate); + const serviceGroup = serviceRegex.exec(wwwAuthenticate); + const scopeGroup = scopeRegex.exec(wwwAuthenticate); + + if (!realmGroup || !serviceGroup) { + output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); + return; + } + + const wwwAuthenticateData = { + realm: realmGroup[1], + service: serviceGroup[1], + scope: scopeGroup ? scopeGroup[1] : '', + }; + + const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); + if (!bearerToken) { + output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); + return; + } + + httpOptions.headers.authorization = `Bearer ${bearerToken}`; + break; + + default: + output.write(`[httpOci] ERR: Unsupported authentication mode '${authenticationMethod}'`, LogLevel.Error); + return; + } + + // Retry the request with the updated authorization header. + const reattemptRes = await requestResolveHeaders(httpOptions, output); + output.write(`[httpOci] ${reattemptRes.statusCode} on reattempt after auth: ${httpOptions.url}`, LogLevel.Trace); + + // Cache the auth header if the request did not result in an unauthorized response. + if (reattemptRes.statusCode !== 401) { + params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; + } + + return reattemptRes; +} + +// Attempts to get the Basic auth credentials for the provided registry. +// This credential is used to offer the registry in exchange for a Bearer token. +// These may be: +// - parsed out of a special DEVCONTAINERS_OCI_AUTH environment variable +// - Read from a docker credential helper (https://docs.docker.com/engine/reference/commandline/login/#credentials-store) +// - Read from a docker config file +// - Crafted from the GITHUB_TOKEN environment variable +// Returns: +// - undefined: No credential was found. +// - object: A credential was found. +// - based64EncodedCredential: The base64 encoded credential, if any. +// - refreshToken: The refresh token, if any. +async function getCredential(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise<{ base64EncodedCredential: string | undefined; refreshToken: string | undefined } | undefined> { + const { output, env } = params; + const { registry } = ociRef; + + if (!!env['DEVCONTAINERS_OCI_AUTH']) { + // eg: DEVCONTAINERS_OCI_AUTH=service1|user1|token1,service2|user2|token2 + const authContexts = env['DEVCONTAINERS_OCI_AUTH'].split(','); + const authContext = authContexts.find(a => a.split('|')[0] === registry); + + if (authContext) { + output.write(`[httpOci] Using match from DEVCONTAINERS_OCI_AUTH for registry '${registry}'`, LogLevel.Trace); + const split = authContext.split('|'); + const userToken = `${split[1]}:${split[2]}`; + return { + base64EncodedCredential: Buffer.from(userToken).toString('base64'), + refreshToken: undefined, + }; + } + } + + // Attempt to use the docker config file or available credential helper(s). + const credentialFromDockerConfig = await getCredentialFromDockerConfigOrCredentialHelper(params, registry); + if (credentialFromDockerConfig) { + return credentialFromDockerConfig; + } + + const githubToken = env['GITHUB_TOKEN']; + const githubHost = env['GITHUB_HOST']; + if (githubHost) { + output.write(`[httpOci] Environment GITHUB_HOST is set to '${githubHost}'`, LogLevel.Trace); + } + if (registry === 'ghcr.io' && githubToken && (!githubHost || githubHost === 'github.com')) { + output.write('[httpOci] Using environment GITHUB_TOKEN for auth', LogLevel.Trace); + const userToken = `USERNAME:${env['GITHUB_TOKEN']}`; + return { + base64EncodedCredential: Buffer.from(userToken).toString('base64'), + refreshToken: undefined, + }; + } + + // Represents anonymous access. + output.write(`[httpOci] No authentication credentials found for registry '${registry}'. Accessing anonymously.`, LogLevel.Trace); + return; +} + +async function existsInPath(filename: string): Promise { + if (!process.env.PATH) { + return false; + } + const paths = process.env.PATH.split(':'); + for (const path of paths) { + const fullPath = `${path}/${filename}`; + if (await isLocalFile(fullPath)) { + return true; + } + } + return false; +} + +async function getCredentialFromDockerConfigOrCredentialHelper(params: CommonParams, registry: string) { + const { output } = params; + + let configContainsAuth = false; + try { + // https://docs.docker.com/engine/reference/commandline/cli/#change-the-docker-directory + const customDockerConfigPath = process.env.DOCKER_CONFIG; + if (customDockerConfigPath) { + output.write(`[httpOci] Environment DOCKER_CONFIG is set to '${customDockerConfigPath}'`, LogLevel.Trace); + } + const dockerConfigRootDir = customDockerConfigPath || path.join(os.homedir(), '.docker'); + const dockerConfigFilePath = path.join(dockerConfigRootDir, 'config.json'); + if (await isLocalFile(dockerConfigFilePath)) { + const dockerConfig: DockerConfigFile = jsonc.parse((await readLocalFile(dockerConfigFilePath)).toString()); + + configContainsAuth = Object.keys(dockerConfig.credHelpers || {}).length > 0 || !!dockerConfig.credsStore || Object.keys(dockerConfig.auths || {}).length > 0; + // https://docs.docker.com/engine/reference/commandline/login/#credential-helpers + if (dockerConfig.credHelpers && dockerConfig.credHelpers[registry]) { + const credHelper = dockerConfig.credHelpers[registry]; + output.write(`[httpOci] Found credential helper '${credHelper}' in '${dockerConfigFilePath}' registry '${registry}'`, LogLevel.Trace); + const auth = await getCredentialFromHelper(params, registry, credHelper); + if (auth) { + return auth; + } + // https://docs.docker.com/engine/reference/commandline/login/#credentials-store + } else if (dockerConfig.credsStore) { + output.write(`[httpOci] Invoking credsStore credential helper '${dockerConfig.credsStore}'`, LogLevel.Trace); + const auth = await getCredentialFromHelper(params, registry, dockerConfig.credsStore); + if (auth) { + return auth; + } + } + if (dockerConfig.auths && dockerConfig.auths[registry]) { + output.write(`[httpOci] Found auths entry in '${dockerConfigFilePath}' for registry '${registry}'`, LogLevel.Trace); + const auth = dockerConfig.auths[registry].auth; + const identityToken = dockerConfig.auths[registry].identitytoken; // Refresh token, seen when running: 'az acr login -n ' + + if (identityToken) { + return { + refreshToken: identityToken, + base64EncodedCredential: undefined, + }; + } + + // Without the presence of an `identityToken`, assume auth is a base64-encoded 'user:token'. + return { + base64EncodedCredential: auth, + refreshToken: undefined, + }; + } + } + } catch (err) { + output.write(`[httpOci] Failed to read docker config.json: ${err}`, LogLevel.Trace); + return; + } + + if (!configContainsAuth) { + let defaultCredHelper = ''; + // Try platform-specific default credential helper + if (process.platform === 'linux') { + if (await existsInPath('pass')) { + defaultCredHelper = 'pass'; + } else { + defaultCredHelper = 'secret'; + } + } else if (process.platform === 'win32') { + defaultCredHelper = 'wincred'; + } else if (process.platform === 'darwin') { + defaultCredHelper = 'osxkeychain'; + } + if (defaultCredHelper !== '') { + output.write(`[httpOci] Invoking platform default credential helper '${defaultCredHelper}'`, LogLevel.Trace); + const auth = await getCredentialFromHelper(params, registry, defaultCredHelper); + if (auth) { + output.write('[httpOci] Found auth from platform default credential helper', LogLevel.Trace); + return auth; + } + } + } + + // No auth found from docker config or credential helper. + output.write(`[httpOci] No authentication credentials found for registry '${registry}' via docker config or credential helper.`, LogLevel.Trace); + return; +} + +async function getCredentialFromHelper(params: CommonParams, registry: string, credHelperName: string): Promise<{ base64EncodedCredential: string | undefined; refreshToken: string | undefined } | undefined> { + const { output } = params; + + let helperOutput: Buffer; + try { + const { stdout } = await runCommandNoPty({ + exec: plainExec(undefined), + cmd: 'docker-credential-' + credHelperName, + args: ['get'], + stdin: Buffer.from(registry, 'utf-8'), + output, + }); + helperOutput = stdout; + } catch (err) { + output.write(`[httpOci] Failed to query for '${registry}' credential from 'docker-credential-${credHelperName}': ${err}`, LogLevel.Trace); + return undefined; + } + if (helperOutput.length === 0) { + return undefined; + } + + let errors: jsonc.ParseError[] = []; + const creds: CredentialHelperResult = jsonc.parse(helperOutput.toString(), errors); + if (errors.length !== 0) { + output.write(`[httpOci] Credential helper ${credHelperName} returned non-JSON response "${helperOutput.toString()}" for registry '${registry}'`, LogLevel.Warning); + return undefined; + } + + if (creds.Username === '') { + return { + refreshToken: creds.Secret, + base64EncodedCredential: undefined, + }; + } + const userToken = `${creds.Username}:${creds.Secret}`; + return { + base64EncodedCredential: Buffer.from(userToken).toString('base64'), + refreshToken: undefined, + }; +} + +// https://docs.docker.com/registry/spec/auth/token/#requesting-a-token +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { + const { output } = params; + const { realm, service, scope } = wwwAuthenticateData; + + // TODO: Remove this. + if (realm.includes('mcr.microsoft.com')) { + return undefined; + } + + const headers: HEADERS = { + 'user-agent': 'devcontainer' + }; + + // The token server should first attempt to authenticate the client using any authentication credentials provided with the request. + // From Docker 1.11 the Docker engine supports both Basic Authentication and OAuth2 for getting tokens. + // Docker 1.10 and before, the registry client in the Docker Engine only supports Basic Authentication. + // If an attempt to authenticate to the token server fails, the token server should return a 401 Unauthorized response + // indicating that the provided credentials are invalid. + // > https://docs.docker.com/registry/spec/auth/token/#requesting-a-token + const userCredential = await getCredential(params, ociRef); + const basicAuthCredential = userCredential?.base64EncodedCredential; + const refreshToken = userCredential?.refreshToken; + + let httpOptions: { type: string; url: string; headers: Record; data?: Buffer }; + + // There are several different ways registries expect to handle the oauth token exchange. + // Depending on the type of credential available, use the most reasonable method. + if (refreshToken) { + const form_url_encoded = new URLSearchParams(); + form_url_encoded.append('client_id', 'devcontainer'); + form_url_encoded.append('grant_type', 'refresh_token'); + form_url_encoded.append('service', service); + form_url_encoded.append('scope', scope); + form_url_encoded.append('refresh_token', refreshToken); + + headers['content-type'] = 'application/x-www-form-urlencoded'; + + const url = realm; + output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); + + httpOptions = { + type: 'POST', + url, + headers: headers, + data: Buffer.from(form_url_encoded.toString()) + }; + } else { + if (basicAuthCredential) { + headers['authorization'] = `Basic ${basicAuthCredential}`; + } + + // realm="https://auth.docker.io/token" + // service="registry.docker.io" + // scope="repository:samalba/my-app:pull,push" + // Example: + // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push + const url = `${realm}?service=${service}&scope=${scope}`; + output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); + + httpOptions = { + type: 'GET', + url: url, + headers: headers, + }; + } + + let res = await requestResolveHeaders(httpOptions, output); + if (res && res.statusCode === 401 || res.statusCode === 403) { + output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); + const body = res.resBody?.toString(); + if (body) { + output.write(`${res.resBody.toString()}.`, LogLevel.Info); + } + + // Try again without user credentials. If we're here, their creds are likely expired. + delete headers['authorization']; + res = await requestResolveHeaders(httpOptions, output); + } + + if (!res || res.statusCode > 299 || !res.resBody) { + output.write(`[httpOci] ${res.statusCode}: Failed to fetch bearer token for '${service}': ${res.resBody.toString()}`, LogLevel.Error); + return; + } + + let scopeToken: string | undefined; + try { + const json = JSON.parse(res.resBody.toString()); + scopeToken = json.token || json.access_token; // ghcr uses 'token', acr uses 'access_token' + } catch { + // not JSON + } + if (!scopeToken) { + output.write(`[httpOci] Unexpected bearer token response format for '${service}: ${res.resBody.toString()}'`, LogLevel.Error); + return; + } + + return scopeToken; +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/lockfile.ts b/extensions/positron-dev-containers/src/spec/spec-configuration/lockfile.ts new file mode 100644 index 000000000000..9de0ba0b2151 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/lockfile.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { DevContainerConfig } from './configuration'; +import { readLocalFile, writeLocalFile } from '../spec-utils/pfs'; +import { ContainerFeatureInternalParams, DirectTarballSourceInformation, FeatureSet, FeaturesConfig, OCISourceInformation } from './containerFeaturesConfiguration'; + + +export interface Lockfile { + features: Record; +} + +export async function generateLockfile(featuresConfig: FeaturesConfig): Promise { + return featuresConfig.featureSets + .map(f => [f, f.sourceInformation] as const) + .filter((tup): tup is [FeatureSet, OCISourceInformation | DirectTarballSourceInformation] => ['oci', 'direct-tarball'].indexOf(tup[1].type) !== -1) + .map(([set, source]) => { + const dependsOn = Object.keys(set.features[0].dependsOn || {}); + return { + id: source.userFeatureId, + version: set.features[0].version!, + resolved: source.type === 'oci' ? + `${source.featureRef.registry}/${source.featureRef.path}@${set.computedDigest}` : + source.tarballUri, + integrity: set.computedDigest!, + dependsOn: dependsOn.length ? dependsOn : undefined, + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)) + .reduce((acc, cur) => { + const feature = { ...cur }; + delete (feature as any).id; + acc.features[cur.id] = feature; + return acc; + }, { + features: {} as Record, + }); +} + +export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile, forceInitLockfile?: boolean): Promise { + const lockfilePath = getLockfilePath(config); + const oldLockfileContent = await readLocalFile(lockfilePath) + .catch(err => { + if (err?.code !== 'ENOENT') { + throw err; + } + }); + + if (!forceInitLockfile && !oldLockfileContent && !params.experimentalLockfile && !params.experimentalFrozenLockfile) { + return; + } + + const newLockfileContentString = JSON.stringify(lockfile, null, 2); + const newLockfileContent = Buffer.from(newLockfileContentString); + if (params.experimentalFrozenLockfile && !oldLockfileContent) { + throw new Error('Lockfile does not exist.'); + } + if (!oldLockfileContent || !newLockfileContent.equals(oldLockfileContent)) { + if (params.experimentalFrozenLockfile) { + throw new Error('Lockfile does not match.'); + } + await writeLocalFile(lockfilePath, newLockfileContent); + } + return; +} + +export async function readLockfile(config: DevContainerConfig): Promise<{ lockfile?: Lockfile; initLockfile?: boolean }> { + try { + const content = await readLocalFile(getLockfilePath(config)); + // If empty file, use as marker to initialize lockfile when build completes. + if (content.toString().trim() === '') { + return { initLockfile: true }; + } + return { lockfile: JSON.parse(content.toString()) as Lockfile }; + } catch (err) { + if (err?.code === 'ENOENT') { + return {}; + } + throw err; + } +} + +export function getLockfilePath(configOrPath: DevContainerConfig | string) { + const configPath = typeof configOrPath === 'string' ? configOrPath : configOrPath.configFilePath!.fsPath; + return path.join(path.dirname(configPath), path.basename(configPath).startsWith('.') ? '.devcontainer-lock.json' : 'devcontainer-lock.json'); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-configuration/tsconfig.json b/extensions/positron-dev-containers/src/spec/spec-configuration/tsconfig.json new file mode 100644 index 000000000000..f33845140c14 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-configuration/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { + "path": "../spec-common" + }, + { + "path": "../spec-utils" + } + ] +} \ No newline at end of file diff --git a/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts new file mode 100644 index 000000000000..0a866f818fce --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; +import { Log, LogLevel } from '../../spec-utils/log'; + +const FEATURES_README_TEMPLATE = ` +# #{Name} + +#{Description} + +## Example Usage + +\`\`\`json +"features": { + "#{Registry}/#{Namespace}/#{Id}:#{Version}": {} +} +\`\`\` + +#{OptionsTable} +#{Customizations} +#{Notes} + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +const TEMPLATE_README_TEMPLATE = ` +# #{Name} + +#{Description} + +#{OptionsTable} + +#{Notes} + +--- + +_Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +export async function generateFeaturesDocumentation( + basePath: string, + ociRegistry: string, + namespace: string, + gitHubOwner: string, + gitHubRepo: string, + output: Log +) { + await _generateDocumentation(output, basePath, FEATURES_README_TEMPLATE, + 'devcontainer-feature.json', ociRegistry, namespace, gitHubOwner, gitHubRepo); +} + +export async function generateTemplatesDocumentation( + basePath: string, + gitHubOwner: string, + gitHubRepo: string, + output: Log +) { + await _generateDocumentation(output, basePath, TEMPLATE_README_TEMPLATE, + 'devcontainer-template.json', '', '', gitHubOwner, gitHubRepo); +} + +async function _generateDocumentation( + output: Log, + basePath: string, + readmeTemplate: string, + metadataFile: string, + ociRegistry: string = '', + namespace: string = '', + gitHubOwner: string = '', + gitHubRepo: string = '' +) { + const directories = fs.readdirSync(basePath); + + await Promise.all( + directories.map(async (f: string) => { + if (!f.startsWith('.')) { + const readmePath = path.join(basePath, f, 'README.md'); + output.write(`Generating ${readmePath}...`, LogLevel.Info); + + const jsonPath = path.join(basePath, f, metadataFile); + + if (!fs.existsSync(jsonPath)) { + output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning); + return; + } + + let parsedJson: any | undefined = undefined; + try { + parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8')); + } catch (err) { + output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error); + return; + } + + if (!parsedJson || !parsedJson?.id) { + output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error); + return; + } + + // Add version + let version = 'latest'; + const parsedVersion: string = parsedJson?.version; + if (parsedVersion) { + // example - 1.0.0 + const splitVersion = parsedVersion.split('.'); + version = splitVersion[0]; + } + + const generateOptionsMarkdown = () => { + const options = parsedJson?.options; + if (!options) { + return ''; + } + + const keys = Object.keys(options); + const contents = keys + .map(k => { + const val = options[k]; + + const desc = val.description || '-'; + const type = val.type || '-'; + const def = val.default !== '' ? val.default : '-'; + + return `| ${k} | ${desc} | ${type} | ${def} |`; + }) + .join('\n'); + + return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents; + }; + + const generateNotesMarkdown = () => { + const notesPath = path.join(basePath, f, 'NOTES.md'); + return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : ''; + }; + + let urlToConfig = `${metadataFile}`; + const basePathTrimmed = basePath.startsWith('./') ? basePath.substring(2) : basePath; + if (gitHubOwner !== '' && gitHubRepo !== '') { + urlToConfig = `https://github.com/${gitHubOwner}/${gitHubRepo}/blob/main/${basePathTrimmed}/${f}/${metadataFile}`; + } + + let header; + const isDeprecated = parsedJson?.deprecated; + const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0; + + if (isDeprecated || hasLegacyIds) { + header = '### **IMPORTANT NOTE**\n'; + + if (isDeprecated) { + header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`; + } + + if (hasLegacyIds) { + const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`); + header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`; + } + } + + let extensions = ''; + if (parsedJson?.customizations?.vscode?.extensions) { + const extensionsList = parsedJson.customizations.vscode.extensions; + if (extensionsList && extensionsList.length > 0) { + extensions = + '\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n'; + } + } + + let newReadme = readmeTemplate + // Templates & Features + .replace('#{Id}', parsedJson.id) + .replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`) + .replace('#{Description}', parsedJson.description ?? '') + .replace('#{OptionsTable}', generateOptionsMarkdown()) + .replace('#{Notes}', generateNotesMarkdown()) + .replace('#{RepoUrl}', urlToConfig) + // Features Only + .replace('#{Registry}', ociRegistry) + .replace('#{Namespace}', namespace) + .replace('#{Version}', version) + .replace('#{Customizations}', extensions); + + if (header) { + newReadme = header + newReadme; + } + + // Remove previous readme + if (fs.existsSync(readmePath)) { + fs.unlinkSync(readmePath); + } + + // Write new readme + fs.writeFileSync(readmePath, newReadme); + } + }) + ); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/package.ts b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/package.ts new file mode 100644 index 000000000000..0204b9c5fb70 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/package.ts @@ -0,0 +1,34 @@ +import { Argv } from 'yargs'; +import { CLIHost } from '../../spec-common/cliHost'; +import { Log } from '../../spec-utils/log'; + +const targetPositionalDescription = (collectionType: string) => ` +Package ${collectionType}s at provided [target] (default is cwd), where [target] is either: + 1. A path to the src folder of the collection with [1..n] ${collectionType}s. + 2. A path to a single ${collectionType} that contains a devcontainer-${collectionType}.json. + + Additionally, a 'devcontainer-collection.json' will be generated in the output directory. +`; + +export function PackageOptions(y: Argv, collectionType: string) { + return y + .options({ + 'output-folder': { type: 'string', alias: 'o', default: './output', description: 'Path to output directory. Will create directories as needed.' }, + 'force-clean-output-folder': { type: 'boolean', alias: 'f', default: false, description: 'Automatically delete previous output directory before packaging' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, + }) + .positional('target', { type: 'string', default: '.', description: targetPositionalDescription(collectionType) }) + .check(_argv => { + return true; + }); +} + +export interface PackageCommandInput { + cliHost: CLIHost; + targetFolder: string; + outputDir: string; + output: Log; + disposables: (() => Promise | undefined)[]; + isSingle?: boolean; // Packaging a collection of many features/templates. Should autodetect. + forceCleanOutputDir?: boolean; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/packageCommandImpl.ts b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/packageCommandImpl.ts new file mode 100644 index 000000000000..271c0a049ce6 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/packageCommandImpl.ts @@ -0,0 +1,267 @@ +import tar from 'tar'; +import * as jsonc from 'jsonc-parser'; +import * as os from 'os'; +import * as recursiveDirReader from 'recursive-readdir'; +import { PackageCommandInput } from './package'; +import { cpDirectoryLocal, isLocalFile, isLocalFolder, mkdirpLocal, readLocalDir, readLocalFile, rmLocal, writeLocalFile } from '../../spec-utils/pfs'; +import { Log, LogLevel } from '../../spec-utils/log'; +import path from 'path'; +import { DevContainerConfig, isDockerFileConfig } from '../../spec-configuration/configuration'; +import { Template } from '../../spec-configuration/containerTemplatesConfiguration'; +import { Feature } from '../../spec-configuration/containerFeaturesConfiguration'; +import { getRef } from '../../spec-configuration/containerCollectionsOCI'; + +export interface SourceInformation { + source: string; + owner?: string; + repo?: string; + tag?: string; + ref?: string; + sha?: string; +} + +export const OCICollectionFileName = 'devcontainer-collection.json'; + +export async function prepPackageCommand(args: PackageCommandInput, collectionType: string): Promise { + const { cliHost, targetFolder, outputDir, forceCleanOutputDir, output, disposables } = args; + + const targetFolderResolved = cliHost.path.resolve(targetFolder); + if (!(await isLocalFolder(targetFolderResolved))) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + const outputDirResolved = cliHost.path.resolve(outputDir); + if (await isLocalFolder(outputDirResolved)) { + // Output dir exists. Delete it automatically if '-f' is true + if (forceCleanOutputDir) { + await rmLocal(outputDirResolved, { recursive: true, force: true }); + } + else { + output.write(`(!) ERR: Output directory '${outputDirResolved}' already exists. Manually delete, or pass '-f' to continue.`, LogLevel.Error); + process.exit(1); + } + } + + // Detect if we're packaging a collection or a single feature/template + const isValidFolder = await isLocalFolder(cliHost.path.join(targetFolderResolved)); + const isSingle = await isLocalFile(cliHost.path.join(targetFolderResolved, `devcontainer-${collectionType}.json`)); + + if (!isValidFolder) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + // Generate output folder. + await mkdirpLocal(outputDirResolved); + + return { + cliHost, + targetFolder: targetFolderResolved, + outputDir: outputDirResolved, + forceCleanOutputDir, + output, + disposables, + isSingle + }; +} + +async function tarDirectory(folder: string, archiveName: string, outputDir: string) { + return new Promise((resolve) => resolve(tar.create({ file: path.join(outputDir, archiveName), cwd: folder }, ['.']))); +} + +export const getArchiveName = (f: string, collectionType: string) => `devcontainer-${collectionType}-${f}.tgz`; + +export async function packageSingleFeatureOrTemplate(args: PackageCommandInput, collectionType: string) { + const { output, targetFolder, outputDir } = args; + let metadatas = []; + + const devcontainerJsonName = `devcontainer-${collectionType}.json`; + const tmpSrcDir = path.join(os.tmpdir(), `/templates-src-output-${Date.now()}`); + await cpDirectoryLocal(targetFolder, tmpSrcDir); + + const jsonPath = path.join(tmpSrcDir, devcontainerJsonName); + if (!(await isLocalFile(jsonPath))) { + output.write(`${collectionType} is missing a ${devcontainerJsonName}`, LogLevel.Error); + return; + } + + if (collectionType === 'template') { + if (!(await addsAdditionalTemplateProps(tmpSrcDir, jsonPath, output))) { + return; + } + } else if (collectionType === 'feature') { + await addsAdditionalFeatureProps(jsonPath, output); + } + + const metadata = jsonc.parse(await readLocalFile(jsonPath, 'utf-8')); + if (!metadata.id || !metadata.version || !metadata.name) { + output.write(`${collectionType} is missing one of the following required properties in its devcontainer-${collectionType}.json: 'id', 'version', 'name'.`, LogLevel.Error); + return; + } + + const archiveName = getArchiveName(metadata.id, collectionType); + + await tarDirectory(tmpSrcDir, archiveName, outputDir); + output.write(`Packaged ${collectionType} '${metadata.id}'`, LogLevel.Info); + + metadatas.push(metadata); + await rmLocal(tmpSrcDir, { recursive: true, force: true }); + return metadatas; +} + +async function addsAdditionalTemplateProps(srcFolder: string, devcontainerTemplateJsonPath: string, output: Log): Promise { + const devcontainerFilePath = await getDevcontainerFilePath(srcFolder); + + if (!devcontainerFilePath) { + output.write(`Template is missing a devcontainer.json`, LogLevel.Error); + return false; + } + + const devcontainerJsonString: Buffer = await readLocalFile(devcontainerFilePath); + const config: DevContainerConfig = jsonc.parse(devcontainerJsonString.toString()); + + let type = undefined; + const devcontainerTemplateJsonString: Buffer = await readLocalFile(devcontainerTemplateJsonPath); + let templateData: Template = jsonc.parse(devcontainerTemplateJsonString.toString()); + + if ('image' in config) { + type = 'image'; + } else if (isDockerFileConfig(config)) { + type = 'dockerfile'; + } else if ('dockerComposeFile' in config) { + type = 'dockerCompose'; + } else { + output.write(`Dev container config (${devcontainerFilePath}) is missing one of "image", "dockerFile" or "dockerComposeFile" properties.`, LogLevel.Error); + return false; + } + + const fileNames = (await recursiveDirReader.default(srcFolder))?.map((f) => path.relative(srcFolder, f)) ?? []; + + templateData.type = type; + templateData.files = fileNames; + templateData.fileCount = fileNames.length; + templateData.featureIds = + config.features + ? Object.keys(config.features) + .map((f) => getRef(output, f)?.resource) + .filter((f) => f !== undefined) as string[] + : []; + + // If the Template is omitting a folder and that folder contains just a single file, + // replace the entry in the metadata with the full file name, + // as that provides a better user experience when tools consume the metadata. + // Eg: If the template is omitting ".github/*" and the Template source contains just a single file + // "workflow.yml", replace ".github/*" with ".github/workflow.yml" + if (templateData.optionalPaths && templateData.optionalPaths?.length) { + const optionalPaths = templateData.optionalPaths; + for (const optPath of optionalPaths) { + // Skip if not a directory + if (!optPath.endsWith('/*') || optPath.length < 3) { + continue; + } + const dirPath = optPath.slice(0, -2); + const dirFiles = fileNames.filter((f) => f.startsWith(dirPath)); + output.write(`Given optionalPath starting with '${dirPath}' has ${dirFiles.length} files`, LogLevel.Trace); + if (dirFiles.length === 1) { + // If that one item is a file and not a directory + const f = dirFiles[0]; + output.write(`Checking if optionalPath '${optPath}' with lone contents '${f}' is a file `, LogLevel.Trace); + const localPath = path.join(srcFolder, f); + if (await isLocalFile(localPath)) { + output.write(`Checked path '${localPath}' on disk is a file. Replacing optionalPaths entry '${optPath}' with '${f}'`, LogLevel.Trace); + templateData.optionalPaths[optionalPaths.indexOf(optPath)] = f; + } + } + } + } + + await writeLocalFile(devcontainerTemplateJsonPath, JSON.stringify(templateData, null, 4)); + + return true; +} + +// Programmatically adds 'currentId' if 'legacyIds' exist. +async function addsAdditionalFeatureProps(devcontainerFeatureJsonPath: string, output: Log): Promise { + const devcontainerFeatureJsonString: Buffer = await readLocalFile(devcontainerFeatureJsonPath); + let featureData: Feature = jsonc.parse(devcontainerFeatureJsonString.toString()); + + if (featureData.legacyIds && featureData.legacyIds.length > 0) { + featureData.currentId = featureData.id; + output.write(`Programmatically adding currentId:${featureData.currentId}...`, LogLevel.Trace); + + await writeLocalFile(devcontainerFeatureJsonPath, JSON.stringify(featureData, null, 4)); + } +} + +async function getDevcontainerFilePath(srcFolder: string): Promise { + const devcontainerFile = path.join(srcFolder, '.devcontainer.json'); + const devcontainerFileWithinDevcontainerFolder = path.join(srcFolder, '.devcontainer/devcontainer.json'); + + if (await isLocalFile(devcontainerFile)) { + return devcontainerFile; + } else if (await isLocalFile(devcontainerFileWithinDevcontainerFolder)) { + return devcontainerFileWithinDevcontainerFolder; + } + + return undefined; +} + +// Packages collection of Features or Templates +export async function packageCollection(args: PackageCommandInput, collectionType: string) { + const { output, targetFolder: srcFolder, outputDir } = args; + + const collectionDirs = await readLocalDir(srcFolder); + let metadatas = []; + + for await (const c of collectionDirs) { + output.write(`Processing ${collectionType}: ${c}...`, LogLevel.Info); + if (!c.startsWith('.')) { + const folder = path.join(srcFolder, c); + + // Validate minimal folder structure + const devcontainerJsonName = `devcontainer-${collectionType}.json`; + + if (!(await isLocalFile(path.join(folder, devcontainerJsonName)))) { + output.write(`(!) WARNING: ${collectionType} '${c}' is missing a ${devcontainerJsonName}. Skipping... `, LogLevel.Warning); + continue; + } + + const tmpSrcDir = path.join(os.tmpdir(), `/templates-src-output-${Date.now()}`); + await cpDirectoryLocal(folder, tmpSrcDir); + + const archiveName = getArchiveName(c, collectionType); + + const jsonPath = path.join(tmpSrcDir, devcontainerJsonName); + + if (collectionType === 'feature') { + const installShPath = path.join(tmpSrcDir, 'install.sh'); + if (!(await isLocalFile(installShPath))) { + output.write(`Feature '${c}' is missing an install.sh`, LogLevel.Error); + return; + } + + await addsAdditionalFeatureProps(jsonPath, output); + } else if (collectionType === 'template') { + if (!(await addsAdditionalTemplateProps(tmpSrcDir, jsonPath, output))) { + return; + } + } + + await tarDirectory(tmpSrcDir, archiveName, outputDir); + + const metadata = jsonc.parse(await readLocalFile(jsonPath, 'utf-8')); + if (!metadata.id || !metadata.version || !metadata.name) { + output.write(`${collectionType} '${c}' is missing one of the following required properties in its ${devcontainerJsonName}: 'id', 'version', 'name'.`, LogLevel.Error); + return; + } + metadatas.push(metadata); + await rmLocal(tmpSrcDir, { recursive: true, force: true }); + } + } + + if (metadatas.length === 0) { + return; + } + + output.write(`Packaged ${metadatas.length} ${collectionType}s!`, LogLevel.Info); + return metadatas; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publish.ts b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publish.ts new file mode 100644 index 000000000000..be749139e620 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publish.ts @@ -0,0 +1,20 @@ +import { Argv } from 'yargs'; + +const targetPositionalDescription = (collectionType: string) => ` +Package and publish ${collectionType}s at provided [target] (default is cwd), where [target] is either: + 1. A path to the src folder of the collection with [1..n] ${collectionType}s. + 2. A path to a single ${collectionType} that contains a devcontainer-${collectionType}.json. +`; + +export function publishOptions(y: Argv, collectionType: string) { + return y + .options({ + 'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.' }, + 'namespace': { type: 'string', alias: 'n', require: true, description: `Unique indentifier for the collection of ${collectionType}s. Example: /` }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' } + }) + .positional('target', { type: 'string', default: '.', description: targetPositionalDescription(collectionType) }) + .check(_argv => { + return true; + }); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publishCommandImpl.ts b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publishCommandImpl.ts new file mode 100644 index 000000000000..ebdb433e8dcc --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/collectionCommonUtils/publishCommandImpl.ts @@ -0,0 +1,83 @@ +import path from 'path'; +import * as semver from 'semver'; +import { Log, LogLevel } from '../../spec-utils/log'; +import { CommonParams, getPublishedTags, OCICollectionRef, OCIRef } from '../../spec-configuration/containerCollectionsOCI'; +import { OCICollectionFileName } from './packageCommandImpl'; +import { pushCollectionMetadata, pushOCIFeatureOrTemplate } from '../../spec-configuration/containerCollectionsOCIPush'; + +let semanticVersions: string[] = []; +function updateSemanticTagsList(publishedTags: string[], version: string, range: string, publishVersion: string) { + // Reference: https://github.com/npm/node-semver#ranges-1 + const publishedMaxVersion = semver.maxSatisfying(publishedTags, range); + if (publishedMaxVersion === null || semver.compare(version, publishedMaxVersion) === 1) { + semanticVersions.push(publishVersion); + } + return; +} + +export function getSemanticTags(version: string, tags: string[], output: Log) { + if (tags.includes(version)) { + output.write(`(!) WARNING: Version ${version} already exists, skipping ${version}...`, LogLevel.Warning); + return undefined; + } + + const parsedVersion = semver.parse(version); + if (!parsedVersion) { + output.write(`(!) ERR: Version ${version} is not a valid semantic version, skipping ${version}...`, LogLevel.Error); + process.exit(1); + } + + semanticVersions = []; + + // Adds semantic versions depending upon the existings (published) versions + // eg. 1.2.3 --> [1, 1.2, 1.2.3, latest] + updateSemanticTagsList(tags, version, `${parsedVersion.major}.x.x`, `${parsedVersion.major}`); + updateSemanticTagsList(tags, version, `${parsedVersion.major}.${parsedVersion.minor}.x`, `${parsedVersion.major}.${parsedVersion.minor}`); + semanticVersions.push(version); + updateSemanticTagsList(tags, version, `x.x.x`, 'latest'); + + return semanticVersions; +} + +export async function doPublishCommand(params: CommonParams, version: string, ociRef: OCIRef, outputDir: string, collectionType: string, archiveName: string, annotations: { [key: string]: string } = {}) { + const { output } = params; + + output.write(`Fetching published versions...`, LogLevel.Info); + const publishedTags = await getPublishedTags(params, ociRef); + + if (!publishedTags) { + return; + } + + const semanticTags: string[] | undefined = getSemanticTags(version, publishedTags, output); + + if (!!semanticTags) { + output.write(`Publishing tags: ${semanticTags.toString()}...`, LogLevel.Info); + const pathToTgz = path.join(outputDir, archiveName); + const digest = await pushOCIFeatureOrTemplate(params, ociRef, pathToTgz, semanticTags, collectionType, annotations); + if (!digest) { + output.write(`(!) ERR: Failed to publish ${collectionType}: '${ociRef.resource}'`, LogLevel.Error); + return; + } + output.write(`Published ${collectionType}: '${ociRef.id}'`, LogLevel.Info); + return { publishedTags: semanticTags, digest }; + } + + return {}; // Not an error if no versions were published, likely they just already existed and were skipped. +} + +export async function doPublishMetadata(params: CommonParams, collectionRef: OCICollectionRef, outputDir: string, collectionType: string): Promise { + const { output } = params; + + // Publishing Feature/Template Collection Metadata + output.write('Publishing collection metadata...', LogLevel.Info); + + const pathToCollectionFile = path.join(outputDir, OCICollectionFileName); + const publishedDigest = await pushCollectionMetadata(params, collectionRef, pathToCollectionFile, collectionType); + if (!publishedDigest) { + output.write(`(!) ERR: Failed to publish collection metadata: ${OCICollectionFileName}`, LogLevel.Error); + return; + } + output.write('Published collection metadata.', LogLevel.Info); + return publishedDigest; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/commandGeneration.ts b/extensions/positron-dev-containers/src/spec/spec-node/commandGeneration.ts new file mode 100644 index 000000000000..64b96e38469a --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/commandGeneration.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Command generation utilities for dev containers. + * This module allows generating docker commands without executing them, + * useful for terminal-based execution. + */ + +/** + * Represents a docker command with progress information + */ +export interface GeneratedCommand { + /** The command to execute (e.g., 'docker') */ + command: string; + /** Arguments for the command */ + args: string[]; + /** Human-readable description of what this command does */ + description: string; + /** Environment variables to set */ + env?: NodeJS.ProcessEnv; + /** Working directory for the command */ + cwd?: string; +} + +/** + * Result of command generation for provision/build operations + */ +export interface GeneratedCommands { + /** All commands to execute in sequence */ + commands: GeneratedCommand[]; + /** The container ID (if known in advance, otherwise set after creation) */ + containerId?: string; + /** The expected container name */ + containerName?: string; + /** The remote workspace folder path */ + remoteWorkspaceFolder?: string; +} + +/** + * Options for command generation mode + */ +export interface CommandGenerationOptions { + /** If true, generate commands instead of executing them */ + dryRun: boolean; + /** Callback to receive generated commands */ + onCommand?: (command: GeneratedCommand) => void; +} + +/** + * Context for accumulating generated commands + */ +export class CommandGenerationContext { + private commands: GeneratedCommand[] = []; + + addCommand(command: GeneratedCommand) { + this.commands.push(command); + } + + getCommands(): GeneratedCommand[] { + return [...this.commands]; + } + + clear() { + this.commands = []; + } +} + +/** + * Escapes a shell argument for safe execution + */ +export function escapeShellArg(arg: string): string { + // If the argument contains special characters, quote it + if (/[^\w@%+=:,./-]/.test(arg)) { + // Escape single quotes by replacing ' with '\'' + return `'${arg.replace(/'/g, "'\\''")}'`; + } + return arg; +} + +/** + * Formats a command for shell execution + */ +export function formatCommandForShell(cmd: GeneratedCommand): string { + const parts = [cmd.command, ...cmd.args.map(escapeShellArg)]; + return parts.join(' '); +} + +/** + * Formats a command with echo statement for terminal display + */ +export function formatCommandWithEcho(cmd: GeneratedCommand): string { + const echoLine = `echo "==> ${cmd.description.replace(/"/g, '\\"')}"`; + const commandLine = formatCommandForShell(cmd); + return `${echoLine}\n${commandLine}`; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/configContainer.ts b/extensions/positron-dev-containers/src/spec/spec-node/configContainer.ts new file mode 100644 index 000000000000..ee6b93cb38b7 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/configContainer.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +import * as jsonc from 'jsonc-parser'; + +import { openDockerfileDevContainer } from './singleContainer'; +import { openDockerComposeDevContainer } from './dockerCompose'; +import { ResolverResult, DockerResolverParameters, isDockerFileConfig, runInitializeCommand, getWorkspaceConfiguration, BindMountConsistency, uriToFsPath, DevContainerAuthority, isDevContainerAuthority, SubstituteConfig, SubstitutedConfig, addSubstitution, envListToObj, findContainerAndIdLabels } from './utils'; +import { beforeContainerSubstitute, substitute } from '../spec-common/variableSubstitution'; +import { ContainerError } from '../spec-common/errors'; +import { Workspace, workspaceFromPath, isWorkspacePath } from '../spec-utils/workspaces'; +import { URI } from 'vscode-uri'; +import { CLIHost } from '../spec-common/commonUtils'; +import { Log } from '../spec-utils/log'; +import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn } from '../spec-configuration/configurationCommonUtils'; +import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, updateFromOldProperties } from '../spec-configuration/configuration'; +import { ensureNoDisallowedFeatures } from './disallowedFeatures'; +import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; +import { createDocuments } from '../spec-configuration/editableFiles'; + + +export async function resolve(params: DockerResolverParameters, configFile: URI | undefined, overrideConfigFile: URI | undefined, providedIdLabels: string[] | undefined, additionalFeatures: Record>): Promise { + if (configFile && !/\/\.?devcontainer\.json$/.test(configFile.path)) { + throw new Error(`Filename must be devcontainer.json or .devcontainer.json (${uriToFsPath(configFile, params.common.cliHost.platform)}).`); + } + const parsedAuthority = params.parsedAuthority; + if (!parsedAuthority || isDevContainerAuthority(parsedAuthority)) { + return resolveWithLocalFolder(params, parsedAuthority, configFile, overrideConfigFile, providedIdLabels, additionalFeatures); + } else { + throw new Error(`Unexpected authority: ${JSON.stringify(parsedAuthority)}`); + } +} + +async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAuthority: DevContainerAuthority | undefined, configFile: URI | undefined, overrideConfigFile: URI | undefined, providedIdLabels: string[] | undefined, additionalFeatures: Record>): Promise { + const { common, workspaceMountConsistencyDefault } = params; + const { cliHost, output } = common; + + const cwd = cliHost.cwd; // Can be inside WSL. + const workspace = parsedAuthority && workspaceFromPath(cliHost.path, isWorkspacePath(parsedAuthority.hostPath) ? cliHost.path.join(cwd, path.basename(parsedAuthority.hostPath)) : cwd); + + const configPath = configFile ? configFile : workspace + ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) + || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) + : overrideConfigFile; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, workspaceMountConsistencyDefault, overrideConfigFile) || undefined; + if (!configs) { + if (configPath || workspace) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configPath || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } else { + throw new ContainerError({ description: `No dev container config and no workspace found.` }); + } + } + const idLabels = providedIdLabels || (await findContainerAndIdLabels(params, undefined, providedIdLabels, workspace?.rootFolderPath, configPath?.fsPath, params.removeOnStartup)).idLabels; + const configWithRaw = addSubstitution(configs.config, config => beforeContainerSubstitute(envListToObj(idLabels), config)); + const { config } = configWithRaw; + + const { dockerCLI, dockerComposeCLI } = params; + const { env } = common; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, platformInfo: params.platformInfo }; + await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels); + + await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); + + let result: ResolverResult; + if (isDockerFileConfig(config) || 'image' in config) { + result = await openDockerfileDevContainer(params, configWithRaw as SubstitutedConfig, configs.workspaceConfig, idLabels, additionalFeatures); + } else if ('dockerComposeFile' in config) { + if (!workspace) { + throw new ContainerError({ description: `A Dev Container using Docker Compose requires a workspace folder.` }); + } + result = await openDockerComposeDevContainer(params, workspace, configWithRaw as SubstitutedConfig, idLabels, additionalFeatures); + } else { + throw new ContainerError({ description: `Dev container config (${(config as DevContainerConfig).configFilePath}) is missing one of "image", "dockerFile" or "dockerComposeFile" properties.` }); + } + return result; +} + +export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { + const documents = createDocuments(cliHost); + const content = await documents.readDocument(overrideConfigFile ?? configFile); + if (!content) { + return undefined; + } + const raw = jsonc.parse(content) as DevContainerConfig | undefined; + const updated = raw && updateFromOldProperties(raw); + if (!updated || typeof updated !== 'object' || Array.isArray(updated)) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) must contain a JSON object literal.` }); + } + const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, output, consistency); + const substitute0: SubstituteConfig = value => substitute({ + platform: cliHost.platform, + localWorkspaceFolder: workspace?.rootFolderPath, + containerWorkspaceFolder: workspaceConfig.workspaceFolder, + configFile, + env: cliHost.env, + }, value); + const config: DevContainerConfig = substitute0(updated); + if (typeof config.workspaceFolder === 'string') { + workspaceConfig.workspaceFolder = config.workspaceFolder; + } + if ('workspaceMount' in config) { + workspaceConfig.workspaceMount = config.workspaceMount; + } + config.configFilePath = configFile; + return { + config: { + config, + raw: updated, + substitute: substitute0, + }, + workspaceConfig, + }; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/containerFeatures.ts b/extensions/positron-dev-containers/src/spec/spec-node/containerFeatures.ts new file mode 100644 index 000000000000..c7e42a56a94b --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/containerFeatures.ts @@ -0,0 +1,485 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +import { DevContainerConfig } from '../spec-configuration/configuration'; +import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { LogLevel, makeLog } from '../spec-utils/log'; +import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; +import { readLocalFile } from '../spec-utils/pfs'; +import { includeAllConfiguredFeatures } from '../spec-utils/product'; +import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig } from './utils'; +import { isEarlierVersion, parseVersion, runCommandNoPty } from '../spec-common/commonUtils'; +import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, ImageMetadataEntry, imageMetadataLabel, MergedDevContainerConfig } from './imageMetadata'; +import { supportsBuildContexts } from './dockerfileUtils'; +import { ContainerError } from '../spec-common/errors'; + +// Escapes environment variable keys. +// +// Environment variables must contain: +// - alpha-numeric values, or +// - the '_' character, and +// - a number cannot be the first character +export const getSafeId = (str: string) => str + .replace(/[^\w_]/g, '_') + .replace(/^[\d_]+/g, '_') + .toUpperCase(); + +export async function extendImage(params: DockerResolverParameters, config: SubstitutedConfig, imageName: string, additionalImageNames: string[], additionalFeatures: Record>, canAddLabelsToContainer: boolean) { + const { common } = params; + const { cliHost, output } = common; + + const imageBuildInfo = await getImageBuildInfoFromImage(params, imageName, config.substitute); + const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, undefined, additionalFeatures, canAddLabelsToContainer); + if (!extendImageDetails?.featureBuildInfo) { + // no feature extensions - return + if (additionalImageNames.length) { + if (params.isTTY) { + await Promise.all(additionalImageNames.map(name => dockerPtyCLI(params, 'tag', imageName, name))); + } else { + await Promise.all(additionalImageNames.map(name => dockerCLI(params, 'tag', imageName, name))); + } + } + return { + updatedImageName: [imageName], + imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, extendImageDetails?.featuresConfig), + imageDetails: async () => imageBuildInfo.imageDetails, + labels: extendImageDetails?.labels, + }; + } + const { featureBuildInfo, featuresConfig } = extendImageDetails; + + // Got feature extensions -> build the image + const dockerfilePath = cliHost.path.join(featureBuildInfo.dstFolder, 'Dockerfile.extended'); + await cliHost.writeFile(dockerfilePath, Buffer.from(featureBuildInfo.dockerfilePrefixContent + featureBuildInfo.dockerfileContent)); + const folderImageName = getFolderImageName(common); + const updatedImageName = `${imageName.startsWith(folderImageName) ? imageName : folderImageName}-features`; + + const args: string[] = []; + if (!params.buildKitVersion && + (params.buildxPlatform || params.buildxPush)) { + throw new ContainerError({ description: '--platform or --push require BuildKit enabled.', data: { fileWithError: dockerfilePath } }); + } + if (params.buildKitVersion) { + args.push('buildx', 'build'); + + // --platform + if (params.buildxPlatform) { + output.write('Setting BuildKit platform(s): ' + params.buildxPlatform, LogLevel.Trace); + args.push('--platform', params.buildxPlatform); + } + + // --push/--output + if (params.buildxPush) { + args.push('--push'); + } else { + if (params.buildxOutput) { + args.push('--output', params.buildxOutput); + } else { + args.push('--load'); // (short for --output=docker, i.e. load into normal 'docker images' collection) + } + } + if (params.buildxCacheTo) { + args.push('--cache-to', params.buildxCacheTo); + } + if (!params.buildNoCache) { + params.additionalCacheFroms.forEach(cacheFrom => args.push('--cache-from', cacheFrom)); + } + + for (const buildContext in featureBuildInfo.buildKitContexts) { + args.push('--build-context', `${buildContext}=${featureBuildInfo.buildKitContexts[buildContext]}`); + } + + for (const securityOpt of featureBuildInfo.securityOpts) { + args.push('--security-opt', securityOpt); + } + } else { + // Not using buildx + args.push( + 'build', + ); + } + if (params.buildNoCache) { + args.push('--no-cache'); + } + for (const buildArg in featureBuildInfo.buildArgs) { + args.push('--build-arg', `${buildArg}=${featureBuildInfo.buildArgs[buildArg]}`); + } + // Once this is step merged with the user Dockerfile (or working against the base image), + // the path will be the dev container context + // Set empty dir under temp path as the context for now to ensure we don't have dependencies on the features content + const emptyTempDir = getEmptyContextFolder(common); + cliHost.mkdirp(emptyTempDir); + args.push( + '--target', featureBuildInfo.overrideTarget, + '-f', dockerfilePath, + ...additionalImageNames.length > 0 ? additionalImageNames.map(name => ['-t', name]).flat() : ['-t', updatedImageName], + ...params.additionalLabels.length > 0 ? params.additionalLabels.map(label => ['--label', label]).flat() : [], + emptyTempDir + ); + + if (params.isTTY) { + const infoParams = { ...toPtyExecParameters(params), output: makeLog(output, LogLevel.Info) }; + await dockerPtyCLI(infoParams, ...args); + } else { + const infoParams = { ...toExecParameters(params), output: makeLog(output, LogLevel.Info), print: 'continuous' as 'continuous' }; + await dockerCLI(infoParams, ...args); + } + return { + updatedImageName: additionalImageNames.length > 0 ? additionalImageNames : [updatedImageName], + imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, featuresConfig), + imageDetails: async () => imageBuildInfo.imageDetails, + }; +} + +export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: SubstitutedConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined, additionalFeatures: Record>, canAddLabelsToContainer: boolean): Promise<{ featureBuildInfo?: ImageBuildOptions; featuresConfig?: FeaturesConfig; labels?: Record } | undefined> { + + // Creates the folder where the working files will be setup. + const dstFolder = await createFeaturesTempFolder(params.common); + + // Processes the user's configuration. + const platform = params.common.cliHost.platform; + + const cacheFolder = await getCacheFolder(params.common.cliHost); + const { experimentalLockfile, experimentalFrozenLockfile } = params; + const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile }, dstFolder, config.config, additionalFeatures); + if (!featuresConfig) { + if (canAddLabelsToContainer && !imageBuildInfo.dockerfile) { + return { + labels: { + [imageMetadataLabel]: JSON.stringify(getDevcontainerMetadata(imageBuildInfo.metadata, config, undefined, [], getOmitDevcontainerPropertyOverride(params.common)).raw), + } + }; + } + return { featureBuildInfo: getImageBuildOptions(params, config, dstFolder, baseName, imageBuildInfo) }; + } + + // Generates the end configuration. + const featureBuildInfo = await getFeaturesBuildOptions(params, config, featuresConfig, baseName, imageBuildInfo, composeServiceUser); + if (!featureBuildInfo) { + return undefined; + } + return { featureBuildInfo, featuresConfig }; + +} + +// NOTE: only exported to enable testing. Not meant to be called outside file. +export function generateContainerEnvsV1(featuresConfig: FeaturesConfig) { + let result = ''; + for (const fSet of featuresConfig.featureSets) { + // We only need to generate this ENV references for the initial features specification. + if (fSet.internalVersion !== '2') + { + result += '\n'; + result += fSet.features + .filter(f => (includeAllConfiguredFeatures || f.included) && f.value) + .reduce((envs, f) => envs.concat(generateContainerEnvs(f.containerEnv)), [] as string[]) + .join('\n'); + } + } + return result; +} + +export interface ImageBuildOptions { + dstFolder: string; + dockerfileContent: string; + overrideTarget: string; + dockerfilePrefixContent: string; + buildArgs: Record; + buildKitContexts: Record; + securityOpts: string[]; +} + +function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): ImageBuildOptions { + const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; + return { + dstFolder, + dockerfileContent: ` +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage +${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, config, { featureSets: [] }, [], getOmitDevcontainerPropertyOverride(params.common)))} +`, + overrideTarget: 'dev_containers_target_stage', + dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''} + ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder +`, + buildArgs: { + _DEV_CONTAINERS_BASE_IMAGE: baseName, + } as Record, + buildKitContexts: {} as Record, + securityOpts: [], + }; +} + +function getOmitDevcontainerPropertyOverride(resolverParams: { omitConfigRemotEnvFromMetadata?: boolean }): (keyof DevContainerConfig & keyof ImageMetadataEntry)[] { + if (resolverParams.omitConfigRemotEnvFromMetadata) { + return ['remoteEnv']; + } + + return []; +} + +async function getFeaturesBuildOptions(params: DockerResolverParameters, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined): Promise { + const { common } = params; + const { cliHost, output } = common; + const { dstFolder } = featuresConfig; + + if (!dstFolder || dstFolder === '') { + output.write('dstFolder is undefined or empty in addContainerFeatures', LogLevel.Error); + return undefined; + } + + // With Buildkit (0.8.0 or later), we can supply an additional build context to provide access to + // the container-features content. + // For non-Buildkit, we build a temporary image to hold the container-features content in a way + // that is accessible from the docker build for non-BuiltKit builds + // TODO generate an image name that is specific to this dev container? + const buildKitVersionParsed = params.buildKitVersion?.versionMatch ? parseVersion(params.buildKitVersion.versionMatch) : undefined; + const minRequiredVersion = [0, 8, 0]; + const useBuildKitBuildContexts = buildKitVersionParsed ? !isEarlierVersion(buildKitVersionParsed, minRequiredVersion) : false; + const buildContentImageName = 'dev_container_feature_content_temp'; + const disableSELinuxLabels = useBuildKitBuildContexts && await isUsingSELinuxLabels(params); + + const omitPropertyOverride = params.common.skipPersistingCustomizationsFromFeatures ? ['customizations'] : []; + const imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, devContainerConfig, featuresConfig, omitPropertyOverride, getOmitDevcontainerPropertyOverride(params.common)); + const { containerUser, remoteUser } = findContainerUsers(imageMetadata, composeServiceUser, imageBuildInfo.user); + const builtinVariables = [ + `_CONTAINER_USER=${containerUser}`, + `_REMOTE_USER=${remoteUser}`, + ]; + const envPath = cliHost.path.join(dstFolder, 'devcontainer-features.builtin.env'); + await cliHost.writeFile(envPath, Buffer.from(builtinVariables.join('\n') + '\n')); + + // When copying via buildkit, the content is accessed via '.' (i.e. in the context root) + // When copying via temp image, the content is in '/tmp/build-features' + const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/'; + const dockerfile = getContainerFeaturesBaseDockerFile(contentSourceRootPath) + .replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`) + .replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath)) + .replace('#{containerEnv}', generateContainerEnvsV1(featuresConfig)) + .replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata)) + .replace('#{containerEnvMetadata}', generateContainerEnvs(devContainerConfig.config.containerEnv, true)) + ; + const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; + const omitSyntaxDirective = common.omitSyntaxDirective; // Can be removed when https://github.com/moby/buildkit/issues/4556 is fixed + const dockerfilePrefixContent = `${omitSyntaxDirective ? '' : + useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' : + syntax ? `# syntax=${syntax}` : ''} +ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder +`; + + // Build devcontainer-features.env and devcontainer-features-install.sh file(s) for each features source folder + for await (const fSet of featuresConfig.featureSets) { + if (fSet.internalVersion === '2') + { + for await (const fe of fSet.features) { + if (fe.cachePath) + { + fe.internalVersion = '2'; + const envPath = cliHost.path.join(fe.cachePath, 'devcontainer-features.env'); + const variables = getFeatureEnvVariables(fe); + await cliHost.writeFile(envPath, Buffer.from(variables.join('\n'))); + + const installWrapperPath = cliHost.path.join(fe.cachePath, 'devcontainer-features-install.sh'); + const installWrapperContent = getFeatureInstallWrapperScript(fe, fSet, variables); + await cliHost.writeFile(installWrapperPath, Buffer.from(installWrapperContent)); + } + } + } else { + const featuresEnv = ([] as string[]).concat( + ...fSet.features + .filter(f => (includeAllConfiguredFeatures || f.included) && f.value) + .map(getFeatureEnvVariables) + ).join('\n'); + const envPath = cliHost.path.join(fSet.features[0].cachePath!, 'devcontainer-features.env'); + await Promise.all([ + cliHost.writeFile(envPath, Buffer.from(featuresEnv)), + ...fSet.features + .filter(f => (includeAllConfiguredFeatures || f.included) && f.value) + .map(f => { + const consecutiveId = f.consecutiveId; + if (!consecutiveId) { + throw new Error('consecutiveId is undefined for Feature ' + f.id); + } + const featuresEnv = [ + ...getFeatureEnvVariables(f), + `_BUILD_ARG_${getSafeId(f.id)}_TARGETPATH=${path.posix.join('/usr/local/devcontainer-features', consecutiveId)}` + ] + .join('\n'); + const envPath = cliHost.path.join(dstFolder, consecutiveId, 'devcontainer-features.env'); // next to bin/acquire + return cliHost.writeFile(envPath, Buffer.from(featuresEnv)); + }) + ]); + } + } + + // For non-BuildKit, build the temporary image for the container-features content + if (!useBuildKitBuildContexts) { + const buildContentDockerfile = ` + FROM scratch + COPY . /tmp/build-features/ + `; + const buildContentDockerfilePath = cliHost.path.join(dstFolder, 'Dockerfile.buildContent'); + await cliHost.writeFile(buildContentDockerfilePath, Buffer.from(buildContentDockerfile)); + const buildContentArgs = [ + 'build', + '-t', buildContentImageName, + '-f', buildContentDockerfilePath, + ]; + buildContentArgs.push(dstFolder); + + if (params.isTTY) { + const buildContentInfoParams = { ...toPtyExecParameters(params), output: makeLog(output, LogLevel.Info) }; + await dockerPtyCLI(buildContentInfoParams, ...buildContentArgs); + } else { + const buildContentInfoParams = { ...toExecParameters(params), output: makeLog(output, LogLevel.Info), print: 'continuous' as 'continuous' }; + await dockerCLI(buildContentInfoParams, ...buildContentArgs); + } + } + return { + dstFolder, + dockerfileContent: dockerfile, + overrideTarget: 'dev_containers_target_stage', + dockerfilePrefixContent, + buildArgs: { + _DEV_CONTAINERS_BASE_IMAGE: baseName, + _DEV_CONTAINERS_IMAGE_USER: imageBuildInfo.user, + _DEV_CONTAINERS_FEATURE_CONTENT_SOURCE: buildContentImageName, + }, + buildKitContexts: useBuildKitBuildContexts ? { dev_containers_feature_content_source: dstFolder } : {}, + securityOpts: disableSELinuxLabels ? ['label=disable'] : [], + }; +} + +async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise { + try { + const { common } = params; + const { cliHost, output } = common; + return params.isPodman && cliHost.platform === 'linux' + && (await runCommandNoPty({ + exec: cliHost.exec, + cmd: 'getenforce', + output, + print: true, + })).stdout.toString().trim() !== 'Disabled' + && (await dockerCLI({ + ...toExecParameters(params), + print: true, + }, 'info', '-f', '{{.Host.Security.SELinuxEnabled}}')).stdout.toString().trim() === 'true'; + } catch { + // If we can't run the commands, assume SELinux is not enabled. + return false; + + } +} + +export function findContainerUsers(imageMetadata: SubstitutedConfig, composeServiceUser: string | undefined, imageUser: string) { + const reversed = imageMetadata.config.slice().reverse(); + const containerUser = reversed.find(entry => entry.containerUser)?.containerUser || composeServiceUser || imageUser; + const remoteUser = reversed.find(entry => entry.remoteUser)?.remoteUser || containerUser; + return { containerUser, remoteUser }; +} + + +function getFeatureEnvVariables(f: Feature) { + const values = getFeatureValueObject(f); + const idSafe = getSafeId(f.id); + const variables = []; + + if(f.internalVersion !== '2') + { + if (values) { + variables.push(...Object.keys(values) + .map(name => `_BUILD_ARG_${idSafe}_${getSafeId(name)}="${values[name]}"`)); + variables.push(`_BUILD_ARG_${idSafe}=true`); + } + if (f.buildArg) { + variables.push(`${f.buildArg}=${getFeatureMainValue(f)}`); + } + return variables; + } else { + if (values) { + variables.push(...Object.keys(values) + .map(name => `${getSafeId(name)}="${values[name]}"`)); + } + if (f.buildArg) { + variables.push(`${f.buildArg}=${getFeatureMainValue(f)}`); + } + return variables; + } +} + +export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise, runArgsUser: string | undefined) { + const { common } = params; + const { cliHost } = common; + const { updateRemoteUserUID } = mergedConfig; + if (params.updateRemoteUserUIDDefault === 'never' || !(typeof updateRemoteUserUID === 'boolean' ? updateRemoteUserUID : params.updateRemoteUserUIDDefault === 'on') || !(cliHost.platform === 'linux' || params.updateRemoteUserUIDOnMacOS && cliHost.platform === 'darwin')) { + return null; + } + const details = await imageDetails(); + const imageUser = details.Config.User || 'root'; + const remoteUser = mergedConfig.remoteUser || runArgsUser || imageUser; + if (remoteUser === 'root' || /^\d+$/.test(remoteUser)) { + return null; + } + const folderImageName = getFolderImageName(common); + const fixedImageName = `${imageName.startsWith(folderImageName) ? imageName : folderImageName}-uid`; + + return { + imageName: fixedImageName, + remoteUser, + imageUser, + platform: [details.Os, details.Architecture, details.Variant].filter(Boolean).join('/') + }; +} + +export async function updateRemoteUserUID(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise, runArgsUser: string | undefined) { + const { common } = params; + const { cliHost } = common; + + const updateDetails = await getRemoteUserUIDUpdateDetails(params, mergedConfig, imageName, imageDetails, runArgsUser); + if (!updateDetails) { + return imageName; + } + const { imageName: fixedImageName, remoteUser, imageUser, platform } = updateDetails; + + const dockerfileName = 'updateUID.Dockerfile'; + const srcDockerfile = path.join(common.extensionPath, 'scripts', dockerfileName); + const version = common.package.version; + const destDockerfile = cliHost.path.join(await getCacheFolder(cliHost), `${dockerfileName}-${version}`); + const tmpDockerfile = `${destDockerfile}-${Date.now()}`; + await cliHost.mkdirp(cliHost.path.dirname(tmpDockerfile)); + await cliHost.writeFile(tmpDockerfile, await readLocalFile(srcDockerfile)); + await cliHost.rename(tmpDockerfile, destDockerfile); + const emptyFolder = getEmptyContextFolder(common); + await cliHost.mkdirp(emptyFolder); + const args = [ + 'build', + '-f', destDockerfile, + '-t', fixedImageName, + ...(platform ? ['--platform', platform] : []), + '--build-arg', `BASE_IMAGE=${params.isPodman && !hasRegistryHostname(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748 + '--build-arg', `REMOTE_USER=${remoteUser}`, + '--build-arg', `NEW_UID=${await cliHost.getuid!()}`, + '--build-arg', `NEW_GID=${await cliHost.getgid!()}`, + '--build-arg', `IMAGE_USER=${imageUser}`, + emptyFolder, + ]; + if (params.isTTY) { + await dockerPtyCLI(params, ...args); + } else { + await dockerCLI(params, ...args); + } + return fixedImageName; +} + +function hasRegistryHostname(imageName: string) { + if (imageName.startsWith('localhost/')) { + return true; + } + const dot = imageName.indexOf('.'); + const slash = imageName.indexOf('/'); + return dot !== -1 && slash !== -1 && dot < slash; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/devContainers.ts b/extensions/positron-dev-containers/src/spec/spec-node/devContainers.ts new file mode 100644 index 000000000000..493d0dc226a3 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/devContainers.ts @@ -0,0 +1,278 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as os from 'os'; + +import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils'; +import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; +import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; +import { resolve } from './configContainer'; +import { URI } from 'vscode-uri'; +import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminalLog, Log, makeLog, LogFormat, createJSONLog, createPlainLog, LogHandler, replaceAllLog } from '../spec-utils/log'; +import { dockerComposeCLIConfig } from './dockerCompose'; +import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; +import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; +import { dockerBuildKitVersion, isPodman } from '../spec-shutdown/dockerUtils'; +import { Event } from '../spec-utils/event'; + + +export interface ProvisionOptions { + dockerPath: string | undefined; + dockerComposePath: string | undefined; + containerDataFolder: string | undefined; + containerSystemDataFolder: string | undefined; + workspaceFolder: string | undefined; + workspaceMountConsistency?: BindMountConsistency; + gpuAvailability?: GPUAvailability; + mountWorkspaceGitRoot: boolean; + configFile: URI | undefined; + overrideConfigFile: URI | undefined; + logLevel: LogLevel; + logFormat: LogFormat; + log: (text: string) => void; + terminalDimensions: LogDimensions | undefined; + onDidChangeTerminalDimensions?: Event; + defaultUserEnvProbe: UserEnvProbe; + removeExistingContainer: boolean; + buildNoCache: boolean; + expectExistingContainer: boolean; + postCreateEnabled: boolean; + skipNonBlocking: boolean; + prebuild: boolean; + persistedFolder: string | undefined; + additionalMounts: Mount[]; + updateRemoteUserUIDDefault: UpdateRemoteUserUIDDefault; + remoteEnv: Record; + additionalCacheFroms: string[]; + useBuildKit: 'auto' | 'never'; + omitLoggerHeader?: boolean | undefined; + buildxPlatform: string | undefined; + buildxPush: boolean; + additionalLabels: string[]; + buildxOutput: string | undefined; + buildxCacheTo: string | undefined; + additionalFeatures?: Record>; + skipFeatureAutoMapping: boolean; + skipPostAttach: boolean; + containerSessionDataFolder?: string; + skipPersistingCustomizationsFromFeatures: boolean; + omitConfigRemotEnvFromMetadata?: boolean; + dotfiles: { + repository?: string; + installCommand?: string; + targetPath?: string; + }; + experimentalLockfile?: boolean; + experimentalFrozenLockfile?: boolean; + secretsP?: Promise>; + omitSyntaxDirective?: boolean; + includeConfig?: boolean; + includeMergedConfig?: boolean; +} + +export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { + const params = await createDockerParams(options, disposables); + const output = params.common.output; + const text = 'Resolving Remote'; + const start = output.start(text); + + const result = await resolve(params, options.configFile, options.overrideConfigFile, providedIdLabels, options.additionalFeatures ?? {}); + output.stop(text, start); + const { dockerContainerId, composeProjectName } = result; + return { + containerId: dockerContainerId, + composeProjectName, + remoteUser: result.properties.user, + remoteWorkspaceFolder: result.properties.remoteWorkspaceFolder, + configuration: options.includeConfig ? result.config : undefined, + mergedConfiguration: options.includeMergedConfig ? result.mergedConfig : undefined, + finishBackgroundTasks: async () => { + try { + await finishBackgroundTasks(result.params.backgroundTasks); + } catch (err) { + output.write(toErrorText(String(err && (err.stack || err.message) || err))); + } + }, + }; +} + +export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise | undefined)[]): Promise { + const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options; + let parsedAuthority: DevContainerAuthority | undefined; + if (options.workspaceFolder) { + parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority; + } + const extensionPath = path.join(__dirname, '..', '..'); + const sessionStart = new Date(); + const pkg = getPackageConfig(); + const output = createLog(options, pkg, sessionStart, disposables, omitLoggerHeader, secretsP ? await secretsP : undefined); + + const appRoot = undefined; + const cwd = options.workspaceFolder || process.cwd(); + const allowInheritTTY = options.logFormat === 'text'; + const cliHost = await getCLIHost(cwd, loadNativeModule, allowInheritTTY); + const sessionId = crypto.randomUUID(); + + const common: ResolverParameters = { + prebuild: options.prebuild, + computeExtensionHostEnv: false, + package: pkg, + containerDataFolder, + containerSystemDataFolder, + appRoot, + extensionPath, // TODO: rename to packagePath + sessionId, + sessionStart, + cliHost, + env: cliHost.env, + cwd, + isLocalContainer: false, + progress: () => { }, + output, + allowSystemConfigChange: true, + defaultUserEnvProbe: options.defaultUserEnvProbe, + lifecycleHook: createNullLifecycleHook(options.postCreateEnabled, options.skipNonBlocking, output), + getLogLevel: () => options.logLevel, + onDidChangeLogLevel: () => ({ dispose() { } }), + loadNativeModule, + allowInheritTTY, + shutdowns: [], + backgroundTasks: [], + persistedFolder: persistedFolder || await getCacheFolder(cliHost), // Fallback to tmp folder, even though that isn't 'persistent' + remoteEnv, + secretsP, + buildxPlatform: options.buildxPlatform, + buildxPush: options.buildxPush, + buildxOutput: options.buildxOutput, + buildxCacheTo: options.buildxCacheTo, + skipFeatureAutoMapping: options.skipFeatureAutoMapping, + skipPostAttach: options.skipPostAttach, + containerSessionDataFolder: options.containerSessionDataFolder, + skipPersistingCustomizationsFromFeatures: options.skipPersistingCustomizationsFromFeatures, + omitConfigRemotEnvFromMetadata: options.omitConfigRemotEnvFromMetadata, + dotfilesConfiguration: { + repository: options.dotfiles.repository, + installCommand: options.dotfiles.installCommand, + targetPath: options.dotfiles.targetPath || '~/dotfiles', + }, + omitSyntaxDirective: options.omitSyntaxDirective, + }; + + const dockerPath = options.dockerPath || 'docker'; + const dockerComposePath = options.dockerComposePath || 'docker-compose'; + const dockerComposeCLI = dockerComposeCLIConfig({ + exec: cliHost.exec, + env: cliHost.env, + output: common.output, + }, dockerPath, dockerComposePath); + + const platformInfo = (() => { + if (common.buildxPlatform) { + const slash1 = common.buildxPlatform.indexOf('/'); + const slash2 = common.buildxPlatform.indexOf('/', slash1 + 1); + // `--platform linux/amd64/v3` `--platform linux/arm64/v8` + if (slash2 !== -1) { + return { + os: common.buildxPlatform.slice(0, slash1), + arch: common.buildxPlatform.slice(slash1 + 1, slash2), + variant: common.buildxPlatform.slice(slash2 + 1), + }; + } + // `--platform linux/amd64` and `--platform linux/arm64` + return { + os: common.buildxPlatform.slice(0, slash1), + arch: common.buildxPlatform.slice(slash1 + 1), + }; + } else { + // `--platform` omitted + return { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; + } + })(); + + const buildKitVersion = options.useBuildKit === 'never' ? undefined : (await dockerBuildKitVersion({ + cliHost, + dockerCLI: dockerPath, + dockerComposeCLI, + env: cliHost.env, + output, + platformInfo + })); + return { + common, + parsedAuthority, + dockerCLI: dockerPath, + isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }), + dockerComposeCLI: dockerComposeCLI, + dockerEnv: cliHost.env, + workspaceMountConsistencyDefault: workspaceMountConsistency, + gpuAvailability: gpuAvailability || 'detect', + mountWorkspaceGitRoot, + updateRemoteUserUIDOnMacOS: false, + cacheMount: 'bind', + removeOnStartup: options.removeExistingContainer, + buildNoCache: options.buildNoCache, + expectExistingContainer: options.expectExistingContainer, + additionalMounts, + userRepositoryConfigurationPaths: [], + updateRemoteUserUIDDefault, + additionalCacheFroms: options.additionalCacheFroms, + buildKitVersion, + isTTY: process.stdout.isTTY || options.logFormat === 'json', + experimentalLockfile, + experimentalFrozenLockfile, + buildxPlatform: common.buildxPlatform, + buildxPush: common.buildxPush, + additionalLabels: options.additionalLabels, + buildxOutput: common.buildxOutput, + buildxCacheTo: common.buildxCacheTo, + platformInfo + }; +} + +export interface LogOptions { + logLevel: LogLevel; + logFormat: LogFormat; + log: (text: string) => void; + terminalDimensions: LogDimensions | undefined; + onDidChangeTerminalDimensions?: Event; +} + +export function createLog(options: LogOptions, pkg: PackageConfiguration, sessionStart: Date, disposables: (() => Promise | undefined)[], omitHeader?: boolean, secrets?: Record) { + const header = omitHeader ? undefined : `${pkg.name} ${pkg.version}. Node.js ${process.version}. ${os.platform()} ${os.release()} ${os.arch()}.`; + const output = createLogFrom(options, sessionStart, header, secrets); + output.dimensions = options.terminalDimensions; + output.onDidChangeDimensions = options.onDidChangeTerminalDimensions; + disposables.push(() => output.join()); + return output; +} + +function createLogFrom({ log: write, logLevel, logFormat }: LogOptions, sessionStart: Date, header: string | undefined = undefined, secrets?: Record): Log & { join(): Promise } { + const handler = logFormat === 'json' ? createJSONLog(write, () => logLevel, sessionStart) : + process.stdout.isTTY ? createTerminalLog(write, () => logLevel, sessionStart) : + createPlainLog(write, () => logLevel); + const log = { + ...makeLog(createCombinedLog([maskSecrets(handler, secrets)], header)), + join: async () => { + // TODO: wait for write() to finish. + }, + }; + return log; +} + +function maskSecrets(handler: LogHandler, secrets?: Record): LogHandler { + if (secrets) { + const mask = '********'; + const secretValues = Object.values(secrets); + return replaceAllLog(handler, secretValues, mask); + } + + return handler; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/devContainersSpecCLI.ts b/extensions/positron-dev-containers/src/spec/spec-node/devContainersSpecCLI.ts new file mode 100644 index 000000000000..d1f40565c5a7 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/devContainersSpecCLI.ts @@ -0,0 +1,1563 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import yargs, { Argv } from 'yargs'; +import textTable from 'text-table'; + +import * as jsonc from 'jsonc-parser'; + +import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; +import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler } from './utils'; +import { URI } from 'vscode-uri'; +import { ContainerError } from '../spec-common/errors'; +import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; +import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; +import { extendImage } from './containerFeatures'; +import { dockerCLI, DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; +import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose'; +import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; +import { workspaceFromPath } from '../spec-utils/workspaces'; +import { readDevContainerConfigFile } from './configContainer'; +import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; +import { CLIHost, getCLIHost } from '../spec-common/cliHost'; +import { loadNativeModule, processSignals } from '../spec-common/commonUtils'; +import { loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; +import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; +import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; +import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; +import { beforeContainerSubstitute, containerSubstitute, substitute } from '../spec-common/variableSubstitution'; +import { getPackageConfig, } from '../spec-utils/product'; +import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; +import { templatesPublishHandler, templatesPublishOptions } from './templatesCLI/publish'; +import { templateApplyHandler, templateApplyOptions } from './templatesCLI/apply'; +import { featuresInfoHandler as featuresInfoHandler, featuresInfoOptions } from './featuresCLI/info'; +import { bailOut, buildNamedImageAndExtend } from './singleContainer'; +import { Event, NodeEventEmitter } from '../spec-utils/event'; +import { ensureNoDisallowedFeatures } from './disallowedFeatures'; +import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions } from './featuresCLI/resolveDependencies'; +import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI'; +import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand'; +import { readFeaturesConfig } from './featureUtils'; +import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; +import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; +import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; + +const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; + +const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; + +(async () => { + + const packageFolder = path.join(__dirname, '..', '..'); + const version = getPackageConfig().version; + const argv = process.argv.slice(2); + const restArgs = argv[0] === 'exec' && argv[1] !== '--help'; // halt-at-non-option doesn't work in subcommands: https://github.com/yargs/yargs/issues/1417 + const y = yargs([]) + .parserConfiguration({ + // By default, yargs allows `--no-myoption` to set a boolean `--myoption` to false + // Disable this to allow `--no-cache` on the `build` command to align with `docker build` syntax + 'boolean-negation': false, + 'halt-at-non-option': restArgs, + }) + .scriptName('devcontainer') + .version(version) + .demandCommand() + .strict(); + y.wrap(Math.min(120, y.terminalWidth())); + y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); + y.command('set-up', 'Set up an existing container as a dev container', setUpOptions, setUpHandler); + y.command('build [path]', 'Build a dev container image', buildOptions, buildHandler); + y.command('run-user-commands', 'Run user commands', runUserCommandsOptions, runUserCommandsHandler); + y.command('read-configuration', 'Read configuration', readConfigurationOptions, readConfigurationHandler); + y.command('outdated', 'Show current and available versions', outdatedOptions, outdatedHandler); + y.command('upgrade', 'Upgrade lockfile', featuresUpgradeOptions, featuresUpgradeHandler); + y.command('features', 'Features commands', (y: Argv) => { + y.command('test [target]', 'Test Features', featuresTestOptions, featuresTestHandler); + y.command('package ', 'Package Features', featuresPackageOptions, featuresPackageHandler); + y.command('publish ', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler); + y.command('info ', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler); + y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler); + y.command('generate-docs', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler); + }); + y.command('templates', 'Templates commands', (y: Argv) => { + y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler); + y.command('publish ', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler); + y.command('metadata ', 'Fetch a published Template\'s metadata', templateMetadataOptions, templateMetadataHandler); + y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler); + }); + y.command(restArgs ? ['exec', '*'] : ['exec [args..]'], 'Execute a command on a running dev container', execOptions, execHandler); + y.epilog(`devcontainer@${version} ${packageFolder}`); + y.parse(restArgs ? argv.slice(1) : argv); + + // --- Start Positron --- + // Don't run this CLI in Positron; we only need the types it provides + // })().catch(console.error); +}); +// --- End Positron --- + +export type UnpackArgv = T extends Argv ? U : T; + +function provisionOptions(y: Argv) { + return y.options({ + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, + 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-mount-consistency': { choices: ['consistent' as 'consistent', 'cached' as 'cached', 'delegated' as 'delegated'], default: 'cached' as 'cached', description: 'Workspace mount consistency.' }, + 'gpu-availability': { choices: ['all' as 'all', 'detect' as 'detect', 'none' as 'none'], default: 'detect' as 'detect', description: 'Availability of GPUs in case the dev container requires any. `all` expects a GPU to be available.' }, + 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. These will be set on the container and used to query for an existing container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, + 'update-remote-user-uid-default': { choices: ['never' as 'never', 'on' as 'on', 'off' as 'off'], default: 'on' as 'on', description: 'Default for updating the remote user\'s UID and GID to the local user\'s one.' }, + 'remove-existing-container': { type: 'boolean', default: false, description: 'Removes the dev container if it already exists.' }, + 'build-no-cache': { type: 'boolean', default: false, description: 'Builds the image with `--no-cache` if the container does not exist.' }, + 'expect-existing-container': { type: 'boolean', default: false, description: 'Fail if the container does not exist.' }, + 'skip-post-create': { type: 'boolean', default: false, description: 'Do not run onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand or postAttachCommand and do not install dotfiles.' }, + 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, + prebuild: { type: 'boolean', default: false, description: 'Stop after onCreateCommand and updateContentCommand, rerunning updateContentCommand if it has run before.' }, + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'mount': { type: 'string', description: 'Additional mount point(s). Format: type=,source=,target=[,external=]' }, + 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, + 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, + 'cache-to': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, + 'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' }, + 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, + 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, + 'skip-post-attach': { type: 'boolean', default: false, description: 'Do not run postAttachCommand.' }, + 'dotfiles-repository': { type: 'string', description: 'URL of a dotfiles Git repository (e.g., https://github.com/owner/repository.git)' }, + 'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' }, + 'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' }, + 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' }, + 'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' }, + 'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' }, + 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, + 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, + 'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' }, + 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' }, + }) + .check(argv => { + const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; + if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { + throw new Error('Unmatched argument format: id-label must match ='); + } + if (!(argv['workspace-folder'] || argv['id-label'])) { + throw new Error('Missing required argument: workspace-folder or id-label'); + } + if (!(argv['workspace-folder'] || argv['override-config'])) { + throw new Error('Missing required argument: workspace-folder or override-config'); + } + const mounts = (argv.mount && (Array.isArray(argv.mount) ? argv.mount : [argv.mount])) as string[] | undefined; + if (mounts?.some(mount => !mountRegex.test(mount))) { + throw new Error('Unmatched argument format: mount must match type=,source=,target=[,external=]'); + } + const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { + throw new Error('Unmatched argument format: remote-env must match ='); + } + return true; + }); +} + +type ProvisionArgs = UnpackArgv>; + +function provisionHandler(args: ProvisionArgs) { + runAsyncHandler(provision.bind(null, args)); +} + +async function provision({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'container-data-folder': containerDataFolder, + 'container-system-data-folder': containerSystemDataFolder, + 'workspace-folder': workspaceFolderArg, + 'workspace-mount-consistency': workspaceMountConsistency, + 'gpu-availability': gpuAvailability, + 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'id-label': idLabel, + config, + 'override-config': overrideConfig, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'default-user-env-probe': defaultUserEnvProbe, + 'update-remote-user-uid-default': updateRemoteUserUIDDefault, + 'remove-existing-container': removeExistingContainer, + 'build-no-cache': buildNoCache, + 'expect-existing-container': expectExistingContainer, + 'skip-post-create': skipPostCreate, + 'skip-non-blocking-commands': skipNonBlocking, + prebuild, + mount, + 'remote-env': addRemoteEnv, + 'cache-from': addCacheFrom, + 'cache-to': addCacheTo, + 'buildkit': buildkit, + 'additional-features': additionalFeaturesJson, + 'skip-feature-auto-mapping': skipFeatureAutoMapping, + 'skip-post-attach': skipPostAttach, + 'dotfiles-repository': dotfilesRepository, + 'dotfiles-install-command': dotfilesInstallCommand, + 'dotfiles-target-path': dotfilesTargetPath, + 'container-session-data-folder': containerSessionDataFolder, + 'omit-config-remote-env-from-metadata': omitConfigRemotEnvFromMetadata, + 'secrets-file': secretsFile, + 'experimental-lockfile': experimentalLockfile, + 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'omit-syntax-directive': omitSyntaxDirective, + 'include-configuration': includeConfig, + 'include-merged-configuration': includeMergedConfig, +}: ProvisionArgs) { + + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; + const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; + const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; + const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + + const cwd = workspaceFolder || process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text'); + const secretsP = readSecretsFromFile({ secretsFile, cliHost }); + + const options: ProvisionOptions = { + dockerPath, + dockerComposePath, + containerDataFolder, + containerSystemDataFolder, + workspaceFolder, + workspaceMountConsistency, + gpuAvailability, + mountWorkspaceGitRoot, + configFile: config ? URI.file(path.resolve(process.cwd(), config)) : undefined, + overrideConfigFile: overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + defaultUserEnvProbe, + removeExistingContainer, + buildNoCache, + expectExistingContainer, + postCreateEnabled: !skipPostCreate, + skipNonBlocking, + prebuild, + persistedFolder, + additionalMounts: mount ? (Array.isArray(mount) ? mount : [mount]).map(mount => { + const [, type, source, target, external] = mountRegex.exec(mount)!; + return { + type: type as 'bind' | 'volume', + source, + target, + external: external === 'true' + }; + }) : [], + dotfiles: { + repository: dotfilesRepository, + installCommand: dotfilesInstallCommand, + targetPath: dotfilesTargetPath, + }, + updateRemoteUserUIDDefault, + remoteEnv: envListToObj(addRemoteEnvs), + secretsP, + additionalCacheFroms: addCacheFroms, + useBuildKit: buildkit, + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: addCacheTo, + additionalFeatures, + skipFeatureAutoMapping, + skipPostAttach, + containerSessionDataFolder, + skipPersistingCustomizationsFromFeatures: false, + omitConfigRemotEnvFromMetadata, + experimentalLockfile, + experimentalFrozenLockfile, + omitSyntaxDirective, + includeConfig, + includeMergedConfig, + }; + + const result = await doProvision(options, providedIdLabels); + const exitCode = result.outcome === 'error' ? 1 : 0; + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); + if (result.outcome === 'success') { + await result.finishBackgroundTasks(); + } + await result.dispose(); + process.exit(exitCode); +} + +async function doProvision(options: ProvisionOptions, providedIdLabels: string[] | undefined) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + try { + const result = await launch(options, providedIdLabels, disposables); + return { + outcome: 'success' as 'success', + dispose, + ...result, + }; + } catch (originalError) { + const originalStack = originalError?.stack; + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred setting up the container.', + originalError + }); + if (originalStack) { + console.error(originalStack); + } + return { + outcome: 'error' as 'error', + message: err.message, + description: err.description, + containerId: err.containerId, + disallowedFeatureId: err.data.disallowedFeatureId, + didStopContainer: err.data.didStopContainer, + learnMoreUrl: err.data.learnMoreUrl, + dispose, + }; + } +} + +function setUpOptions(y: Argv) { + return y.options({ + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, + 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, + 'container-id': { type: 'string', required: true, description: 'Id of the container.' }, + 'config': { type: 'string', description: 'devcontainer.json path.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, + 'skip-post-create': { type: 'boolean', default: false, description: 'Do not run onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand or postAttachCommand and do not install dotfiles.' }, + 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, + 'dotfiles-repository': { type: 'string', description: 'URL of a dotfiles Git repository (e.g., https://github.com/owner/repository.git)' }, + 'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' }, + 'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' }, + 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' }, + 'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' }, + 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' }, + }) + .check(argv => { + const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { + throw new Error('Unmatched argument format: remote-env must match ='); + } + return true; + }); +} + +type SetUpArgs = UnpackArgv>; + +function setUpHandler(args: SetUpArgs) { + runAsyncHandler(setUp.bind(null, args)); +} + +async function setUp(args: SetUpArgs) { + const result = await doSetUp(args); + const exitCode = result.outcome === 'error' ? 1 : 0; + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); + await result.dispose(); + process.exit(exitCode); +} + +async function doSetUp({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'container-data-folder': containerDataFolder, + 'container-system-data-folder': containerSystemDataFolder, + 'container-id': containerId, + config: configParam, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'default-user-env-probe': defaultUserEnvProbe, + 'skip-post-create': skipPostCreate, + 'skip-non-blocking-commands': skipNonBlocking, + 'remote-env': addRemoteEnv, + 'dotfiles-repository': dotfilesRepository, + 'dotfiles-install-command': dotfilesInstallCommand, + 'dotfiles-target-path': dotfilesTargetPath, + 'container-session-data-folder': containerSessionDataFolder, + 'include-configuration': includeConfig, + 'include-merged-configuration': includeMergedConfig, +}: SetUpArgs) { + + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + try { + const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const params = await createDockerParams({ + dockerPath, + dockerComposePath: undefined, + containerSessionDataFolder, + containerDataFolder, + containerSystemDataFolder, + workspaceFolder: undefined, + mountWorkspaceGitRoot: false, + configFile, + overrideConfigFile: undefined, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + defaultUserEnvProbe, + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: !skipPostCreate, + skipNonBlocking, + prebuild: false, + persistedFolder, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: envListToObj(addRemoteEnvs), + additionalCacheFroms: [], + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: dotfilesRepository, + installCommand: dotfilesInstallCommand, + targetPath: dotfilesTargetPath, + }, + }, disposables); + + const { common } = params; + const { cliHost, output } = common; + const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, output, undefined, undefined); + if (configFile && !configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` }); + } + + const config0 = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const container = await inspectContainer(params, containerId); + if (!container) { + bailOut(common.output, 'Dev container not found.'); + } + + const config = addSubstitution(config0, config => beforeContainerSubstitute(undefined, config)); + + const imageMetadata = getImageMetadataFromContainer(container, config, undefined, undefined, output).config; + const mergedConfig = mergeConfiguration(config.config, imageMetadata); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + const res = await setupInContainer(common, containerProperties, config.config, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); + return { + outcome: 'success' as 'success', + configuration: includeConfig ? res.updatedConfig : undefined, + mergedConfiguration: includeMergedConfig ? res.updatedMergedConfig : undefined, + dispose, + }; + } catch (originalError) { + const originalStack = originalError?.stack; + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred running user commands in the container.', + originalError + }); + if (originalStack) { + console.error(originalStack); + } + return { + outcome: 'error' as 'error', + message: err.message, + description: err.description, + dispose, + }; + } +} + +function buildOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'no-cache': { type: 'boolean', default: false, description: 'Builds the image with `--no-cache`.' }, + 'image-name': { type: 'string', description: 'Image name.' }, + 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache' }, + 'cache-to': { type: 'string', description: 'A destination of buildx cache' }, + 'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' }, + 'platform': { type: 'string', description: 'Set target platforms.' }, + 'push': { type: 'boolean', default: false, description: 'Push to a container registry.' }, + 'label': { type: 'string', description: 'Provide key and value configuration that adds metadata to an image' }, + 'output': { type: 'string', description: 'Overrides the default behavior to load built images into the local docker registry. Valid options are the same ones provided to the --output option of docker buildx build.' }, + 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, + 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, + 'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' }, + 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, + 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, + }); +} + +type BuildArgs = UnpackArgv>; + +function buildHandler(args: BuildArgs) { + runAsyncHandler(build.bind(null, args)); +} + +async function build(args: BuildArgs) { + const result = await doBuild(args); + const exitCode = result.outcome === 'error' ? 1 : 0; + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); + await result.dispose(); + process.exit(exitCode); +} + +async function doBuild({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'workspace-folder': workspaceFolderArg, + config: configParam, + 'log-level': logLevel, + 'log-format': logFormat, + 'no-cache': buildNoCache, + 'image-name': argImageName, + 'cache-from': addCacheFrom, + 'buildkit': buildkit, + 'platform': buildxPlatform, + 'push': buildxPush, + 'label': buildxLabel, + 'output': buildxOutput, + 'cache-to': buildxCacheTo, + 'additional-features': additionalFeaturesJson, + 'skip-feature-auto-mapping': skipFeatureAutoMapping, + 'skip-persisting-customizations-from-features': skipPersistingCustomizationsFromFeatures, + 'experimental-lockfile': experimentalLockfile, + 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'omit-syntax-directive': omitSyntaxDirective, +}: BuildArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + try { + const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const configFile: URI | undefined = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined; + const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; + const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; + const params = await createDockerParams({ + dockerPath, + dockerComposePath, + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + workspaceFolder, + mountWorkspaceGitRoot: false, + configFile, + overrideConfigFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: {}, + additionalCacheFroms: addCacheFroms, + useBuildKit: buildkit, + buildxPlatform, + buildxPush, + additionalLabels: [], + buildxOutput, + buildxCacheTo, + skipFeatureAutoMapping, + skipPostAttach: true, + skipPersistingCustomizationsFromFeatures: skipPersistingCustomizationsFromFeatures, + dotfiles: {}, + experimentalLockfile, + experimentalFrozenLockfile, + omitSyntaxDirective, + }, disposables); + + const { common, dockerComposeCLI } = params; + const { cliHost, env, output } = common; + const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const configPath = configFile ? configFile : workspace + ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) + || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) + : overrideConfigFile; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + if (!configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + const configWithRaw = configs.config; + const { config } = configWithRaw; + let imageNameResult: string[] = ['']; + + if (buildxOutput && buildxPush) { + throw new ContainerError({ description: '--push true cannot be used with --output.' }); + } + + const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, platformInfo: params.platformInfo }; + await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined); + + // Support multiple use of `--image-name` + const imageNames = (argImageName && (Array.isArray(argImageName) ? argImageName : [argImageName]) as string[]) || undefined; + + // Support multiple use of `--label` + params.additionalLabels = (buildxLabel && (Array.isArray(buildxLabel) ? buildxLabel : [buildxLabel]) as string[]) || []; + + if (isDockerFileConfig(config)) { + + // Build the base image and extend with features etc. + let { updatedImageName } = await buildNamedImageAndExtend(params, configWithRaw as SubstitutedConfig, additionalFeatures, false, imageNames); + + if (imageNames) { + imageNameResult = imageNames; + } else { + imageNameResult = updatedImageName; + } + } else if ('dockerComposeFile' in config) { + + if (buildxPlatform || buildxPush) { + throw new ContainerError({ description: '--platform or --push not supported.' }); + } + + if (buildxOutput) { + throw new ContainerError({ description: '--output not supported.' }); + } + + if (buildxCacheTo) { + throw new ContainerError({ description: '--cache-to not supported.' }); + } + + const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); + const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; + const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, workspaceFolder); + + // If dockerComposeFile is an array, add -f in order. https://docs.docker.com/compose/extends/#multiple-compose-files + const composeGlobalArgs = ([] as string[]).concat(...composeFiles.map(composeFile => ['-f', composeFile])); + if (envFile) { + composeGlobalArgs.push('--env-file', envFile); + } + + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + const projectName = await getProjectName(params, workspace, composeFiles, composeConfig); + const services = Object.keys(composeConfig.services || {}); + if (services.indexOf(config.service) === -1) { + throw new Error(`Service '${config.service}' configured in devcontainer.json not found in Docker Compose configuration.`); + } + + const versionPrefix = await readVersionPrefix(cliHost, composeFiles); + const infoParams = { ...params, common: { ...params.common, output: makeLog(buildParams.output, LogLevel.Info) } }; + const { overrideImageName } = await buildAndExtendDockerCompose(configWithRaw as SubstitutedConfig, projectName, infoParams, composeFiles, envFile, composeGlobalArgs, [config.service], params.buildNoCache || false, params.common.persistedFolder, 'docker-compose.devcontainer.build', versionPrefix, additionalFeatures, false, addCacheFroms); + + const service = composeConfig.services[config.service]; + const originalImageName = overrideImageName || service.image || getDefaultImageName(await buildParams.dockerComposeCLI(), projectName, config.service); + + if (imageNames) { + // Future improvement: Compose 2.6.0 (released 2022-05-30) added `tags` to the compose file. + if (params.isTTY) { + await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', originalImageName, imageName))); + } else { + await Promise.all(imageNames.map(imageName => dockerCLI(params, 'tag', originalImageName, imageName))); + } + imageNameResult = imageNames; + } else { + imageNameResult = originalImageName; + } + } else { + + if (!config.image) { + throw new ContainerError({ description: 'No image information specified in devcontainer.json.' }); + } + + await inspectDockerImage(params, config.image, true); + const { updatedImageName } = await extendImage(params, configWithRaw, config.image, imageNames || [], additionalFeatures, false); + + if (imageNames) { + imageNameResult = imageNames; + } else { + imageNameResult = updatedImageName; + } + } + + return { + outcome: 'success' as 'success', + imageName: imageNameResult, + dispose, + }; + } catch (originalError) { + const originalStack = originalError?.stack; + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred building the container.', + originalError + }); + if (originalStack) { + console.error(originalStack); + } + return { + outcome: 'error' as 'error', + message: err.message, + description: err.description, + dispose, + }; + } +} + +function runUserCommandsOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, + 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, + 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, + 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, + prebuild: { type: 'boolean', default: false, description: 'Stop after onCreateCommand and updateContentCommand, rerunning updateContentCommand if it has run before.' }, + 'stop-for-personalization': { type: 'boolean', default: false, description: 'Stop for personalization.' }, + 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, + 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, + 'skip-post-attach': { type: 'boolean', default: false, description: 'Do not run postAttachCommand.' }, + 'dotfiles-repository': { type: 'string', description: 'URL of a dotfiles Git repository (e.g., https://github.com/owner/repository.git)' }, + 'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' }, + 'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' }, + 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' }, + 'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' }, + }) + .check(argv => { + const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; + if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { + throw new Error('Unmatched argument format: id-label must match ='); + } + const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { + throw new Error('Unmatched argument format: remote-env must match ='); + } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } + return true; + }); +} + +type RunUserCommandsArgs = UnpackArgv>; + +function runUserCommandsHandler(args: RunUserCommandsArgs) { + runAsyncHandler(runUserCommands.bind(null, args)); +} +async function runUserCommands(args: RunUserCommandsArgs) { + const result = await doRunUserCommands(args); + const exitCode = result.outcome === 'error' ? 1 : 0; + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); + await result.dispose(); + process.exit(exitCode); +} + +async function doRunUserCommands({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'container-data-folder': containerDataFolder, + 'container-system-data-folder': containerSystemDataFolder, + 'workspace-folder': workspaceFolderArg, + 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'container-id': containerId, + 'id-label': idLabel, + config: configParam, + 'override-config': overrideConfig, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'default-user-env-probe': defaultUserEnvProbe, + 'skip-non-blocking-commands': skipNonBlocking, + prebuild, + 'stop-for-personalization': stopForPersonalization, + 'remote-env': addRemoteEnv, + 'skip-feature-auto-mapping': skipFeatureAutoMapping, + 'skip-post-attach': skipPostAttach, + 'dotfiles-repository': dotfilesRepository, + 'dotfiles-install-command': dotfilesInstallCommand, + 'dotfiles-target-path': dotfilesTargetPath, + 'container-session-data-folder': containerSessionDataFolder, + 'secrets-file': secretsFile, +}: RunUserCommandsArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + try { + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; + + const cwd = workspaceFolder || process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text'); + const secretsP = readSecretsFromFile({ secretsFile, cliHost }); + + const params = await createDockerParams({ + dockerPath, + dockerComposePath, + containerDataFolder, + containerSystemDataFolder, + workspaceFolder, + mountWorkspaceGitRoot, + configFile, + overrideConfigFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + defaultUserEnvProbe, + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: true, + skipNonBlocking, + prebuild, + persistedFolder, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: envListToObj(addRemoteEnvs), + additionalCacheFroms: [], + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping, + skipPostAttach, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: dotfilesRepository, + installCommand: dotfilesInstallCommand, + targetPath: dotfilesTargetPath, + }, + containerSessionDataFolder, + secretsP, + }, disposables); + + const { common } = params; + const { output } = common; + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; + const configPath = configFile ? configFile : workspace + ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) + || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) + : overrideConfigFile; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + + const config0 = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); + if (!container) { + bailOut(common.output, 'Dev container not found.'); + } + + const config1 = addSubstitution(config0, config => beforeContainerSubstitute(envListToObj(idLabels), config)); + const config = addSubstitution(config1, config => containerSubstitute(cliHost.platform, config1.config.configFilePath, envListToObj(container.Config.Env), config)); + + const imageMetadata = getImageMetadataFromContainer(container, config, undefined, idLabels, output).config; + const mergedConfig = mergeConfiguration(config.config, imageMetadata); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig); + const remoteEnvP = probeRemoteEnv(common, containerProperties, updatedConfig); + const result = await runLifecycleHooks(common, lifecycleCommandOriginMapFromMetadata(imageMetadata), containerProperties, updatedConfig, remoteEnvP, secretsP, stopForPersonalization); + return { + outcome: 'success' as 'success', + result, + dispose, + }; + } catch (originalError) { + const originalStack = originalError?.stack; + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred running user commands in the container.', + originalError + }); + if (originalStack) { + console.error(originalStack); + } + return { + outcome: 'error' as 'error', + message: err.message, + description: err.description, + dispose, + }; + } +} + + +function readConfigurationOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, + 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'include-features-configuration': { type: 'boolean', default: false, description: 'Include features configuration.' }, + 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration.' }, + 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, + 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, + }) + .check(argv => { + const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; + if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { + throw new Error('Unmatched argument format: id-label must match ='); + } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } + return true; + }); +} + +type ReadConfigurationArgs = UnpackArgv>; + +function readConfigurationHandler(args: ReadConfigurationArgs) { + runAsyncHandler(readConfiguration.bind(null, args)); +} + +async function readConfiguration({ + // 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'workspace-folder': workspaceFolderArg, + 'mount-workspace-git-root': mountWorkspaceGitRoot, + config: configParam, + 'override-config': overrideConfig, + 'container-id': containerId, + 'id-label': idLabel, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'include-features-configuration': includeFeaturesConfig, + 'include-merged-configuration': includeMergedConfig, + 'additional-features': additionalFeaturesJson, + 'skip-feature-auto-mapping': skipFeatureAutoMapping, +}: ReadConfigurationArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + let output: Log | undefined; + try { + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; + const cwd = workspaceFolder || process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text'); + const extensionPath = path.join(__dirname, '..', '..'); + const sessionStart = new Date(); + const pkg = getPackageConfig(); + output = createLog({ + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + }, pkg, sessionStart, disposables); + + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; + const configPath = configFile ? configFile : workspace + ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) + || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) + : overrideConfigFile; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + + let configuration = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const dockerCLI = dockerPath || 'docker'; + const dockerComposeCLI = dockerComposeCLIConfig({ + exec: cliHost.exec, + env: cliHost.env, + output, + }, dockerCLI, dockerComposePath || 'docker-compose'); + const params: DockerCLIParameters = { + cliHost, + dockerCLI, + dockerComposeCLI, + env: cliHost.env, + output, + platformInfo: { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + } + }; + const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); + if (container) { + configuration = addSubstitution(configuration, config => beforeContainerSubstitute(envListToObj(idLabels), config)); + configuration = addSubstitution(configuration, config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config)); + } + + const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; + const needsFeaturesConfig = includeFeaturesConfig || (includeMergedConfig && !container); + const featuresConfiguration = needsFeaturesConfig ? await readFeaturesConfig(params, pkg, configuration.config, extensionPath, skipFeatureAutoMapping, additionalFeatures) : undefined; + let mergedConfig: MergedDevContainerConfig | undefined; + if (includeMergedConfig) { + let imageMetadata: ImageMetadataEntry[]; + if (container) { + imageMetadata = getImageMetadataFromContainer(container, configuration, featuresConfiguration, idLabels, output).config; + const substitute2: SubstituteConfig = config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config); + imageMetadata = imageMetadata.map(substitute2); + } else { + const imageBuildInfo = await getImageBuildInfo(params, configuration); + imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, configuration, featuresConfiguration).config; + } + mergedConfig = mergeConfiguration(configuration.config, imageMetadata); + } + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify({ + configuration: configuration.config, + workspace: configs?.workspaceConfig, + featuresConfiguration, + mergedConfiguration: mergedConfig, + }) + '\n', err => err ? reject(err) : resolve()); + }); + } catch (err) { + if (output) { + output.write(err && (err.stack || err.message) || String(err)); + } else { + console.error(err); + } + await dispose(); + process.exit(1); + } + await dispose(); + process.exit(0); +} + +function outdatedOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + }); +} + +type OutdatedArgs = UnpackArgv>; + +function outdatedHandler(args: OutdatedArgs) { + runAsyncHandler(outdated.bind(null, args)); +} + +async function outdated({ + // 'user-data-folder': persistedFolder, + 'workspace-folder': workspaceFolderArg, + config: configParam, + 'output-format': outputFormat, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, +}: OutdatedArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + let output: Log | undefined; + try { + const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, logFormat === 'text'); + const extensionPath = path.join(__dirname, '..', '..'); + const sessionStart = new Date(); + const pkg = getPackageConfig(); + output = createLog({ + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + }, pkg, sessionStart, disposables); + + const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const configPath = configFile ? configFile : await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath); + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; + if (!configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + + const cacheFolder = await getCacheFolder(cliHost); + const params = { + extensionPath, + cacheFolder, + cwd: cliHost.cwd, + output, + env: cliHost.env, + skipFeatureAutoMapping: false, + platform: cliHost.platform, + }; + + const outdated = await loadVersionInfo(params, configs.config.config); + await new Promise((resolve, reject) => { + let text; + if (outputFormat === 'text') { + const rows = Object.keys(outdated.features).map(key => { + const value = outdated.features[key]; + return [getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest] + .map(v => v === undefined ? '-' : v); + }); + const header = ['Feature', 'Current', 'Wanted', 'Latest']; + text = textTable([ + header, + ...rows, + ]); + } else { + text = JSON.stringify(outdated, undefined, process.stdout.isTTY ? ' ' : undefined); + } + process.stdout.write(text + '\n', err => err ? reject(err) : resolve()); + }); + } catch (err) { + if (output) { + output.write(err && (err.stack || err.message) || String(err)); + } else { + console.error(err); + } + await dispose(); + process.exit(1); + } + await dispose(); + process.exit(0); +} + +function execOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, + 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, + 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, + 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, + 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, + }) + .positional('cmd', { + type: 'string', + description: 'Command to execute.', + demandOption: true, + }).positional('args', { + type: 'string', + array: true, + description: 'Arguments to the command.', + demandOption: true, + }) + .check(argv => { + const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; + if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { + throw new Error('Unmatched argument format: id-label must match ='); + } + const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { + throw new Error('Unmatched argument format: remote-env must match ='); + } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } + return true; + }); +} + +export type ExecArgs = UnpackArgv>; + +function execHandler(args: ExecArgs) { + runAsyncHandler(exec.bind(null, args)); +} + +async function exec(args: ExecArgs) { + const result = await doExec(args); + const exitCode = typeof result.code === 'number' && (result.code || !result.signal) ? result.code : + typeof result.signal === 'number' && result.signal > 0 ? 128 + result.signal : // 128 + signal number convention: https://tldp.org/LDP/abs/html/exitcodes.html + typeof result.signal === 'string' && processSignals[result.signal] ? 128 + processSignals[result.signal]! : 1; + await result.dispose(); + process.exit(exitCode); +} + +export async function doExec({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'container-data-folder': containerDataFolder, + 'container-system-data-folder': containerSystemDataFolder, + 'workspace-folder': workspaceFolderArg, + 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'container-id': containerId, + 'id-label': idLabel, + config: configParam, + 'override-config': overrideConfig, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'default-user-env-probe': defaultUserEnvProbe, + 'remote-env': addRemoteEnv, + 'skip-feature-auto-mapping': skipFeatureAutoMapping, + _: restArgs, +}: ExecArgs & { _?: string[] }) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + let output: Log | undefined; + const isTTY = process.stdin.isTTY && process.stdout.isTTY || logFormat === 'json'; // If stdin or stdout is a pipe, we don't want to use a PTY. + try { + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; + const params = await createDockerParams({ + dockerPath, + dockerComposePath, + containerDataFolder, + containerSystemDataFolder, + workspaceFolder, + mountWorkspaceGitRoot, + configFile, + overrideConfigFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : isTTY ? { columns: process.stdout.columns, rows: process.stdout.rows } : undefined, + onDidChangeTerminalDimensions: terminalColumns && terminalRows ? undefined : isTTY ? createStdoutResizeEmitter(disposables) : undefined, + defaultUserEnvProbe, + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: true, + skipNonBlocking: false, + prebuild: false, + persistedFolder, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: envListToObj(addRemoteEnvs), + additionalCacheFroms: [], + useBuildKit: 'auto', + omitLoggerHeader: true, + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxCacheTo: undefined, + skipFeatureAutoMapping, + buildxOutput: undefined, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: {} + }, disposables); + + const { common } = params; + const { cliHost } = common; + output = common.output; + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; + const configPath = configFile ? configFile : workspace + ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) + || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) + : overrideConfigFile; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + + const config = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); + if (!container) { + bailOut(common.output, 'Dev container not found.'); + } + const imageMetadata = getImageMetadataFromContainer(container, config, undefined, idLabels, output).config; + const mergedConfig = mergeConfiguration(config.config, imageMetadata); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig); + const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig); + const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder; + await runRemoteCommand({ ...common, output, stdin: process.stdin, ...(logFormat !== 'json' ? { stdout: process.stdout, stderr: process.stderr } : {}) }, containerProperties, restArgs || [], remoteCwd, { remoteEnv: await remoteEnv, pty: isTTY, print: 'continuous' }); + return { + code: 0, + dispose, + }; + + } catch (err) { + if (!err?.code && !err?.signal) { + if (output) { + output.write(err?.stack || err?.message || String(err), LogLevel.Error); + } else { + console.error(err?.stack || err?.message || String(err)); + } + } + return { + code: err?.code as number | undefined, + signal: err?.signal as string | number | undefined, + dispose, + }; + } +} + +function createStdoutResizeEmitter(disposables: (() => Promise | void)[]): Event { + const resizeListener = () => { + emitter.fire({ + rows: process.stdout.rows, + columns: process.stdout.columns + }); + }; + const emitter = new NodeEventEmitter({ + on: () => process.stdout.on('resize', resizeListener), + off: () => process.stdout.off('resize', resizeListener), + }); + disposables.push(() => emitter.dispose()); + return emitter.event; +} + +async function readSecretsFromFile(params: { output?: Log; secretsFile?: string; cliHost: CLIHost }) { + const { secretsFile, cliHost, output } = params; + if (!secretsFile) { + return {}; + } + + try { + const fileBuff = await cliHost.readFile(secretsFile); + const parseErrors: jsonc.ParseError[] = []; + const secrets = jsonc.parse(fileBuff.toString(), parseErrors) as Record; + if (parseErrors.length) { + throw new Error('Invalid json data'); + } + + return secrets; + } + catch (e) { + if (output) { + output.write(`Failed to read/parse secrets from file '${secretsFile}'`, LogLevel.Error); + } + + throw new ContainerError({ + description: 'Failed to read/parse secrets', + originalError: e + }); + } +} + +/** + * Generate docker build command for terminal execution (exported for extension use) + * This function generates the docker build/buildx command with all necessary arguments + */ +export async function generateDockerBuildCommand(options: { + dockerPath: string; + dockerfilePath: string; + contextPath: string; + imageName: string; + buildArgs?: Record; + target?: string; + noCache?: boolean; + cacheFrom?: string[]; + buildKitEnabled?: boolean; + additionalArgs?: string[]; +}): Promise { + const args: string[] = []; + + if (options.buildKitEnabled) { + args.push('buildx', 'build'); + args.push('--load'); // Load into normal docker images collection + args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1'); + } else { + args.push('build'); + } + + args.push('-f', options.dockerfilePath); + args.push('-t', options.imageName); + + if (options.target) { + args.push('--target', options.target); + } + + if (options.noCache) { + args.push('--no-cache'); + if (options.buildKitEnabled) { + args.push('--pull'); + } + } + + if (options.cacheFrom) { + for (const cacheFrom of options.cacheFrom) { + args.push('--cache-from', cacheFrom); + } + } + + if (options.buildArgs) { + for (const [key, value] of Object.entries(options.buildArgs)) { + args.push('--build-arg', `${key}=${value}`); + } + } + + if (options.additionalArgs) { + args.push(...options.additionalArgs); + } + + args.push(options.contextPath); + + return { + command: options.dockerPath, + args, + description: 'Building dev container image', + }; +} + +/** + * Generate docker create command for terminal execution + */ +export async function generateDockerCreateCommand(options: { + dockerPath: string; + imageName: string; + workspaceFolder: string; + remoteWorkspaceFolder: string; + containerUser?: string; + env?: Record; + mounts?: string[]; + labels?: Record; + runArgs?: string[]; +}): Promise { + const args = ['create']; + + // Add labels + if (options.labels) { + for (const [key, value] of Object.entries(options.labels)) { + args.push('--label', `${key}=${value}`); + } + } + + // Add workspace mount + args.push('-v', `${options.workspaceFolder}:${options.remoteWorkspaceFolder}`); + + // Add additional mounts + if (options.mounts) { + for (const mount of options.mounts) { + args.push('--mount', mount); + } + } + + // Set working directory + args.push('-w', options.remoteWorkspaceFolder); + + // Add user + if (options.containerUser) { + args.push('-u', options.containerUser); + } + + // Add environment variables + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + args.push('-e', `${key}=${value}`); + } + } + + // Add custom run args + if (options.runArgs) { + args.push(...options.runArgs); + } + + // Add image name and command + args.push(options.imageName, 'sleep', 'infinity'); + + return { + command: options.dockerPath, + args, + description: 'Creating container', + }; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/disallowedFeatures.ts b/extensions/positron-dev-containers/src/spec/spec-node/disallowedFeatures.ts new file mode 100644 index 000000000000..4f7b08a8a01d --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/disallowedFeatures.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { DevContainerConfig } from '../spec-configuration/configuration'; +import { ContainerError } from '../spec-common/errors'; +import { DockerCLIParameters, dockerCLI } from '../spec-shutdown/dockerUtils'; +import { findDevContainer } from './singleContainer'; +import { DevContainerControlManifest, DisallowedFeature, getControlManifest } from '../spec-configuration/controlManifest'; +import { getCacheFolder } from './utils'; + + +export async function ensureNoDisallowedFeatures(params: DockerCLIParameters, config: DevContainerConfig, additionalFeatures: Record>, idLabels: string[] | undefined) { + const controlManifest = await getControlManifest(await getCacheFolder(params.cliHost), params.output); + const disallowed = Object.keys({ + ...config.features, + ...additionalFeatures, + }).map(configFeatureId => { + const disallowedFeatureEntry = findDisallowedFeatureEntry(controlManifest, configFeatureId); + return disallowedFeatureEntry ? { configFeatureId, disallowedFeatureEntry } : undefined; + }).filter(Boolean) as { + configFeatureId: string; + disallowedFeatureEntry: DisallowedFeature; + }[]; + + if (!disallowed.length) { + return; + } + + let stopped = false; + if (idLabels) { + const container = await findDevContainer(params, idLabels); + if (container?.State?.Status === 'running') { + await dockerCLI(params, 'stop', '-t', '0', container.Id); + stopped = true; + } + } + + const d = disallowed[0]!; + const documentationURL = d.disallowedFeatureEntry.documentationURL; + throw new ContainerError({ + description: `Cannot use the '${d.configFeatureId}' Feature since it was reported to be problematic. Please remove this Feature from your configuration and rebuild any dev container using it before continuing.${stopped ? ' The existing dev container was stopped.' : ''}${documentationURL ? ` See ${documentationURL} to learn more.` : ''}`, + data: { + disallowedFeatureId: d.configFeatureId, + didStopContainer: stopped, + learnMoreUrl: documentationURL, + }, + }); +} + +export function findDisallowedFeatureEntry(controlManifest: DevContainerControlManifest, featureId: string): DisallowedFeature | undefined { + return controlManifest.disallowedFeatures.find( + disallowedFeature => + featureId.startsWith(disallowedFeature.featureIdPrefix) && + (featureId.length === disallowedFeature.featureIdPrefix.length || // Feature id equal to prefix. + '/:@'.indexOf(featureId[disallowedFeature.featureIdPrefix.length]) !== -1) // Feature id with prefix and continued by separator. + ); +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/dockerCompose.ts b/extensions/positron-dev-containers/src/spec/spec-node/dockerCompose.ts new file mode 100644 index 000000000000..4c20ebd68350 --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/dockerCompose.ts @@ -0,0 +1,764 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as yaml from 'js-yaml'; +import * as shellQuote from 'shell-quote'; + +import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError } from './utils'; +import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec-common/injectHeadless'; +import { ContainerError } from '../spec-common/errors'; +import { Workspace } from '../spec-utils/workspaces'; +import { equalPaths, parseVersion, isEarlierVersion, CLIHost } from '../spec-common/commonUtils'; +import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer } from '../spec-shutdown/dockerUtils'; +import { DevContainerFromDockerComposeConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; +import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/log'; +import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; +import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; +import path from 'path'; +import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; +import { ensureDockerfileHasFinalStageName } from './dockerfileUtils'; +import { randomUUID } from 'crypto'; + +const projectLabel = 'com.docker.compose.project'; +const serviceLabel = 'com.docker.compose.service'; + +export async function openDockerComposeDevContainer(params: DockerResolverParameters, workspace: Workspace, config: SubstitutedConfig, idLabels: string[], additionalFeatures: Record>): Promise { + const { common, dockerCLI, dockerComposeCLI } = params; + const { cliHost, env, output } = common; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, platformInfo: params.platformInfo }; + return _openDockerComposeDevContainer(params, buildParams, workspace, config, getRemoteWorkspaceFolder(config.config), idLabels, additionalFeatures); +} + +async function _openDockerComposeDevContainer(params: DockerResolverParameters, buildParams: DockerCLIParameters, workspace: Workspace, configWithRaw: SubstitutedConfig, remoteWorkspaceFolder: string, idLabels: string[], additionalFeatures: Record>): Promise { + const { common } = params; + const { cliHost: buildCLIHost } = buildParams; + const { config } = configWithRaw; + + let container: ContainerDetails | undefined; + let containerProperties: ContainerProperties | undefined; + try { + + const composeFiles = await getDockerComposeFilePaths(buildCLIHost, config, buildCLIHost.env, buildCLIHost.cwd); + const cwdEnvFile = buildCLIHost.path.join(buildCLIHost.cwd, '.env'); + const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await buildCLIHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + const projectName = await getProjectName(buildParams, workspace, composeFiles, composeConfig); + const containerId = await findComposeContainer(params, projectName, config.service); + if (params.expectExistingContainer && !containerId) { + throw new ContainerError({ description: 'The expected container does not exist.' }); + } + container = containerId ? await inspectContainer(params, containerId) : undefined; + + if (container && (params.removeOnStartup === true || params.removeOnStartup === container.Id)) { + const text = 'Removing existing container.'; + const start = common.output.start(text); + await removeContainer(params, container.Id); + common.output.stop(text, start); + container = undefined; + } + + // let collapsedFeaturesConfig: CollapsedFeaturesConfig | undefined; + if (!container || container.State.Status !== 'running') { + const res = await startContainer(params, buildParams, configWithRaw, projectName, composeFiles, envFile, composeConfig, container, idLabels, additionalFeatures); + container = await inspectContainer(params, res.containerId); + // collapsedFeaturesConfig = res.collapsedFeaturesConfig; + // } else { + // const labels = container.Config.Labels || {}; + // const featuresConfig = await generateFeaturesConfig(params.common, (await createFeaturesTempFolder(params.common)), config, async () => labels, getContainerFeaturesFolder); + // collapsedFeaturesConfig = collapseFeaturesConfig(featuresConfig); + } + + const imageMetadata = getImageMetadataFromContainer(container, configWithRaw, undefined, idLabels, common.output).config; + const mergedConfig = mergeConfiguration(configWithRaw.config, imageMetadata); + containerProperties = await createContainerProperties(params, container.Id, remoteWorkspaceFolder, mergedConfig.remoteUser); + + const { + remoteEnv: extensionHostEnv, + updatedConfig, + updatedMergedConfig, + } = await setupInContainer(common, containerProperties, config, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); + + return { + params: common, + properties: containerProperties, + config: updatedConfig, + mergedConfig: updatedMergedConfig, + resolvedAuthority: { + extensionHostEnv, + }, + tunnelInformation: common.isLocalContainer ? getTunnelInformation(container) : {}, + dockerParams: params, + dockerContainerId: container.Id, + composeProjectName: projectName, + }; + + } catch (originalError) { + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred setting up the container.', + originalError + }); + if (container) { + err.manageContainer = true; + err.params = params.common; + err.containerId = container.Id; + err.dockerParams = params; + } + if (containerProperties) { + err.containerProperties = containerProperties; + } + err.config = config; + throw err; + } +} + +export function getRemoteWorkspaceFolder(config: DevContainerFromDockerComposeConfig) { + return config.workspaceFolder || '/'; +} + +// exported for testing +export function getBuildInfoForService(composeService: any, cliHostPath: typeof path, localComposeFiles: string[]) { + // composeService should taken from readDockerComposeConfig + // the 'build' property can be a string or an object (https://docs.docker.com/compose/compose-file/build/#build-definition) + + const image = composeService.image as string | undefined; + const composeBuild = composeService.build; + if (!composeBuild) { + return { + image + }; + } + if (typeof (composeBuild) === 'string') { + return { + image, + build: { + context: composeBuild, + dockerfilePath: 'Dockerfile' + } + }; + } + return { + image, + build: { + dockerfilePath: (composeBuild.dockerfile as string | undefined) ?? 'Dockerfile', + context: (composeBuild.context as string | undefined) ?? cliHostPath.dirname(localComposeFiles[0]), + target: composeBuild.target as string | undefined, + args: composeBuild.args as Record | undefined, + } + }; +} + +export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConfig, projectName: string, params: DockerResolverParameters, localComposeFiles: string[], envFile: string | undefined, composeGlobalArgs: string[], runServices: string[], noCache: boolean, overrideFilePath: string, overrideFilePrefix: string, versionPrefix: string, additionalFeatures: Record>, canAddLabelsToContainer: boolean, additionalCacheFroms?: string[], noBuild?: boolean) { + + const { common, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc } = params; + const { cliHost, env, output } = common; + const { config } = configWithRaw; + + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, platformInfo: params.platformInfo }; + const composeConfig = await readDockerComposeConfig(cliParams, localComposeFiles, envFile); + const composeService = composeConfig.services[config.service]; + + // determine base imageName for generated features build stage(s) + let baseName = 'dev_container_auto_added_stage_label'; + let dockerfile: string | undefined; + let imageBuildInfo: ImageBuildInfo; + const serviceInfo = getBuildInfoForService(composeService, cliHost.path, localComposeFiles); + if (serviceInfo.build) { + const { context, dockerfilePath, target } = serviceInfo.build; + const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath); + const originalDockerfile = (await cliHost.readFile(resolvedDockerfilePath)).toString(); + dockerfile = originalDockerfile; + if (target) { + // Explictly set build target for the dev container build features on that + baseName = target; + } else { + // Use the last stage in the Dockerfile + // Find the last line that starts with "FROM" (possibly preceeded by white-space) + const { lastStageName, modifiedDockerfile } = ensureDockerfileHasFinalStageName(originalDockerfile, baseName); + baseName = lastStageName; + if (modifiedDockerfile) { + dockerfile = modifiedDockerfile; + } + } + imageBuildInfo = await getImageBuildInfoFromDockerfile(params, originalDockerfile, serviceInfo.build?.args || {}, serviceInfo.build?.target, configWithRaw.substitute); + } else { + imageBuildInfo = await getImageBuildInfoFromImage(params, composeService.image, configWithRaw.substitute); + } + + // determine whether we need to extend with features + const version = parseVersion((await params.dockerComposeCLI()).version); + const supportsAdditionalBuildContexts = !params.isPodman && version && !isEarlierVersion(version, [2, 17, 0]); + const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; + const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); + + let overrideImageName: string | undefined; + let buildOverrideContent = ''; + if (extendImageBuildInfo?.featureBuildInfo) { + // Avoid retagging a previously pulled image. + if (!serviceInfo.build) { + overrideImageName = getFolderImageName(common); + buildOverrideContent += ` image: ${overrideImageName}\n`; + } + // Create overridden Dockerfile and generate docker-compose build override content + buildOverrideContent += ' build:\n'; + if (!dockerfile) { + dockerfile = `FROM ${composeService.image} AS ${baseName}\n`; + } + const { featureBuildInfo } = extendImageBuildInfo; + // We add a '# syntax' line at the start, so strip out any existing line + const syntaxMatch = dockerfile.match(/^\s*#\s*syntax\s*=.*[\r\n]/g); + if (syntaxMatch) { + dockerfile = dockerfile.slice(syntaxMatch[0].length); + } + let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`; + const finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features'); + await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent)); + buildOverrideContent += ` dockerfile: ${finalDockerfilePath}\n`; + if (serviceInfo.build?.target) { + // Replace target. (Only when set because it is only supported with Docker Compose file version 3.4 and later.) + buildOverrideContent += ` target: ${featureBuildInfo.overrideTarget}\n`; + } + + if (!serviceInfo.build?.context) { + // need to supply a context as we don't have one inherited + const emptyDir = getEmptyContextFolder(common); + await cliHost.mkdirp(emptyDir); + buildOverrideContent += ` context: ${emptyDir}\n`; + } + // track additional build args to include + if (Object.keys(featureBuildInfo.buildArgs).length > 0 || params.buildKitVersion) { + buildOverrideContent += ' args:\n'; + if (params.buildKitVersion) { + buildOverrideContent += ' - BUILDKIT_INLINE_CACHE=1\n'; + } + for (const buildArg in featureBuildInfo.buildArgs) { + buildOverrideContent += ` - ${buildArg}=${featureBuildInfo.buildArgs[buildArg]}\n`; + } + } + + if (Object.keys(featureBuildInfo.buildKitContexts).length > 0) { + buildOverrideContent += ' additional_contexts:\n'; + for (const buildKitContext in featureBuildInfo.buildKitContexts) { + buildOverrideContent += ` - ${buildKitContext}=${featureBuildInfo.buildKitContexts[buildKitContext]}\n`; + } + } + } + + // Generate the docker-compose override and build + const args = ['--project-name', projectName, ...composeGlobalArgs]; + const additionalComposeOverrideFiles: string[] = []; + if (additionalCacheFroms && additionalCacheFroms.length > 0 || buildOverrideContent) { + const composeFolder = cliHost.path.join(overrideFilePath, 'docker-compose'); + await cliHost.mkdirp(composeFolder); + const composeOverrideFile = cliHost.path.join(composeFolder, `${overrideFilePrefix}-${Date.now()}.yml`); + const cacheFromOverrideContent = (additionalCacheFroms && additionalCacheFroms.length > 0) ? ` cache_from:\n${additionalCacheFroms.map(cacheFrom => ` - ${cacheFrom}\n`).join('\n')}` : ''; + const composeOverrideContent = `${versionPrefix}services: + ${config.service}: +${buildOverrideContent?.trimEnd()} +${cacheFromOverrideContent} +`; + output.write(`Docker Compose override file for building image:\n${composeOverrideContent}`); + await cliHost.writeFile(composeOverrideFile, Buffer.from(composeOverrideContent)); + additionalComposeOverrideFiles.push(composeOverrideFile); + args.push('-f', composeOverrideFile); + } + + if (!noBuild) { + args.push('build'); + if (noCache) { + args.push('--no-cache'); + // `docker build --pull` pulls local image: https://github.com/devcontainers/cli/issues/60 + if (!extendImageBuildInfo) { + args.push('--pull'); + } + } + if (runServices.length) { + args.push(...runServices); + if (runServices.indexOf(config.service) === -1) { + args.push(config.service); + } + } + try { + if (params.isTTY) { + const infoParams = { ...toPtyExecParameters(params, await dockerComposeCLIFunc()), output: makeLog(output, LogLevel.Info) }; + await dockerComposePtyCLI(infoParams, ...args); + } else { + const infoParams = { ...toExecParameters(params, await dockerComposeCLIFunc()), output: makeLog(output, LogLevel.Info), print: 'continuous' as 'continuous' }; + await dockerComposeCLI(infoParams, ...args); + } + } catch (err) { + if (isBuildKitImagePolicyError(err)) { + throw new ContainerError({ description: 'Could not resolve image due to policy.', originalError: err, data: { fileWithError: localComposeFiles[0] } }); + } + + throw err instanceof ContainerError ? err : new ContainerError({ description: 'An error occurred building the Docker Compose images.', originalError: err, data: { fileWithError: localComposeFiles[0] } }); + } + } + + return { + imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, configWithRaw, extendImageBuildInfo?.featuresConfig), + additionalComposeOverrideFiles, + overrideImageName, + labels: extendImageBuildInfo?.labels, + }; +} + +async function checkForPersistedFile(cliHost: CLIHost, output: Log, files: string[], prefix: string) { + const file = files.find((f) => f.indexOf(prefix) > -1); + if (file) { + const composeFileExists = await cliHost.isFile(file); + + if (composeFileExists) { + output.write(`Restoring ${file} from persisted storage`); + return { + foundLabel: true, + fileExists: true, + file + }; + } else { + output.write(`Expected ${file} to exist, but it did not`, LogLevel.Error); + return { + foundLabel: true, + fileExists: false, + file + }; + } + } else { + output.write(`Expected to find a docker-compose file prefixed with ${prefix}, but did not.`, LogLevel.Error); + } + return { + foundLabel: false + }; +} + +async function startContainer(params: DockerResolverParameters, buildParams: DockerCLIParameters, configWithRaw: SubstitutedConfig, projectName: string, composeFiles: string[], envFile: string | undefined, composeConfig: any, container: ContainerDetails | undefined, idLabels: string[], additionalFeatures: Record>) { + const { common } = params; + const { persistedFolder, output } = common; + const { cliHost: buildCLIHost } = buildParams; + const { config } = configWithRaw; + const featuresBuildOverrideFilePrefix = 'docker-compose.devcontainer.build'; + const featuresStartOverrideFilePrefix = 'docker-compose.devcontainer.containerFeatures'; + + common.progress(ResolverProgress.StartingContainer); + + // If dockerComposeFile is an array, add -f in order. https://docs.docker.com/compose/extends/#multiple-compose-files + const composeGlobalArgs = ([] as string[]).concat(...composeFiles.map(composeFile => ['-f', composeFile])); + if (envFile) { + composeGlobalArgs.push('--env-file', envFile); + } + + const infoOutput = makeLog(buildParams.output, LogLevel.Info); + const services = Object.keys(composeConfig.services || {}); + if (services.indexOf(config.service) === -1) { + throw new ContainerError({ description: `Service '${config.service}' configured in devcontainer.json not found in Docker Compose configuration.`, data: { fileWithError: composeFiles[0] } }); + } + + let cancel: () => void; + const canceled = new Promise((_, reject) => cancel = reject); + const { started } = await startEventSeen(params, { [projectLabel]: projectName, [serviceLabel]: config.service }, canceled, common.output, common.getLogLevel() === LogLevel.Trace); // await getEvents, but only assign started. + + const service = composeConfig.services[config.service]; + const originalImageName = service.image || getDefaultImageName(await buildParams.dockerComposeCLI(), projectName, config.service); + + // Try to restore the 'third' docker-compose file and featuresConfig from persisted storage. + // This file may have been generated upon a Codespace creation. + const labels = container?.Config?.Labels; + output.write(`PersistedPath=${persistedFolder}, ContainerHasLabels=${!!labels}`); + + let didRestoreFromPersistedShare = false; + if (container) { + if (labels) { + // update args for `docker-compose up` to use cached overrides + const configFiles = labels['com.docker.compose.project.config_files']; + output.write(`Container was created with these config files: ${configFiles}`); + + // Parse out the full name of the 'containerFeatures' configFile + const files = configFiles?.split(',') ?? []; + const persistedBuildFile = await checkForPersistedFile(buildCLIHost, output, files, featuresBuildOverrideFilePrefix); + const persistedStartFile = await checkForPersistedFile(buildCLIHost, output, files, featuresStartOverrideFilePrefix); + if ((persistedBuildFile.fileExists || !persistedBuildFile.foundLabel) // require build file if in label + && persistedStartFile.fileExists // always require start file + ) { + didRestoreFromPersistedShare = true; + if (persistedBuildFile.fileExists) { + composeGlobalArgs.push('-f', persistedBuildFile.file); + } + if (persistedStartFile.fileExists) { + composeGlobalArgs.push('-f', persistedStartFile.file); + } + } + } + } + + if (!container || !didRestoreFromPersistedShare) { + const noBuild = !!container; //if we have an existing container, just recreate override files but skip the build + + const versionPrefix = await readVersionPrefix(buildCLIHost, composeFiles); + const infoParams = { ...params, common: { ...params.common, output: infoOutput } }; + const { imageMetadata, additionalComposeOverrideFiles, overrideImageName, labels } = await buildAndExtendDockerCompose(configWithRaw, projectName, infoParams, composeFiles, envFile, composeGlobalArgs, config.runServices ?? [], params.buildNoCache ?? false, persistedFolder, featuresBuildOverrideFilePrefix, versionPrefix, additionalFeatures, true, params.additionalCacheFroms, noBuild); + additionalComposeOverrideFiles.forEach(overrideFilePath => composeGlobalArgs.push('-f', overrideFilePath)); + + const currentImageName = overrideImageName || originalImageName; + let cache: Promise | undefined; + const imageDetails = () => cache || (cache = inspectDockerImage(params, currentImageName, true)); + const mergedConfig = mergeConfiguration(config, imageMetadata.config); + const updatedImageName = noBuild ? currentImageName : await updateRemoteUserUID(params, mergedConfig, currentImageName, imageDetails, service.user); + + // Save override docker-compose file to disk. + // Persisted folder is a path that will be maintained between sessions + // Note: As a fallback, persistedFolder is set to the build's tmpDir() directory + const additionalLabels = labels ? idLabels.concat(Object.keys(labels).map(key => `${key}=${labels[key]}`)) : idLabels; + const overrideFilePath = await writeFeaturesComposeOverrideFile(updatedImageName, currentImageName, mergedConfig, config, versionPrefix, imageDetails, service, additionalLabels, params.additionalMounts, persistedFolder, featuresStartOverrideFilePrefix, buildCLIHost, params, output); + + if (overrideFilePath) { + // Add file path to override file as parameter + composeGlobalArgs.push('-f', overrideFilePath); + } + } + + const args = ['--project-name', projectName, ...composeGlobalArgs]; + args.push('up', '-d'); + if (container || params.expectExistingContainer) { + args.push('--no-recreate'); + } + if (config.runServices && config.runServices.length) { + args.push(...config.runServices); + if (config.runServices.indexOf(config.service) === -1) { + args.push(config.service); + } + } + try { + if (params.isTTY) { + await dockerComposePtyCLI({ ...buildParams, output: infoOutput }, ...args); + } else { + await dockerComposeCLI({ ...buildParams, output: infoOutput }, ...args); + } + } catch (err) { + cancel!(); + + let description = 'An error occurred starting Docker Compose up.'; + if (err?.cmdOutput?.includes('Cannot create container for service app: authorization denied by plugin')) { + description = err.cmdOutput; + } + + throw new ContainerError({ description, originalError: err, data: { fileWithError: composeFiles[0] } }); + } + + await started; + return { + containerId: (await findComposeContainer(params, projectName, config.service))!, + }; +} + +export async function readVersionPrefix(cliHost: CLIHost, composeFiles: string[]) { + if (!composeFiles.length) { + return ''; + } + const firstComposeFile = (await cliHost.readFile(composeFiles[0])).toString(); + const version = (/^\s*(version:.*)$/m.exec(firstComposeFile) || [])[1]; + return version ? `${version}\n\n` : ''; +} + +export function getDefaultImageName(dockerComposeCLI: DockerComposeCLI, projectName: string, serviceName: string) { + const version = parseVersion(dockerComposeCLI.version); + const separator = version && isEarlierVersion(version, [2, 8, 0]) ? '_' : '-'; + return `${projectName}${separator}${serviceName}`; +} + +async function writeFeaturesComposeOverrideFile( + updatedImageName: string, + originalImageName: string, + mergedConfig: MergedDevContainerConfig, + config: DevContainerFromDockerComposeConfig, + versionPrefix: string, + imageDetails: () => Promise, + service: any, + additionalLabels: string[], + additionalMounts: Mount[], + overrideFilePath: string, + overrideFilePrefix: string, + buildCLIHost: CLIHost, + params: DockerResolverParameters, + output: Log, +) { + const composeOverrideContent = await generateFeaturesComposeOverrideContent(updatedImageName, originalImageName, mergedConfig, config, versionPrefix, imageDetails, service, additionalLabels, additionalMounts, params); + const overrideFileHasContents = !!composeOverrideContent && composeOverrideContent.length > 0 && composeOverrideContent.trim() !== ''; + if (overrideFileHasContents) { + output.write(`Docker Compose override file for creating container:\n${composeOverrideContent}`); + + const fileName = `${overrideFilePrefix}-${Date.now()}-${randomUUID()}.yml`; + const composeFolder = buildCLIHost.path.join(overrideFilePath, 'docker-compose'); + const composeOverrideFile = buildCLIHost.path.join(composeFolder, fileName); + output.write(`Writing ${fileName} to ${composeFolder}`); + await buildCLIHost.mkdirp(composeFolder); + await buildCLIHost.writeFile(composeOverrideFile, Buffer.from(composeOverrideContent)); + + return composeOverrideFile; + } else { + output.write('Override file was generated, but was empty and thus not persisted or included in the docker-compose arguments.'); + return undefined; + } +} + +async function generateFeaturesComposeOverrideContent( + updatedImageName: string, + originalImageName: string, + mergedConfig: MergedDevContainerConfig, + config: DevContainerFromDockerComposeConfig, + versionPrefix: string, + imageDetails: () => Promise, + service: any, + additionalLabels: string[], + additionalMounts: Mount[], + params: DockerResolverParameters, +) { + const overrideImage = updatedImageName !== originalImageName; + + const user = mergedConfig.containerUser; + const env = mergedConfig.containerEnv || {}; + const capAdd = mergedConfig.capAdd || []; + const securityOpts = mergedConfig.securityOpt || []; + const mounts = [ + ...mergedConfig.mounts || [], + ...additionalMounts, + ].map(m => typeof m === 'string' ? parseMount(m) : m); + const namedVolumeMounts = mounts.filter(m => m.type === 'volume' && m.source); + const customEntrypoints = mergedConfig.entrypoints || []; + const composeEntrypoint: string[] | undefined = typeof service.entrypoint === 'string' ? shellQuote.parse(service.entrypoint) : service.entrypoint; + const composeCommand: string[] | undefined = typeof service.command === 'string' ? shellQuote.parse(service.command) : service.command; + const { overrideCommand } = mergedConfig; + const userEntrypoint = overrideCommand ? [] : composeEntrypoint /* $ already escaped. */ + || ((await imageDetails()).Config.Entrypoint || []).map(c => c.replace(/\$/g, '$$$$')); // $ > $$ to escape docker-compose.yml's interpolation. + const userCommand = overrideCommand ? [] : composeCommand /* $ already escaped. */ + || (composeEntrypoint ? [/* Ignore image CMD per docker-compose.yml spec. */] : ((await imageDetails()).Config.Cmd || []).map(c => c.replace(/\$/g, '$$$$'))); // $ > $$ to escape docker-compose.yml's interpolation. + + const hasGpuRequirement = config.hostRequirements?.gpu; + const addGpuCapability = hasGpuRequirement && await checkDockerSupportForGPU(params); + if (hasGpuRequirement && hasGpuRequirement !== 'optional' && !addGpuCapability) { + params.common.output.write('No GPU support found yet a GPU was required - consider marking it as "optional"', LogLevel.Warning); + } + const gpuResources = addGpuCapability ? ` + deploy: + resources: + reservations: + devices: + - capabilities: [gpu]` : ''; + + return `${versionPrefix}services: + '${config.service}':${overrideImage ? ` + image: ${updatedImageName}` : ''} + entrypoint: ["/bin/sh", "-c", "echo Container started\\n +trap \\"exit 0\\" 15\\n +${customEntrypoints.join('\\n\n')}\\n +exec \\"$$@\\"\\n +while sleep 1 & wait $$!; do :; done", "-"${userEntrypoint.map(a => `, ${JSON.stringify(a)}`).join('')}]${userCommand !== composeCommand ? ` + command: ${JSON.stringify(userCommand)}` : ''}${mergedConfig.init ? ` + init: true` : ''}${user ? ` + user: ${user}` : ''}${Object.keys(env).length ? ` + environment:${Object.keys(env).map(key => ` + - '${key}=${String(env[key]).replace(/\n/g, '\\n').replace(/\$/g, '$$$$').replace(/'/g, '\'\'')}'`).join('')}` : ''}${mergedConfig.privileged ? ` + privileged: true` : ''}${capAdd.length ? ` + cap_add:${capAdd.map(cap => ` + - ${cap}`).join('')}` : ''}${securityOpts.length ? ` + security_opt:${securityOpts.map(securityOpt => ` + - ${securityOpt}`).join('')}` : ''}${additionalLabels.length ? ` + labels:${additionalLabels.map(label => ` + - '${label.replace(/\$/g, '$$$$').replace(/'/g, '\'\'')}'`).join('')}` : ''}${mounts.length ? ` + volumes:${mounts.map(m => ` + - ${convertMountToVolume(m)}`).join('')}` : ''}${gpuResources}${namedVolumeMounts.length ? ` +volumes:${namedVolumeMounts.map(m => ` + ${convertMountToVolumeTopLevelElement(m)}`).join('')}` : ''} +`; +} + +export async function readDockerComposeConfig(params: DockerCLIParameters, composeFiles: string[], envFile: string | undefined) { + try { + const composeGlobalArgs = ([] as string[]).concat(...composeFiles.map(composeFile => ['-f', composeFile])); + if (envFile) { + composeGlobalArgs.push('--env-file', envFile); + } + const composeCLI = await params.dockerComposeCLI(); + if ((parseVersion(composeCLI.version) || [])[0] >= 2) { + composeGlobalArgs.push('--profile', '*'); + } + try { + const partial = toExecParameters(params, 'dockerComposeCLI' in params ? await params.dockerComposeCLI() : undefined); + const { stdout } = await dockerComposeCLI({ + ...partial, + output: makeLog(params.output, LogLevel.Info), + print: 'onerror' + }, ...composeGlobalArgs, 'config'); + const stdoutStr = stdout.toString(); + params.output.write(stdoutStr); + return yaml.load(stdoutStr) || {} as any; + } catch (err) { + if (!Buffer.isBuffer(err?.stderr) || err?.stderr.toString().indexOf('UnicodeEncodeError') === -1) { + throw err; + } + // Upstream issues. https://github.com/microsoft/vscode-remote-release/issues/5308 + if (params.cliHost.platform === 'win32') { + const { cmdOutput } = await dockerComposePtyCLI({ + ...params, + output: makeLog({ + event: params.output.event, + dimensions: { + columns: 999999, + rows: 1, + }, + }, LogLevel.Info), + }, ...composeGlobalArgs, 'config'); + return yaml.load(cmdOutput.replace(terminalEscapeSequences, '')) || {} as any; + } + const { stdout } = await dockerComposeCLI({ + ...params, + env: { + ...params.env, + LANG: 'en_US.UTF-8', + LC_CTYPE: 'en_US.UTF-8', + } + }, ...composeGlobalArgs, 'config'); + const stdoutStr = stdout.toString(); + params.output.write(stdoutStr); + return yaml.load(stdoutStr) || {} as any; + } + } catch (err) { + throw err instanceof ContainerError ? err : new ContainerError({ description: 'An error occurred retrieving the Docker Compose configuration.', originalError: err, data: { fileWithError: composeFiles[0] } }); + } +} + +export async function findComposeContainer(params: DockerCLIParameters | DockerResolverParameters, projectName: string, serviceName: string): Promise { + const list = await listContainers(params, true, [ + `${projectLabel}=${projectName}`, + `${serviceLabel}=${serviceName}` + ]); + return list && list[0]; +} + +export async function getProjectName(params: DockerCLIParameters | DockerResolverParameters, workspace: Workspace, composeFiles: string[], composeConfig: any) { + const { cliHost } = 'cliHost' in params ? params : params.common; + const newProjectName = await useNewProjectName(params); + const envName = toProjectName(cliHost.env.COMPOSE_PROJECT_NAME || '', newProjectName); + if (envName) { + return envName; + } + try { + const envPath = cliHost.path.join(cliHost.cwd, '.env'); + const buffer = await cliHost.readFile(envPath); + const match = /^COMPOSE_PROJECT_NAME=(.+)$/m.exec(buffer.toString()); + const value = match && match[1].trim(); + const envFileName = toProjectName(value || '', newProjectName); + if (envFileName) { + return envFileName; + } + } catch (err) { + if (!(err && (err.code === 'ENOENT' || err.code === 'EISDIR'))) { + throw err; + } + } + if (composeConfig?.name) { + if (composeConfig.name !== 'devcontainer') { + return toProjectName(composeConfig.name, newProjectName); + } + // Check if 'devcontainer' is from a compose file or just the default. + for (let i = composeFiles.length - 1; i >= 0; i--) { + try { + const fragment = yaml.load((await cliHost.readFile(composeFiles[i])).toString()) || {} as any; + if (fragment.name) { + // Use composeConfig.name ('devcontainer') because fragment.name could include environment variables. + return toProjectName(composeConfig.name, newProjectName); + } + } catch (error) { + // Ignore when parsing fails due to custom yaml tags (e.g., !reset) + } + } + } + const configDir = workspace.configFolderPath; + const workingDir = composeFiles[0] ? cliHost.path.dirname(composeFiles[0]) : cliHost.cwd; // From https://github.com/docker/compose/blob/79557e3d3ab67c3697641d9af91866d7e400cfeb/compose/config/config.py#L290 + if (equalPaths(cliHost.platform, workingDir, cliHost.path.join(configDir, '.devcontainer'))) { + return toProjectName(`${cliHost.path.basename(configDir)}_devcontainer`, newProjectName); + } + return toProjectName(cliHost.path.basename(workingDir), newProjectName); +} + +function toProjectName(basename: string, newProjectName: boolean) { + // From https://github.com/docker/compose/blob/79557e3d3ab67c3697641d9af91866d7e400cfeb/compose/cli/command.py#L152 + if (!newProjectName) { + return basename.toLowerCase().replace(/[^a-z0-9]/g, ''); + } + return basename.toLowerCase().replace(/[^-_a-z0-9]/g, ''); +} + +async function useNewProjectName(params: DockerCLIParameters | DockerResolverParameters) { + try { + const version = parseVersion((await params.dockerComposeCLI()).version); + if (!version) { + return true; // Optimistically continue. + } + return !isEarlierVersion(version, [1, 21, 0]); // 1.21.0 changed allowed characters in project names (added hyphen and underscore). + } catch (err) { + return true; // Optimistically continue. + } +} + +export function dockerComposeCLIConfig(params: Omit, dockerCLICmd: string, dockerComposeCLICmd: string) { + let result: Promise; + return () => { + return result || (result = (async () => { + let v2 = true; + let stdout: Buffer; + try { + stdout = (await dockerComposeCLI({ + ...params, + cmd: dockerCLICmd, + }, 'compose', 'version', '--short')).stdout; + } catch (err) { + stdout = (await dockerComposeCLI({ + ...params, + cmd: dockerComposeCLICmd, + }, 'version', '--short')).stdout; + v2 = false; + } + const version = stdout.toString().trim(); + params.output.write(`Docker Compose version: ${version}`); + return { + version, + cmd: v2 ? dockerCLICmd : dockerComposeCLICmd, + args: v2 ? ['compose'] : [], + }; + })()); + }; +} + +/** + * Convert mount command arguments to Docker Compose volume + * @param mount + * @returns mount command representation for Docker compose + */ +function convertMountToVolume(mount: Mount): string { + let volume: string = ''; + + if (mount.source) { + volume = `${mount.source}:`; + } + + volume += mount.target; + + return volume; +} + +/** + * Convert mount command arguments to volume top-level element + * @param mount + * @returns mount object representation as volumes top-level element + */ +function convertMountToVolumeTopLevelElement(mount: Mount): string { + let volume: string = ` + ${mount.source}:`; + + if (mount.external) { + volume += '\n external: true'; + } + + return volume; +} diff --git a/extensions/positron-dev-containers/src/spec/spec-node/dockerfileUtils.ts b/extensions/positron-dev-containers/src/spec/spec-node/dockerfileUtils.ts new file mode 100644 index 000000000000..2a07023aa2ef --- /dev/null +++ b/extensions/positron-dev-containers/src/spec/spec-node/dockerfileUtils.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as semver from 'semver'; +import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; + + +const findFromLines = new RegExp(/^(?\s*FROM.*)/, 'gmi'); +const parseFromLine = /FROM\s+(?--platform=\S+\s+)?(?"?[^\s]+"?)(\s+AS\s+(?