diff --git a/.changes/unreleased/added-20260417-105920.yaml b/.changes/unreleased/added-20260417-105920.yaml deleted file mode 100644 index e5bee1bf4..000000000 --- a/.changes/unreleased/added-20260417-105920.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: added -body: Adds support for environment definitions -time: 2026-04-17T10:59:20.288002+03:00 -custom: - Author: v-alexmoraru - AuthorLink: https://github.com/v-alexmoraru diff --git a/.changes/unreleased/added-20260512-150558.yaml b/.changes/unreleased/added-20260512-150558.yaml deleted file mode 100644 index b3e8c0848..000000000 --- a/.changes/unreleased/added-20260512-150558.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: added -body: Return the job instance ID as a structured field in the JSON output of ``job run`` and ``job start`` commands -time: 2026-05-12T15:05:58.3242491+03:00 -custom: - Author: shirasassoon - AuthorLink: https://github.com/shirasassoon diff --git a/.changes/unreleased/added-20260625-145755.yaml b/.changes/unreleased/added-20260625-145755.yaml deleted file mode 100644 index 768759c25..000000000 --- a/.changes/unreleased/added-20260625-145755.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: added -body: Support creating SQLDatabase with optional parameters. Support `creationMode` of type `New`, `Restore` and `RestoreDeletedDatabase`. -time: 2026-06-25T14:57:55.03150683Z -custom: - Author: aviatco - AuthorLink: https://github.com/aviatco diff --git a/.changes/unreleased/added-20260701-121400.yaml b/.changes/unreleased/added-20260701-121400.yaml deleted file mode 100644 index 4a7280ebb..000000000 --- a/.changes/unreleased/added-20260701-121400.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: added -body: Adds the `--bulk_publish` flag to the `deploy` command to opt into experimental bulk publish -time: 2026-07-01T12:14:00.000000+00:00 -custom: - Author: ayeshurun - AuthorLink: https://github.com/ayeshurun diff --git a/.changes/unreleased/added-20260706-091126.yaml b/.changes/unreleased/added-20260706-091126.yaml deleted file mode 100644 index 46352c5c0..000000000 --- a/.changes/unreleased/added-20260706-091126.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: added -body: Add new 'bulk-export' command for exporting workspace or folder items -time: 2026-07-06T09:11:26.916814199Z -custom: - Author: ohadedry - AuthorLink: https://github.com/ohadedry diff --git a/.changes/unreleased/fixed-20260427-142055.yaml b/.changes/unreleased/fixed-20260427-142055.yaml deleted file mode 100644 index fe7b1e8fa..000000000 --- a/.changes/unreleased/fixed-20260427-142055.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Wraps long text on multiple lines -time: 2026-04-27T14:20:55.3215682+03:00 -custom: - Author: v-alexmoraru - AuthorLink: https://github.com/v-alexmoraru diff --git a/.changes/unreleased/fixed-20260607-134043.yaml b/.changes/unreleased/fixed-20260607-134043.yaml deleted file mode 100644 index e3d83b44f..000000000 --- a/.changes/unreleased/fixed-20260607-134043.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Restrict file and directory permissions on auth, config, context, and log paths to prevent local credential exposure on multi-user systems -time: 2026-06-07T13:40:43+02:00 -custom: - Author: iemejia - AuthorLink: https://github.com/iemejia diff --git a/.changes/unreleased/fixed-20260607-135834.yaml b/.changes/unreleased/fixed-20260607-135834.yaml deleted file mode 100644 index 34284f531..000000000 --- a/.changes/unreleased/fixed-20260607-135834.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Clean up temporary file after table maintenance job execution -time: 2026-06-07T13:58:34+02:00 -custom: - Author: iemejia - AuthorLink: https://github.com/iemejia diff --git a/.changes/unreleased/fixed-20260618-123845.yaml b/.changes/unreleased/fixed-20260618-123845.yaml deleted file mode 100644 index 515ba8161..000000000 --- a/.changes/unreleased/fixed-20260618-123845.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Enforce owner-only permissions on local files on POSIX -time: 2026-06-18T12:38:45Z -custom: - Author: aviatcohen - AuthorLink: https://github.com/aviatcohen diff --git a/.changes/unreleased/fixed-20260706-165104.yaml b/.changes/unreleased/fixed-20260706-165104.yaml deleted file mode 100644 index 30afdd7c7..000000000 --- a/.changes/unreleased/fixed-20260706-165104.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Remove invalid 'host-app/' prefix from User-Agent host app suffix -time: 2026-07-06T16:51:04+03:00 -custom: - Author: KupoCat - AuthorLink: https://github.com/KupoCat diff --git a/.changes/unreleased/fixed-20260716-092645.yaml b/.changes/unreleased/fixed-20260716-092645.yaml deleted file mode 100644 index c456bf246..000000000 --- a/.changes/unreleased/fixed-20260716-092645.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: fixed -body: Handle HTTP 429 responses missing the Retry-After header instead of failing with an unexpected error -time: 2026-07-16T09:26:45+03:00 -custom: - Author: ayeshurun - AuthorLink: https://github.com/ayeshurun diff --git a/.changes/unreleased/fixed-20260826-112432.yaml b/.changes/unreleased/fixed-20260826-112432.yaml new file mode 100644 index 000000000..b3c5f30e6 --- /dev/null +++ b/.changes/unreleased/fixed-20260826-112432.yaml @@ -0,0 +1,6 @@ +kind: fixed +body: Fix dev container creation by removing the unused Node.js toolchain, installing changie from its upstream release, dropping the stale Yarn apt source that broke apt-get update, and reporting package installation failures instead of skipping them silently +time: 2026-08-26T11:24:32+03:00 +custom: + Author: ayeshurun + AuthorLink: https://github.com/ayeshurun diff --git a/.changes/unreleased/new-items-20260513-105250.yaml b/.changes/unreleased/new-items-20260513-105250.yaml deleted file mode 100644 index f04b07677..000000000 --- a/.changes/unreleased/new-items-20260513-105250.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: new-items -body: Supports Digital Twin Builder Flow item -time: 2026-05-13T10:52:50.730594+03:00 -custom: - Author: v-alexmoraru - AuthorLink: https://github.com/v-alexmoraru diff --git a/.changes/v1.7.0.md b/.changes/v1.7.0.md new file mode 100644 index 000000000..457b7ffe6 --- /dev/null +++ b/.changes/v1.7.0.md @@ -0,0 +1,22 @@ +## [v1.7.0](https://pypi.org/project/ms-fabric-cli/v1.7.0) - August 18, 2026 + +### ๐Ÿ†• New Items Support + +* Supports Digital Twin Builder Flow item by [v-alexmoraru](https://github.com/v-alexmoraru) + +### โœจ New Functionality + +* Adds support for environment definitions by [v-alexmoraru](https://github.com/v-alexmoraru) +* Return the job instance ID as a structured field in the JSON output of ``job run`` and ``job start`` commands by [shirasassoon](https://github.com/shirasassoon) +* Support creating SQLDatabase with optional parameters. Support `creationMode` of type `New`, `Restore` and `RestoreDeletedDatabase`. by [aviatco](https://github.com/aviatco) +* Adds the `--bulk_publish` flag to the `deploy` command to opt into experimental bulk publish by [ayeshurun](https://github.com/ayeshurun) +* Add new 'bulk-export' command for exporting workspace or folder items by [ohadedry](https://github.com/ohadedry) + +### ๐Ÿ”ง Bug Fix + +* Wraps long text on multiple lines by [v-alexmoraru](https://github.com/v-alexmoraru) +* Restrict file and directory permissions on auth, config, context, and log paths to prevent local credential exposure on multi-user systems by [iemejia](https://github.com/iemejia) +* Clean up temporary file after table maintenance job execution by [iemejia](https://github.com/iemejia) +* Enforce owner-only permissions on local files on POSIX by [aviatcohen](https://github.com/aviatcohen) +* Handle HTTP 429 responses missing the Retry-After header instead of failing with an unexpected error by [ayeshurun](https://github.com/ayeshurun) + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28dfc7b9c..460512a0b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,7 @@ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "features": { - "ghcr.io/devcontainers/features/azure-cli:1": {}, - "ghcr.io/devcontainers/features/node:2": {} + "ghcr.io/devcontainers/features/azure-cli:1": {} }, // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, diff --git a/.devcontainer/local.env.example b/.devcontainer/local.env.example new file mode 100644 index 000000000..d8d6fa985 --- /dev/null +++ b/.devcontainer/local.env.example @@ -0,0 +1,44 @@ +# Optional local overrides for the dev container setup script. +# +# Copy this file to `.devcontainer/local.env` and edit it. That path is +# git-ignored, which helps prevent private mirror URLs from being committed by +# accident. It is not a secrets store: the file is plaintext, readable by +# anything in the workspace, and `git add -f` still overrides the ignore rule. +# Prefer short-lived or credential-free mirror URLs. +# +# cp .devcontainer/local.env.example .devcontainer/local.env +# +# scripts/install_dev_container_dependencies.sh reads this file before any +# packages are installed. Lines are parsed as plain `KEY=value` data and are +# never executed as shell, so this file cannot run commands. Only the keys +# listed below are applied; anything else is reported and ignored. Surrounding +# single or double quotes are stripped, and leading/trailing spaces around the +# key and value are ignored. +# +# This is useful on corporate networks that block public package registries. +# Note that pypi.org may be reachable while files.pythonhosted.org is blocked, +# which makes pip resolve dependencies and then fail during download. + +# Route pip through an internal mirror instead of PyPI. +# PIP_INDEX_URL=https://your-mirror.example.com/pypi/simple/ +# PIP_EXTRA_INDEX_URL= +# PIP_TRUSTED_HOST= + +# Raise pip's per-request timeout and retry count. Useful when an internal +# mirror is reachable but intermittently slow; pip defaults to 15s and 5 tries. +# PIP_TIMEOUT=60 +# PIP_RETRIES=10 + +# Route apt through an internal proxy. These three are forwarded explicitly +# through sudo, which otherwise discards them. +# +# Use the lowercase names. apt honours only lowercase proxy variables, so +# setting HTTP_PROXY alone leaves pip and curl working while apt silently +# gets no proxy at all. The uppercase names are accepted here for pip and +# curl, but set the lowercase ones too if apt needs the proxy. +# http_proxy=http://your-proxy.example.com:8080 +# https_proxy=http://your-proxy.example.com:8080 +# no_proxy=localhost,127.0.0.1,.internal.example.com + +# Install a different changie release than the version pinned in the script. +# CHANGIE_VERSION=1.26.0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33a02cd1..ed2148072 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,12 @@ updates: directory: "/" schedule: interval: weekly + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json new file mode 100644 index 000000000..f94d97b65 --- /dev/null +++ b/.github/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "b=\"${HOME:-${USERPROFILE:-}}/.copilot/hooks\"; s=\"$b/.last-copilot-toolkit-plugin-update.$(date -u +%Y%m%d)\"; if mkdir -p \"$b\" 2>/dev/null && ( set -C; : > \"$s\" ) 2>/dev/null; then copilot plugin install https://dev.azure.com/msdata/A365/_git/copilot-toolkit 2>/dev/null || echo 'Plugin already installed or unavailable' >&2; fi", + "powershell": "try { $b = Join-Path $HOME '.copilot/hooks'; New-Item -ItemType Directory -Force -Path $b -ErrorAction Stop | Out-Null; $s = Join-Path $b ('.last-copilot-toolkit-plugin-update.' + [DateTime]::UtcNow.ToString('yyyyMMdd')); $fs = [System.IO.File]::Open($s, [System.IO.FileMode]::CreateNew); $fs.Close(); copilot plugin install https://dev.azure.com/msdata/A365/_git/copilot-toolkit 2>$null; if ($LASTEXITCODE -ne 0) { Write-Host 'Plugin already installed or unavailable' -ForegroundColor Yellow } } catch { }; exit 0", + "cwd": ".", + "timeoutSec": 120, + "comment": "Install/update Copilot Toolkit plugins at most once per day (daily stamp guard) to avoid Azure DevOps throttling" + } + ] + } +} diff --git a/.github/workflows/ai-issue-triage.yml b/.github/workflows/ai-issue-triage.yml index b42198f99..4cd0b1910 100644 --- a/.github/workflows/ai-issue-triage.yml +++ b/.github/workflows/ai-issue-triage.yml @@ -30,11 +30,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Resolve issue details id: issue - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const issueNumber = context.payload.issue?.number || ${{ inputs.issue_number || 0 }}; @@ -88,7 +88,7 @@ jobs: - name: Run AI assessment id: ai-assessment - uses: github/ai-assessment-comment-labeler@v1.0.1 + uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} issue_number: ${{ steps.issue.outputs.number }} @@ -105,7 +105,7 @@ jobs: - name: Post-process triage results if: steps.ai-assessment.outputs.ai_assessments != '' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ASSESSMENT_OUTPUT: ${{ steps.ai-assessment.outputs.ai_assessments }} SUPPRESS_LABELS: ${{ env.SUPPRESS_LABELS }} @@ -219,7 +219,7 @@ jobs: - name: Generate triage summary if: always() && steps.ai-assessment.outputs.ai_assessments != '' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ASSESSMENT_OUTPUT: ${{ steps.ai-assessment.outputs.ai_assessments }} LABEL_DECISIONS: ${{ env.LABEL_DECISIONS }} @@ -272,7 +272,7 @@ jobs: - name: Upload triage report if: always() && steps.ai-assessment.outputs.ai_assessments != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: triage-report-issue-${{ steps.issue.outputs.number }} path: triage-reports/ diff --git a/.github/workflows/changelog-existence.yml b/.github/workflows/changelog-existence.yml index 72d9ffbd1..643f610b0 100644 --- a/.github/workflows/changelog-existence.yml +++ b/.github/workflows/changelog-existence.yml @@ -31,13 +31,13 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} steps: - name: โคต๏ธ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Required to access full commit history - name: โœ”๏ธ Check for changelog changes id: changelog_check - uses: actions/github-script@v6 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { execSync } = require('child_process'); @@ -51,7 +51,7 @@ jobs: - name: ๐Ÿšง Setup Node if: steps.changelog_check.outputs.exists == 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 12603c365..a30a1e3ce 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -18,7 +18,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Required to access full commit history for validation diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 3b64ca394..26dc18e22 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -12,11 +12,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - name: Install Requirements diff --git a/.github/workflows/fab-build.yml b/.github/workflows/fab-build.yml index c90b2dbfa..aff68884d 100644 --- a/.github/workflows/fab-build.yml +++ b/.github/workflows/fab-build.yml @@ -21,10 +21,10 @@ jobs: name: Lint Code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" # Use any stable Python version for linting @@ -34,7 +34,7 @@ jobs: pip install tox - name: Cache Tox environments - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tox key: ${{ runner.os }}-tox-lint-${{ hashFiles('**/tox.toml') }} @@ -50,10 +50,10 @@ jobs: name: Type Check Code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" # Use any stable Python version for type checking @@ -63,7 +63,7 @@ jobs: pip install tox - name: Cache Tox environments - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tox key: ${{ runner.os }}-tox-type-${{ hashFiles('**/tox.toml') }} @@ -89,10 +89,10 @@ jobs: - python-version: "3.13" tox-env: "py313" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -102,7 +102,7 @@ jobs: pip install tox - name: Cache Tox environments - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tox key: ${{ runner.os }}-tox-${{ matrix.tox-env }}-${{ hashFiles('**/tox.toml') }} @@ -115,7 +115,7 @@ jobs: # Upload the coverage report as an artifact - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-html-report-${{ matrix.python-version }} path: coverage_html @@ -127,10 +127,10 @@ jobs: needs: - test # Ensure all test jobs complete successfully steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 2b68f112e..edec84224 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Run Semantic PR Validation id: validation - uses: actions/github-script@v6 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const prTitle = context.payload.pull_request.title; @@ -33,7 +33,7 @@ jobs: } - name: Handle Invalid Title if: failure() - uses: actions/github-script@v6 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const commentMarker = ''; @@ -95,7 +95,7 @@ jobs: }); - name: Handle Valid Title if: success() - uses: actions/github-script@v6 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ diff --git a/.gitignore b/.gitignore index 1fc5ba856..6e7cf5d24 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,4 @@ marimo/_lsp/ __marimo__/ devcontainer.local.json +.devcontainer/local.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e1e7128d..003f459f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,18 @@ All PRs must be linked with a "help wanted" issue. To avoid rework after investi Please review [engineering guidelines](https://github.com/microsoft/fabric-cli/wiki) for coding guidelines and common flows to help you with your task. +### Development environment + +The repository ships a [dev container](https://containers.dev). Opening the project in it installs the build dependencies, the Python requirements, and `changie`. + +On a restricted network that blocks public package registries, point the setup script at an internal mirror instead: + +```bash +cp .devcontainer/local.env.example .devcontainer/local.env +``` + +Then uncomment and edit the entries you need, and rebuild the container. `.devcontainer/local.env` is git-ignored, so mirror URLs stay out of the repository. See the comments in the example file for the supported keys. + ### Pull request process Please use a descriptive title and provide a clear summary of your changes. diff --git a/Features/1694265/design-spec.md b/Features/1694265/design-spec.md new file mode 100644 index 000000000..c6a337529 --- /dev/null +++ b/Features/1694265/design-spec.md @@ -0,0 +1,229 @@ +# Design Spec โ€” Feature 1694265 (fabric-cli) + +> Repo-specific design for adding Azure CLI as an explicit Fabric CLI authentication source. +> The parent [engineering design](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/engineering-design.md), [implementation handoff](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/implementation-handoff.md), and [test plan](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/test-plan.md) define the product contract. + +## Scope + +Fabric CLI owns the complete client-side implementation: + +- Add `azure-cli` as an explicit authentication source while preserving all existing direct Fabric CLI sources. +- Introduce a shared pre-dispatch authentication coordinator for command-line, batch, auth, and REPL execution. +- Acquire Azure CLI tokens noninteractively through `AzureCliCredential`. +- Persist a versioned source and identity binding, but never persist delegated Azure CLI tokens. +- Add passive and active authentication status, stable error/exit behavior, source-local logout, and source-aware SDK/deploy integration. +- Preserve the existing route labels and scopes: `fabric` and `powerbi` use the Fabric scope, `storage` uses the OneLake scope, and `azure` uses the ARM scope. +- Add feature gating, telemetry, documentation, and the cross-platform regression matrix required for rollout. + +### Non-goals + +- Running or wrapping `az login`, `az logout`, `az account set`, or any Azure CLI context mutation. +- Using `DefaultAzureCredential` or merging Azure CLI tokens into the MSAL cache. +- Persisting Azure CLI access or refresh tokens. +- Supporting arbitrary scopes, claims challenges, SQL, XMLA, Kusto, or non-Public Azure clouds in the first release. +- Normalizing every existing identity mode under the new `--source` syntax. + +## Current Architecture + +Authentication currently spans several independent paths: + +- `fabric_cli.main` special-cases auth commands and uses `_execute_command` only for other one-shot commands. +- `InteractiveCLI.handle_command` parses and invokes handlers independently. +- `FabAuth` combines persistent state, environment loading, provider selection, MSAL acquisition, and interactive renewal. +- `fab_api_client.do_request` acquires tokens during request execution, which can trigger interactive renewal. +- `fab_auth.status` requests three tokens, and `FabAuth.logout` resets unrelated configuration. +- `MsalTokenCredential` supports only the current Fabric CLI provider and remains headless. +- Config-file deploy creates the credential inside a catch-all that maps failures to `DeploymentFailed`. + +The implementation must separate policy, coordination, provider behavior, and persistence without regressing existing authentication modes. + +## Proposed Design + +### Shared parsed-command executor + +Create one executor used by one-shot, batch, auth, and REPL surfaces. It will: + +1. Classify the parsed command as local, passive auth, active auth, or authenticated. +2. Resolve interaction policy from command flags, output mode, host capability, CI/batch/pipe context, and `FAB_INTERACTION`. +3. Resolve the effective source using runtime environment overrides before the configured source. +4. Run authentication readiness before handler dispatch when required. +5. Invoke the parsed handler at most once and return its exit code without replay. + +Local commands such as help, version, passive status, and logout bypass token acquisition. Batch execution fails fast and reports executed, failed, and skipped counts. + +### Authentication coordinator + +Add a coordinator responsible for source resolution, interaction eligibility, chooser orchestration, candidate validation, atomic binding, and exactly-once continuation. Provider classes remain noninteractive. + +Source precedence: + +1. Runtime environment credentials for the current process. +2. Persisted configured source. +3. Shared chooser only when no source exists and interaction is allowed. +4. `AuthenticationRequired` for unattended or deferred execution. + +The coordinator exposes a small result model containing configured source, effective source, principal capability, readiness state, and optional checked-audience expiration. It does not expose token values. + +### Provider boundary + +Define a provider protocol used by `FabAuth`, HTTP requests, status checks, and the SDK bridge: + +- Acquire exactly one allowlisted audience. +- Return token and expiration metadata. +- Validate tenant and principal against the active binding. +- Clear only process-local cached data for a requested audience. +- Report stable Fabric CLI errors without raw SDK or process output. + +Existing MSAL user, service-principal, managed-identity, federation, certificate, and raw-token behavior remain behind the direct `fabric-cli` provider. Interactive MSAL renewal moves out of ordinary provider acquisition and is initiated only by the coordinator when policy allows it. + +### Azure CLI provider + +Add `azure-identity` as a dependency and implement the provider with `AzureCliCredential`: + +- Resolve only a trusted Azure CLI executable through Azure Identity. +- Use disconnected standard input, no shell, a safe working directory, and a bounded 10-second acquisition. +- Pass one resolved `.default` scope per call. +- Allow only `fabric`, `storage`, `azure`, and `powerbi` route labels. +- Keep `powerbi` mapped to the existing Fabric scope. +- Cache successful tokens in process by source, tenant, principal, and audience. +- Coalesce concurrent misses for the same key; refresh inside the configured buffer; never cache failures. +- Clear and reject only the affected token when a claims challenge is returned. + +The provider never invokes Azure CLI login, logout, account selection, tenant selection, or subscription selection. + +### Command contract + +Extend `fab auth` with: + +```text +fab auth login --source azure-cli [--tenant ] [--no-prompt] +fab auth status [--check] [--audience fabric|storage|azure|powerbi] +``` + +Rules: + +- Azure CLI source flags conflict with direct source flags. +- Unattended Azure CLI login requires `--tenant` and `--no-prompt`. +- Bare attended login and eligible progressive first use share the existing chooser. +- Progressive discovery offers Azure CLI only for a supported user identity. +- Workload identities require explicit login in the first release. +- Default chooser action is defer; defer returns `AuthenticationRequired` and exit 4 without writing state. +- Cancellation returns exit 2 without writing state. + +### Persistent state + +Evolve `auth.json` to a versioned source record: + +```json +{ + "version": 2, + "source": "azure-cli", + "cloud": "AzureCloud", + "tenant_id": "", + "principal_id": "", + "principal_type": "user", + "account": "", + "subscription_id": null, + "subscription_name": null, + "app_id": "", + "bound_at": "" +} +``` + +Legacy records migrate idempotently to source `fabric-cli` without deleting the MSAL cache. Candidate source replacement follows validate-then-commit semantics: + +1. Validate Fabric readiness and identity. +2. Acquire the interprocess state lock. +3. Recheck the current state. +4. Atomically replace `auth.json`. +5. Preserve unrelated configuration and the prior state if any step fails. + +The configuration directory remains owner-only (`0700`) and auth state remains owner-only (`0600`). Runtime environment overrides never modify persistent state. + +### Identity binding + +The binding pins cloud, canonical tenant ID, stable principal ID, and principal type. Subscription metadata is display-only and nullable. Every fresh token must match the binding before a service request. + +The implementation mechanism for establishing stable tenant and principal identity is blocked on security decision Q4. If token-derived validation is approved, it must verify issuer, signature, audience, expiration, `tid`, and `oid`; otherwise the provider must use the approved metadata contract. Missing stable identity fails closed. + +### Status and logout + +Plain `fab auth status` remains exit 0 and becomes passive: no provider call, no Azure CLI process, and readiness `unknown` when local state is insufficient. + +`fab auth status --check --audience ` performs one active check and returns exit 0 when ready or exit 4 for readiness failures. Text output retains the current leading status line and legacy field order. Structured output retains the current envelope and legacy token keys with `"N/A"` during the deprecation window. + +Logout clears only Fabric-owned state for the configured source: + +- Azure CLI source: binding and in-process token cache. +- Direct source: current Fabric CLI auth state and its MSAL cache. +- All sources: Fabric CLI memory and context caches as required. + +Unrelated CLI configuration and Azure CLI state remain unchanged. + +### Errors and output + +Add structured error definitions through `fabric_cli.errors` and constants through `fab_constant`; do not hardcode user-facing messages in handlers. Readiness errors map to exit 4, usage/conflict/cancellation errors map to exit 2, and unexpected errors remain exit 1. + +All output uses existing `fab_ui` text and JSON renderers. JSON stdout contains one document; prompts and diagnostics use the diagnostic stream. Logs and telemetry exclude tokens, claims challenges, process output, command arguments, and identity identifiers. + +### HTTP, SDK, deploy, and user capability integration + +- `fab_api_client.do_request` requests tokens from the effective provider without allowing interaction or replay. +- Generalize `create_fabric_token_credential` behind its existing public factory so `fabric-cicd` receives a headless credential for the effective source. +- Run Fabric readiness preflight before entering deploy's catch-all so readiness failures preserve exit 4 and are not wrapped as `DeploymentFailed`. +- Replace source-specific `identity_type == "user"` checks with principal-capability checks for browser-open and personal-workspace behavior. + +## Dependencies + +- `azure-identity` for `AzureCliCredential`. +- Existing `azure-core`, MSAL, secure file utilities, output renderers, and command parser infrastructure. +- Security ownership and approval for executable resolution, principal binding, state, errors, and telemetry. +- Fabric agent-experience decision for the attended-host interaction channel. +- Fabric CLI engineering decision for the feature flag and rollout rings. + +## Rollout and Compatibility + +- Gate Azure CLI source selection and progressive offers independently where possible. +- Enable explicit unattended login before progressive offers. +- Preserve every existing direct authentication syntax and route mapping. +- Rollback disables Azure CLI selection and offers without destructively rewriting a recoverable source marker. +- Existing scripts consuming status retain legacy token keys as `"N/A"` for one deprecation window. + +## Testing Strategy + +### Unit tests + +- Parser conflicts, source selection, interaction classification, error mapping, and output models. +- Azure CLI executable absence, timeout, sanitized failures, scope allowlist, tenant/principal mismatch, refresh, cache coalescing, and failure non-caching. +- State migration, locking, atomic replacement, permissions, failed replacement preservation, and runtime-only environment precedence. +- Passive status zero-call behavior, active single-audience behavior, and source-local logout. + +### Integration tests + +- Shared execution across command-line, auth, REPL, JSON, pipe, CI, callbacks, and batch paths. +- Exactly-once handler and request continuation after consent. +- HTTP route-to-scope mapping and no request replay. +- Headless SDK bridge and deploy preflight error preservation. +- Existing direct user, service-principal, certificate, federation, managed-identity, and raw-token suites. + +### End-to-end and release tests + +- Windows, Linux, and macOS with Azure CLI installed, absent, signed out, user signed in, workload identity, guest tenant, and no subscription. +- Identity drift, Azure CLI context race, timeout, refresh, concurrency, and claims challenge. +- Representative Fabric Skills runs using a prepared Azure CLI identity without a second interactive login. +- Feature-gate enablement, disablement, and rollback. + +The parent test plan remains the acceptance evidence ledger for all 59 requirements and 40 mapped tests. + +## Open Gates + +| Gate | Owner | Blocks | +| --- | --- | --- | +| Q2: Select feature flag and rollout rings | Fabric CLI engineering | Rollout implementation | +| Q3: Assign final security approval owner | Fabric security and Fabric CLI leadership | Design lock | +| Q4: Approve principal identity validation mechanism | Fabric security | Binding implementation | +| Q5: Select attended-host interaction channel | Fabric agent experience and Fabric CLI | Attended-agent release | + +## Implementation Plan + +See [implementation-plan.md](implementation-plan.md) for the sequenced workstreams, dependencies, and proposed ADO task breakdown. diff --git a/Features/1694265/implementation-plan.md b/Features/1694265/implementation-plan.md new file mode 100644 index 000000000..74fcaebb1 --- /dev/null +++ b/Features/1694265/implementation-plan.md @@ -0,0 +1,71 @@ +# Implementation Plan โ€” Feature 1694265 (fabric-cli) + +This plan decomposes the repo-specific [design spec](design-spec.md) into independently reviewable workstreams. The parent ADO item is User Story 1694265. + +## Delivery Principles + +- Land policy and compatibility tests before changing provider behavior. +- Keep all providers noninteractive below the coordinator. +- Preserve existing direct authentication while adding Azure CLI. +- Do not merge slices that depend on unresolved security or host-integration gates. +- Track requirement and test-plan evidence from the first implementation pull request. + +## Proposed Task Breakdown + +| Slice | Proposed ADO Task | Implementation scope | Primary files | Depends on | Exit criteria | +| ---: | --- | --- | --- | --- | --- | +| 1 | Add shared executor, authentication coordinator, and interaction policy | Unify command-line, auth, REPL, and batch dispatch; classify interaction; resolve effective source; map readiness exits; guarantee exactly-once handler execution | `main.py`, `core/fab_interactive.py`, new coordinator/executor modules, `core/fab_decorators.py` | None | All execution surfaces use one policy; local commands bypass auth; unattended commands do not prompt; readiness errors exit 4 | +| 2 | Add Azure CLI provider and explicit login contract | Add `azure-identity`; parser flags and conflicts; provider protocol; trusted executable behavior; timeout; sanitization; one-scope allowlist | `pyproject.toml`, `parsers/fab_auth_parser.py`, `commands/auth/fab_auth.py`, new provider modules, `errors/auth.py` | Slice 1 interface alignment | Explicit attended and unattended login paths validate Fabric without invoking Azure CLI login or accepting arbitrary scopes | +| 3 | Implement identity binding and atomic auth state | Versioned source state; legacy migration; runtime-only environment override; locking; atomic writes; permissions; pinned identity; process cache and concurrency | `core/fab_auth.py`, `core/fab_state_config.py`, `utils/fab_secure_io.py`, new state/cache modules | Slices 1-2; Q4 for principal validation | Failed replacement preserves prior state; fresh tokens match binding; no delegated token reaches disk | +| 4 | Implement shared chooser and exactly-once continuation | Progressive eligible-user discovery; default defer; alternate direct options; direct-terminal chooser; attended-host abstraction; separate-login fallback | Coordinator, `commands/auth/fab_auth.py`, `utils/fab_ui.py`, host interaction abstraction | Slices 1 and 3; Q5 for host transport | Default Enter defers; cancellation and defer write no state; successful consent invokes one handler/request | +| 5 | Implement passive/active status, stable errors, and source-local logout | Passive status; `--check`; audience selection; compatibility output; all stable errors; stream separation; capability checks; scoped logout | Auth parser/commands, `core/fab_constant.py`, `errors/auth.py`, output models, `commands/fs/fab_fs_open.py` | Slices 1-3 | Passive status makes zero provider calls; active status checks one audience; logout preserves unrelated and Azure CLI state | +| 6 | Integrate HTTP, SDK bridge, deploy, and batch paths | Source-aware request acquisition; headless credential factory; deploy preflight before catch-all; claims behavior; fail-fast batch counts; Power BI route compatibility | `client/fab_api_client.py`, `core/fab_msal_bridge.py`, deploy command, `main.py` | Slices 1-5 | No replay; SDK callbacks stay headless; deploy readiness exits 4; Power BI remains mapped to Fabric scope | +| 7 | Complete release matrix, docs, telemetry, and Skills pilot | Full regression matrix; telemetry safety; docs/examples; feature gates; rollout and rollback evidence; representative Skills runs | Tests, docs, telemetry integration, release configuration | Slices 1-6; Q2-Q3 | All parent test-plan rows have evidence; direct sources regress cleanly; rollout and rollback are approved | + +## Dependency Graph + +```text +Slice 1 โ”€โ”€โ”ฌโ”€โ”€> Slice 2 โ”€โ”€> Slice 3 โ”€โ”€โ”ฌโ”€โ”€> Slice 4 โ”€โ”€โ” + โ”‚ โ””โ”€โ”€> Slice 5 โ”€โ”€โ”ผโ”€โ”€> Slice 6 โ”€โ”€> Slice 7 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Q4 gates identity validation in Slice 3. +Q5 gates attended-host integration in Slice 4. +Q2 and Q3 gate rollout completion in Slice 7. +``` + +## Pull Request Sequence + +1. **Policy and compatibility harness:** interaction classifier, executor contract, current-behavior regression tests, and stable error categories. +2. **Provider foundation:** provider protocol, Azure CLI parser contract, allowlist, timeout, sanitization, and mocked provider tests. +3. **State and binding:** versioned migration, runtime override model, locking, atomic write, cache, and approved principal validation. +4. **User interaction:** direct chooser and continuation first; attended-host transport only after Q5. +5. **Status and lifecycle:** passive/active status, compatibility output, capability checks, and source-local logout. +6. **Integration surfaces:** HTTP, SDK bridge, deploy, batch, claims, and Power BI route regression. +7. **Release hardening:** platform matrix, docs, telemetry, Skills pilot, flags, and rollback. + +Each pull request should link to its ADO task and update the requirement/test evidence ledger. + +## Validation Matrix + +| Layer | Required coverage | +| --- | --- | +| Parser | New flags, conflicts, required tenant/no-prompt combinations, audience allowlist | +| Policy | Direct terminal, approved attended host, undeclared host, JSON, pipe, batch, CI, callback, status, logout | +| Provider | Installed/signed-in states, timeout, sanitized error, audience mapping, refresh, concurrency, drift | +| State | Migration, permissions, atomicity, lock contention, failed replacement, environment precedence | +| Execution | CLI, auth commands, REPL, batch fail-fast, exactly-once continuation | +| Integration | HTTP routes, SDK credential, deploy preflight, user capabilities, claims handling | +| Regression | MSAL user, SPN secret/certificate/federation, managed identity, raw tokens | +| Release | Windows/Linux/macOS, guest/no-subscription, Skills pilot, feature gates, rollback | + +## Task Creation Readiness + +Before creating ADO work items: + +- The Feature Registry artifacts must be present on its default branch. +- This repo's `Features/1694265/design-spec.md` must be reviewed and merged to the code repo's default branch. +- The ADO organization, project, hierarchy, area path, iteration, and owners must be confirmed. +- Existing closed Task 1728448 remains decision history and is not reused. + +After the readiness gate passes, create the seven work items through the `manage-tasks` workflow so each ADO ID is used to generate its local `task-.md` file. diff --git a/Features/1694265/registry.md b/Features/1694265/registry.md new file mode 100644 index 000000000..684582345 --- /dev/null +++ b/Features/1694265/registry.md @@ -0,0 +1,20 @@ +# Feature 1694265 โ€” Registry Pointer + +This folder contains area-specific artifacts for Feature 1694265 in the **fabric-cli** repo. + +## Feature Registry (Parent) + +- **Registry Repo:** [fabric-cli](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry) +- **Feature Folder:** `Features/active/1694265/` (moves to `Features/done/1694265/` when archived) +- **ADO Work Item:** [#1694265](https://powerbi@dev.azure.com/powerbi/Trident/_workitems/edit/1694265) *(stable canonical link)* + +> **Note:** The Feature Registry uses an `active/done` folder hierarchy. After a feature is archived, the folder path changes from `Features/active/` to `Features/done/`. Use the ADO Work Item link above as the stable reference. + +## Navigation + +> **Tip:** These links point to the `active/` path and will break after archival. Use the ADO Work Item link above to find the feature regardless of its current folder. + +- [Requirements Spec](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/requirements-spec.md) (in Feature Registry โ€” active only) +- [Engineering Design](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/engineering-design.md) (in Feature Registry โ€” active only) +- [Implementation Handoff](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/implementation-handoff.md) (in Feature Registry โ€” active only) +- [Test Plan](https://powerbi@dev.azure.com/powerbi/Trident/_git/PlatformDevFeatureRegistry?path=/Features/active/1694265/test-plan.md) (in Feature Registry โ€” active only) diff --git a/docs/release-notes.md b/docs/release-notes.md index 1ee24bac9..6cd148c81 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -6,6 +6,29 @@ hide: # Release Notes +## [v1.7.0](https://pypi.org/project/ms-fabric-cli/v1.7.0) - August 18, 2026 + +### ๐Ÿ†• New Items Support + +* Supports Digital Twin Builder Flow item by [v-alexmoraru](https://github.com/v-alexmoraru) + +### โœจ New Functionality + +* Adds support for environment definitions by [v-alexmoraru](https://github.com/v-alexmoraru) +* Return the job instance ID as a structured field in the JSON output of ``job run`` and ``job start`` commands by [shirasassoon](https://github.com/shirasassoon) +* Support creating SQLDatabase with optional parameters. Support `creationMode` of type `New`, `Restore` and `RestoreDeletedDatabase`. by [aviatco](https://github.com/aviatco) +* Adds the `--bulk_publish` flag to the `deploy` command to opt into experimental bulk publish by [ayeshurun](https://github.com/ayeshurun) +* Add new 'bulk-export' command for exporting workspace or folder items by [ohadedry](https://github.com/ohadedry) + +### ๐Ÿ”ง Bug Fix + +* Wraps long text on multiple lines by [v-alexmoraru](https://github.com/v-alexmoraru) +* Restrict file and directory permissions on auth, config, context, and log paths to prevent local credential exposure on multi-user systems by [iemejia](https://github.com/iemejia) +* Clean up temporary file after table maintenance job execution by [iemejia](https://github.com/iemejia) +* Enforce owner-only permissions on local files on POSIX by [aviatcohen](https://github.com/aviatcohen) +* Handle HTTP 429 responses missing the Retry-After header instead of failing with an unexpected error by [ayeshurun](https://github.com/ayeshurun) + + ## [v1.6.1](https://pypi.org/project/ms-fabric-cli/v1.6.1) - April 29, 2026 ### ๐Ÿ†• New Items Support diff --git a/pyproject.toml b/pyproject.toml index bcda6e7ce..dee33a6ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "psutil==7.0.0", "requests", "cryptography", - "fabric-cicd>=1.2.0", + "fabric-cicd>=1.3.0", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index 23e909b6d..342111a39 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ argcomplete>=3.6.2 psutil==7.0.0 requests cryptography -fabric-cicd>=1.2.0 +fabric-cicd>=1.3.0 # Testing and Building Requirements tox>=4.20.0 diff --git a/scripts/install_dev_container_dependencies.sh b/scripts/install_dev_container_dependencies.sh old mode 100644 new mode 100755 index 4a2d80954..e5e966a2f --- a/scripts/install_dev_container_dependencies.sh +++ b/scripts/install_dev_container_dependencies.sh @@ -1,12 +1,142 @@ #!/usr/bin/env bash -set -e +set -euo pipefail -apt-get update && apt-get install -y \ +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" + +# postCreateCommand runs as the container's remote user, which may be non-root. +# Route privileged steps through sudo, leaving the prefix empty when already root +# so the script behaves identically under either user. sudo resets the +# environment by default, so proxy variables are preserved explicitly for apt. +if [ "$(id -u)" -eq 0 ]; then + # `env` is a no-op prefix. An empty array would be equivalent on bash >= 4.4, + # but expanding one under `set -u` is an error on older bash. + sudo_cmd=(env) +else + if ! command -v sudo >/dev/null 2>&1; then + echo "Not running as root and sudo is unavailable; cannot install packages." >&2 + exit 1 + fi + if ! sudo -n true >/dev/null 2>&1; then + echo "sudo requires a password; expected passwordless sudo in the dev container." >&2 + exit 1 + fi + # Quoted because the commas belong to sudo's --preserve-env list, not to the array. + sudo_cmd=(sudo -n "--preserve-env=http_proxy,https_proxy,no_proxy") +fi + +# Optional local overrides for restricted networks, e.g. pointing PIP_INDEX_URL at +# an internal mirror. The file is git-ignored so mirror URLs stay out of the repo. +# Lines are parsed as plain KEY=value data rather than sourced, so the file cannot +# run commands or clobber this script's own variables. See local.env.example. +local_env="${repo_root}/.devcontainer/local.env" +if [ -f "$local_env" ]; then + echo "Applying local overrides from ${local_env}" + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + line="${raw_line%$'\r'}" + case "$line" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + + key="${line%%=*}" + value="${line#*=}" + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + + case "$key" in + PIP_INDEX_URL|PIP_EXTRA_INDEX_URL|PIP_TRUSTED_HOST|\ +PIP_RETRIES|PIP_TIMEOUT|CHANGIE_VERSION|\ +http_proxy|https_proxy|no_proxy|HTTP_PROXY|HTTPS_PROXY|NO_PROXY) + export "${key}=${value}" + echo " applied ${key}" + ;; + *) + echo " ignoring unsupported key: ${key}" >&2 + ;; + esac + done < "$local_env" +fi + +# The base image ships a Yarn apt source whose bundled keyring predates Yarn's +# switch to an EdDSA signing key, so `apt-get update` fails verification and +# exits 100 on an otherwise healthy network. Nothing here needs Yarn, so the +# stale source is removed rather than worked around. +yarn_list="/etc/apt/sources.list.d/yarn.list" +if [ -f "$yarn_list" ]; then + echo "Removing stale Yarn apt source (${yarn_list})" + "${sudo_cmd[@]}" rm -f "$yarn_list" +fi + +# Kept as separate statements: under `set -e` a failing left-hand side of `&&` +# does not abort the script, which previously let package installation be skipped +# silently. +"${sudo_cmd[@]}" apt-get update +"${sudo_cmd[@]}" apt-get install -y \ cmake \ libcairo2-dev \ pkg-config \ python3-dev -pip3 install -r requirements-dev.txt -r requirements-docs.txt +# Not routed through sudo: pip falls back to a --user install when site-packages +# is not writable, and sudo would discard PIP_INDEX_URL set above. +pip3 install -r "${repo_root}/requirements-dev.txt" -r "${repo_root}/requirements-docs.txt" + +# changie ships as a standalone Go binary, so it is installed straight from the +# upstream GitHub release. This avoids pulling a full Node.js toolchain into the +# dev container solely to run `npm install -g changie`. +CHANGIE_VERSION="${CHANGIE_VERSION:-1.26.0}" + +case "$(uname -m)" in + x86_64) changie_arch="amd64" ;; + aarch64 | arm64) changie_arch="arm64" ;; + *) echo "Unsupported architecture for changie: $(uname -m)" >&2; exit 1 ;; +esac + +changie_archive="changie_${CHANGIE_VERSION}_linux_${changie_arch}.tar.gz" +changie_base_url="https://github.com/miniscruff/changie/releases/download/v${CHANGIE_VERSION}" +changie_tmp="$(mktemp -d)" +trap 'rm -rf "$changie_tmp"' EXIT + +curl_opts=( + --fail --silent --show-error --location + --proto '=https' --proto-redir '=https' + --retry 3 --retry-connrefused + --connect-timeout 15 --max-time 300 +) +curl "${curl_opts[@]}" -o "${changie_tmp}/${changie_archive}" "${changie_base_url}/${changie_archive}" +curl "${curl_opts[@]}" -o "${changie_tmp}/checksums.txt" "${changie_base_url}/checksums.txt" + +# Require exactly one checksum line matching the archive name on field 2, so a +# truncated or unexpected checksums.txt cannot skip verification. +checksum_line="$(awk -v archive="$changie_archive" '$2 == archive' "${changie_tmp}/checksums.txt")" +match_count="$(printf '%s' "$checksum_line" | grep -c . || true)" +if [ "$match_count" -ne 1 ]; then + echo "Expected 1 checksum entry for ${changie_archive}, found ${match_count}." >&2 + exit 1 +fi +(cd "$changie_tmp" && printf '%s\n' "$checksum_line" | sha256sum -c -) + +# Extract the member's contents rather than materialising the archive entry. A +# malformed or substituted archive could ship `changie` as a symlink or hard +# link, and `install` running under sudo would follow it and copy an arbitrary +# root-readable file into world-readable /usr/local/bin. `tar -O` emits no +# content for link entries, so the empty check below rejects them, and the +# redirect always writes a regular file inside the 0700 temp directory. +# +# This bounds the outcome to bytes carried by the archive itself. A substituted +# archive can still stream a directory child or concatenated duplicate members +# through this path, which is no worse than it shipping a malicious regular +# member; what it cannot do is make the privileged install read a host path. +tar -xzOf "${changie_tmp}/${changie_archive}" changie > "${changie_tmp}/changie.bin" +if [ ! -s "${changie_tmp}/changie.bin" ]; then + echo "Extracted changie is empty; refusing to install." >&2 + exit 1 +fi -npm install -g changie \ No newline at end of file +# /usr/local/bin is on PATH for the remote user; ~/.local/bin is not. +"${sudo_cmd[@]}" install -m 0755 "${changie_tmp}/changie.bin" /usr/local/bin/changie diff --git a/src/fabric_cli/__init__.py b/src/fabric_cli/__init__.py index a8209acbc..ad8102238 100644 --- a/src/fabric_cli/__init__.py +++ b/src/fabric_cli/__init__.py @@ -2,4 +2,4 @@ # Licensed under the MIT License. # Don't change this -__version__ = "1.6.1" +__version__ = "1.7.0" diff --git a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py index 1a8e3fbcd..e5e35433b 100644 --- a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py +++ b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py @@ -4,7 +4,13 @@ import json from argparse import Namespace -from fabric_cicd import append_feature_flag, configure_external_file_logging, deploy_with_config, disable_file_logging # type: ignore +from fabric_cicd import ( # type: ignore + append_feature_flag, + configure_external_file_logging, + deploy_with_config, + disable_file_logging, + remove_feature_flag, +) from fabric_cli.core import fab_constant, fab_state_config from fabric_cli.core import fab_logger @@ -37,26 +43,33 @@ def deploy_with_config_file(args: Namespace) -> None: for param in deploy_parameters: if isinstance(deploy_parameters[param], str): try: - deploy_parameters[param] = json.loads( - deploy_parameters[param]) + deploy_parameters[param] = json.loads(deploy_parameters[param]) except json.JSONDecodeError: # If it's not a valid JSON string, keep it as is pass + + deploy_parameters["host_app"] = ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" + ) + result = deploy_with_config( config_file_path=deploy_config_file, environment=args.target_env, token_credential=create_fabric_token_credential(), # MSAL bridge TokenCredential - **deploy_parameters + **deploy_parameters, ) if result: - fab_ui.print_output_format( - args, message=result.message) + fab_ui.print_output_format(args, message=result.message) except Exception as e: raise FabricCLIError( - f"Deployment failed: {str(e)}", - fab_constant.ERROR_IN_DEPLOYMENT) + f"Deployment failed: {str(e)}", fab_constant.ERROR_IN_DEPLOYMENT + ) + finally: + bulk_publish_enabled = getattr(args, "bulk_publish", False) + if bulk_publish_enabled: + _remove_bulk_publish_feature_flags() def _apply_bulk_publish_feature_flags(args: Namespace) -> None: @@ -77,3 +90,9 @@ def _apply_bulk_publish_feature_flags(args: Namespace) -> None: "fabric-cicd and may change or fail; omit the '--bulk_publish' flag " "to use standard per-item publish." ) + + +def _remove_bulk_publish_feature_flags() -> None: + """Remove command-scoped bulk publish flags from fabric-cicd global state.""" + remove_feature_flag("enable_experimental_features") + remove_feature_flag("enable_bulk_publish") diff --git a/tests/test_commands/test_deploy.py b/tests/test_commands/test_deploy.py index 2b8e9e8f6..4697548dc 100644 --- a/tests/test_commands/test_deploy.py +++ b/tests/test_commands/test_deploy.py @@ -3,7 +3,8 @@ import os import platform -from unittest.mock import patch +from argparse import Namespace +from unittest.mock import MagicMock, patch import pytest import yaml @@ -354,3 +355,68 @@ def test_deploy_with_home_directory_path_success( mock_print_done.assert_called() assert "Deployment completed successfully" in str( mock_print_done.call_args) + + def _run_deploy_with_config_file(self, deploy_with_config, params=None): + """Invoke deploy_with_config_file with fabric-cicd symbols patched (no network).""" + from fabric_cli.commands.fs.deploy import ( + fab_fs_deploy_config_file as deploy_mod, + ) + + args = Namespace( + config="config.yml", + target_env="dev", + command_path="deploy", + params=params if params is not None else [], + ) + + with ( + patch.object(deploy_mod, "deploy_with_config", deploy_with_config), + patch.object( + deploy_mod, "create_fabric_token_credential", MagicMock()), + patch.object(deploy_mod, "append_feature_flag", MagicMock()), + patch.object(deploy_mod, "disable_file_logging", MagicMock()), + patch.object( + deploy_mod, "configure_external_file_logging", MagicMock()), + patch.object( + deploy_mod.fab_state_config, "get_config", return_value="false" + ), + patch.object(deploy_mod.fab_ui, + "print_output_format", MagicMock()), + ): + deploy_mod.deploy_with_config_file(args) + + def _capture_deploy_host_app(self, params=None): + """Run deploy_with_config_file and return the host_app passed to fabric-cicd.""" + captured = {} + + def fake_deploy_with_config( + *, + config_file_path, + token_credential, + environment="N/A", + config_override=None, + host_app=None, + ): + captured["host_app"] = host_app + return MagicMock(message="Deployment completed successfully") + + self._run_deploy_with_config_file( + fake_deploy_with_config, params=params) + + return captured["host_app"] + + def test_deploy_passes_host_app_success(self): + """CLI passes host_app as 'ms-fabric-cli/'.""" + from fabric_cli.core import fab_constant + + host_app = self._capture_deploy_host_app() + + assert host_app == f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" + + def test_deploy_host_app_cannot_be_spoofed_via_params_success(self): + """A user-supplied host_app (via -P) is overridden by the CLI-controlled value.""" + from fabric_cli.core import fab_constant + + host_app = self._capture_deploy_host_app(params=["host_app=spoofed"]) + + assert host_app == f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" diff --git a/tests/test_utils/test_fab_deploy_bulk_publish.py b/tests/test_utils/test_fab_deploy_bulk_publish.py index 0886554da..d241fd89d 100644 --- a/tests/test_utils/test_fab_deploy_bulk_publish.py +++ b/tests/test_utils/test_fab_deploy_bulk_publish.py @@ -3,6 +3,10 @@ from unittest.mock import patch +import pytest + +from fabric_cli.core.fab_exceptions import FabricCLIError + class TestDeployBulkPublish: """ @@ -11,26 +15,30 @@ class TestDeployBulkPublish: HTTP cassettes. """ - def _run_deploy(self, tmp_path, bulk_publish, mock_fab_set_state_config): - """Invoke deploy_with_config_file with fabric-cicd mocked, returning the - append_feature_flag mock for assertions.""" + def _create_deploy_args(self, tmp_path, bulk_publish): + """Create arguments for deploy_with_config_file.""" from argparse import Namespace - import fabric_cli.commands.fs.deploy.fab_fs_deploy_config_file as deploy_mod - from fabric_cli.core import fab_constant - - # disable debug mode so fabric-cicd file logging is disabled during the run - mock_fab_set_state_config(fab_constant.FAB_DEBUG_ENABLED, "false") - - args = Namespace( + return Namespace( config=str(tmp_path / "config.yml"), target_env="dev", params=None, bulk_publish=bulk_publish, + command_path="deploy", ) + def _run_deploy_success(self, tmp_path, bulk_publish, mock_fab_set_state_config): + """Run a successful deployment and return feature flag mocks.""" + import fabric_cli.commands.fs.deploy.fab_fs_deploy_config_file as deploy_mod + from fabric_cli.core import fab_constant + + # disable debug mode so fabric-cicd file logging is disabled during the run + mock_fab_set_state_config(fab_constant.FAB_DEBUG_ENABLED, "false") + args = self._create_deploy_args(tmp_path, bulk_publish) + with ( patch.object(deploy_mod, "append_feature_flag") as mock_flag, + patch.object(deploy_mod, "remove_feature_flag") as mock_remove_flag, patch.object(deploy_mod, "deploy_with_config", return_value=None), patch.object(deploy_mod, "disable_file_logging"), patch.object(deploy_mod, "configure_external_file_logging"), @@ -40,28 +48,74 @@ def _run_deploy(self, tmp_path, bulk_publish, mock_fab_set_state_config): ): deploy_mod.deploy_with_config_file(args) - return mock_flag + return mock_flag, mock_remove_flag + + def _run_deploy_failure(self, tmp_path, bulk_publish, mock_fab_set_state_config): + """Run a failed deployment and return feature flag mocks.""" + import fabric_cli.commands.fs.deploy.fab_fs_deploy_config_file as deploy_mod + from fabric_cli.core import fab_constant + + # disable debug mode so fabric-cicd file logging is disabled during the run + mock_fab_set_state_config(fab_constant.FAB_DEBUG_ENABLED, "false") + args = self._create_deploy_args(tmp_path, bulk_publish) + + with ( + patch.object(deploy_mod, "append_feature_flag") as mock_flag, + patch.object(deploy_mod, "remove_feature_flag") as mock_remove_flag, + patch.object( + deploy_mod, + "deploy_with_config", + side_effect=Exception("Simulated deployment failure"), + ), + patch.object(deploy_mod, "disable_file_logging"), + patch.object(deploy_mod, "configure_external_file_logging"), + patch.object( + deploy_mod, "create_fabric_token_credential", return_value=None + ), + ): + with pytest.raises(FabricCLIError): + deploy_mod.deploy_with_config_file(args) + + return mock_flag, mock_remove_flag def test_deploy_bulk_publish_enabled_appends_experimental_flags_success( self, tmp_path, mock_fab_set_state_config ): """When --bulk_publish is set, both experimental bulk publish flags are appended.""" - mock_flag = self._run_deploy(tmp_path, True, mock_fab_set_state_config) + mock_flag, mock_remove_flag = self._run_deploy_success( + tmp_path, True, mock_fab_set_state_config + ) appended = [call.args[0] for call in mock_flag.call_args_list] assert "enable_experimental_features" in appended assert "enable_bulk_publish" in appended # existing behavior is preserved assert "disable_print_identity" in appended + removed = [call.args[0] for call in mock_remove_flag.call_args_list] + assert removed == ["enable_experimental_features", "enable_bulk_publish"] + + def test_deploy_bulk_publish_enabled_removes_flags_on_failure( + self, tmp_path, mock_fab_set_state_config + ): + """Bulk publish flags are removed when deployment raises an exception.""" + _, mock_remove_flag = self._run_deploy_failure( + tmp_path, True, mock_fab_set_state_config + ) + + removed = [call.args[0] for call in mock_remove_flag.call_args_list] + assert removed == ["enable_experimental_features", "enable_bulk_publish"] def test_deploy_bulk_publish_disabled_by_default_omits_flags_success( self, tmp_path, mock_fab_set_state_config ): - """When --bulk_publish is not set (default), bulk publish flags are not appended.""" - mock_flag = self._run_deploy(tmp_path, False, mock_fab_set_state_config) + """When --bulk_publish is not set, bulk publish flags are not changed.""" + mock_flag, mock_remove_flag = self._run_deploy_success( + tmp_path, False, mock_fab_set_state_config + ) appended = [call.args[0] for call in mock_flag.call_args_list] assert "enable_experimental_features" not in appended assert "enable_bulk_publish" not in appended # existing behavior is preserved assert "disable_print_identity" in appended + mock_remove_flag.assert_not_called()