diff --git a/docs/README.md b/docs/README.md index 2bb6f2ad..90900afd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,6 +66,7 @@ docs/ │ ├── multi-team-workflows.md — Selective extraction, CODEOWNERS │ ├── code-first-workflow.md — IDE → git → CI/CD → APIM │ ├── token-substitution.md — Pipeline token/placeholder substitution +│ ├── prompt-files.md — Copilot prompt files for APIOps tasks │ └── migration-from-v1.md — Migrate from Azure/apiops toolkit ├── ci-cd/ │ ├── github-actions.md — GitHub Actions integration diff --git a/docs/commands/init.md b/docs/commands/init.md index 4879c4eb..3c963ae3 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -78,27 +78,28 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| -| `.github/workflows/run-apim-extractor.yml` | Workflow to extract APIM artifacts | -| `.github/workflows/run-apim-publisher.yml` | Workflow to publish artifacts to APIM | +| `.github/workflows/run-apiops-extractor.yml` | Workflow to extract APIM artifacts | +| `.github/workflows/run-apiops-publisher.yml` | Workflow to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment (e.g., `configuration.dev.yaml`, `configuration.prod.yaml`) | -| `IDENTITY-SETUP-GITHUB.md` | Step-by-step guide for configuring federated credentials | +| `.github/prompts/apiops-setup-workflow-identity.prompt.md` | Copilot prompt for GitHub Actions identity setup | +| `APIOPS-WORKFLOW-IDENTITY-SETUP.md` | Step-by-step guide for configuring GitHub Actions Azure access and workflow identity | ### Azure DevOps (`--ci azure-devops`) | File | Purpose | |------|---------| -| `.azdo/pipelines/run-apim-extractor.yml` | Pipeline to extract APIM artifacts | -| `.azdo/pipelines/run-apim-publisher.yml` | Pipeline to publish artifacts to APIM | +| `.azdo/pipelines/run-apiops-extractor.yml` | Pipeline to extract APIM artifacts | +| `.azdo/pipelines/run-apiops-publisher.yml` | Pipeline to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment | -| `IDENTITY-SETUP-AZDO.md` | Step-by-step guide for configuring service connections | +| `.github/prompts/apiops-setup-pipeline-identity.prompt.md` | Copilot prompt for Azure DevOps identity setup | +| `APIOPS-PIPELINE-IDENTITY-SETUP.md` | Step-by-step guide for configuring Azure DevOps pipeline identity and repo permissions | ### Both platforms | File | Purpose | |------|---------| -| `.github/prompts/apiops-setup-identity.prompt.md` | Copilot prompt for identity setup | | `.github/prompts/apiops-configure-filter.prompt.md` | Copilot prompt for creating extraction filter files | | `.github/prompts/apiops-configure-overrides.prompt.md` | Copilot prompt for creating environment override files | | `/` | Empty artifact directory (default: `./apim-artifacts`) | @@ -118,7 +119,7 @@ If you pass `--cli-package `, the tarball is copied into a `.apiops/` dire ## Next steps after init -1. **Set up identity** — Follow the generated `IDENTITY-SETUP-*.md` guide to configure Azure credentials for your CI/CD platform. Or use the `.github/prompts/apiops-setup-identity.prompt.md` Copilot prompt. +1. **Set up identity** — Follow the generated `APIOPS-*-IDENTITY-SETUP.md` guide or provider-specific Copilot prompt to configure Azure credentials for your CI/CD platform. 2. **Extract your first snapshot** — Run [`apiops extract`](./extract.md) to pull your current APIM configuration into the artifact directory. 3. **Configure filters** — Edit `configuration.extractor.yaml` to control which resources are extracted. Use the `.github/prompts/apiops-configure-filter.prompt.md` Copilot prompt for guided setup. 4. **Commit and push** — Check the generated files into version control. diff --git a/docs/guides/prompt-files.md b/docs/guides/prompt-files.md new file mode 100644 index 00000000..d958693b --- /dev/null +++ b/docs/guides/prompt-files.md @@ -0,0 +1,83 @@ +# Prompt Files + +APIOps provides [prompt files](https://code.visualstudio.com/docs/copilot/copilot-customization#_reusable-prompt-files-experimental) that guide GitHub Copilot through common APIOps configuration tasks. These are reusable `.prompt.md` files that give Copilot the context it needs to help you configure extraction filters, environment overrides, and CI/CD identity setup. + +## What are prompt files? + +Prompt files are markdown files with a `.prompt.md` extension that provide instructions and context to GitHub Copilot. When you open a prompt file in VS Code and invoke Copilot, it uses the file's content as context to guide you through a task interactively. + +## Available prompt files + +| File | Description | +|------|-------------| +| `apiops-configure-filter.prompt.md` | Guides Copilot through creating a `configuration.extractor.yaml` filter file to control which API Management resources are extracted. See also: [Filtering resources guide](./filtering-resources.md) | +| `apiops-configure-overrides.prompt.md` | Guides Copilot through creating environment override files (`configuration..yaml`) for promoting APIs across environments. See also: [Environment overrides guide](./environment-overrides.md) | +| `apiops-setup-workflow-identity-github-actions.prompt.md` | Walks through setting up Azure identity (app registration, federated credentials, RBAC) for GitHub Actions CI/CD pipelines. | +| `apiops-setup-workflow-identity-azure-devops.prompt.md` | Walks through setting up Azure identity (app registration, federated credentials, RBAC) for Azure DevOps CI/CD pipelines. | + +## Getting prompt files + +### Via `apiops init` (recommended) + +The easiest way to get prompt files is to run [`apiops init`](../commands/init.md), which scaffolds your repository with prompt files already in place at `.github/prompts/`. + +### Download individually + +If you already have an APIOps repository and want to add prompt files without re-running init, you can download them directly from this repository. + +#### Bash + +```bash +# Resolve repo root so this works from any subdirectory +repo_root="$(git rev-parse --show-toplevel)" + +# Create the prompts directory +mkdir -p "${repo_root}/.github/prompts" + +# Available prompt files — remove any you don't need +files=( + "apiops-configure-filter.prompt.md" + "apiops-configure-overrides.prompt.md" + # Remove the identity setup prompt that doesn't apply to your CI provider: + "apiops-setup-workflow-identity-github-actions.prompt.md" + "apiops-setup-workflow-identity-azure-devops.prompt.md" +) + +base_url="https://raw.githubusercontent.com/Azure/apiops-cli/main/src/templates/copilot" + +for file in "${files[@]}"; do + curl -sL "${base_url}/${file}" -o "${repo_root}/.github/prompts/${file}" + echo "Downloaded ${file}" +done +``` + +#### PowerShell + +```powershell +# Resolve repo root so this works from any subdirectory +$repoRoot = git rev-parse --show-toplevel + +# Create the prompts directory +New-Item -ItemType Directory -Path "$repoRoot/.github/prompts" -Force | Out-Null + +# Available prompt files — remove any you don't need +$files = @( + "apiops-configure-filter.prompt.md" + "apiops-configure-overrides.prompt.md" + # Remove the identity setup prompt that doesn't apply to your CI provider: + "apiops-setup-workflow-identity-github-actions.prompt.md" + "apiops-setup-workflow-identity-azure-devops.prompt.md" +) + +$baseUrl = "https://raw.githubusercontent.com/Azure/apiops-cli/main/src/templates/copilot" + +foreach ($file in $files) { + Invoke-WebRequest -Uri "$baseUrl/$file" -OutFile "$repoRoot/.github/prompts/$file" + Write-Host "Downloaded $file" +} +``` + +## Further reading + +- [VS Code: Reusable prompt files](https://code.visualstudio.com/docs/copilot/copilot-customization#_reusable-prompt-files-experimental) +- [GitHub Copilot in the CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli/using-github-copilot-in-the-cli) diff --git a/docs/walkthrough/air-gapped-azure-devops-local-registry.md b/docs/walkthrough/air-gapped-azure-devops-local-registry.md index eca55db7..c61ade03 100644 --- a/docs/walkthrough/air-gapped-azure-devops-local-registry.md +++ b/docs/walkthrough/air-gapped-azure-devops-local-registry.md @@ -162,11 +162,11 @@ This generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a dependency | -| `pipelines/run-extractor.yaml` | Extract pipeline | -| `pipelines/run-publisher.yaml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md` or run `/apiops-setup-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and service connections. ### 2.2 Generate the Lock File @@ -178,7 +178,7 @@ This creates `package-lock.json`. Commit it — the lock file is **required** fo ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`pipelines/run-extractor.yaml` and `pipelines/run-publisher.yaml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -207,8 +207,8 @@ Commit the files required to run the local-registry workflow on self-hosted agen | `.npmrc` | Points npm to the local Azure Artifacts feed (`registry=...`, `always-auth=true`). | | `package.json` | Declares the CLI dependency. | | `package-lock.json` | Required for deterministic installs with `npm ci`. | -| `pipelines/run-extractor.yaml` | Azure DevOps extract pipeline definition. | -| `pipelines/run-publisher.yaml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apiops-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -216,8 +216,8 @@ git add \ .npmrc \ package.json \ package-lock.json \ - pipelines/run-extractor.yaml \ - pipelines/run-publisher.yaml \ + .azdo/pipelines/run-apiops-extractor.yml \ + .azdo/pipelines/run-apiops-publisher.yml \ configuration.*.yaml git commit -m "chore: commit local-registry apiops bootstrap files" git push @@ -245,7 +245,7 @@ Verify the following: ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and service connections. --- diff --git a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md index 6bda8cbe..763001c9 100644 --- a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md +++ b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md @@ -78,11 +78,11 @@ This command generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a `file:` dependency pointing at the tarball | -| `pipelines/run-extractor.yaml` | Extract pipeline | -| `pipelines/run-publisher.yaml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md` or run `/apiops-setup-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and service connections. ### 2.2 Generate the Lock File and Pre-Stage the npm Cache @@ -97,7 +97,7 @@ npm ci # populates ~/.npm/_cacache/ with every package the lock file refe ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`pipelines/run-extractor.yaml` and `pipelines/run-publisher.yaml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -114,8 +114,8 @@ For the offline-tarball workflow, commit the files that make the pipeline fully | `.apiops/peterhauge-apiops-cli-.tgz` | CLI package consumed by the pipelines. | | `package.json` | Contains the `file:` dependency pointing to the tarball. | | `package-lock.json` | Required for deterministic offline installs with `npm ci --offline`. | -| `pipelines/run-extractor.yaml` | Azure DevOps extract pipeline definition. | -| `pipelines/run-publisher.yaml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apiops-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -123,8 +123,8 @@ git add \ .apiops/peterhauge-apiops-cli-*.tgz \ package.json \ package-lock.json \ - pipelines/run-extractor.yaml \ - pipelines/run-publisher.yaml \ + .azdo/pipelines/run-apiops-extractor.yml \ + .azdo/pipelines/run-apiops-publisher.yml \ configuration.*.yaml git commit -m "chore: commit offline-tarball apiops bootstrap files" git push @@ -164,7 +164,7 @@ tar -xzf npm-cacache.tar.gz -C ~/.npm ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and service connections. ## 5 — Commit and Validate diff --git a/scripts/embed-markdown-templates.mjs b/scripts/embed-markdown-templates.mjs index 9f8af03f..5f4411a8 100644 --- a/scripts/embed-markdown-templates.mjs +++ b/scripts/embed-markdown-templates.mjs @@ -21,10 +21,6 @@ const templates = [ exportName: 'copilotConfigureOverridesPromptTemplate', path: 'src/templates/copilot/configure-overrides-prompt.md', }, - { - exportName: 'azureDevOpsIdentitySetupCoreTemplate', - path: 'src/templates/shared/identity-setup-azure-devops-core.md', - }, { exportName: 'githubActionsIdentityGuideTemplate', path: 'src/templates/identity/identity-guide-github-actions.md', diff --git a/src/cli/init-command.ts b/src/cli/init-command.ts index 7a15e15c..ea040523 100644 --- a/src/cli/init-command.ts +++ b/src/cli/init-command.ts @@ -83,18 +83,18 @@ export function createInitCommand(): Command { generatedFiles.directories.forEach((dir) => logger.info(` - ${dir.startsWith('./') ? dir : './' + dir}`)); // Determine which CI provider was actually used by checking generated files - const isGitHub = allFiles.some((f) => f.includes('IDENTITY-SETUP-GITHUB.md')); + const isGitHub = allFiles.some((f) => f.includes('APIOPS-WORKFLOW-IDENTITY-SETUP.md')); logger.info('\nNext steps:'); logger.info(' 1. Review and customize the generated configuration files'); logger.info(' 2. Commit the generated files to your repository'); logger.info(' 3. Set up CI/CD identity authentication:'); if (isGitHub) { - logger.info(' - Follow ./IDENTITY-SETUP-GITHUB.md for manual setup, OR'); - logger.info(' - Open ./.github/prompts/apiops-setup-identity.prompt.md with GitHub Copilot for guided setup'); + logger.info(' - Follow APIOPS-WORKFLOW-IDENTITY-SETUP.md for manual setup, OR'); + logger.info(' - Use github/prompts/apiops-setup-workflow-identity.prompt.md prompt file with GitHub Copilot for guided setup'); } else { - logger.info(' - Follow ./IDENTITY-SETUP-AZDO.md for manual setup, OR'); - logger.info(' - Open ./.github/prompts/apiops-setup-identity.prompt.md with GitHub Copilot for guided setup'); + logger.info(' - Follow ./APIOPS-PIPELINE-IDENTITY-SETUP.md for manual setup, OR'); + logger.info(' - Open ./.github/prompts/apiops-setup-pipeline-identity.prompt.md with GitHub Copilot for guided setup'); } logger.info(''); } catch (error) { diff --git a/src/services/identity-guide-service.ts b/src/services/identity-guide-service.ts index 366c1839..d8aa8165 100644 --- a/src/services/identity-guide-service.ts +++ b/src/services/identity-guide-service.ts @@ -2,76 +2,26 @@ // Licensed under the MIT license. /** * Identity setup guide generator - * Step-by-step instructions for service principal, RBAC, federated credentials, - * pipeline secrets/service connections. Optional az CLI automation per FR-021. + * Returns the static manual guide content for the selected CI provider. */ import { - azureDevOpsIdentitySetupCoreTemplate, azureDevOpsIdentityGuideTemplate, githubActionsIdentityGuideTemplate, } from '../templates/generated/embedded-markdown.js'; -import { renderTemplate } from '../lib/render-template.js'; export interface IdentityGuideService { - generateGitHubActionsGuide( - subscriptionId: string, - resourceGroup: string, - environments: string[] - ): string; - - generateAzureDevOpsGuide( - environments: string[] - ): string; + generateGitHubActionsGuide(): string; + generateAzureDevOpsGuide(): string; } class IdentityGuideServiceImpl implements IdentityGuideService { - generateGitHubActionsGuide( - subscriptionId: string, - resourceGroup: string, - environments: string[] - ): string { - const federatedCredentialsPerEnvironment = environments.map((env) => `az ad app federated-credential create \\ - --id "$APP_ID" \\ - --parameters '{ - "name": "github-env-${env}", - "issuer": "https://token.actions.githubusercontent.com", - "subject": "repo:'"$GITHUB_ORG"'/'"$GITHUB_REPO"':environment:${env}", - "audiences": ["api://AzureADTokenExchange"] - }'`).join('\n\n'); - - const environmentSecrets = environments.map((env) => ` -**For ${env} environment:** -- \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`: Resource group for ${env} -- \`APIM_SERVICE_NAME_${env.toUpperCase()}\`: APIM service name for ${env} -`).join('\n'); - - return renderTemplate(githubActionsIdentityGuideTemplate, { - SUBSCRIPTION_ID: subscriptionId, - RESOURCE_GROUP: resourceGroup, - FEDERATED_CREDENTIALS_PER_ENV: federatedCredentialsPerEnvironment, - ENVIRONMENT_SECRETS: environmentSecrets, - }); + generateGitHubActionsGuide(): string { + return githubActionsIdentityGuideTemplate; } - generateAzureDevOpsGuide( - environments: string[] - ): string { - const environmentsArrayPowerShell = environments - .map((environment) => `"${environment}"`) - .join(', '); - const environmentsArrayBash = environments - .map((environment) => `"${environment}"`) - .join(' '); - - const coreSteps = renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { - ENVIRONMENTS_ARRAY_POWERSHELL: environmentsArrayPowerShell, - ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, - }); - - return renderTemplate(azureDevOpsIdentityGuideTemplate, { - AZURE_DEVOPS_CORE_STEPS: coreSteps, - }); + generateAzureDevOpsGuide(): string { + return azureDevOpsIdentityGuideTemplate; } } diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 210d6637..b3d8e865 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -35,10 +35,6 @@ import { generateIdentitySetupPrompt } from '../templates/copilot/identity-setup import { generateConfigureFilterPrompt } from '../templates/copilot/configure-filter-prompt.js'; import { generateConfigureOverridesPrompt } from '../templates/copilot/configure-overrides-prompt.js'; -/** Placeholder values used in generated identity setup guides */ -const PLACEHOLDER_SUBSCRIPTION_ID = ''; -const PLACEHOLDER_RESOURCE_GROUP = ''; - export interface GeneratedFiles { pipelines: string[]; configs: string[]; @@ -138,15 +134,15 @@ class InitServiceImpl implements InitService { if (config.ciProvider === 'github-actions') { const extractWorkflow = path.join( config.outputDir, - '.github/workflows/run-apim-extractor.yml' + '.github/workflows/run-apiops-extractor.yml' ); const publishWorkflow = path.join( config.outputDir, - '.github/workflows/run-apim-publisher.yml' + '.github/workflows/run-apiops-publisher.yml' ); const promptFile = path.join( config.outputDir, - '.github/prompts/apiops-setup-identity.prompt.md' + '.github/prompts/apiops-setup-workflow-identity.prompt.md' ); const filterPromptFile = path.join( config.outputDir, @@ -158,7 +154,7 @@ class InitServiceImpl implements InitService { ); const identityGuide = path.join( config.outputDir, - 'IDENTITY-SETUP-GITHUB.md' + 'APIOPS-WORKFLOW-IDENTITY-SETUP.md' ); if (await this.fileExists(extractWorkflow)) { @@ -182,19 +178,19 @@ class InitServiceImpl implements InitService { } else if (config.ciProvider === 'azure-devops') { const extractPipeline = path.join( config.outputDir, - '.azdo/pipelines/run-apim-extractor.yml' + '.azdo/pipelines/run-apiops-extractor.yml' ); const publishPipeline = path.join( config.outputDir, - '.azdo/pipelines/run-apim-publisher.yml' + '.azdo/pipelines/run-apiops-publisher.yml' ); const identityGuide = path.join( config.outputDir, - 'IDENTITY-SETUP-AZDO.md' + 'APIOPS-PIPELINE-IDENTITY-SETUP.md' ); const promptFile = path.join( config.outputDir, - '.github/prompts/apiops-setup-identity.prompt.md' + '.github/prompts/apiops-setup-pipeline-identity.prompt.md' ); const filterPromptFile = path.join( config.outputDir, @@ -332,9 +328,9 @@ class InitServiceImpl implements InitService { artifactDir: config.artifactDir, }; const extractContent = generateExtractWorkflow(extractWorkflowConfig); - const extractPath = path.join(workflowsDir, 'run-apim-extractor.yml'); + const extractPath = path.join(workflowsDir, 'run-apiops-extractor.yml'); await fs.writeFile(extractPath, extractContent); - generatedFiles.pipelines.push('.github/workflows/run-apim-extractor.yml'); + generatedFiles.pipelines.push('.github/workflows/run-apiops-extractor.yml'); // Publish workflow const publishWorkflowConfig: PublishWorkflowConfig = { @@ -342,9 +338,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishWorkflow(publishWorkflowConfig); - const publishPath = path.join(workflowsDir, 'run-apim-publisher.yml'); + const publishPath = path.join(workflowsDir, 'run-apiops-publisher.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.github/workflows/run-apim-publisher.yml'); + generatedFiles.pipelines.push('.github/workflows/run-apiops-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); @@ -366,9 +362,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const extractContent = generateExtractPipeline(extractPipelineConfig); - const extractPath = path.join(pipelinesDir, 'run-apim-extractor.yml'); + const extractPath = path.join(pipelinesDir, 'run-apiops-extractor.yml'); await fs.writeFile(extractPath, extractContent); - generatedFiles.pipelines.push('.azdo/pipelines/run-apim-extractor.yml'); + generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-extractor.yml'); // Publish pipeline const publishPipelineConfig: PublishPipelineConfig = { @@ -376,9 +372,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishPipeline(publishPipelineConfig); - const publishPath = path.join(pipelinesDir, 'run-apim-publisher.yml'); + const publishPath = path.join(pipelinesDir, 'run-apiops-publisher.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.azdo/pipelines/run-apim-publisher.yml'); + generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); @@ -394,9 +390,12 @@ class InitServiceImpl implements InitService { }); const promptsDir = path.join(config.outputDir, '.github/prompts'); await fs.mkdir(promptsDir, { recursive: true }); - const promptPath = path.join(promptsDir, 'apiops-setup-identity.prompt.md'); + const promptFileName = config.ciProvider === 'github-actions' + ? 'apiops-setup-workflow-identity.prompt.md' + : 'apiops-setup-pipeline-identity.prompt.md'; + const promptPath = path.join(promptsDir, promptFileName); await fs.writeFile(promptPath, promptContent); - generatedFiles.configs.push('.github/prompts/apiops-setup-identity.prompt.md'); + generatedFiles.configs.push(`.github/prompts/${promptFileName}`); } private async generateCopilotConfigurationPrompts( @@ -450,26 +449,18 @@ class InitServiceImpl implements InitService { * Save identity setup guide to file and tell user where to find it */ private async outputIdentityGuide(config: InitConfig, generatedFiles: GeneratedFiles): Promise { - // Use placeholder values for the guide — users replace these with their actual Azure details - const subscriptionId = PLACEHOLDER_SUBSCRIPTION_ID; - const resourceGroup = PLACEHOLDER_RESOURCE_GROUP; - let guide: string; if (config.ciProvider === 'github-actions') { - guide = identityGuideService.generateGitHubActionsGuide( - subscriptionId, - resourceGroup, - config.environments - ); + guide = identityGuideService.generateGitHubActionsGuide(); } else { - guide = identityGuideService.generateAzureDevOpsGuide(config.environments); + guide = identityGuideService.generateAzureDevOpsGuide(); } // Save guide to file const guideFileName = config.ciProvider === 'github-actions' - ? 'IDENTITY-SETUP-GITHUB.md' - : 'IDENTITY-SETUP-AZDO.md'; + ? 'APIOPS-WORKFLOW-IDENTITY-SETUP.md' + : 'APIOPS-PIPELINE-IDENTITY-SETUP.md'; const guidePath = path.join(config.outputDir, guideFileName); await fs.writeFile(guidePath, guide); generatedFiles.configs.push(guideFileName); diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 9db87a19..82b053db 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -4,13 +4,472 @@ > Copilot to help you run through the steps. Copilot will prompt you for > required values and generate exact CLI commands for your environment. +> **Important identity distinction:** The Azure app registration/service principal created in this flow is only for Azure and APIM access. Repository contributions and pull request creation come from the Azure DevOps Build Service identity, which must be granted repo permissions separately. + +## Agent Behavior + +- **One step at a time.** Complete each step fully before moving to the next. +- **Confirm information.** After gathering user input, summarize what was provided and ask the user to confirm it is correct before proceeding. +- **Ask before proceeding.** At the end of each step, ask: "Step N is complete. Ready to proceed to Step N+1?" +- **Never combine steps.** Do not run commands from multiple steps together, even if they could be batched. +- **Stop on errors.** If any command fails, show the full error output and wait for the user to decide how to proceed. + ## Goal Configure workload identity federation (OIDC), Azure DevOps federated service connections, and variable groups for APIOps extract and publish pipelines. -{{AZURE_DEVOPS_CORE_STEPS}} +## Prerequisites +- Azure DevOps organization and project +- Azure CLI installed and authenticated (`az login`) +- `az devops` extension (`az extension add --name azure-devops`) + +This flow is designed for Microsoft-hosted or self-hosted agents and uses workload identity federation (OIDC) instead of managed identity. + +> **Note:** All commands are shown for both **PowerShell** and **Git Bash** where syntax differs. + +--- + +## Step 1: Gather Per-Environment Information + +**Copilot:** Ask the user for the following values before proceeding. Store each answer for use in later steps. + +For each environment, provide either **Option A** (three separate values) or **Option B** (a single full APIM resource ID in the form `/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/`). Copilot will parse Option B into the individual components automatically. + +| Variable | Description | Example | +|----------|-------------|---------| +| `APP_NAME` | Display name for the Entra application | `apiops-azdo-sp` | +| `AZDO_ORG` | Azure DevOps organization URL | `https://dev.azure.com/my-org` | +| `AZDO_PROJECT` | Azure DevOps project name | `my-project` | +| `TENANT_ID` | Tenant ID that should own the app/service connection | `11111111-2222-3333-4444-555555555555` | + +For each environment in the configured list, gather these values (where `` is the upper-case environment name): +- `APIM_SUBSCRIPTION_` +- `APIM_RG_` +- `APIM_NAME_` +- `APIM_RESOURCE_ID_` *(optional Option B shorthand)* + +**Copilot:** After collecting all values, present a summary table and ask: "Please confirm these values are correct before I proceed." + +--- + +## Step 2: Set Variables + +**PowerShell:** +```powershell +$APP_NAME = "apiops-azdo-sp" +$AZDO_ORG = "" +$AZDO_PROJECT = "" +$TENANT_ID = "" +$ENVIRONMENTS = @({{ENVIRONMENTS_ARRAY_POWERSHELL}}) + +# Fill these maps with values for each environment. +$APIM_SUBSCRIPTIONS = @{} +$APIM_RESOURCE_GROUPS = @{} +$APIM_SERVICE_NAMES = @{} + +foreach ($env in $ENVIRONMENTS) { + # Option A: provide values directly. + $APIM_SUBSCRIPTIONS[$env] = "" + $APIM_RESOURCE_GROUPS[$env] = "" + $APIM_SERVICE_NAMES[$env] = "" + + # Option B: if APIM resource ID is provided, parse it into the same maps. + # $resourceId = "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" + # if ($resourceId) { + # $parts = $resourceId.Trim('/') -split '/' + # $APIM_SUBSCRIPTIONS[$env] = $parts[1] + # $APIM_RESOURCE_GROUPS[$env] = $parts[3] + # $APIM_SERVICE_NAMES[$env] = $parts[7] + # } +} +``` + +**Git Bash:** +```bash +APP_NAME="apiops-azdo-sp" +AZDO_ORG="" +AZDO_PROJECT="" +TENANT_ID="" +ENVIRONMENTS=({{ENVIRONMENTS_ARRAY_BASH}}) + +# Fill these maps with values for each environment. +declare -A APIM_SUBSCRIPTIONS +declare -A APIM_RESOURCE_GROUPS +declare -A APIM_SERVICE_NAMES + +for env in "${ENVIRONMENTS[@]}"; do + # Option A: provide values directly. + APIM_SUBSCRIPTIONS["$env"]="" + APIM_RESOURCE_GROUPS["$env"]="" + APIM_SERVICE_NAMES["$env"]="" + + # Option B: if APIM resource ID is provided, parse it into the same maps. + # resource_id="/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" + # if [[ -n "$resource_id" ]]; then + # IFS='/' read -r _ subscriptions sub resourceGroups rg providers provider service svc <<< "$resource_id" + # APIM_SUBSCRIPTIONS["$env"]="$sub" + # APIM_RESOURCE_GROUPS["$env"]="$rg" + # APIM_SERVICE_NAMES["$env"]="$svc" + # fi +done +``` + +--- + +## Step 3: Configure Azure DevOps CLI + +Install the extension (works in both shells): +```bash +az extension add --name azure-devops +``` + +Install the Azure DevOps Replace Tokens extension (required by publish pipeline): + +**PowerShell:** +```powershell +az devops extension install --publisher-id qetza --extension-id replacetokens +``` + +**Git Bash:** +```bash +az devops extension install --publisher-id qetza --extension-id replacetokens +``` + +Set organization defaults: + +For self-hosted Azure DevOps Server, use your server/collection URL format: +- `https:///` + +**PowerShell:** +```powershell +az devops configure --defaults organization=$AZDO_ORG project=$AZDO_PROJECT +$ORG_NAME = $AZDO_ORG -replace 'https://dev\.azure\.com/', '' +``` + +**Git Bash:** +```bash +az devops configure --defaults organization="$AZDO_ORG" project="$AZDO_PROJECT" +ORG_NAME="${AZDO_ORG##*/}" +``` + +**Self-hosted note:** If your server URL includes a collection segment (for example, `https://ado.contoso.local/DefaultCollection`), set `ORG_NAME` to the value expected in the Build Service identity display name for your server/project. + +--- + +## Step 4: Verify Tenant ID + +Before creating identity objects, confirm you are logged into the intended tenant. + +**PowerShell:** +```powershell +$CURRENT_TENANT_ID = az account show --query tenantId -o tsv +Write-Host "Current tenant: $CURRENT_TENANT_ID" +if ($CURRENT_TENANT_ID -ne $TENANT_ID) { + throw "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID. Run: az login --tenant $TENANT_ID" +} +``` + +**Git Bash:** +```bash +CURRENT_TENANT_ID=$(az account show --query tenantId -o tsv) +echo "Current tenant: $CURRENT_TENANT_ID" +if [[ "$CURRENT_TENANT_ID" != "$TENANT_ID" ]]; then + echo "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID" + echo "Run: az login --tenant $TENANT_ID" + exit 1 +fi +``` + +--- + +## Step 5: Create Entra Application and Service Principal (No Secret) + +> ⚠️ **Error Handling:** If any command fails, stop immediately and show the user the full error output verbatim. Do NOT retry silently. + +**PowerShell:** +```powershell +az ad app create --display-name $APP_NAME | Out-Null +$APP_ID = az ad app list --display-name $APP_NAME --query "[0].appId" -o tsv +az ad sp create --id $APP_ID | Out-Null +Write-Host "App ID: $APP_ID" +Write-Host "Tenant ID: $TENANT_ID" +``` + +**Git Bash:** +```bash +az ad app create --display-name "$APP_NAME" >/dev/null +APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv) +az ad sp create --id "$APP_ID" >/dev/null +echo "App ID: $APP_ID" +echo "Tenant ID: $TENANT_ID" +``` + +No client secret is required in this flow. + +--- + +## Step 6: Assign RBAC Roles Per Environment + +Grant the service principal **Reader** on each resource group and **API Management Service Contributor** on each APIM instance. + +### PowerShell +```powershell +foreach ($env in $ENVIRONMENTS) { + az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])" + az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])/providers/Microsoft.ApiManagement/service/$($APIM_SERVICE_NAMES[$env])" +} +``` + +### Git Bash +```bash +for env in "${ENVIRONMENTS[@]}"; do + az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}" + az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}/providers/Microsoft.ApiManagement/service/${APIM_SERVICE_NAMES[$env]}" +done +``` + +--- + +## Step 7: Create Workload Identity Federation Service Connections + +Create one service connection per environment, each scoped to that environment's subscription, +using Azure Resource Manager with Workload Identity Federation. + +This step is fully automatable with `az devops service-endpoint create`. +After each endpoint is created, capture its generated issuer/subject and create +the corresponding federated credential in Entra. + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + $name = "AZURE_SERVICE_CONNECTION_$envUpper" + $subscriptionName = az account show --subscription $($APIM_SUBSCRIPTIONS[$env]) --query name -o tsv + + $payload = @{ + name = $name + type = "azurerm" + url = "https://management.azure.com/" + authorization = @{ + scheme = "WorkloadIdentityFederation" + parameters = @{ + tenantid = $TENANT_ID + serviceprincipalid = $APP_ID + } + } + data = @{ + environment = "AzureCloud" + identityType = "AppRegistrationManual" + scopeLevel = "Subscription" + subscriptionId = $APIM_SUBSCRIPTIONS[$env] + subscriptionName = $subscriptionName + } + } | ConvertTo-Json -Depth 8 + + $file = "se-$env.json" + $payload | Out-File -Encoding utf8 -FilePath $file + az devops service-endpoint create --service-endpoint-configuration $file | Out-Null + Remove-Item $file -ErrorAction SilentlyContinue + + $endpoint = az devops service-endpoint list --query "[?name=='$name'] | [0]" -o json | ConvertFrom-Json + $issuer = $endpoint.authorization.parameters.workloadIdentityFederationIssuer + $subject = $endpoint.authorization.parameters.workloadIdentityFederationSubject + + $FED_CRED_NAME = "azdo-$env" + + $payload = @{ + name = $FED_CRED_NAME + issuer = $issuer + subject = $subject + audiences = @("api://AzureADTokenExchange") + } | ConvertTo-Json -Depth 5 + + az ad app federated-credential create --id $APP_ID --parameters $payload +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + name="AZURE_SERVICE_CONNECTION_$env_upper" + subscription_name=$(az account show --subscription "${APIM_SUBSCRIPTIONS[$env]}" --query name -o tsv) + + cat > "se-$env.json" </dev/null + rm -f "se-$env.json" + + issuer=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationIssuer" -o tsv) + subject=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationSubject" -o tsv) + + FED_CRED_NAME="azdo-$env" + + az ad app federated-credential create \ + --id "$APP_ID" \ + --parameters "{\"name\":\"$FED_CRED_NAME\",\"issuer\":\"$issuer\",\"subject\":\"$subject\",\"audiences\":[\"api://AzureADTokenExchange\"]}" +done +``` + +Authorize service connections for all pipelines (prevents first-run permission prompts): + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + $name = "AZURE_SERVICE_CONNECTION_$envUpper" + $id = az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv + if ($id) { + az devops service-endpoint update --id $id --enable-for-all true | Out-Null + } +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + name="AZURE_SERVICE_CONNECTION_$env_upper" + id=$(az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv) + if [[ -n "$id" ]]; then + az devops service-endpoint update --id "$id" --enable-for-all true >/dev/null + fi +done +``` + +Verify: +```bash +az devops service-endpoint list --query "[].name" -o table +``` + +--- + +## Step 8: Create Variable Groups + +Create one variable group per environment. Each group includes the extractor pipeline's **non-suffixed** variables (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`) plus the publish pipeline's environment-suffixed APIM variables (`APIM_RESOURCE_GROUP_`, `APIM_SERVICE_NAME_`). + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) APIM_RESOURCE_GROUP_$envUpper=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME_$envUpper=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" APIM_RESOURCE_GROUP_$env_upper="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME_$env_upper="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" +done +``` + +Authorize all groups for pipeline use: + +**PowerShell:** +```powershell +$groupIds = az pipelines variable-group list --query "[].id" -o tsv +foreach ($id in $groupIds) { + az pipelines variable-group update --group-id $id --authorize true +} +``` + +**Git Bash:** +```bash +for id in $(az pipelines variable-group list --query "[].id" -o tsv); do + az pipelines variable-group update --group-id "$id" --authorize true +done +``` + +Verify: +```bash +az pipelines variable-group list --query "[].name" -o table +``` + +--- + +## Step 9 (in pipeline): Create Environments + +Create deployment environments in Azure DevOps: + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $body = "{\"name\": \"$env\"}" + $body | Out-File -Encoding utf8 -FilePath env-body.json + az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method POST --api-version 7.1 --in-file env-body.json +} +Remove-Item env-body.json -ErrorAction SilentlyContinue +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + echo "{\"name\": \"$env\"}" > env-body.json + az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method POST --api-version 7.1 --in-file env-body.json +done +rm -f env-body.json +``` + +Authorize each environment for all pipelines (prevents first-run permission prompts): + +**PowerShell:** +```powershell +$ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798" +$TOKEN = az account get-access-token --resource $ADO_RESOURCE --query accessToken -o tsv + +foreach ($env in $ENVIRONMENTS) { + $envId = az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv + if ($envId) { + $url = "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$envId?api-version=7.1-preview.1" + $body = '{"allPipelines":{"authorized":true}}' + Invoke-RestMethod -Method Patch -Uri $url -Headers @{ Authorization = "Bearer $TOKEN" } -ContentType "application/json" -Body $body | Out-Null + } +} +``` + +**Git Bash:** +```bash +ADO_RESOURCE="499b84ac-1321-427f-aa17-267ca6975798" +TOKEN=$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv) + +for env in "${ENVIRONMENTS[@]}"; do + env_id=$(az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv) + if [[ -n "$env_id" ]]; then + curl -sS -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$env_id?api-version=7.1-preview.1" \ + -d '{"allPipelines":{"authorized":true}}' >/dev/null + fi +done +``` + +**Note:** Environment approvals and checks still must be configured via the Azure DevOps UI. + +--- ## Step 10: Enable Pipeline Contributions @@ -73,16 +532,16 @@ Create Azure Pipelines from the YAML files in your repository. ```powershell $REPO_NAME = $AZDO_PROJECT -az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-publisher.yml" --repository-type tfsgit --skip-first-run true ``` **Git Bash:** ```bash REPO_NAME="$AZDO_PROJECT" -az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-publisher.yml" --repository-type tfsgit --skip-first-run true ``` Verify pipelines were created: diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index e2f28e31..f06c215a 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -4,6 +4,16 @@ > Copilot to help you run through the steps. Copilot will prompt you for > the required values and generate the exact CLI commands for your environment. +> **Important identity distinction:** `GITHUB_TOKEN` handles pull request creation automatically. The Azure app registration/service principal in this flow is only for Azure and APIM access. + +## Agent Behavior + +- **One step at a time.** Complete each step fully before moving to the next. +- **Confirm information.** After gathering user input, summarize what was provided and ask the user to confirm it is correct before proceeding. +- **Ask before proceeding.** At the end of each step, ask: "Step N is complete. Ready to proceed to Step N+1?" +- **Never combine steps.** Do not run commands from multiple steps together, even if they could be batched. +- **Stop on errors.** If any command fails, show the full error output and wait for the user to decide how to proceed. + ## Goal Configure Azure AD federated credentials and GitHub repository secrets so the @@ -84,6 +94,8 @@ each answer for use in later steps. | `APP_NAME` | Display name for the Azure AD application | `apiops-github-sp` | {{ENV_APIM_TABLE_ROWS}} +**Copilot:** After collecting all values, present a summary table and ask: "Please confirm these values are correct before I proceed." + --- ## Step 2 — Create Azure AD Application & Service Principal diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 41734811..39230bb4 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -10,7 +10,6 @@ */ import { - azureDevOpsIdentitySetupCoreTemplate, copilotAzureDevOpsIdentitySetupPromptTemplate, copilotGithubEnvironmentFederatedCredentialTemplate, copilotGithubEnvironmentSecretCommandsTemplate, @@ -32,14 +31,10 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig): .map((environment) => `"${environment}"`) .join(' '); - const coreSteps = renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { + return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { ENVIRONMENTS_ARRAY_POWERSHELL: environmentsArrayPowerShell, ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, }); - - return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { - AZURE_DEVOPS_CORE_STEPS: coreSteps, - }); } const envSecrets = config.environments.map((env) => diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 0643b2c1..602b8120 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -1,164 +1,128 @@ -# Azure DevOps Identity Setup Guide +# Identity setup guide for APIOps extract and publish Azure DevOps Pipelines -{{AZURE_DEVOPS_CORE_STEPS}} +> Prefer an automated walkthrough? Open `.github/prompts/apiops-setup-pipeline-identity.prompt.md` in VS Code with GitHub Copilot and ask Copilot to guide or automate the setup for you. -## Step 10: Enable Pipeline Contributions +> **Important identity distinction:** In Azure DevOps, the Azure app registration/service principal is for Azure and APIM access only. Repository contributions and pull request creation come from the project **Build Service identity**, which must be granted repository permissions separately. -Grant the Build Service permission to contribute to the repository. This allows pipelines to push commits (e.g., extracted API artifacts). +## Before you start -First, get the project and repository IDs: +- Access to the **Azure portal** +- Access to the **Azure DevOps web portal** +- Permission to create or manage app registrations in Microsoft Entra ID +- Permission to create Azure DevOps service connections, variable groups, environments, and pipelines -**PowerShell:** -```powershell -$PROJECT_ID = az devops project show --project $AZDO_PROJECT --query id -o tsv -$REPO_NAME = $AZDO_PROJECT # Change if your repo name differs from project name -$REPO_ID = az repos show --repository $REPO_NAME --query id -o tsv -``` +Helpful documentation: -**Git Bash:** -```bash -PROJECT_ID=$(az devops project show --project "$AZDO_PROJECT" --query id -o tsv) -REPO_NAME="$AZDO_PROJECT" # Change if your repo name differs from project name -REPO_ID=$(az repos show --repository "$REPO_NAME" --query id -o tsv) -``` +- [Create and manage app registrations in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) +- [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) +- [Create Azure Resource Manager service connections with workload identity federation](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure) +- [Create variable groups in Azure Pipelines](https://learn.microsoft.com/azure/devops/pipelines/library/variable-groups) +- [Set repository permissions in Azure DevOps](https://learn.microsoft.com/azure/devops/repos/git/set-git-repository-permissions) -Next, find the Build Service identity descriptor: +## Step 1: Review the generated pipeline files in your repo -**PowerShell:** -```powershell -$GRAPH_USERS = az devops invoke --area graph --resource users --query-parameters 'api-version=7.1-preview.1' --http-method GET -o json | ConvertFrom-Json -$BUILD_SERVICE_NAME = "$AZDO_PROJECT Build Service ($ORG_NAME)" -$BUILD_SERVICE_DESCRIPTOR = ($GRAPH_USERS.value | Where-Object { $_.displayName -eq $BUILD_SERVICE_NAME }).descriptor -``` +1. Commit and push the generated files to your Azure DevOps repository. +2. Confirm these files are available in the repo browser: + - `.azdo/pipelines/run-apiops-extractor.yml` + - `.azdo/pipelines/run-apiops-publisher.yml` +3. **IMPORTANT:** Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. -**Git Bash:** -```bash -BUILD_SERVICE_NAME="$AZDO_PROJECT Build Service ($ORG_NAME)" -BUILD_SERVICE_DESCRIPTOR=$(az devops invoke --area graph --resource users --query-parameters 'api-version=7.1-preview.1' --http-method GET -o json | grep -B5 "\"displayName\": \"$BUILD_SERVICE_NAME\"" | grep '"descriptor"' | head -1 | cut -d'"' -f4) -``` +## Step 2: Create or choose an app registration in Azure portal -Finally, grant the Contribute permission (bit 4) on the repository: +> 📖 [Register an application in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) -**PowerShell:** -```powershell -$GIT_REPOS_NAMESPACE = az devops security permission namespace list --query "[?name=='Git Repositories'].namespaceId" -o tsv -$TOKEN = "repoV2/$PROJECT_ID/$REPO_ID" -az devops security permission update --namespace-id $GIT_REPOS_NAMESPACE --subject $BUILD_SERVICE_DESCRIPTOR --token $TOKEN --allow-bit 4 -``` +1. Open the **Azure portal**. +2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. +3. Enter a friendly name such as `apiops-azdo-sp`. +4. Register the application and open its overview page. +5. Record: + - **Application (client) ID** + - **Directory (tenant) ID** +6. You do not need to create a client secret for this workload identity federation flow. -**Git Bash:** -```bash -GIT_REPOS_NAMESPACE=$(az devops security permission namespace list --query "[?name=='Git Repositories'].namespaceId" -o tsv) -TOKEN="repoV2/$PROJECT_ID/$REPO_ID" -az devops security permission update --namespace-id "$GIT_REPOS_NAMESPACE" --subject "$BUILD_SERVICE_DESCRIPTOR" --token "$TOKEN" --allow-bit 4 -``` +## Step 3: Grant the Azure identity access to each APIM environment -Verify the permission was set: +> 📖 [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) -**PowerShell:** -```powershell -az devops security permission show --namespace-id $GIT_REPOS_NAMESPACE --subject $BUILD_SERVICE_DESCRIPTOR --token $TOKEN --query "[].acesDictionary.*.resolvedPermissions" -o json -``` +For each environment, use the **Azure portal** to grant: -**Git Bash:** -```bash -az devops security permission show --namespace-id "$GIT_REPOS_NAMESPACE" --subject "$BUILD_SERVICE_DESCRIPTOR" --token "$TOKEN" --query "[].acesDictionary.*.resolvedPermissions" -o json -``` +- **Reader** on the resource group that contains the APIM instance +- **API Management Service Contributor** on the APIM service itself ---- +Use **Access control (IAM)** on each resource group and APIM instance to create the assignments. -## Step 11: Verify Setup +## Step 4: Create one Azure service connection per environment -Verify all resources were created correctly: +> 📖 [Create an Azure Resource Manager service connection using workload identity federation](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure) -**Service Connections:** -```bash -az devops service-endpoint list --query "[].name" -o table -``` +For each environment, do the following steps: -**Variable Groups:** -```bash -az pipelines variable-group list --query "[].name" -o table -``` +1. In **Azure DevOps**, open **Project settings** → **Service connections** → **New service connection**. +2. Choose **Azure Resource Manager**. +3. Select **Workload identity federation**. +4. Use the app registration from Step 2. +5. Name the service connection `AZURE_SERVICE_CONNECTION_` (replace `` with the upper-case environment name) unless you plan to edit the generated pipeline YAML. +6. Complete the validation flow in Azure DevOps so the service connection can issue the issuer and subject values required for federation. +7. If Azure DevOps asks you to finish the federated credential in Azure, follow the linked experience or copy the issuer/subject into the app registration's **Federated credentials** blade in the Azure portal. -**Environments:** +## Step 5: Create variable groups in Azure DevOps -**PowerShell:** -```powershell -(az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method GET --api-version 7.1 -o json | ConvertFrom-Json).value | Select-Object name -``` +> 📖 [Add and use variable groups in Azure Pipelines](https://learn.microsoft.com/azure/devops/pipelines/library/variable-groups) -**Git Bash:** -```bash -az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method GET --api-version 7.1 -o json | grep -o '"name": *"[^"]*"' | cut -d'"' -f4 -``` +For each environment, do the following steps: -**Service Principal Role Assignment:** +1. Go to **Pipelines** → **Library** → **+ Variable group**. +2. Name the variable group `apim-` (e.g. `apim-dev`, `apim-prod`). +3. Add the following variables: + - `AZURE_SERVICE_CONNECTION` — the service connection name from Step 4 + - `AZURE_SUBSCRIPTION_ID` — the Azure subscription ID for that environment + - `APIM_RESOURCE_GROUP` — the resource group containing the APIM instance + - `APIM_SERVICE_NAME` — the APIM service name + - `APIM_RESOURCE_GROUP_` — same value, upper-case environment suffix (used by the publish pipeline) + - `APIM_SERVICE_NAME_` — same value, upper-case environment suffix (used by the publish pipeline) +4. Authorize each variable group for pipeline use when Azure DevOps prompts you. -**PowerShell:** -```powershell -az role assignment list --assignee $APP_ID --query "[].{Role:roleDefinitionName, Scope:scope}" -o table -``` +## Step 6: Create Azure DevOps environments and approvals -**Git Bash:** -```bash -az role assignment list --assignee "$APP_ID" --query "[].{Role:roleDefinitionName, Scope:scope}" -o table -``` +> 📖 [Create and target an environment](https://learn.microsoft.com/azure/devops/pipelines/process/environments) -**Final Test:** Run the extract pipeline manually to verify end-to-end authentication and permissions. +1. Go to **Pipelines** → **Environments**. +2. Create an environment for each deployment target used by the publish pipeline. +3. Add approvals and checks for production or other protected environments as needed by your release process. ---- +## Step 7: Grant repository permissions to the Build Service identity -## Step 12: Create Pipelines +> 📖 [Set repository permissions in Azure DevOps](https://learn.microsoft.com/azure/devops/repos/git/set-git-repository-permissions) -Create Azure Pipelines from the YAML files in your repository. +1. In **Azure DevOps**, open **Project settings** → **Repositories** → your repository → **Security**. +2. Find the entry named **` Build Service ()`**. +3. Grant the minimum permissions required for your workflow, typically: + - **Contribute** + - **Create branch** + - **Create pull request** +4. If the pipeline can read APIM but cannot push extracted artifacts or open a PR, this is usually the missing step because the Build Service identity is separate from the Azure app registration. -**Prerequisites:** Ensure your pipeline YAML files are committed to the repository (e.g., `.azdo/pipelines/run-apim-extractor.yml`, `.azdo/pipelines/run-apim-publisher.yml`). +## Step 8: Create the pipelines in Azure DevOps -**Create Extract Pipeline:** +> 📖 [Create your first pipeline](https://learn.microsoft.com/azure/devops/pipelines/create-first-pipeline) -**PowerShell:** -```powershell -az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -``` +1. Go to **Pipelines** → **Create Pipeline**. +2. Choose your repository. +3. Select **Existing Azure Pipelines YAML file**. +4. Create a pipeline for each generated YAML file: + - `.azdo/pipelines/run-apiops-extractor.yml` + - `.azdo/pipelines/run-apiops-publisher.yml` +5. Save without running if you still need approvals or variable authorization. -**Git Bash:** -```bash -az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -``` +## Step 9: Verify the setup -**Create Publish Pipeline:** +1. Run the extractor pipeline manually. +2. Confirm the pipeline can authenticate to Azure and read/write the expected APIM resources. +3. Confirm it can push artifacts and create a pull request in Azure DevOps. +4. If Azure access works but PR creation fails, re-check repository permissions for the Build Service identity. -**PowerShell:** -```powershell -az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true -``` +## Security notes -**Git Bash:** -```bash -az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true -``` - -**Verify pipelines were created:** -```bash -az pipelines list --query "[].name" -o table -``` - -**Run the extract pipeline:** - -**PowerShell:** -```powershell -az pipelines run --name "apiops-extract" -``` - -**Git Bash:** -```bash -az pipelines run --name "apiops-extract" -``` - ---- - -## Security Notes -- Enable environment approvals for production deployments -- Prefer workload identity federation (OIDC) over client secrets -- Review RBAC assignments regularly +- Prefer workload identity federation over client secrets. +- Scope Azure roles and service connections only to the subscriptions and APIM instances each environment needs. +- Review Build Service repository permissions regularly and remove extra rights if they are no longer needed. diff --git a/src/templates/identity/identity-guide-github-actions.md b/src/templates/identity/identity-guide-github-actions.md index 4cf2c5ca..e80e2422 100644 --- a/src/templates/identity/identity-guide-github-actions.md +++ b/src/templates/identity/identity-guide-github-actions.md @@ -1,96 +1,104 @@ -# GitHub Actions Identity Setup Guide +# APIOps GitHub Actions identity setup guide -## Prerequisites -- Azure subscription: {{SUBSCRIPTION_ID}} -- Resource group: {{RESOURCE_GROUP}} -- GitHub repository with OIDC enabled +> Prefer an automated walkthrough? Open `.github/prompts/apiops-setup-workflow-identity.prompt.md` in VS Code with GitHub Copilot and ask Copilot to guide or automate the setup for you. -## Step 1: Create Service Principal +> **Important identity distinction:** In GitHub Actions, the Azure app registration/service principal is only for Azure and APIM access. Pull request creation is handled separately by the workflow's `GITHUB_TOKEN`, so Azure RBAC alone will not give the workflow permission to open PRs. -Run the following Azure CLI commands to create a service principal with federated credentials: +## Before you start -```bash -# Set variables -SUBSCRIPTION_ID="{{SUBSCRIPTION_ID}}" -RESOURCE_GROUP="{{RESOURCE_GROUP}}" -APP_NAME="apiops-github-sp" -GITHUB_ORG="" -GITHUB_REPO="" +- An Azure subscription with an API Management instance +- GitHub repository admin access +- Permission in Microsoft Entra ID to create or manage app registrations -# Create Azure AD Application -APP_ID=$(az ad app create \ - --display-name "$APP_NAME" \ - --query appId -o tsv) +Helpful documentation: -# Create Service Principal -az ad sp create --id "$APP_ID" +- [Use OpenID Connect with Azure Login in GitHub Actions](https://learn.microsoft.com/azure/developer/github/connect-from-azure-openid-connect) +- [Create and manage app registrations in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) +- [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) +- [Manage GitHub Actions secrets](https://docs.github.com/actions/security-guides/encrypted-secrets) +- [Manage GitHub environments](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment) -# Get Service Principal Object ID -SP_OBJECT_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv) +## Step 1: Review the generated workflow files in GitHub -echo "Application (client) ID: $APP_ID" -echo "Service Principal Object ID: $SP_OBJECT_ID" -``` +1. Commit and push the generated files to your repository. +2. Open your repository in the **GitHub web UI**. +3. Go to **Actions** and confirm you can see: + - **Run APIM Extractor** + - **Run APIM Publisher** +4. If your organization requires approval for new workflows, complete that approval before continuing. -## Step 2: Assign RBAC Roles +## Step 2: Create or choose an app registration in Azure portal -Grant the service principal "API Management Service Contributor" role on your APIM instance: +> 📖 [Register an application in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) -```bash -# Get APIM resource ID -APIM_RESOURCE_ID=$(az apim show \ - --resource-group "$RESOURCE_GROUP" \ - --name "" \ - --query id -o tsv) +1. Open the **Azure portal**. +2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. +3. Enter a friendly name such as `apiops-github-sp`. +4. Register the application and open its overview page. +5. Record these values for later: + - **Application (client) ID** + - **Directory (tenant) ID** -# Assign role -az role assignment create \ - --assignee "$APP_ID" \ - --role "API Management Service Contributor" \ - --scope "$APIM_RESOURCE_ID" -``` +## Step 3: Grant the Azure identity access to APIM -## Step 3: Configure Federated Credentials +> 📖 [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) -Set up OIDC federation for GitHub Actions: +For each environment, do the following steps: -```bash -# For main branch deployments -az ad app federated-credential create \ - --id "$APP_ID" \ - --parameters '{ - "name": "github-main-branch", - "issuer": "https://token.actions.githubusercontent.com", - "subject": "repo:'"$GITHUB_ORG"'/'"$GITHUB_REPO"':ref:refs/heads/main", - "audiences": ["api://AzureADTokenExchange"] - }' +1. In the **Azure portal**, open the resource group that contains the APIM instance for that environment. +2. Go to **Access control (IAM)** → **Add role assignment**. +3. Assign **Reader** on the resource group to the app registration. +4. Open the APIM service itself and assign **API Management Service Contributor** to the app registration. +5. Wait a few minutes for RBAC changes to propagate before you test the workflow. -# For environment deployments (repeat for each environment) -{{FEDERATED_CREDENTIALS_PER_ENV}} -``` +## Step 4: Add federated credentials in Azure portal -## Step 4: Configure GitHub Secrets +> 📖 [Configure a federated identity credential on an app](https://learn.microsoft.com/entra/workload-id/workload-identity-federation-create-trust) -Add the following secrets to your GitHub repository (Settings → Secrets and variables → Actions): +1. Return to **Microsoft Entra ID** → **App registrations** → your app. +2. Open **Certificates & secrets** → **Federated credentials** → **Add credential**. +3. Add one credential for the main branch publish workflow: + - **Scenario**: **GitHub Actions deploying Azure resources** + - **Organization**: your GitHub org or user + - **Repository**: your repository + - **Entity type**: **Branch** + - **Branch name**: `main` + - **Credential name**: `github-main-branch` +4. For each environment, add one additional federated credential: + - **Entity type**: **Environment** + - **Environment name**: the GitHub environment name (must match what you create in Step 5) + - **Credential name**: `github-env-` -### Repository Secrets: -- `AZURE_CLIENT_ID`: $APP_ID (from Step 1) -- `AZURE_TENANT_ID`: Run `az account show --query tenantId -o tsv` -- `AZURE_SUBSCRIPTION_ID`: {{SUBSCRIPTION_ID}} +## Step 5: Create GitHub environments in the web UI -### Environment-Specific Secrets: -{{ENVIRONMENT_SECRETS}} +> 📖 [Using environments for deployment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment) -### Extract Workflow Secrets: -- `APIM_RESOURCE_GROUP`: Default resource group for extract -- `APIM_SERVICE_NAME`: Default APIM service name for extract +1. In GitHub, go to **Settings** → **Environments**. +2. For each environment, create a GitHub environment with the same name used during `apiops init`. +3. Add protection rules such as required reviewers for production environments if your process needs approvals. -## Step 5: Verify Setup +## Step 6: Add repository and environment secrets in GitHub -Test the authentication by running a workflow manually or pushing to main branch. +> 📖 [Using secrets in GitHub Actions](https://docs.github.com/actions/security-guides/using-secrets-in-github-actions) + +1. Go to **Settings** → **Secrets and variables** → **Actions**. +2. Under **Repository secrets**, create: + - `AZURE_CLIENT_ID` — the Application (client) ID from the app registration + - `AZURE_TENANT_ID` — the Directory (tenant) ID from the app registration +3. For each environment, add the following **environment secrets**: + - `AZURE_SUBSCRIPTION_ID` — the Azure subscription ID for that environment + - `APIM_RESOURCE_GROUP_` — the resource group containing the APIM instance (replace `` with the upper-case environment name) + - `APIM_SERVICE_NAME_` — the APIM service name (replace `` with the upper-case environment name) + +## Step 7: Verify the workflow end to end + +1. Go to **Actions** → **Run APIM Extractor** → **Run workflow**. +2. Select the environment you configured in GitHub and choose whether to run **Extract All APIs** or use `configuration.extractor.yaml`. +3. Confirm the workflow can authenticate to Azure, export artifacts, and create a pull request. +4. If the Azure login succeeds but the PR step fails, review repository permissions for `GITHUB_TOKEN` and any branch protection rules because PR creation is not controlled by the Azure identity. ## Security Notes -- Use GitHub Environments for production deployments with required reviewers -- Review federated credential subjects periodically (no secrets to rotate — OIDC authentication has no stored credentials) -- Review RBAC role assignments regularly and remove any no longer needed -- Use least-privilege RBAC assignments + +- Prefer OIDC/federated credentials over client secrets so there is no stored Azure credential to rotate. +- Grant the workflow identity only the Azure roles it actually needs by following a least-privilege approach. +- Periodically review GitHub environments, repository secrets, and Azure role assignments. diff --git a/src/templates/shared/identity-setup-azure-devops-core.md b/src/templates/shared/identity-setup-azure-devops-core.md deleted file mode 100644 index b876fec8..00000000 --- a/src/templates/shared/identity-setup-azure-devops-core.md +++ /dev/null @@ -1,448 +0,0 @@ -## Prerequisites -- Azure DevOps organization and project -- Azure CLI installed and authenticated (`az login`) -- `az devops` extension (`az extension add --name azure-devops`) - -This flow is designed for Microsoft-hosted or self-hosted agents and uses workload identity federation (OIDC) instead of managed identity. - -> **Note:** All commands are shown for both **PowerShell** and **Git Bash** where syntax differs. - ---- - -## Step 1: Gather Per-Environment Information - -**Copilot:** Ask the user for the following values before proceeding. Store each answer for use in later steps. - -For each environment, provide either **Option A** (three separate values) or **Option B** (a single full APIM resource ID in the form `/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/`). Copilot will parse Option B into the individual components automatically. - -| Variable | Description | Example | -|----------|-------------|---------| -| `APP_NAME` | Display name for the Entra application | `apiops-azdo-sp` | -| `AZDO_ORG` | Azure DevOps organization URL | `https://dev.azure.com/my-org` | -| `AZDO_PROJECT` | Azure DevOps project name | `my-project` | -| `TENANT_ID` | Tenant ID that should own the app/service connection | `11111111-2222-3333-4444-555555555555` | - -For each environment in the configured list, gather these values (where `` is the upper-case environment name): -- `APIM_SUBSCRIPTION_` -- `APIM_RG_` -- `APIM_NAME_` -- `APIM_RESOURCE_ID_` *(optional Option B shorthand)* - ---- - -## Step 2: Set Variables - -**PowerShell:** -```powershell -$APP_NAME = "apiops-azdo-sp" -$AZDO_ORG = "" -$AZDO_PROJECT = "" -$TENANT_ID = "" -$ENVIRONMENTS = @({{ENVIRONMENTS_ARRAY_POWERSHELL}}) - -# Fill these maps with values for each environment. -$APIM_SUBSCRIPTIONS = @{} -$APIM_RESOURCE_GROUPS = @{} -$APIM_SERVICE_NAMES = @{} - -foreach ($env in $ENVIRONMENTS) { - # Option A: provide values directly. - $APIM_SUBSCRIPTIONS[$env] = "" - $APIM_RESOURCE_GROUPS[$env] = "" - $APIM_SERVICE_NAMES[$env] = "" - - # Option B: if APIM resource ID is provided, parse it into the same maps. - # $resourceId = "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" - # if ($resourceId) { - # $parts = $resourceId.Trim('/') -split '/' - # $APIM_SUBSCRIPTIONS[$env] = $parts[1] - # $APIM_RESOURCE_GROUPS[$env] = $parts[3] - # $APIM_SERVICE_NAMES[$env] = $parts[7] - # } -} -``` - -**Git Bash:** -```bash -APP_NAME="apiops-azdo-sp" -AZDO_ORG="" -AZDO_PROJECT="" -TENANT_ID="" -ENVIRONMENTS=({{ENVIRONMENTS_ARRAY_BASH}}) - -# Fill these maps with values for each environment. -declare -A APIM_SUBSCRIPTIONS -declare -A APIM_RESOURCE_GROUPS -declare -A APIM_SERVICE_NAMES - -for env in "${ENVIRONMENTS[@]}"; do - # Option A: provide values directly. - APIM_SUBSCRIPTIONS["$env"]="" - APIM_RESOURCE_GROUPS["$env"]="" - APIM_SERVICE_NAMES["$env"]="" - - # Option B: if APIM resource ID is provided, parse it into the same maps. - # resource_id="/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" - # if [[ -n "$resource_id" ]]; then - # IFS='/' read -r _ subscriptions sub resourceGroups rg providers provider service svc <<< "$resource_id" - # APIM_SUBSCRIPTIONS["$env"]="$sub" - # APIM_RESOURCE_GROUPS["$env"]="$rg" - # APIM_SERVICE_NAMES["$env"]="$svc" - # fi -done -``` - ---- - -## Step 3: Configure Azure DevOps CLI - -Install the extension (works in both shells): -```bash -az extension add --name azure-devops -``` - -Install the Azure DevOps Replace Tokens extension (required by publish pipeline): - -**PowerShell:** -```powershell -az devops extension install --publisher-id qetza --extension-id replacetokens -``` - -**Git Bash:** -```bash -az devops extension install --publisher-id qetza --extension-id replacetokens -``` - -Set organization defaults: - -For self-hosted Azure DevOps Server, use your server/collection URL format: -- `https:///` - -**PowerShell:** -```powershell -az devops configure --defaults organization=$AZDO_ORG project=$AZDO_PROJECT -$ORG_NAME = $AZDO_ORG -replace 'https://dev\.azure\.com/', '' -``` - -**Git Bash:** -```bash -az devops configure --defaults organization="$AZDO_ORG" project="$AZDO_PROJECT" -ORG_NAME="${AZDO_ORG##*/}" -``` - -**Self-hosted note:** If your server URL includes a collection segment (for example, `https://ado.contoso.local/DefaultCollection`), set `ORG_NAME` to the value expected in the Build Service identity display name for your server/project. - ---- - -## Step 4: Verify Tenant ID - -Before creating identity objects, confirm you are logged into the intended tenant. - -**PowerShell:** -```powershell -$CURRENT_TENANT_ID = az account show --query tenantId -o tsv -Write-Host "Current tenant: $CURRENT_TENANT_ID" -if ($CURRENT_TENANT_ID -ne $TENANT_ID) { - throw "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID. Run: az login --tenant $TENANT_ID" -} -``` - -**Git Bash:** -```bash -CURRENT_TENANT_ID=$(az account show --query tenantId -o tsv) -echo "Current tenant: $CURRENT_TENANT_ID" -if [[ "$CURRENT_TENANT_ID" != "$TENANT_ID" ]]; then - echo "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID" - echo "Run: az login --tenant $TENANT_ID" - exit 1 -fi -``` - ---- - -## Step 5: Create Entra Application and Service Principal (No Secret) - -> ⚠️ **Error Handling:** If any command fails, stop immediately and show the user the full error output verbatim. Do NOT retry silently. - -**PowerShell:** -```powershell -az ad app create --display-name $APP_NAME | Out-Null -$APP_ID = az ad app list --display-name $APP_NAME --query "[0].appId" -o tsv -az ad sp create --id $APP_ID | Out-Null -Write-Host "App ID: $APP_ID" -Write-Host "Tenant ID: $TENANT_ID" -``` - -**Git Bash:** -```bash -az ad app create --display-name "$APP_NAME" >/dev/null -APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv) -az ad sp create --id "$APP_ID" >/dev/null -echo "App ID: $APP_ID" -echo "Tenant ID: $TENANT_ID" -``` - -No client secret is required in this flow. - ---- - -## Step 6: Assign RBAC Roles Per Environment - -Grant the service principal **Reader** on each resource group and **API Management Service Contributor** on each APIM instance. - -### PowerShell -```powershell -foreach ($env in $ENVIRONMENTS) { - az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])" - az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])/providers/Microsoft.ApiManagement/service/$($APIM_SERVICE_NAMES[$env])" -} -``` - -### Git Bash -```bash -for env in "${ENVIRONMENTS[@]}"; do - az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}" - az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}/providers/Microsoft.ApiManagement/service/${APIM_SERVICE_NAMES[$env]}" -done -``` - ---- - -## Step 7: Create Workload Identity Federation Service Connections - -Create one service connection per environment, each scoped to that environment's subscription, -using Azure Resource Manager with Workload Identity Federation. - -This step is fully automatable with `az devops service-endpoint create`. -After each endpoint is created, capture its generated issuer/subject and create -the corresponding federated credential in Entra. - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - $name = "AZURE_SERVICE_CONNECTION_$envUpper" - $subscriptionName = az account show --subscription $($APIM_SUBSCRIPTIONS[$env]) --query name -o tsv - - $payload = @{ - name = $name - type = "azurerm" - url = "https://management.azure.com/" - authorization = @{ - scheme = "WorkloadIdentityFederation" - parameters = @{ - tenantid = $TENANT_ID - serviceprincipalid = $APP_ID - } - } - data = @{ - environment = "AzureCloud" - identityType = "AppRegistrationManual" - scopeLevel = "Subscription" - subscriptionId = $APIM_SUBSCRIPTIONS[$env] - subscriptionName = $subscriptionName - } - } | ConvertTo-Json -Depth 8 - - $file = "se-$env.json" - $payload | Out-File -Encoding utf8 -FilePath $file - az devops service-endpoint create --service-endpoint-configuration $file | Out-Null - Remove-Item $file -ErrorAction SilentlyContinue - - $endpoint = az devops service-endpoint list --query "[?name=='$name'] | [0]" -o json | ConvertFrom-Json - $issuer = $endpoint.authorization.parameters.workloadIdentityFederationIssuer - $subject = $endpoint.authorization.parameters.workloadIdentityFederationSubject - - $FED_CRED_NAME = "azdo-$env" - - $payload = @{ - name = $FED_CRED_NAME - issuer = $issuer - subject = $subject - audiences = @("api://AzureADTokenExchange") - } | ConvertTo-Json -Depth 5 - - az ad app federated-credential create --id $APP_ID --parameters $payload -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - name="AZURE_SERVICE_CONNECTION_$env_upper" - subscription_name=$(az account show --subscription "${APIM_SUBSCRIPTIONS[$env]}" --query name -o tsv) - - cat > "se-$env.json" </dev/null - rm -f "se-$env.json" - - issuer=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationIssuer" -o tsv) - subject=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationSubject" -o tsv) - - FED_CRED_NAME="azdo-$env" - - az ad app federated-credential create \ - --id "$APP_ID" \ - --parameters "{\"name\":\"$FED_CRED_NAME\",\"issuer\":\"$issuer\",\"subject\":\"$subject\",\"audiences\":[\"api://AzureADTokenExchange\"]}" -done -``` - -Authorize service connections for all pipelines (prevents first-run permission prompts): - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - $name = "AZURE_SERVICE_CONNECTION_$envUpper" - $id = az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv - if ($id) { - az devops service-endpoint update --id $id --enable-for-all true | Out-Null - } -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - name="AZURE_SERVICE_CONNECTION_$env_upper" - id=$(az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv) - if [[ -n "$id" ]]; then - az devops service-endpoint update --id "$id" --enable-for-all true >/dev/null - fi -done -``` - -Verify: -```bash -az devops service-endpoint list --query "[].name" -o table -``` - ---- - -## Step 8: Create Variable Groups - -Create one variable group per environment. Each group uses the **non-suffixed** variable names expected by the pipelines (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`). - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" -done -``` - -Authorize all groups for pipeline use: - -**PowerShell:** -```powershell -$groupIds = az pipelines variable-group list --query "[].id" -o tsv -foreach ($id in $groupIds) { - az pipelines variable-group update --group-id $id --authorize true -} -``` - -**Git Bash:** -```bash -for id in $(az pipelines variable-group list --query "[].id" -o tsv); do - az pipelines variable-group update --group-id "$id" --authorize true -done -``` - -Verify: -```bash -az pipelines variable-group list --query "[].name" -o table -``` - ---- - -## Step 9 (in pipeline): Create Environments - -Create deployment environments in Azure DevOps: - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $body = "{\"name\": \"$env\"}" - $body | Out-File -Encoding utf8 -FilePath env-body.json - az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method POST --api-version 7.1 --in-file env-body.json -} -Remove-Item env-body.json -ErrorAction SilentlyContinue -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - echo "{\"name\": \"$env\"}" > env-body.json - az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method POST --api-version 7.1 --in-file env-body.json -done -rm -f env-body.json -``` - -Authorize each environment for all pipelines (prevents first-run permission prompts): - -**PowerShell:** -```powershell -$ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798" -$TOKEN = az account get-access-token --resource $ADO_RESOURCE --query accessToken -o tsv - -foreach ($env in $ENVIRONMENTS) { - $envId = az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv - if ($envId) { - $url = "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$envId?api-version=7.1-preview.1" - $body = '{"allPipelines":{"authorized":true}}' - Invoke-RestMethod -Method Patch -Uri $url -Headers @{ Authorization = "Bearer $TOKEN" } -ContentType "application/json" -Body $body | Out-Null - } -} -``` - -**Git Bash:** -```bash -ADO_RESOURCE="499b84ac-1321-427f-aa17-267ca6975798" -TOKEN=$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv) - -for env in "${ENVIRONMENTS[@]}"; do - env_id=$(az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv) - if [[ -n "$env_id" ]]; then - curl -sS -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$env_id?api-version=7.1-preview.1" \ - -d '{"allPipelines":{"authorized":true}}' >/dev/null - fi -done -``` - -**Note:** Environment approvals and checks still must be configured via the Azure DevOps UI. - ---- diff --git a/tests/unit/services/identity-guide-service.test.ts b/tests/unit/services/identity-guide-service.test.ts index 03d78e49..5f63a25a 100644 --- a/tests/unit/services/identity-guide-service.test.ts +++ b/tests/unit/services/identity-guide-service.test.ts @@ -9,142 +9,86 @@ import { identityGuideService } from '../../../src/services/identity-guide-servi describe('identity-guide-service', () => { describe('generateGitHubActionsGuide', () => { - it('should include subscription ID in guide', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'prod'] - ); - expect(guide).toContain('sub-12345'); + it('should return static GitHub Actions guide content', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('APIOps GitHub Actions identity setup guide'); }); - it('should include resource group in guide', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('my-rg'); + it('should mention the Copilot prompt file and UI flow', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('.github/prompts/apiops-setup-workflow-identity.prompt.md'); + expect(guide).toContain('Azure portal'); + expect(guide).toContain('GitHub web UI'); }); - it('should include service principal creation steps', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('Create Service Principal'); - expect(guide).toContain('az ad app create'); - expect(guide).toContain('az ad sp create'); + it('should explain the GitHub identity distinction', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('GITHUB_TOKEN'); + expect(guide).toContain('only for Azure and APIM access'); }); - it('should include RBAC role assignment steps', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('Assign RBAC Roles'); + it('should include portal-based Azure access steps', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('API Management Service Contributor'); - expect(guide).toContain('az role assignment create'); + expect(guide).toContain('Access control (IAM)'); + expect(guide).toContain('Federated credentials'); }); - it('should include federated credentials setup', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('Configure Federated Credentials'); - expect(guide).toContain('az ad app federated-credential create'); - expect(guide).toContain('token.actions.githubusercontent.com'); + it('should include documentation links', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('https://learn.microsoft.com/'); + expect(guide).toContain('https://docs.github.com/'); }); - it('should include GitHub secrets configuration', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('Configure GitHub Secrets'); - expect(guide).toContain('AZURE_CLIENT_ID'); - expect(guide).toContain('AZURE_TENANT_ID'); - expect(guide).toContain('AZURE_SUBSCRIPTION_ID'); - }); - - it('should include environment-specific secrets for each environment', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'staging', 'prod'] - ); - expect(guide).toContain('dev environment'); - expect(guide).toContain('staging environment'); - expect(guide).toContain('prod environment'); - expect(guide).toContain('APIM_RESOURCE_GROUP_DEV'); - expect(guide).toContain('APIM_RESOURCE_GROUP_STAGING'); - expect(guide).toContain('APIM_RESOURCE_GROUP_PROD'); + it('should describe environment secrets generically', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('APIM_RESOURCE_GROUP_'); + expect(guide).toContain('APIM_SERVICE_NAME_'); + expect(guide).toContain('For each environment'); }); it('should include security notes', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('Security Notes'); expect(guide).toContain('least-privilege'); }); - it('should create federated credential for each environment', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'prod'] - ); - expect(guide).toContain('github-env-dev'); - expect(guide).toContain('github-env-prod'); - expect(guide).toContain('environment:dev'); - expect(guide).toContain('environment:prod'); - }); - - it('should render all template placeholders', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).not.toContain('{{'); - expect(guide).not.toContain('}}'); + it('should not contain any template placeholders', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).not.toMatch(/\{\{[^}]+\}\}/); }); }); describe('generateAzureDevOpsGuide', () => { - it('should ask for per-environment subscription and resource details', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('APIM_SUBSCRIPTION_'); - expect(guide).toContain('APIM_RG_'); - expect(guide).toContain('APIM_NAME_'); + it('should return static Azure DevOps guide content', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('Identity setup guide for APIOps extract and publish Azure DevOps Pipelines'); }); - it('should create non-suffixed variable groups per environment', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('--name "apim-$env"'); - expect(guide).toContain('APIM_RESOURCE_GROUP='); - expect(guide).toContain('APIM_SERVICE_NAME='); - expect(guide).toContain('AZURE_SUBSCRIPTION_ID='); + it('should mention the Copilot prompt file and UI flow', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('.github/prompts/apiops-setup-pipeline-identity.prompt.md'); + expect(guide).toContain('Azure DevOps'); + expect(guide).toContain('Azure portal'); }); - it('should render environment arrays for PowerShell and Bash', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('$ENVIRONMENTS = @("dev", "prod")'); - expect(guide).toContain('ENVIRONMENTS=("dev" "prod")'); + it('should explain the Azure DevOps identity distinction', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('Build Service identity'); + expect(guide).toContain('separate from the Azure app registration'); + expect(guide).toContain('Create pull request'); }); - it('should render all template placeholders', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev']); - expect(guide).not.toMatch(/\{\{[^}]+\}\}/); + it('should describe service connections and variable groups generically', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('AZURE_SERVICE_CONNECTION_'); + expect(guide).toContain('apim-'); + expect(guide).toContain('For each environment'); }); + it('should not contain any template placeholders', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).not.toMatch(/\{\{[^}]+\}\}/); + }); }); }); diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index d9b3f89b..37ea7545 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -104,8 +104,8 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.pipelines).toContain('.github/workflows/run-apim-extractor.yml'); - expect(result.pipelines).toContain('.github/workflows/run-apim-publisher.yml'); + expect(result.pipelines).toContain('.github/workflows/run-apiops-extractor.yml'); + expect(result.pipelines).toContain('.github/workflows/run-apiops-publisher.yml'); }); it('should generate Azure DevOps pipelines when ciProvider is azure-devops', async () => { @@ -121,8 +121,8 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.pipelines).toContain('.azdo/pipelines/run-apim-extractor.yml'); - expect(result.pipelines).toContain('.azdo/pipelines/run-apim-publisher.yml'); + expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-extractor.yml'); + expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-publisher.yml'); }); it('should generate filter configuration file', async () => { @@ -202,7 +202,7 @@ describe('init-service', () => { // Mock file exists for extract workflow and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { const p = filePath.toString(); - if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apim-extractor.yml')) { + if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apiops-extractor.yml')) { return Promise.resolve(); } throw new Error('ENOENT'); @@ -227,7 +227,7 @@ describe('init-service', () => { // Mock file exists for extract workflow and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { const p = filePath.toString(); - if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apim-extractor.yml')) { + if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apiops-extractor.yml')) { return Promise.resolve(); } throw new Error('ENOENT'); @@ -310,7 +310,7 @@ describe('init-service', () => { await initService.run(config); const guideCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', 'IDENTITY-SETUP-GITHUB.md') + (call) => call[0] === path.join('/test', 'APIOPS-WORKFLOW-IDENTITY-SETUP.md') ); expect(guideCalls).toHaveLength(1); }); @@ -329,7 +329,7 @@ describe('init-service', () => { await initService.run(config); const guideCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', 'IDENTITY-SETUP-AZDO.md') + (call) => call[0] === path.join('/test', 'APIOPS-PIPELINE-IDENTITY-SETUP.md') ); expect(guideCalls).toHaveLength(1); }); @@ -347,14 +347,15 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.configs).toContain('.github/prompts/apiops-setup-identity.prompt.md'); + expect(result.configs).toContain('.github/prompts/apiops-setup-workflow-identity.prompt.md'); const promptCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-identity.prompt.md') + (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-workflow-identity.prompt.md') ); expect(promptCalls).toHaveLength(1); const content = promptCalls[0][1] as string; expect(content).toContain('Setup GitHub Actions Identity'); expect(content).toContain('gh secret set'); + expect(content).toContain('pull request creation automatically'); }); it('should generate Copilot configuration prompts for GitHub Actions', async () => { @@ -399,14 +400,15 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.configs).toContain('.github/prompts/apiops-setup-identity.prompt.md'); + expect(result.configs).toContain('.github/prompts/apiops-setup-pipeline-identity.prompt.md'); const promptCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-identity.prompt.md') + (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-pipeline-identity.prompt.md') ); expect(promptCalls).toHaveLength(1); const content = promptCalls[0][1] as string; expect(content).toContain('Setup Azure DevOps Identity for APIOps'); expect(content).toContain('az devops service-endpoint create --service-endpoint-configuration'); + expect(content).toContain('Build Service identity'); }); it('should detect conflicts for Copilot configuration prompts', async () => { diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index c1985516..ab257a2b 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -111,6 +111,11 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).toContain('Open this file in VS Code with GitHub Copilot'); }); + it('should include the GitHub identity distinction note', () => { + const prompt = generateIdentitySetupPrompt({ environments: ['dev'] }); + expect(prompt).toContain('pull request creation automatically'); + }); + it('should include tool authentication check in Step 0', () => { const prompt = generateIdentitySetupPrompt({ environments: ['dev'] }); expect(prompt).toContain('## Step 0 — Tool Authentication Check'); @@ -165,6 +170,14 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).not.toContain('gh secret set'); }); + it('should include the Azure DevOps identity distinction note and UI guide context', () => { + const prompt = generateIdentitySetupPrompt({ + environments: ['dev', 'prod'], + ciProvider: 'azure-devops', + }); + expect(prompt).toContain('Build Service identity'); + }); + it('should ask Copilot to gather per-environment APIM info for each ADO environment', () => { const prompt = generateIdentitySetupPrompt({ environments: ['dev', 'prod'],