diff --git a/.github/config/pr-autoflow.json b/.github/config/pr-autoflow.json new file mode 100644 index 0000000..1ba0419 --- /dev/null +++ b/.github/config/pr-autoflow.json @@ -0,0 +1,4 @@ +{ + "AUTO_MERGE_PACKAGE_WILDCARD_EXPRESSIONS": "[\"Endjin.*\",\"Corvus.*\"]", + "AUTO_RELEASE_PACKAGE_WILDCARD_EXPRESSIONS": "[\"Corvus.AzureFunctionsKeepAlive\",\"Corvus.Configuration\",\"Corvus.ContentHandling\",\"Corvus.Deployment\",\"Corvus.DotLiquidAsync\",\"Corvus.EventStore\",\"Corvus.Extensions\",\"Corvus.Extensions.CosmosDb\",\"Corvus.Extensions.Newtonsoft.Json\",\"Corvus.Extensions.System.Text.Json\",\"Corvus.Identity\",\"Corvus.JsonSchema\",\"Corvus.Leasing\",\"Corvus.Monitoring\",\"Corvus.Retry\",\"Corvus.Storage\",\"Corvus.Tenancy\"]" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b452f75 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: nuget + directory: /Solutions + schedule: + interval: daily + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/workflows/auto_merge.yml b/.github/workflows/auto_merge.yml new file mode 100644 index 0000000..9ef00ec --- /dev/null +++ b/.github/workflows/auto_merge.yml @@ -0,0 +1,68 @@ +name: auto_merge +on: + check_run: + types: + # Check runs completing successfully can unblock the + # corresponding pull requests and make them mergeable. + - completed + pull_request: + types: + # A closed pull request makes the checks on the other + # pull request on the same base outdated. + - closed + # Adding the autosquash label to a pull request can + # trigger an update or a merge. + - labeled + - synchronize + pull_request_review: + types: + # Review approvals can unblock the pull request and + # make it mergeable. + - submitted + # Success statuses can unblock the corresponding + # pull requests and make them mergeable. + status: {} + workflow_run: + workflows: [approve_and_label] + types: + - completed + +permissions: + contents: write + pull-requests: write + issues: write + checks: read + +jobs: + + auto_merge: + name: Auto-squash the PR + runs-on: ubuntu-18.04 + steps: + # This may not be strictly required, but should keep unmerged, closed PRs cleaner + - name: Remove 'autosquash' label from closed PRs + id: remove_autosquash_label_from_closed_prs + uses: actions/github-script@v2 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + const pulls = await github.search.issuesAndPullRequests({ + q: 'is:pr is:closed label:autosquash', + }); + core.info(`pulls: ${pulls.data.items}`) + const repoUrl = `https://api.github.com/repos/${context.payload.repository.owner.login}/${context.payload.repository.name}` + const prs_to_unlabel = pulls.data.items. + filter(function (x) { return x.repository_url == repoUrl; }). + map(p=>p.number); + for (const i of prs_to_unlabel) { + core.info(`Removing label 'autosquash' from issue #${i}`) + github.issues.removeLabel({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: i, + name: 'autosquash' + }); + } + - uses: endjin/autosquash@v2.4 + with: + github_token: '${{ secrets.GITHUB_TOKEN }}' \ No newline at end of file diff --git a/.github/workflows/auto_release.yml b/.github/workflows/auto_release.yml new file mode 100644 index 0000000..0add2f7 --- /dev/null +++ b/.github/workflows/auto_release.yml @@ -0,0 +1,212 @@ +name: auto_release +on: + pull_request: + types: [closed] + +jobs: + lookup_default_branch: + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.lookup_default_branch.outputs.result }} + head_commit: ${{ steps.lookup_default_branch_head.outputs.result }} + steps: + - name: Lookup default branch name + id: lookup_default_branch + uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const repo = await github.repos.get({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name + }); + return repo.data.default_branch + - name: Display default_branch_name + run: | + echo "default_branch_name : ${{ steps.lookup_default_branch.outputs.result }}" + + - name: Lookup HEAD commit on default branch + id: lookup_default_branch_head + uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const branch = await github.repos.getBranch({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + branch: '${{ steps.lookup_default_branch.outputs.result }}' + }); + return branch.data.commit.sha + - name: Display default_branch_name_head + run: | + echo "default_branch_head_commit : ${{ steps.lookup_default_branch_head.outputs.result }}" + + check_for_norelease_label: + runs-on: ubuntu-latest + outputs: + no_release: ${{ steps.check_for_norelease_label.outputs.result }} + steps: + - name: Check for 'no_release' label on PR + id: check_for_norelease_label + uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const labels = await github.issues.listLabelsOnIssue({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.number + }); + core.info("labels: " + JSON.stringify(labels.data)) + if ( labels.data.map(l => l.name).includes("no_release") ) { + core.info("Label found") + return true + } + return false + - name: Display 'no_release' status + run: | + echo "no_release: ${{ steps.check_for_norelease_label.outputs.result }}" + + check_ready_to_release: + runs-on: ubuntu-latest + needs: [check_for_norelease_label,lookup_default_branch] + if: | + needs.check_for_norelease_label.outputs.no_release == 'false' + outputs: + no_open_prs: ${{ steps.watch_dependabot_prs.outputs.is_complete }} + pending_release_pr_list: ${{ steps.get_release_pending_pr_list.outputs.result }} + ready_to_release: ${{ steps.watch_dependabot_prs.outputs.is_complete == 'True' && steps.get_release_pending_pr_list.outputs.is_release_pending }} + steps: + - name: Get Open PRs + id: get_open_pr_list + uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + # find all open PRs that are targetting the default branch (i.e. main/master) + # return their titles, so they can parsed later to determine if they are + # Dependabot PRs and whether we should wait for them to be auto-merged before + # allowing a release event. + script: | + const pulls = await github.pulls.list({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + state: 'open', + base: '${{ needs.lookup_default_branch.outputs.branch_name }}' + }); + return JSON.stringify(pulls.data.map(p=>p.title)) + result-encoding: string + - name: Display open_pr_list + run: | + echo "open_pr_list : ${{ steps.get_open_pr_list.outputs.result }}" + + - name: Get 'pending_release' PRs + id: get_release_pending_pr_list + uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pulls = await github.search.issuesAndPullRequests({ + q: 'is:pr is:merged label:pending_release', + }); + core.info(`pulls: ${pulls.data.items}`) + const repoUrl = `https://api.github.com/repos/${context.payload.repository.owner.login}/${context.payload.repository.name}` + const release_pending_prs = pulls.data.items. + filter(function (x) { return x.repository_url == repoUrl; }). + map(p=>p.number); + core.info(`release_pending_prs: ${release_pending_prs}`) + core.setOutput('is_release_pending', (release_pending_prs.length > 0)) + return JSON.stringify(release_pending_prs) + result-encoding: string + - name: Display release_pending_pr_list + run: | + echo "release_pending_pr_list : ${{ steps.get_release_pending_pr_list.outputs.result }}" + echo "is_release_pending : ${{ steps.get_release_pending_pr_list.outputs.is_release_pending }}" + + - uses: actions/checkout@v2 + - name: Read pr-autoflow configuration + id: get_pr_autoflow_config + uses: endjin/pr-autoflow/actions/read-configuration@v1 + with: + config_file: .github/config/pr-autoflow.json + + - name: Watch Dependabot PRs + id: watch_dependabot_prs + uses: endjin/pr-autoflow/actions/dependabot-pr-watcher@v1 + with: + pr_titles: ${{ steps.get_open_pr_list.outputs.result }} + package_wildcard_expressions: ${{ steps.get_pr_autoflow_config.outputs.AUTO_MERGE_PACKAGE_WILDCARD_EXPRESSIONS }} + max_semver_increment: minor + verbose_mode: 'False' + + - name: Display job outputs + run: | + echo "no_open_prs: ${{ steps.watch_dependabot_prs.outputs.is_complete }}" + echo "pending_release_pr_list: ${{ steps.get_release_pending_pr_list.outputs.result }}" + echo "ready_to_release : ${{ steps.watch_dependabot_prs.outputs.is_complete == 'True' && steps.get_release_pending_pr_list.outputs.is_release_pending }}" + + tag_for_release: + runs-on: ubuntu-latest + needs: [check_ready_to_release,lookup_default_branch] + if: | + needs.check_ready_to_release.outputs.ready_to_release == 'true' + steps: + - uses: actions/setup-dotnet@v1 + with: + dotnet-version: '3.1.x' + + - uses: actions/checkout@v2 + with: + # ensure we are creating the release tag on the default branch + ref: ${{ needs.lookup_default_branch.outputs.branch_name }} + fetch-depth: 0 + + - name: Install GitVersion + run: | + dotnet tool install -g GitVersion.Tool --version 5.6.6 + echo "/github/home/.dotnet/tools" >> $GITHUB_PATH + - name: Run GitVersion + id: run_gitversion + run: | + pwsh -noprofile -c 'dotnet-gitversion /diag' + pwsh -noprofile -c '(dotnet-gitversion | ConvertFrom-Json).psobject.properties | % { echo ("::set-output name={0}::{1}" -f $_.name, $_.value) }' + + - name: Generate token + id: generate_token + uses: tibdex/github-app-token@v1 + with: + app_id: ${{ secrets.ENDJIN_BOT_APP_ID }} + private_key: ${{ secrets.ENDJIN_BOT_PRIVATE_KEY }} + + - name: Create SemVer tag + uses: actions/github-script@v2 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + const uri_path = '/repos/' + context.payload.repository.owner.login + '/' + context.payload.repository.name + '/git/refs' + const tag = await github.request(('POST ' + uri_path), { + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + ref: 'refs/tags/${{ steps.run_gitversion.outputs.MajorMinorPatch }}', + sha: '${{ needs.lookup_default_branch.outputs.head_commit }}' + }) + + - name: Remove 'release_pending' label from PRs + id: remove_pending_release_labels + uses: actions/github-script@v2 + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + core.info('PRs to unlabel: ${{ needs.check_ready_to_release.outputs.pending_release_pr_list }}') + const pr_list = JSON.parse('${{ needs.check_ready_to_release.outputs.pending_release_pr_list }}') + core.info(`pr_list: ${pr_list}`) + for (const i of pr_list) { + core.info(`Removing label 'pending_release' from issue #${i}`) + github.issues.removeLabel({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: i, + name: 'pending_release' + }); + } diff --git a/.github/workflows/dependabot_approve_and_label.yml b/.github/workflows/dependabot_approve_and_label.yml new file mode 100644 index 0000000..03c7d23 --- /dev/null +++ b/.github/workflows/dependabot_approve_and_label.yml @@ -0,0 +1,125 @@ +name: approve_and_label +on: + pull_request: + types: [opened, reopened] + +permissions: + issues: write + pull-requests: write + +jobs: + evaluate_dependabot_pr: + runs-on: ubuntu-latest + name: Parse Dependabot PR title + # Don't process PRs from forked repos + if: + github.event.pull_request.head.repo.full_name == github.repository + outputs: + dependency_name: ${{ steps.parse_dependabot_pr_automerge.outputs.dependency_name }} + version_from: ${{ steps.parse_dependabot_pr_automerge.outputs.version_from }} + version_to: ${{ steps.parse_dependabot_pr_automerge.outputs.version_to }} + is_auto_merge_candidate: ${{ steps.parse_dependabot_pr_automerge.outputs.is_interesting_package }} + is_auto_release_candidate: ${{ steps.parse_dependabot_pr_autorelease.outputs.is_interesting_package }} + semver_increment: ${{ steps.parse_dependabot_pr_automerge.outputs.semver_increment }} + steps: + - uses: actions/checkout@v2 + - name: Read pr-autoflow configuration + id: get_pr_autoflow_config + uses: endjin/pr-autoflow/actions/read-configuration@v1 + with: + config_file: .github/config/pr-autoflow.json + - name: Dependabot PR - AutoMerge Candidate + id: parse_dependabot_pr_automerge + uses: endjin/pr-autoflow/actions/dependabot-pr-parser@v1 + with: + pr_title: ${{ github.event.pull_request.title }} + package_wildcard_expressions: ${{ steps.get_pr_autoflow_config.outputs.AUTO_MERGE_PACKAGE_WILDCARD_EXPRESSIONS }} + - name: Dependabot PR - AutoRelease Candidate + id: parse_dependabot_pr_autorelease + uses: endjin/pr-autoflow/actions/dependabot-pr-parser@v1 + with: + pr_title: ${{ github.event.pull_request.title }} + package_wildcard_expressions: ${{ steps.get_pr_autoflow_config.outputs.AUTO_RELEASE_PACKAGE_WILDCARD_EXPRESSIONS }} + - name: debug + run: | + echo "dependency_name : ${{ steps.parse_dependabot_pr_automerge.outputs.dependency_name }}" + echo "is_interesting_package (merge) : ${{ steps.parse_dependabot_pr_automerge.outputs.is_interesting_package }}" + echo "is_interesting_package (release) : ${{ steps.parse_dependabot_pr_autorelease.outputs.is_interesting_package }}" + echo "semver_increment : ${{ steps.parse_dependabot_pr_automerge.outputs.semver_increment }}" + + approve: + runs-on: ubuntu-latest + needs: evaluate_dependabot_pr + name: Approve auto-mergeable dependabot PRs + if: | + (github.actor == 'dependabot[bot]' || github.actor == 'dependjinbot[bot]' || github.actor == 'nektos/act') && + needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' + steps: + - name: Show PR Details + run: | + echo "<------------------------------------------------>" + echo "dependency_name : ${{needs.evaluate_dependabot_pr.outputs.dependency_name}}" + echo "semver_increment : ${{needs.evaluate_dependabot_pr.outputs.semver_increment}}" + echo "auto_merge : ${{needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate}}" + echo "auto_release : ${{needs.evaluate_dependabot_pr.outputs.is_auto_release_candidate}}" + echo "from_version : ${{needs.evaluate_dependabot_pr.outputs.version_from}}" + echo "to_version : ${{needs.evaluate_dependabot_pr.outputs.version_to}}" + echo "<------------------------------------------------>" + shell: bash + - name: Approve pull request + if: | + needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && + (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') + uses: andrewmusgrave/automatic-pull-request-review@0.0.2 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + event: APPROVE + body: 'Thank you dependabot 🎊' + - name: 'Update PR body' + if: | + needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && + (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') + uses: actions/github-script@v2 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + await github.pulls.update({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + pull_number: context.payload.pull_request.number, + body: "Bumps '${{needs.evaluate_dependabot_pr.outputs.dependency_name}}' from ${{needs.evaluate_dependabot_pr.outputs.version_from}} to ${{needs.evaluate_dependabot_pr.outputs.version_to}}" + }) + label: + runs-on: ubuntu-latest + needs: evaluate_dependabot_pr + name: Label + steps: + - name: 'Label auto-mergeable dependabot PRs with "autosquash"' + if: | + (github.actor == 'dependabot[bot]' || github.actor == 'dependjinbot[bot]' || github.actor == 'nektos/act') && + needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && + (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') + uses: actions/github-script@v2 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + await github.issues.addLabels({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.pull_request.number, + labels: ['autosquash'] + }) + - name: 'Label non-dependabot PRs and auto-releasable dependabot PRs with "pending_release"' + if: | + (github.actor != 'dependabot[bot]' && github.actor != 'dependjinbot[bot]') || + needs.evaluate_dependabot_pr.outputs.is_auto_release_candidate == 'True' + uses: actions/github-script@v2 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + await github.issues.addLabels({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.pull_request.number, + labels: ['pending_release'] + }) \ No newline at end of file diff --git a/.gitignore b/.gitignore index dfcfd56..147d449 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,4 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ +/Solutions/AzAppConfigToUserSecrets/AzAppConfigToUserSecrets.Cli/Properties/launchSettings.json diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..fd530b1 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,23 @@ +# DO NOT EDIT THIS FILE +# This file was generated by the pr-autoflow mechanism as a result of executing this action: +# https://github.com/endjin/.github/actions/workflows/deploy_pr_autoflow.yml +# This repository participates in this mechanism due to an entry in this file: +# https://github.com/endjin/.github/blob/b69ff1d66541ae049fb0457c65c719c6d7e9b862/repos/live/corvus-dotnet.yml + +mode: ContinuousDeployment +branches: + master: + regex: ^main + tag: preview + increment: patch + dependabot-pr: + regex: ^dependabot + tag: dependabot + source-branches: + - develop + - master + - release + - feature + - support + - hotfix +next-version: "0.1" \ No newline at end of file diff --git a/README.md b/README.md index 8d08d06..65754fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ # AzAppConfigToUserSecrets A dotnet global tool for extracting settings from Azure App Configuration Service and stashing them as User Secrets. + +## Usage + +`actus export --tenant-id --user-secrets-id --endpoint "https://.azconfig.io"` + +This will spawn an interactive authentication flow. + +## Notes + +You can configure your application to use User Secrets by right clicking on the project in Visual Studio 2022 Solution Explorer and selecting the "Manage User Secrets" option from the context menu. + +User Secrets are stored in `%APPDATA%\Microsoft\UserSecrets\\secrets.json` on Windows and `~/.microsoft/usersecrets//secrets.json` on Linux/macOS. + +Note: User Secrets does not encrypt the stored secrets and shouldn't be treated as a trusted store. They are stored in plain text in one of the above locations. + +## Licenses + +[![GitHub license](https://img.shields.io/badge/License-Apache%202-blue.svg)](https://raw.githubusercontent.com/endjin/AzAppConfigToUserSecrets/blob/main/LICENSE) + +AzAppConfigToUserSecrets is available under the Apache 2.0 open source license. + +For any licensing questions, please email [licensing@endjin.com](mailto:licensing@endjin.com) + +## Project Sponsor + +This project is sponsored by [endjin](https://endjin.com), a UK based Microsoft Gold Partner for Cloud Platform, Data Platform, Data Analytics, DevOps, and a Power BI Partner. + +For more information about our products and services, or for commercial support of this project, please [contact us](https://endjin.com/contact-us). + +We produce two free weekly newsletters; [Azure Weekly](https://azureweekly.info) for all things about the Microsoft Azure Platform, and [Power BI Weekly](https://powerbiweekly.info). + +Keep up with everything that's going on at endjin via our [blog](https://endjin.com/blog), follow us on [Twitter](https://twitter.com/endjin), or [LinkedIn](https://www.linkedin.com/company/1671851/). + +Our other Open Source projects can be found on [GitHub](https://github.com/endjin) + +## Code of conduct + +This project has adopted a code of conduct adapted from the [Contributor Covenant](http://contributor-covenant.org/) to clarify expected behavior in our community. This code of conduct has been [adopted by many other projects](http://contributor-covenant.org/adopters/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [hello@endjin.com](mailto:hello@endjin.com) with any additional questions or comments. \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/AzAppConfigToUserSecrets.Cli.csproj b/Solutions/AzAppConfigToUserSecrets.Cli/AzAppConfigToUserSecrets.Cli.csproj new file mode 100644 index 0000000..dffbe87 --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/AzAppConfigToUserSecrets.Cli.csproj @@ -0,0 +1,27 @@ + + + + Exe + net6.0 + enable + enable + + + + true + actus + + + + + + + + + + + + + + + diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/CommandLineParser.cs b/Solutions/AzAppConfigToUserSecrets.Cli/CommandLineParser.cs new file mode 100644 index 0000000..ae3a4a1 --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/CommandLineParser.cs @@ -0,0 +1,78 @@ +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using AzAppConfigToUserSecrets.Cli; +using AzAppConfigToUserSecrets.Cli.Infrastructure; + +public class CommandLineParser +{ + private readonly ICompositeConsole console; + + public CommandLineParser(ICompositeConsole console) + { + this.console = console; + } + + public delegate Task Export(string tenantId, string userSecretsId, string endpoint, ICompositeConsole console, InvocationContext invocationContext = null); + + public Parser Create(Export export = null) + { + // if environmentInit hasn't been provided (for testing) then assign the Command Handler + export ??= ExportHandler.ExecuteAsync; + + RootCommand rootCommand = Root(); + rootCommand.AddCommand(Export()); + + var commandBuilder = new CommandLineBuilder(rootCommand); + + return commandBuilder.UseDefaults().Build(); + + static RootCommand Root() + { + return new RootCommand + { + Name = "actus", + Description = "Export Azure App Configuration to .NET User Secrets", + }; + } + + Command Export() + { + var cmd = new Command("export", ""); + + var tenantIdArg = new Option("--tenant-id") + { + Description = "Id of AAD Tenant that contains your App Configuration Service", + Arity = ArgumentArity.ExactlyOne, + }; + + var userSecretsIdArg = new Option("--user-secrets-id") + { + Description = "User Secrets Id for your application", + Arity = ArgumentArity.ExactlyOne, + }; + + var endpointArg = new Option("--endpoint") + { + Description = "Azure Application Configuration Service endpoint", + Arity = ArgumentArity.ExactlyOne, + }; + + cmd.Add(tenantIdArg); + cmd.Add(userSecretsIdArg); + cmd.Add(endpointArg); + + cmd.SetHandler(async (context) => + { + string? tenantId = context.ParseResult.GetValueForOption(tenantIdArg); + string? userSecretsId = context.ParseResult.GetValueForOption(userSecretsIdArg); + string? endpoint = context.ParseResult.GetValueForOption(endpointArg); + + await export(tenantId!, userSecretsId!, endpoint!, (ICompositeConsole)context.Console, context).ConfigureAwait(false); + }); + + return cmd; + } + } +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/ExportHandler.cs b/Solutions/AzAppConfigToUserSecrets.Cli/ExportHandler.cs new file mode 100644 index 0000000..6f5750d --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/ExportHandler.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.CommandLine.Invocation; +using AzAppConfigToUserSecrets.Cli.Infrastructure; +using Azure; +using Azure.Data.AppConfiguration; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; + +namespace AzAppConfigToUserSecrets.Cli; + +public static class ExportHandler +{ + public static async Task ExecuteAsync( + string tenantId, + string userSecretsId, + string endpoint, + ICompositeConsole console, + InvocationContext? context = null) + { + var credentials = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { TenantId = tenantId }); + var client = new ConfigurationClient(new Uri(endpoint), credentials); + + var selector = new SettingSelector { Fields = SettingFields.All }; + Pageable settings = client.GetConfigurationSettings(selector); + + var userSecretsStore = new UserSecretsStore(userSecretsId); + var secrets = userSecretsStore.Load(); + + foreach (ConfigurationSetting setting in settings) + { + if (setting is SecretReferenceConfigurationSetting secretReference) + { + var identifier = new KeyVaultSecretIdentifier(secretReference.SecretId); + var secretClient = new SecretClient(identifier.VaultUri, credentials); + Response? secret = await secretClient.GetSecretAsync(identifier.Name, identifier.Version); + userSecretsStore.Set(setting.Key, secret.Value.Value); + continue; + } + + userSecretsStore.Set(setting.Key, setting.Value); + } + + userSecretsStore.Save(); + + return 0; + } +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/AnsiConsoleStreamWriter.cs b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/AnsiConsoleStreamWriter.cs new file mode 100644 index 0000000..9641248 --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/AnsiConsoleStreamWriter.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.CommandLine.IO; +using Spectre.Console; + +namespace AzAppConfigToUserSecrets.Cli.Infrastructure; + +internal sealed class AnsiConsoleStreamWriter : IStandardStreamWriter +{ + private readonly IAnsiConsole console; + + public AnsiConsoleStreamWriter(IAnsiConsole console) + { + this.console = console; + } + + public void Write(string value) + { + this.console.Write(value); + } +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/CompositeConsole.cs b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/CompositeConsole.cs new file mode 100644 index 0000000..b9f59ce --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/CompositeConsole.cs @@ -0,0 +1,51 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.CommandLine.IO; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace AzAppConfigToUserSecrets.Cli.Infrastructure; + +internal sealed class CompositeConsole : ICompositeConsole +{ + private readonly AnsiConsoleStreamWriter standardOut; + private readonly IStandardStreamWriter standardError; + + public CompositeConsole() + { + this.standardOut = new AnsiConsoleStreamWriter(AnsiConsole.Console); + this.standardError = StandardStreamWriter.Create(Console.Error); + } + + bool IStandardOut.IsOutputRedirected => Console.IsOutputRedirected; + + bool IStandardError.IsErrorRedirected => Console.IsErrorRedirected; + + bool IStandardIn.IsInputRedirected => Console.IsInputRedirected; + + IStandardStreamWriter IStandardOut.Out => this.standardOut; + + IStandardStreamWriter IStandardError.Error => this.standardError; + + public Profile Profile => AnsiConsole.Console.Profile; + + public IAnsiConsoleCursor Cursor => AnsiConsole.Console.Cursor; + + public IAnsiConsoleInput Input => AnsiConsole.Console.Input; + + public IExclusivityMode ExclusivityMode => AnsiConsole.Console.ExclusivityMode; + + public RenderPipeline Pipeline => AnsiConsole.Console.Pipeline; + + public void Clear(bool home) + { + AnsiConsole.Console.Clear(home); + } + + public void Write(IRenderable renderable) + { + AnsiConsole.Console.Write(renderable); + } +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/ICompositeConsole.cs b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/ICompositeConsole.cs new file mode 100644 index 0000000..fddf1a5 --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/Infrastructure/ICompositeConsole.cs @@ -0,0 +1,12 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.CommandLine; +using Spectre.Console; + +namespace AzAppConfigToUserSecrets.Cli.Infrastructure; + +public interface ICompositeConsole : IConsole, IAnsiConsole +{ +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/Program.cs b/Solutions/AzAppConfigToUserSecrets.Cli/Program.cs new file mode 100644 index 0000000..42f8b8f --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/Program.cs @@ -0,0 +1,6 @@ +using System.CommandLine.Parsing; +using AzAppConfigToUserSecrets.Cli.Infrastructure; + +ICompositeConsole console = new CompositeConsole(); + +return await new CommandLineParser(console).Create().InvokeAsync(args, console).ConfigureAwait(false); \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.Cli/UserSecretsStore.cs b/Solutions/AzAppConfigToUserSecrets.Cli/UserSecretsStore.cs new file mode 100644 index 0000000..627170f --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.Cli/UserSecretsStore.cs @@ -0,0 +1,50 @@ +namespace AzAppConfigToUserSecrets.Cli; + +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; + +public class UserSecretsStore +{ + private IDictionary secrets = new Dictionary(); + private readonly string userSecretsId; + + public UserSecretsStore(string userSecretsId) + { + this.userSecretsId = userSecretsId; + } + + public void Set(string key, string value) => this.secrets[key] = value; + + public string this[string key] => this.secrets[key]; + + public void Save() + { + var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(this.userSecretsId); + JsonObject jsonObject = new JsonObject(); + + foreach (KeyValuePair keyValuePair in secrets.AsEnumerable()) + { + jsonObject[keyValuePair.Key] = keyValuePair.Value; + } + + File.WriteAllText(secretsFilePath, jsonObject.ToString()); + } + + public IDictionary Load() + { + var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(this.userSecretsId); + + this.secrets = new ConfigurationBuilder() + .AddJsonFile(secretsFilePath, true) + .Build() + .AsEnumerable() + .Where((Func, bool>) (i => i.Value != null)) + .ToDictionary( + i => i.Key, + i => i.Value, + StringComparer.OrdinalIgnoreCase); + + return this.secrets; + } +} \ No newline at end of file diff --git a/Solutions/AzAppConfigToUserSecrets.sln b/Solutions/AzAppConfigToUserSecrets.sln new file mode 100644 index 0000000..671281f --- /dev/null +++ b/Solutions/AzAppConfigToUserSecrets.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.2.32630.192 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzAppConfigToUserSecrets.Cli", "AzAppConfigToUserSecrets.Cli\AzAppConfigToUserSecrets.Cli.csproj", "{F1656810-2BE3-493D-9701-8BB2B350F3BB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F1656810-2BE3-493D-9701-8BB2B350F3BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1656810-2BE3-493D-9701-8BB2B350F3BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1656810-2BE3-493D-9701-8BB2B350F3BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1656810-2BE3-493D-9701-8BB2B350F3BB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4CE583AE-EF6E-4257-ABDF-BF06212AB116} + EndGlobalSection +EndGlobal diff --git a/azure-pipelines.release.yml b/azure-pipelines.release.yml new file mode 100644 index 0000000..4706281 --- /dev/null +++ b/azure-pipelines.release.yml @@ -0,0 +1,15 @@ +trigger: none +pr: none + +resources: + repositories: + - repository: recommended_practices + type: github + name: endjin/Endjin.RecommendedPractices.AzureDevopsPipelines.GitHub + endpoint: endjin-github + +jobs: +- template: templates/tag.for.release.yml@recommended_practices + parameters: + vmImage: 'windows-latest' + service_connection_github: $(Endjin_Service_Connection_GitHub) \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..8b7ec4d --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,24 @@ +trigger: + branches: + include: + - master + - feature/* + tags: + include: + - '*' + +resources: + repositories: + - repository: recommended_practices + type: github + name: endjin/Endjin.RecommendedPractices.AzureDevopsPipelines.GitHub + endpoint: endjin-github + +jobs: +- template: templates/build.and.release.scripted.yml@recommended_practices + parameters: + vmImage: 'ubuntu-latest' + service_connection_nuget_org: $(Endjin_Service_Connection_NuGet_Org) + service_connection_github: $(Endjin_Service_Connection_GitHub) + solution_to_build: $(Endjin_Solution_To_Build) + netSdkVersion: '6.x' \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..d9a1032 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Runs a .NET flavoured build process. +.DESCRIPTION + This script was scaffolded using a template from the Endjin.RecommendedPractices.Build PowerShell module. + It uses the InvokeBuild module to orchestrate an opinonated software build process for .NET solutions. +.EXAMPLE + PS C:\> ./build.ps1 + Downloads any missing module dependencies (Endjin.RecommendedPractices.Build & InvokeBuild) and executes + the build process. +.PARAMETER Tasks + Optionally override the default task executed as the entry-point of the build. +.PARAMETER Configuration + The build configuration, defaults to 'Release'. +.PARAMETER BuildRepositoryUri + Optional URI that supports pulling MSBuild logic from a web endpoint (e.g. a GitHub blob). +.PARAMETER SourcesDir + The path where the source code to be built is located, defaults to the current working directory. +.PARAMETER CoverageDir + The output path for the test coverage data, if run. +.PARAMETER TestReportTypes + The test report format that should be generated by the test report generator, if run. +.PARAMETER PackagesDir + The output path for any packages produced as part of the build. +.PARAMETER LogLevel + The logging verbosity. +.PARAMETER Clean + When true, the .NET solution will be cleaned and all output/intermediate folders deleted. +.PARAMETER BuildModulePath + The path to import the Endjin.RecommendedPractices.Build module from. This is useful when + testing pre-release versions of the Endjin.RecommendedPractices.Build that are not yet + available in the PowerShell Gallery. +#> +[CmdletBinding()] +param ( + [Parameter(Position=0)] + [string[]] $Tasks = @("."), + + [Parameter()] + [string] $Configuration = "Release", + + [Parameter()] + [string] $BuildRepositoryUri = "", + + [Parameter()] + [string] $SourcesDir = $PWD, + + [Parameter()] + [string] $CoverageDir = "_codeCoverage", + + [Parameter()] + [string] $TestReportTypes = "Cobertura", + + [Parameter()] + [string] $PackagesDir = "_packages", + + [Parameter()] + [ValidateSet("minimal","normal","detailed")] + [string] $LogLevel = "minimal", + + [Parameter()] + [switch] $Clean, + + [Parameter()] + [string] $BuildModulePath +) + +$ErrorActionPreference = $ErrorActionPreference ? $ErrorActionPreference : 'Stop' +$InformationPreference = $InformationAction ? $InformationAction : 'Continue' + +$here = Split-Path -Parent $PSCommandPath + +#region InvokeBuild setup +if (!(Get-Module -ListAvailable InvokeBuild)) { + Install-Module InvokeBuild -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery +} +Import-Module InvokeBuild +# This handles calling the build engine when this file is run like a normal PowerShell script +# (i.e. avoids the need to have another script to setup the InvokeBuild environment and issue the 'Invoke-Build' command ) +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + try { + Invoke-Build $Tasks $MyInvocation.MyCommand.Path @PSBoundParameters + } + catch { + $_.ScriptStackTrace + throw + } + return +} +#endregion + +# Import shared tasks and initialise build framework +if (!($BuildModulePath)) { + if (!(Get-Module -ListAvailable Endjin.RecommendedPractices.Build)) { + Write-Information "Installing 'Endjin.RecommendedPractices.Build' module..." + Install-Module Endjin.RecommendedPractices.Build -RequiredVersion 0.1.1 -AllowPrerelease -Scope CurrentUser -Force -Repository PSGallery + } + $BuildModulePath = "Endjin.RecommendedPractices.Build" +} +else { + Write-Information "BuildModulePath: $BuildModulePath" +} +Import-Module $BuildModulePath -Force + +# Load the build process & tasks +. Endjin.RecommendedPractices.Build.tasks + +# +# Build process control options +# +$SkipVersion = $false +$SkipBuild = $false +$CleanBuild = $false +$SkipTest = $true +$SkipTestReport = $true +$SkipPackage = $false + +# Advanced build settings +$EnableGitVersionAdoVariableWorkaround = $false + +# +# Build process configuration +# +$SolutionToBuild = (Resolve-Path (Join-Path $here ".\Solutions\AzAppConfigToUserSecrets.sln")).Path + +# +# Specify files to exclude from test coverage +# This option is for excluding generated code +$ExcludeFilesFromCodeCoverage = "" + +# Synopsis: Build, Test and Package +task . FullBuild + +# build extensibility tasks +task PreBuild {} +task PostBuild {} +task PreTest {} +task PostTest {} +task PreTestReport {} +task PostTestReport {} +task PrePackage {} +task PostPackage {} \ No newline at end of file