diff --git a/.env.example b/.env.example index 0bda7b7..fdc867d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ +# All Values Required AZURE_ENV= -ENGINE_APP_ID= -ENGINE_APP_SECRET= -UI_APP_ID= +ENGINE_APP_ID= +ENGINE_APP_SECRET= +UI_APP_ID= TENANT_ID= COSMOS_URL=https://.documents.azure.com -COSMOS_KEY= KEYVAULT_URL=https://.vault.azure.net + +# Legacy Values +# COSMOS_KEY= diff --git a/.github/workflows/azure-ipam-build.yml b/.github/workflows/azure-ipam-build.yml index 180897f..b842616 100644 --- a/.github/workflows/azure-ipam-build.yml +++ b/.github/workflows/azure-ipam-build.yml @@ -8,18 +8,116 @@ on: permissions: id-token: write - contents: read + contents: write + pull-requests: read env: ACR_NAME: ${{ vars.IPAM_PROD_ACR }} jobs: - deploy: - name: Update Azure IPAM Containers + version: + name: Update Azure IPAM Version runs-on: ubuntu-latest + outputs: + ipamVersion: ${{ steps.updateVersion.outputs.ipamVersion }} steps: - run: echo "Job triggered by a ${{ github.event_name }} event to main." + - name: Checkout Azure IPAM Code + uses: actions/checkout@v4 + + - uses: actions/github-script@v7 + id: getPullRequestData + with: + script: | + return ( + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + commit_sha: context.sha, + owner: context.repo.owner, + repo: context.repo.repo, + }) + ).data[0]; + + - name: "Increment Azure IPAM Version" + id: updateVersion + working-directory: tools + env: + prBody: ${{ fromJson(steps.getPullRequestData.outputs.result).body }} + shell: pwsh + run: | + $version = [regex]::matches($env:prBody, '(?<=\[version:).*(?=])').value + $major = $env:prBody -match '(?<=\[)major(?=])' + $minor = $env:prBody -match '(?<=\[)minor(?=])' + $build = $env:prBody -match '(?<=\[)build(?=])' + + try { + $version = [System.Version]$version + $newVersion = "{0}.{1}.{2}" -f $version.Major, $version.Minor, $version.Build + } catch { + $version = $null + } + + if ($version) { + ./version.ps1 -Version $newVersion + } else if ($major) { + ./version.ps1 -BumpMajor + } else if ($minor) { + ./version.ps1 -BumpMinor + } else { + ./version.ps1 -BumpBuild + } + + - name: "Create Azure IPAM ZIP Asset" + id: buildZipAsset + working-directory: tools + shell: pwsh + run: | + ./build.ps1 -Path ../assets/ + + - name: Commit Updated Azure IPAM Code + id: commitCode + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "ipam@users.noreply.github.com" + git commit -a -m "Updated Azure IPAM Version" + git push + + release: + name: Create Azure IPAM Release + runs-on: ubuntu-latest + needs: [ version ] + steps: + - name: Checkout Azure IPAM Code + uses: actions/checkout@v4 + with: + sparse-checkout: | + assets + + - name: Publish Azure IPAM Release + id: publishRelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tagName: v${{ needs.version.outputs.ipamVersion }} + run: | + gh release create "$tagName" \ + --repo="$GITHUB_REPOSITORY" \ + --title="$tagName" \ + --notes "Azure IPAM Release" + + - name: Upload Azure IPAM Release Asset + id: uploadReleaseAsset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tagName: v${{ needs.version.outputs.ipamVersion }} + assetPath: ./assets/ipam.zip + run: | + gh release upload "$tagName" "$assetPath" + + update: + name: Update Azure IPAM Containers + runs-on: ubuntu-latest + needs: [ version, release ] + steps: - name: Azure login uses: azure/login@v1 with: @@ -29,26 +127,21 @@ jobs: enable-AzPSSession: true - name: Checkout Azure IPAM Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: sparse-checkout: | engine ui lb - - name: "Upload Azure IPAM Version" - id: updateVersion - shell: pwsh - run: | - $newVersion = "latest" - - Write-Output "ipamVersion=$newVersion" >> $Env:GITHUB_OUTPUT - - name: Build Azure IPAM Containers env: - IPAM_VERSION: ${{ steps.updateVersion.outputs.ipamVersion }} + IPAM_VERSION: ${{ needs.version.outputs.ipamVersion }} run: | - az acr build -r $ACR_NAME -t ipam-engine:$IPAM_VERSION -f ./engine/Dockerfile.deb ./engine - az acr build -r $ACR_NAME -t ipam-func:$IPAM_VERSION -f ./engine/Dockerfile.func ./engine - az acr build -r $ACR_NAME -t ipam-ui:$IPAM_VERSION -f ./ui/Dockerfile.deb ./ui - az acr build -r $ACR_NAME -t ipam-lb:$IPAM_VERSION -f ./lb/Dockerfile ./lb + az acr build -r $ACR_NAME -t ipam:$IPAM_VERSION -t ipam:latest -f ./Dockerfile.deb . + az acr build -r $ACR_NAME -t ipamfunc:$IPAM_VERSION -t ipamfunc:latest -f ./Dockerfile.func . + + az acr build -r $ACR_NAME -t ipam-engine:$IPAM_VERSION -t ipam-engine:latest -f ./engine/Dockerfile.deb ./engine + az acr build -r $ACR_NAME -t ipam-func:$IPAM_VERSION -t ipam-func:latest -f ./engine/Dockerfile.func ./engine + az acr build -r $ACR_NAME -t ipam-ui:$IPAM_VERSION -t ipam-ui:latest -f ./ui/Dockerfile.deb ./ui + az acr build -r $ACR_NAME -t ipam-lb:$IPAM_VERSION -t ipam-lb:latest -f ./lb/Dockerfile ./lb diff --git a/.github/workflows/azure-ipam-testing.yml b/.github/workflows/azure-ipam-testing.yml index b32c112..19bc4bc 100644 --- a/.github/workflows/azure-ipam-testing.yml +++ b/.github/workflows/azure-ipam-testing.yml @@ -8,8 +8,8 @@ on: env: ACR_NAME: ${{ vars.IPAM_TEST_ACR }} - IPAM_UI_ID: ipam-ui-${{ github.run_id }}-${{ github.run_attempt }} - IPAM_ENGINE_ID: ipam-engine-${{ github.run_id }}-${{ github.run_attempt }} + IPAM_UI_NAME: ipam-ui-${{ github.run_id }}-${{ github.run_attempt }} + IPAM_ENGINE_NAME: ipam-engine-${{ github.run_id }}-${{ github.run_attempt }} jobs: deploy: @@ -26,7 +26,7 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Az, Microsoft.Graph, powershell-yaml -AllowClobber -Force + Install-Module Az, Microsoft.Graph -AllowClobber -Force - name: Azure Login uses: azure/login@v1 @@ -35,42 +35,36 @@ jobs: enable-AzPSSession: true - name: Checkout Azure IPAM Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: sparse-checkout: | deploy engine ui - lb - - name: Build Azure IPAM Containers + - name: Build Azure IPAM Container run: | - az acr build -r $ACR_NAME -t ipam-engine:${{ github.run_id }}-${{ github.run_attempt }} -f ./engine/Dockerfile.deb ./engine - az acr build -r $ACR_NAME -t ipam-func:${{ github.run_id }}-${{ github.run_attempt }} -f ./engine/Dockerfile.func ./engine - az acr build -r $ACR_NAME -t ipam-ui:${{ github.run_id }}-${{ github.run_attempt }} -f ./ui/Dockerfile.deb ./ui - az acr build -r $ACR_NAME -t ipam-lb:${{ github.run_id }}-${{ github.run_attempt }} -f ./lb/Dockerfile ./lb + az acr build -r $ACR_NAME -t ipam:${{ github.run_id }}-${{ github.run_attempt }} -f ./Dockerfile.deb . - - name: Update Docker-Compose YAML + - name: Update Bicep File + working-directory: deploy shell: pwsh run: | - $uiContainer = "$env:ACR_NAME.azurecr.io/ipam-ui:${{ github.run_id }}-${{ github.run_attempt }}" - $engineContainer = "$env:ACR_NAME.azurecr.io/ipam-engine:${{ github.run_id }}-${{ github.run_attempt }}" - $lbContainer = "$env:ACR_NAME.azurecr.io/ipam-lb:${{ github.run_id }}-${{ github.run_attempt }}" + $acrName = "$env:ACR_NAME.azurecr.io" + $containerName = "ipam:${{ github.run_id }}-${{ github.run_attempt }}" - $composeFile = Get-Content -Path ./docker-compose.prod.yml - $composeYaml = $composeFile | ConvertFrom-Yaml + $bicepFile = Get-Content -Path ./modules/appService.bicep - $composeYaml['services']['ipam-ui'].image = $uiContainer - $composeYaml['services']['ipam-engine'].image = $engineContainer - $composeYaml['services']['nginx-proxy'].image = $lbContainer + $bicepFile = $bicepFile -replace "azureipam.azurecr.io", $acrName + $bicepFile = $bicepFile -replace "ipam:latest", $containerName - $composeYaml | ConvertTo-Yaml | Out-File -FilePath ./docker-compose.prod.yml + $bicepFile | Out-File -FilePath ./modules/appService.bicep -Force - name: Deploy Azure IPAM working-directory: deploy id: deployScript shell: pwsh - run: ./deploy.ps1 -Location "westus3" -UIAppName $env:IPAM_UI_ID -EngineAppName $env:IPAM_ENGINE_ID + run: ./deploy.ps1 -Location "westus3" -UIAppName $env:IPAM_UI_NAME -EngineAppName $env:IPAM_ENGINE_NAME - name: "Upload Logs" working-directory: logs @@ -121,6 +115,10 @@ jobs: Write-Host ($deployDetails | Format-Table | Out-String) -NoNewline Write-Host "-------------------" + - name: "Sleep for 5 Minutes" + shell: pwsh + run: Start-Sleep -s 300 + test: name: Test Azure IPAM runs-on: ubuntu-latest @@ -139,7 +137,7 @@ jobs: enable-AzPSSession: true - name: Checkout Azure IPAM Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: sparse-checkout: | tests @@ -174,7 +172,7 @@ jobs: runs-on: ubuntu-latest needs: [ deploy, test ] steps: - - name: Install Deployment Prerequisites + - name: Install Cleanup Prerequisites shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted @@ -207,9 +205,6 @@ jobs: $uiApp | Remove-AzADApplication $engineApp | Remove-AzADApplication - - name: "Remove Azure IPAM Containers" + - name: "Remove Azure IPAM Container" run: | - az acr repository delete --name $ACR_NAME --repository ipam-engine --yes - az acr repository delete --name $ACR_NAME --repository ipam-func --yes - az acr repository delete --name $ACR_NAME --repository ipam-ui --yes - az acr repository delete --name $ACR_NAME --repository ipam-lb --yes + az acr repository delete --name $ACR_NAME --repository ipam --yes diff --git a/.gitignore b/.gitignore index 7f94507..122321e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ NOTES.md TODO.md /logs -/deployV2 diff --git a/Dockerfile b/Dockerfile.deb similarity index 57% rename from Dockerfile rename to Dockerfile.deb index d1fbf81..8083284 100644 --- a/Dockerfile +++ b/Dockerfile.deb @@ -1,11 +1,18 @@ -# syntax=docker/dockerfile:1 -FROM node:18-slim AS builder +ARG BUILD_IMAGE=node:18-slim +ARG SERVE_IMAGE=python:3.9-slim -# Set Working Directory -WORKDIR /app +ARG PORT=8080 + +FROM $BUILD_IMAGE AS builder + +# Disable NPM Update Notifications +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +# Set the Working Directory +WORKDIR /tmp # Add `/app/node_modules/.bin` to $PATH -ENV PATH /app/node_modules/.bin:$PATH +ENV PATH /tmp/node_modules/.bin:$PATH # Install UI Dependencies COPY ./ui/package.json ./ @@ -20,14 +27,22 @@ COPY ./ui/. ./ # Build IPAM UI RUN npm run build -FROM python:3.9-slim +FROM $SERVE_IMAGE + +ARG PORT + +# Set Environment Variable +ENV PORT=${PORT} + +# Disable PIP Root Warnings +ENV PIP_ROOT_USER_ACTION=ignore # Set Working Directory WORKDIR /tmp # Install OpenSSH and set the password for root to "Docker!" -RUN apt update -RUN apt install openssh-server -y \ +RUN apt-get update +RUN apt-get install -qq openssh-server -y \ && echo "root:Docker!" | chpasswd # Enable SSH root login with Password Authentication @@ -39,15 +54,8 @@ COPY sshd_config /etc/ssh/ RUN ssh-keygen -A RUN mkdir /var/run/sshd -# Install NodeJS 16.x -RUN apt install curl -y -RUN curl -sL https://deb.nodesource.com/setup_18.x -o nodesource_setup.sh -RUN bash ./nodesource_setup.sh -RUN apt install nodejs -RUN npm install -g react-inject-env - # Set Working Directory -WORKDIR /code +WORKDIR /ipam # Install Engine Dependencies COPY ./engine/requirements.txt /code/requirements.txt @@ -56,19 +64,20 @@ COPY ./engine/requirements.txt /code/requirements.txt RUN pip install --upgrade pip --progress-bar off # Install Dependencies -RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt --progress-bar off # Copy Engine Code -COPY ./engine/app /code/app -COPY --from=builder /app/build ./app/build +COPY ./engine/app ./app +COPY --from=builder /tmp/dist ./dist # Copy Init Script -COPY ./init.sh /code +COPY ./init.sh . -# Expose Ports -EXPOSE 80 2222 +# Set Script Execute Permissions +RUN chmod +x init.sh -# Execute Init Script -CMD ["bash", "./init.sh"] +# Expose Ports +EXPOSE $PORT 2222 -# CMD npx --yes react-inject-env set -d /code/app/build ; uvicorn "app.main:app" --reload --host "0.0.0.0" --port 80 +# Execute Startup Script +ENTRYPOINT ./init.sh ${PORT} diff --git a/Dockerfile.func b/Dockerfile.func new file mode 100644 index 0000000..4bbc7d7 --- /dev/null +++ b/Dockerfile.func @@ -0,0 +1,50 @@ +ARG BUILD_IMAGE=mcr.microsoft.com/azure-functions/node:4-node18-appservice +ARG SERVE_IMAGE=mcr.microsoft.com/azure-functions/python:4-python3.9-appservice + +FROM $BUILD_IMAGE AS builder + +# Disable NPM Update Notifications +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +# Set the Working Directory +WORKDIR /tmp + +# Add `/app/node_modules/.bin` to $PATH +ENV PATH /tmp/node_modules/.bin:$PATH + +# Install UI Dependencies +COPY ./ui/package.json ./ +COPY ./ui/package-lock.json ./ + +RUN npm install +RUN chmod 777 node_modules + +# Copy UI Code +COPY ./ui/. ./ + +# Build IPAM UI +RUN npm run build + +FROM $SERVE_IMAGE + +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +# Disable PIP Root Warnings +ENV PIP_ROOT_USER_ACTION=ignore + +# Set Working Directory +WORKDIR /tmp + +# Copy Requirements File +COPY ./engine/requirements.txt . + +# Upgrade PIP +RUN pip install --upgrade pip --progress-bar off + +# Install Dependencies +RUN pip install --no-cache-dir --upgrade -r ./requirements.txt --progress-bar off + +# Copy Application Code to Function App Root Directory +COPY ./engine/. /home/site/wwwroot +COPY --from=builder /tmp/dist /home/site/wwwroot/dist diff --git a/Dockerfile.rhel b/Dockerfile.rhel new file mode 100644 index 0000000..e0b3baf --- /dev/null +++ b/Dockerfile.rhel @@ -0,0 +1,92 @@ +ARG BUILD_IMAGE=registry.access.redhat.com/ubi8/nodejs-18 +ARG SERVE_IMAGE=registry.access.redhat.com/ubi8/python-39 + +ARG PORT=8080 + +FROM $BUILD_IMAGE AS builder + +# Disable NPM Update Notifications +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +# Set the Working Directory +WORKDIR /tmp + +# Add `/app/node_modules/.bin` to $PATH +ENV PATH /tmp/node_modules/.bin:$PATH + +# Install UI Dependencies +COPY ./ui/package.json ./ +COPY ./ui/package-lock.json ./ + +RUN npm install +RUN chmod 777 node_modules + +# Copy UI Code +COPY ./ui/. ./ + +# Build IPAM UI +RUN npm run build + +FROM $SERVE_IMAGE + +ARG PORT + +# Set Environment Variable +ENV PORT=${PORT} + +# Disable PIP Root Warnings +ENV PIP_ROOT_USER_ACTION=ignore + +# Set Working Directory +WORKDIR /tmp + +# Switch to Root User +USER root + +# Install OpenSSH and set the password for root to "Docker!" +RUN yum update -y +RUN yum install -qq openssh-server -y \ + && echo "root:Docker!" | chpasswd \ + && systemctl enable sshd + +# Enable SSH root login with Password Authentication +# RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config + +# Copy 'sshd_config File' to /etc/ssh/ +COPY sshd_config /etc/ssh/ + +RUN ssh-keygen -A +RUN mkdir /var/run/sshd + +# Set Working Directory +WORKDIR /ipam + +# Install Engine Dependencies +COPY ./engine/requirements.txt /code/requirements.txt + +# Upgrade PIP +RUN pip install --upgrade pip --progress-bar off + +# Install Dependencies +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt --progress-bar off + +# Copy Engine Code +COPY ./engine/app ./app +COPY --from=builder /tmp/dist ./dist + +# Copy Init Script +COPY ./init.sh . + +# Set Script Execute Permissions +RUN chmod +x init.sh +RUN chown -R 1001:0 /ipam +RUN chown -R 1001:0 /etc/profile + +# Switch to Standard User +USER 1001 + +# Expose Ports +EXPOSE $PORT 2222 + +# Execute Startup Script +ENTRYPOINT ./init.sh ${PORT} diff --git a/README.md b/README.md index 2f6bf04..fb49bee 100644 --- a/README.md +++ b/README.md @@ -29,21 +29,26 @@ Azure IPAM is a lightweight solution developed on top of the Azure platform desi | File/folder | Description | |----------------------|---------------------------------------------------------------| -| `.github/` | Bug Report & Issue Templates | +| `.github/` | Bug Report, Issue Templates and GitHub Actions | | `.vscode/` | VSCode Configuration | | `deploy/` | Deployment Bicep Templates & PowerShell Deployment Scripts | +| `assets/` | Compiled ZIP Archive | | `docs/` | Documentation Folder | | `engine/` | Engine Application Code | +| `examples/` | Example Templates, Scripts and Code Snippets for Azure IPAM | | `lb/` | Load Balancer (NGINX) Configs | +| `tests/` | Testing Scripts | +| `tools/` | Lifecycle Scripts (Build/Version/Update) | | `ui/` | UI Application Code | | `.dockerignore` | Untracked Docker Files to Ignore | +| `.env.example` | Example ENV File to be Used with Docker Compose | +| `.gitattributes` | Git File and Path Attributes | | `.gitignore` | Untracked Git Files to Ignore | | `CODE_OF_CONDUCT.md` | Microsoft Code of Conduct | -| `default.conf` | NGINX Default Configuration File | -| `default.dev.conf` | NGINX Development Default Configuration File | -| `docker-compose.prod.yml` | Production Docker Compose File | | `docker-compose.yml` | Development Docker Compose File | -| `Dockerfile` | Development Dockerfile | +| `Dockerfile.deb` | Single Container Dockerfile (Debian) | +| `Dockerfile.func` | Single Container Dockerfile (Function) | +| `Dockerfile.rhel` | Single Container Dockerfile (Red Hat) | | `init.sh` | Single Container Init Script | | `LICENSE` | Microsoft MIT License | | `README.md` | This README File | diff --git a/assets/ipam.zip b/assets/ipam.zip new file mode 100644 index 0000000..10fa75a Binary files /dev/null and b/assets/ipam.zip differ diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 index f530cab..7be5aae 100644 --- a/deploy/deploy.ps1 +++ b/deploy/deploy.ps1 @@ -6,27 +6,39 @@ # Set minimum version requirements #Requires -Version 7.2 -#Requires -Modules @{ ModuleName="Az"; ModuleVersion="8.0.0"} -#Requires -Modules @{ ModuleName="Microsoft.Graph"; ModuleVersion="1.9.6"} +#Requires -Modules @{ ModuleName="Az"; ModuleVersion="10.3.0"} +#Requires -Modules @{ ModuleName="Microsoft.Graph"; ModuleVersion="2.0.0"} # Intake and set global parameters -[CmdletBinding(DefaultParameterSetName = 'Full')] +[CmdletBinding(DefaultParameterSetName = 'AppContainer')] param( [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, - ParameterSetName = 'Full')] + ParameterSetName = 'App')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, - ParameterSetName = 'TemplateOnly')] + ParameterSetName = 'AppContainer')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'Function')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, + ParameterSetName = 'FunctionContainer')] [string] $Location, [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, - ParameterSetName = 'Full')] + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'Function')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, ParameterSetName = 'AppsOnly')] @@ -35,73 +47,132 @@ param( [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, - ParameterSetName = 'Full')] + ParameterSetName = 'App')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, - ParameterSetName = 'AppsOnly')] + ParameterSetName = 'AppContainer')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, ParameterSetName = 'Function')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $false, - ParameterSetName = 'FuncAppsOnly')] + ParameterSetName = 'FunctionContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppsOnly')] [string] $EngineAppName = 'ipam-engine-app', - [Parameter(Mandatory = $false, - ParameterSetName = 'Full')] - [Parameter(Mandatory = $false, - ParameterSetName = 'TemplateOnly')] - [Parameter(Mandatory = $false, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, ParameterSetName = 'Function')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] [ValidateLength(1,7)] [string] $NamePrefix, - [Parameter(Mandatory = $false, - ParameterSetName = 'Full')] - [Parameter(Mandatory = $false, - ParameterSetName = 'TemplateOnly')] - [Parameter(Mandatory = $false, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, ParameterSetName = 'Function')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] [hashtable] $Tags, - [Parameter(Mandatory = $true, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, ParameterSetName = 'AppsOnly')] - [Parameter(Mandatory = $true, - ParameterSetName = 'FuncAppsOnly')] [switch] $AppsOnly, - [Parameter(Mandatory = $true, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, ParameterSetName = 'Function')] - [Parameter(Mandatory = $true, - ParameterSetName = 'FuncAppsOnly')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppsOnly')] [switch] - $AsFunction, + $DisableUI, - [Parameter(Mandatory = $false, - ParameterSetName = 'Full')] - [Parameter(Mandatory = $false, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, ParameterSetName = 'Function')] - [Parameter(Mandatory = $false, - ParameterSetName = 'TemplateOnly')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, + ParameterSetName = 'FunctionContainer')] [switch] - $PrivateACR, + $Function, + + # [Parameter(ValueFromPipelineByPropertyName = $true, + # Mandatory = $true, + # ParameterSetName = 'AppContainer')] + # [Parameter(ValueFromPipelineByPropertyName = $true, + # Mandatory = $true, + # ParameterSetName = 'FunctionContainer')] + # [switch] + # $Container, - [Parameter(Mandatory = $false, - ParameterSetName = 'Full')] - [Parameter(Mandatory = $false, + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, ParameterSetName = 'Function')] - [Parameter(Mandatory = $false, - ParameterSetName = 'TemplateOnly')] + [switch] + $Native, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] + [switch] + $PrivateACR, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] [ValidateSet('Debian', 'RHEL')] [string] $ContainerType = 'Debian', - [Parameter(Mandatory = $true, - ParameterSetName = 'TemplateOnly')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'App')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'AppContainer')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'Function')] + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'FunctionContainer')] [ValidateScript({ if(-Not ($_ | Test-Path) ){ throw [System.ArgumentException]::New("Target file or does not exist.") @@ -122,6 +193,7 @@ DynamicParam { $validators = @{ functionName = '^(?=^.{2,59}$)([^-][\w-]*[^-])$' appServiceName = '^(?=^.{2,59}$)([^-][\w-]*[^-])$' + functionPlanName = '^(?=^.{1,40}$)([\w-]*)$' appServicePlanName = '^(?=^.{1,40}$)([\w-]*)$' cosmosAccountName = '^(?=^.{3,44}$)([^-][a-z0-9-]*[^-])$' cosmosContainerName = '^(?=^.{1,255}$)([^/\\#?]*)$' @@ -138,30 +210,33 @@ DynamicParam { $validators.Remove('containerRegistryName') } - if(-not $AsFunction) { + if(-not $Function) { $validators.Remove('functionName') + $validators.Remove('functionPlanName') $validators.Remove('storageAccountName') } - if($AsFunction) { + if($Function) { $validators.Remove('appServiceName') + $validators.Remove('appServicePlanName') } - $attrFull = [System.Management.Automation.ParameterAttribute]::new() - $attrFull.ParameterSetName = 'ResourceNames' - $attrFull.ParameterSetName = "Full" - $attrFull.Mandatory = $false + $attrApp = [System.Management.Automation.ParameterAttribute]::new() + $attrApp.ParameterSetName = "App" + $attrApp.Mandatory = $false - $attrTemplateOnly = [System.Management.Automation.ParameterAttribute]::new() - $attrTemplateOnly.ParameterSetName = 'ResourceNames' - $attrTemplateOnly.ParameterSetName = "TemplateOnly" - $attrTemplateOnly.Mandatory = $false + $attrAppContainer = [System.Management.Automation.ParameterAttribute]::new() + $attrAppContainer.ParameterSetName = "AppContainer" + $attrAppContainer.Mandatory = $false $attrFunction = [System.Management.Automation.ParameterAttribute]::new() - $attrFunction.ParameterSetName = 'ResourceNames' $attrFunction.ParameterSetName = "Function" $attrFunction.Mandatory = $false + $attrFunctionContainer = [System.Management.Automation.ParameterAttribute]::new() + $attrFunctionContainer.ParameterSetName = "FunctionContainer" + $attrFunctionContainer.Mandatory = $false + $attrValidation = [System.Management.Automation.ValidateScriptAttribute]::new({ $invalidFields = [System.Collections.ArrayList]@() $missingFields = [System.Collections.ArrayList]@() @@ -178,7 +253,8 @@ DynamicParam { if ($invalidFields -or $missingFields) { $deploymentType = $PrivateAcr ? "'$($PSCmdlet.ParameterSetName) w/ Private ACR'" : $PSCmdlet.ParameterSetName - Write-Host "ERROR: Missing or improperly formatted field(s) in 'ResourceNames' parameter for deploment type '$deploymentType'" -ForegroundColor Red + Write-Host + Write-Host "ERROR: Missing or improperly formatted field(s) in 'ResourceNames' parameter for deploment type $deploymentType" -ForegroundColor Red foreach ($field in $invalidFields) { Write-Host "ERROR: Invalid Field ->" $field -ForegroundColor Red @@ -191,7 +267,7 @@ DynamicParam { Write-Host "ERROR: Please refer to the 'Naming Rules and Restrictions for Azure Resources'" -ForegroundColor Red Write-Host "ERROR: " -ForegroundColor Red -NoNewline Write-Host "https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules" -ForegroundColor Yellow - Write-Host "" + Write-Host throw [System.ArgumentException]::New("One of the required resource names is missing or invalid.") } @@ -200,9 +276,10 @@ DynamicParam { }) $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() - $attributeCollection.Add($attrFull) - $attributeCollection.Add($attrTemplateOnly) + $attributeCollection.Add($attrApp) + $attributeCollection.Add($attrAppContainer) $attributeCollection.Add($attrFunction) + $attributeCollection.Add($attrFunctionContainer) $attributeCollection.Add($attrValidation) $param = [System.Management.Automation.RuntimeDefinedParameter]::new('ResourceNames', [hashtable], $attributeCollection) @@ -218,24 +295,35 @@ process { $AZURE_ENV_MAP = @{ AzureCloud = "AZURE_PUBLIC" AzureUSGovernment = "AZURE_US_GOV" + USSec = "AZURE_US_GOV_SECRET" AzureGermanCloud = "AZURE_GERMANY" AzureChinaCloud = "AZURE_CHINA" } + # Root Directory + $ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName + + # Minimum Required Azure CLI Version $MIN_AZ_CLI_VER = [System.Version]'2.35.0' + # Check for Debug Flag $DEBUG_MODE = [bool]$PSCmdlet.MyInvocation.BoundParameters[“Debug”].IsPresent # Set preference variables $ErrorActionPreference = "Stop" $DebugPreference = 'SilentlyContinue' + $ProgressPreference = 'SilentlyContinue' # Hide Azure PowerShell SDK Warnings $Env:SuppressAzurePowerShellBreakingChangeWarnings = $true + # Hide Azure PowerShell SDK & Azure CLI Survey Prompts + $Env:AzSurveyMessage = $false + $Env:AZURE_CORE_SURVEY_MESSAGE = $false + # Set Log File Location - $logPath = [Io.Path]::Combine('..', 'logs') - New-Item -ItemType Directory -Force -Path $logpath | Out-Null + $logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" + New-Item -ItemType Directory -Path $logpath -Force| Out-Null $debugLog = Join-Path -Path $logPath -ChildPath "debug_$(get-date -format `"yyyyMMddhhmmsstt`").log" $errorLog = Join-Path -Path $logPath -ChildPath "error_$(get-date -format `"yyyyMMddhhmmsstt`").log" @@ -243,6 +331,8 @@ process { $debugSetting = $DEBUG_MODE ? 'Continue' : 'SilentlyContinue' + $deploymentSuccess = $false + Start-Transcript -Path $transcriptLog | Out-Null Function Test-Location { @@ -267,8 +357,8 @@ process { [Parameter(Mandatory=$true)] [string]$AzureCloud, [Parameter(Mandatory=$false)] - [bool]$AsFunction - ) + [bool]$DisableUI = $false + ) $uiResourceAccess = [System.Collections.ArrayList]@( @{ @@ -305,10 +395,10 @@ process { } } - # Create IPAM UI Application (If not deployed as a Function App) - if (-not $AsFunction) { + # Create IPAM UI Application (If -UI:$false not specified) + if (-not $DisableUI) { Write-Host "INFO: Creating Azure IPAM UI Application" -ForegroundColor Green - Write-Verbose -Message "Creating Azure IPAM UI Application" + $uiApp = New-AzADApplication ` -DisplayName $UiAppName ` -SPARedirectUri "https://replace-this-value.azurewebsites.net" ` @@ -317,20 +407,24 @@ process { $engineResourceMap = @{ "AZURE_PUBLIC" = @{ - ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" - ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") + ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" # Azure Service Management + ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") # user_impersonation } "AZURE_US_GOV" = @{ - ResourceAppId = "40a69793-8fe6-4db1-9591-dbc5c57b17d8" - ResourceAccessIds = @("8eb49ffc-05ac-454c-9027-8648349217dd", "e59ee429-1fb1-4054-b99f-f542e8dc9b95") + ResourceAppId = "40a69793-8fe6-4db1-9591-dbc5c57b17d8" # Azure Service Management + ResourceAccessIds = @("8eb49ffc-05ac-454c-9027-8648349217dd", "e59ee429-1fb1-4054-b99f-f542e8dc9b95") # user_impersonation + } + "AZURE_US_GOV_SECRET" = @{ + ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" # Azure Service Management + ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") # user_impersonation } "AZURE_GERMANY" = @{ - ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" - ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") + ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" # Azure Service Management + ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") # user_impersonation } "AZURE_CHINA" = @{ - ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" - ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") + ResourceAppId = "797f4846-ba00-4fd7-ba43-dac1f8f63013" # Azure Service Management + ResourceAccessIds = @("41094075-9dad-400e-a0bd-54e686782033") # user_impersonation } } @@ -385,42 +479,40 @@ process { RequestedAccessTokenVersion = 2 } - # Add the UI App as a Known Client App (If not deployed as Function App) - if (-not $AsFunction) { + # Add the UI App as a Known Client App (If -UI:$false not specified) + if (-not $DisableUI) { $engineApiSettings.Add("KnownClientApplication", $knownClientApplication) } - # Create IPAM Engine Application Write-Host "INFO: Creating Azure IPAM Engine Application" -ForegroundColor Green - Write-Verbose -Message "Creating Azure IPAM Engine Application" + + # Create IPAM Engine Application $engineApp = New-AzADApplication ` -DisplayName $EngineAppName ` -Api $engineApiSettings ` -RequiredResourceAccess $engineResourceAccessList - # Update IPAM Engine API Endpoint (If not deployed as Function App) - if (-not $AsFunction) { - Write-Host "INFO: Updating Azure IPAM Engine API Endpoint" -ForegroundColor Green - Write-Verbose -Message "Updating Azure IPAM UI Engine API Endpoint" - Update-AzADApplication -ApplicationId $engineApp.AppId -IdentifierUri "api://$($engineApp.AppId)" + Write-Host "INFO: Updating Azure IPAM Engine API Endpoint" -ForegroundColor Green - $uiEngineApiAccess =@{ - ResourceAppId = $engineApp.AppId - ResourceAccess = @( - @{ - Id = $engineApiGuid - Type = "Scope" - } - ) - } + # Update IPAM Engine API Endpoint + Update-AzADApplication -ApplicationId $engineApp.AppId -IdentifierUri "api://$($engineApp.AppId)" - $uiResourceAccess.Add($uiEngineApiAccess) | Out-Null + $uiEngineApiAccess =@{ + ResourceAppId = $engineApp.AppId + ResourceAccess = @( + @{ + Id = $engineApiGuid + Type = "Scope" + } + ) } - # Update IPAM UI Application Resource Access (If not deployed as Function App) - if (-not $AsFunction) { + $uiResourceAccess.Add($uiEngineApiAccess) | Out-Null + + # Update IPAM UI Application Resource Access (If -UI:$false not specified) + if (-not $DisableUI) { Write-Host "INFO: Updating Azure IPAM UI Application Resource Access" -ForegroundColor Green - Write-Verbose -Message "Updating Azure IPAM UI Application Resource Access" + Update-AzADApplication -ApplicationId $uiApp.AppId -RequiredResourceAccess $uiResourceAccess $uiObject = Get-AzADApplication -ApplicationId $uiApp.AppId @@ -428,38 +520,41 @@ process { $engineObject = Get-AzADApplication -ApplicationId $engineApp.AppId - # Create IPAM UI Service Principal (If not deployed as Function App) - if (-not $AsFunction) { + # Create IPAM UI Service Principal (If -UI:$false not specified) + if (-not $DisableUI) { Write-Host "INFO: Creating Azure IPAM UI Service Principal" -ForegroundColor Green - Write-Verbose -Message "Creating Azure IPAM UI Service Principal" + New-AzADServicePrincipal -ApplicationObject $uiObject | Out-Null } $scope = "/providers/Microsoft.Management/managementGroups/$TenantId" - # Create IPAM Engine Service Principal Write-Host "INFO: Creating Azure IPAM Engine Service Principal" -ForegroundColor Green - Write-Verbose -Message "Creating Azure IPAM Engine Service Principal" + + # Create IPAM Engine Service Principal New-AzADServicePrincipal -ApplicationObject $engineObject ` -Role "Reader" ` -Scope $scope ` | Out-Null - # Create IPAM Engine Secret Write-Host "INFO: Creating Azure IPAM Engine Secret" -ForegroundColor Green - Write-Verbose -Message "Creating Azure IPAM Engine Secret" + + # Create IPAM Engine Secret $engineSecret = New-AzADAppCredential -ApplicationObject $engineObject -StartDate (Get-Date) -EndDate (Get-Date).AddYears(2) - Write-Host "INFO: Azure IPAM Engine & UI Applications/Service Principals created successfully" -ForegroundColor Green - Write-Verbose -Message "Azure IPAM Engine & UI Applications/Service Principals created successfully" + if (-not $DisableUI) { + Write-Host "INFO: Azure IPAM Engine & UI Applications/Service Principals created successfully" -ForegroundColor Green + } else { + Write-Host "INFO: Azure IPAM Engine Application/Service Principal created successfully" -ForegroundColor Green + } $appDetails = @{ EngineAppId = $engineApp.AppId EngineSecret = $engineSecret.SecretText } - # Add UI AppID to AppDetails (If not deployed as Function App) - if (-not $AsFunction) { + # Add UI AppID to AppDetails (If -UI:$false not specified) + if (-not $DisableUI) { $appDetails.Add("UIAppId", $uiApp.AppId) } @@ -469,13 +564,13 @@ process { Function Grant-AdminConsent { Param( [Parameter(Mandatory=$false)] - [string]$UIAppId, + [string]$UIAppId = [GUID]::Empty, [Parameter(Mandatory=$true)] [string]$EngineAppId, - [Parameter(Mandatory=$false)] - [bool]$AsFunction, [Parameter(Mandatory=$true)] - [string]$AzureCloud + [string]$AzureCloud, + [Parameter(Mandatory=$false)] + [bool]$DisableUI = $false ) $msGraphMap = @{ @@ -487,6 +582,10 @@ process { Endpoint = "graph.microsoft.us" Environment = "USGov" } + AZURE_US_GOV_SECRET = @{ + Endpoint = "graph.cloudapi.microsoft.scloud" + Environment = "USSec" + } AZURE_GERMANY = @{ Endpoint = "graph.microsoft.de" Environment = "Germany" @@ -515,19 +614,20 @@ process { $accesstoken = (Get-AzAccessToken -Resource "https://$($msGraphMap[$AzureCloud].Endpoint)/").Token # Switch Access Token to SecureString if Graph Version is 2.x - $graphVersion = [System.Version](Get-InstalledModule -Name Microsoft.Graph).Version + $graphVersion = [System.Version](Get-InstalledModule -Name Microsoft.Graph | Sort-Object -Property Version | Select-Object -Last 1).Version ` + ?? (Get-Module -Name Microsoft.Graph | Sort-Object -Property Version | Select-Object -Last 1).Version if ($graphVersion.Major -gt 1) { $accesstoken = ConvertTo-SecureString $accesstoken -AsPlainText -Force } - # Connect to Microsoft Graph Write-Host "INFO: Logging in to Microsoft Graph" -ForegroundColor Green - Write-Verbose -Message "Logging in to Microsoft Graph" + + # Connect to Microsoft Graph Connect-MgGraph -Environment $msGraphMap[$AzureCloud].Environment -AccessToken $accesstoken | Out-Null - # Fetch Azure IPAM UI Service Principal (If not deployed as Function App) - if (-not $AsFunction) { + # Fetch Azure IPAM UI Service Principal (If -UI:$false not specified) + if (-not $DisableUI) { $uiSpn = Get-AzADServicePrincipal ` -ApplicationId $UIAppId } @@ -536,10 +636,10 @@ process { $engineSpn = Get-AzADServicePrincipal ` -ApplicationId $EngineAppId - # Grant admin consent for Microsoft Graph API permissions assigned to IPAM UI application (If not deployed as Function App) - if (-not $AsFunction) { + # Grant admin consent for Microsoft Graph API permissions assigned to IPAM UI application (If -UI:$false not specified) + if (-not $DisableUI) { Write-Host "INFO: Granting admin consent for Microsoft Graph API permissions assigned to IPAM UI application" -ForegroundColor Green - Write-Verbose -Message "Granting admin consent for Microsoft Graph API permissions assigned to IPAM UI application" + foreach($scope in $uiGraphScopes) { $msGraphId = Get-AzADServicePrincipal ` -ApplicationId $scope.scopeId @@ -553,13 +653,12 @@ process { } Write-Host "INFO: Admin consent for Microsoft Graph API permissions granted successfully" -ForegroundColor Green - Write-Verbose -Message "Admin consent for Microsoft Graph API permissions granted successfully" } - # Grant admin consent to the IPAM UI application for exposed API from the IPAM Engine application (If not deployed as a Function App) - if (-not $AsFunction) { + # Grant admin consent to the IPAM UI application for exposed API from the IPAM Engine application (If -UI:$false not specified) + if (-not $DisableUI) { Write-Host "INFO: Granting admin consent to the IPAM UI application for exposed API from the IPAM Engine application" -ForegroundColor Green - Write-Verbose -Message "Granting admin consent to the IPAM UI application for exposed API from the IPAM Engine application" + New-MgOauth2PermissionGrant ` -ResourceId $engineSpn.Id ` -Scope "access_as_user" ` @@ -568,12 +667,11 @@ process { | Out-Null Write-Host "INFO: Admin consent for IPAM Engine exposed API granted successfully" -ForegroundColor Green - Write-Verbose -Message "Admin consent for IPAM Engine exposed API granted successfully" } - # Grant admin consent for Azure Service Management API permissions assigned to IPAM Engine application Write-Host "INFO: Granting admin consent for Azure Service Management API permissions assigned to IPAM Engine application" -ForegroundColor Green - Write-Verbose -Message "Granting admin consent for Azure Service Management API permissions assigned to IPAM Engine application" + + # Grant admin consent for Azure Service Management API permissions assigned to IPAM Engine application foreach($scope in $engineGraphScopes) { $msGraphId = Get-AzADServicePrincipal ` -ApplicationId $scope.scopeId @@ -587,23 +685,21 @@ process { } Write-Host "INFO: Admin consent for Azure Service Management API permissions granted successfully" -ForegroundColor Green - Write-Verbose -Message "Admin consent for Azure Service Management API permissions granted successfully" } Function Save-Parameters { Param( [Parameter(Mandatory=$false)] - [string]$UIAppId = '00000000-0000-0000-000000000000', + [string]$UIAppId = [GUID]::Empty, [Parameter(Mandatory=$true)] [string]$EngineAppId, [Parameter(Mandatory=$true)] [string]$EngineSecret, [Parameter(Mandatory=$false)] - [bool]$AsFunction + [bool]$DisableUI = $false ) Write-Host "INFO: Populating Bicep parameter file for infrastructure deployment" -ForegroundColor Green - Write-Verbose -Message "Populating Bicep parameter file for infrastructure deployment" # Retrieve JSON object from sample parameter file $parametersObject = Get-Content main.parameters.example.json | ConvertFrom-Json @@ -611,20 +707,18 @@ process { # Update Parameter Values $parametersObject.parameters.engineAppId.value = $EngineAppId $parametersObject.parameters.engineAppSecret.value = $EngineSecret - $parametersObject.parameters.deployAsFunc.value = $AsFunction - if (-not $AsFunction) { + if (-not $DisableUI) { $parametersObject.parameters.uiAppId.value = $UIAppId - $parametersObject.parameters = $parametersObject.parameters | Select-Object * -ExcludeProperty namePrefix, tags + $parametersObject.parameters = $parametersObject.parameters | Select-Object -Property uiAppId, engineAppId, engineAppSecret } else { - $parametersObject.parameters = $parametersObject.parameters | Select-Object * -ExcludeProperty uiAppId, namePrefix, tags + $parametersObject.parameters = $parametersObject.parameters | Select-Object -Property engineAppId, engineAppSecret } # Output updated parameter file for Bicep deployment $parametersObject | ConvertTo-Json -Depth 4 | Out-File -FilePath main.parameters.json Write-Host "INFO: Bicep parameter file populated successfully" -ForegroundColor Green - Write-Verbose -Message "Bicep parameter file populated successfully" } Function Import-Parameters { @@ -634,21 +728,32 @@ process { ) Write-Host "INFO: Importing values from Bicep parameters file" -ForegroundColor Green - Write-Verbose -Message "Importing values from Bicep parameters file" # Retrieve JSON object from sample parameter file $parametersObject = Get-Content $ParameterFile | ConvertFrom-Json # Read Values from Parameters - $UIAppId = $parametersObject.parameters.uiAppId.value + $UIAppId = $parametersObject.parameters.uiAppId.value ?? [GUID]::Empty $EngineAppId = $parametersObject.parameters.engineAppId.value $EngineSecret = $parametersObject.parameters.engineAppSecret.value - $script:AsFunction = $parametersObject.parameters.deployAsFunc.value + $script:DisableUI = ($UIAppId -eq [GUID]::Empty) ? $true : $false - $deployType = $script:AsFunction ? 'Function' : 'Full' + if ((-not $EngineAppId) -or (-not $EngineSecret)) { + Write-Host "ERROR: Missing required parameters from Bicep parameter file" -ForegroundColor Red + Write-Host "ERROR: Please ensure the following parameters are present in the Bicep parameter file" -ForegroundColor Red + Write-Host "ERROR: Required: [engineAppId, engineAppSecret]" -ForegroundColor Red + Write-Host "" + Write-Host "ERROR: Please refer to the deployment documentation for more information" -ForegroundColor Red + Write-Host "ERROR: " -ForegroundColor Red -NoNewline + Write-Host "https://azure.github.io/ipam/#/deployment/README" -ForegroundColor Yellow + Write-Host "" - Write-Host "INFO: Successfully import Bicep parameter values for $deployType deployment" -ForegroundColor Green - Write-Verbose -Message "Successfully import Bicep parameter values for $deployType deployment" + throw [System.ArgumentException]::New("One of the required parameters are missing or invalid.") + } + + # $deployType = $script:AsFunction ? 'Function' : 'Full' + + Write-Host "INFO: Successfully import Bicep parameter values for deployment" -ForegroundColor Green $appDetails = @{ UIAppId = $UIAppId @@ -662,7 +767,7 @@ process { Function Deploy-Bicep { Param( [Parameter(Mandatory=$false)] - [string]$UIAppId = '00000000-0000-0000-0000-000000000000', + [string]$UIAppId = [GUID]::Empty, [Parameter(Mandatory=$true)] [string]$EngineAppId, [Parameter(Mandatory=$true)] @@ -672,7 +777,9 @@ process { [Parameter(Mandatory=$false)] [string]$AzureCloud, [Parameter(Mandatory=$false)] - [bool]$AsFunction, + [bool]$Function, + [Parameter(Mandatory=$false)] + [bool]$Native, [Parameter(Mandatory=$false)] [bool]$PrivateAcr, [Parameter(Mandatory=$false)] @@ -682,7 +789,6 @@ process { ) Write-Host "INFO: Deploying IPAM bicep templates" -ForegroundColor Green - Write-Verbose -Message "Deploying bicep templates" # Instantiate deployment parameter object $deploymentParameters = @{ @@ -699,8 +805,12 @@ process { $deploymentParameters.Add('azureCloud', $AzureCloud) } - if($AsFunction) { - $deploymentParameters.Add('deployAsFunc', $AsFunction) + if($Function) { + $deploymentParameters.Add('deployAsFunc', $Function) + } + + if(-not $Native) { + $deploymentParameters.Add('deployAsContainer', !$Native) } if($PrivateAcr) { @@ -730,11 +840,76 @@ process { $DebugPreference = 'SilentlyContinue' Write-Host "INFO: IPAM bicep templates deployed successfully" -ForegroundColor Green - Write-Verbose -Message "IPAM bicep template deployed successfully" return $deployment } + Function Publish-ZipFile { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$false)] + [switch]$UseAPI + ) + + if ($UseAPI) { + Write-Host "INFO: Using Kudu API for ZIP Deploy" -ForegroundColor Green + } + + $zipPath = Join-Path -Path $ROOT_DIR -ChildPath 'assets' -AdditionalChildPath "ipam.zip" + + $publishRetries = 3 + $publishSuccess = $False + + if ($UseAPI) { + $accessToken = (Get-AzAccessToken).Token + $zipContents = Get-Item -Path $zipPath + + $publishProfile = Get-AzWebAppPublishingProfile -Name $AppName -ResourceGroupName $ResourceGroupName + $zipUrl = ([System.uri]($publishProfile | Select-Xml -XPath "//publishProfile[@publishMethod='ZipDeploy']" | Select-Object -ExpandProperty Node).publishUrl).Scheme + } + + do { + try { + if (-not $UseAPI) { + Publish-AzWebApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ArchivePath $zipPath ` + -Restart ` + -Force ` + | Out-Null + } else { + Invoke-RestMethod ` + -Uri "https://${zipUrl}/api/zipdeploy" ` + -Method Post ` + -ContentType "multipart/form-data" ` + -Headers @{ "Authorization" = "Bearer $accessToken" } ` + -Form @{ file = $zipContents } ` + -StatusCodeVariable statusCode ` + | Out-Null + + if ($statusCode -ne 200) { + throw [System.Exception]::New("Error while uploading ZIP Deploy via Kudu API! ($statusCode)") + } + } + + $publishSuccess = $True + Write-Host "INFO: ZIP Deploy archive successfully uploaded" -ForegroundColor Green + } catch { + if($publishRetries -gt 0) { + Write-Host "WARNING: Problem while uploading ZIP Deploy archive! Retrying..." -ForegroundColor Yellow + $publishRetries-- + } else { + Write-Host "ERROR: Unable to upload ZIP Deploy archive!" -ForegroundColor Red + throw $_ + } + } + } while ($publishSuccess -eq $False -and $publishRetries -ge 0) + } + Function Update-UIApplication { Param( [Parameter(Mandatory=$true)] @@ -744,7 +919,6 @@ process { ) Write-Host "INFO: Updating UI Application with SPA configuration" -ForegroundColor Green - Write-Verbose -Message "Updating UI Application with SPA configuration" $appServiceEndpoint = "https://$Endpoint" @@ -752,7 +926,6 @@ process { Update-AzADApplication -ApplicationId $UIAppId -SPARedirectUri $appServiceEndpoint Write-Host "INFO: UI Application SPA configuration update complete" -ForegroundColor Green - Write-Verbose -Message "UI Application SPA configuration update complete" } # Main Deployment Script Section @@ -765,25 +938,23 @@ process { try { if($PrivateAcr) { - # Verify Minimum Azure CLI Version Write-Host "INFO: PrivateACR flag set, verifying minimum Azure CLI version" -ForegroundColor Green - Write-Verbose -Message "PrivateACR flag set, verifying minimum Azure CLI version" + # Verify Minimum Azure CLI Version $azureCliVer = [System.Version](az version | ConvertFrom-Json).'azure-cli' if($azureCliVer -lt $MIN_AZ_CLI_VER) { - Write-Host "ERROR: Azure CLI must be version $MIN_AZ_CLI_VER or greater!" -ForegroundColor red + Write-Host "ERROR: Azure CLI must be version $MIN_AZ_CLI_VER or greater!" -ForegroundColor Red exit } - # Verify Azure PowerShell and Azure CLI Contexts Match Write-Host "INFO: PrivateACR flag set, verifying Azure PowerShell and Azure CLI contexts match" -ForegroundColor Green - Write-Verbose -Message "PrivateACR flag set, verifying Azure PowerShell and Azure CLI contexts match" + # Verify Azure PowerShell and Azure CLI Contexts Match $azureCliContext = $(az account show | ConvertFrom-Json) 2>$null if(-not $azureCliContext) { - Write-Host "ERROR: Azure CLI not logged in or no subscription has been selected!" -ForegroundColor red + Write-Host "ERROR: Azure CLI not logged in or no subscription has been selected!" -ForegroundColor Red exit } @@ -791,216 +962,174 @@ process { $azurePowerShellSub = (Get-AzContext).Subscription.Id if($azurePowerShellSub -ne $azureCliSub) { - Write-Host "ERROR: Azure PowerShell and Azure CLI must be set to the same context!" -ForegroundColor red + Write-Host "ERROR: Azure PowerShell and Azure CLI must be set to the same context!" -ForegroundColor Red exit } } - if ($PSCmdlet.ParameterSetName -in ('Full', 'AppsOnly', 'Function', 'FuncAppsOnly')) { - # Fetch Tenant ID + if ($PSCmdlet.ParameterSetName -in ('App', 'AppContainer', 'Function', 'FunctionContainer', 'AppsOnly')) { Write-Host "INFO: Fetching Tenant ID from Azure PowerShell SDK" -ForegroundColor Green - Write-Verbose -Message "Fetching Tenant ID from Azure PowerShell SDK" + + # Fetch Tenant ID $tenantId = (Get-AzContext).Tenant.Id - # Fetch Azure Cloud Type Write-Host "INFO: Fetching Azure Cloud type from Azure PowerShell SDK" -ForegroundColor Green - Write-Verbose -Message "Fetching Azure Cloud type from Azure PowerShell SDK" + + # Fetch Azure Cloud Type $azureCloud = $AZURE_ENV_MAP[(Get-AzContext).Environment.Name] + + # Verify Azure Cloud Type is Supported + if (-not [bool]$azureCloud) { + Write-Host "ERROR: Azure Cloud type is not currently supported!" -ForegroundColor Red + Write-Host + Write-Host "Azure Cloud type: " -ForegroundColor Yellow -NoNewline + Write-Host (Get-AzContext).Environment.Name -ForegroundColor Cyan + exit + } } - if ($PSCmdlet.ParameterSetName -in ('Full', 'TemplateOnly', 'Function', 'FuncTemplateOnly')) { - # Validate Azure Region + if ($PSCmdlet.ParameterSetName -in ('App', 'AppContainer', 'Function', 'FunctionContainer')) { Write-Host "INFO: Validating Azure Region selected for deployment" -ForegroundColor Green - Write-Verbose -Message "Validating Azure Region selected for deployment" + # Validate Azure Region if (Test-Location -Location $Location) { Write-Host "INFO: Azure Region validated successfully" -ForegroundColor Green - Write-Verbose -Message "Azure Region validated successfully" } else { - Write-Host "ERROR: Location provided is not a valid Azure Region!" -ForegroundColor red + Write-Host "ERROR: Location provided is not a valid Azure Region!" -ForegroundColor Red + Write-Host + Write-Host "Azure Region: " -ForegroundColor Yellow -NoNewline + Write-Host $Location -ForegroundColor Cyan exit } } - if ($PSCmdlet.ParameterSetName -in ('Full', 'AppsOnly', 'Function', 'FuncAppsOnly')) { + if (-not $ParameterFile) { $appDetails = Deploy-IPAMApplications ` -UIAppName $UIAppName ` -EngineAppName $EngineAppName ` -TenantId $tenantId ` -AzureCloud $azureCloud ` - -AsFunction $AsFunction + -DisableUI $DisableUI $consentDetails = @{ EngineAppId = $appDetails.EngineAppId - AsFunction = $AsFunction } - if ($PSCmdlet.ParameterSetName -in ('Full', 'AppsOnly')) { + if (-not $DisableUI) { $consentDetails.Add("UIAppId", $appDetails.UIAppId) } - Grant-AdminConsent @consentDetails -AzureCloud $azureCloud + Grant-AdminConsent @consentDetails -AzureCloud $azureCloud -DisableUI $DisableUI } - if ($PSCmdlet.ParameterSetName -in ('AppsOnly', 'FuncAppsOnly')) { - Save-Parameters @appDetails -AsFunction $AsFunction + if ($PSCmdlet.ParameterSetName -in ('AppsOnly')) { + Save-Parameters @appDetails -DisableUI $DisableUI } - if ($PSCmdlet.ParameterSetName -in ('TemplateOnly', 'FuncTemplateOnly')) { + if ($ParameterFile) { $appDetails = Import-Parameters ` -ParameterFile $ParameterFile } - if ($PSCmdlet.ParameterSetName -in ('Full', 'TemplateOnly', 'Function', 'FuncTemplateOnly')) { + if ($PSCmdlet.ParameterSetName -in ('App', 'AppContainer', 'Function', 'FunctionContainer')) { $deployment = Deploy-Bicep @appDetails ` -NamePrefix $NamePrefix ` -AzureCloud $azureCloud ` -PrivateAcr $PrivateAcr ` - -AsFunction $AsFunction ` + -Function $Function ` + -Native $Native ` -Tags $Tags ` -ResourceNames $ResourceNames - - # Write-Output "ipamSuffix=$($deployment.Outputs["suffix"].Value)" >> $Env:GITHUB_OUTPUT } - if ($PSCmdlet.ParameterSetName -eq 'Full') { + if (($PSCmdlet.ParameterSetName -notin 'AppsOnly') -and (-not $DisableUI)) { Update-UIApplication ` -UIAppId $appDetails.UIAppId ` -Endpoint $deployment.Outputs["appServiceHostName"].Value } - if ($PSCmdlet.ParameterSetName -in ('Full', 'Function', 'TemplateOnly') -and $PrivateAcr) { + if ($PSCmdlet.ParameterSetName -in ('App', 'Function')) { + Write-Host "INFO: Uploading ZIP Deploy archive..." -ForegroundColor Green + + try { + Publish-ZipFile -AppName $deployment.Outputs["appServiceName"].Value -ResourceGroupName $deployment.Outputs["resourceGroupName"].Value + } catch { + Write-Host "SWITCH: Retrying ZIP Deploy with Kudu API..." -ForegroundColor Blue + Publish-ZipFile -AppName $deployment.Outputs["appServiceName"].Value -ResourceGroupName $deployment.Outputs["resourceGroupName"].Value -UseAPI + } + } + + if ($PSCmdlet.ParameterSetName -in ('AppContainer', 'FunctionContainer') -and $PrivateAcr) { Write-Host "INFO: Building and pushing container images to Azure Container Registry" -ForegroundColor Green - Write-Verbose -Message "Building and pushing container images to Azure Container Registry" $containerMap = @{ Debian = @{ - Extension = "deb" + Extension = 'deb' Port = 80 Images = @{ - UI = 'node:18-slim' - Engine = 'python:3.9-slim' - LB = 'nginx:alpine' + Build = 'node:18-slim' + Serve = 'python:3.9-slim' } } RHEL = @{ - Extension = "rhel" + Extension = 'rhel' Port = 8080 Images = @{ - UI = 'registry.access.redhat.com/ubi8/nodejs-18' - Engine = 'registry.access.redhat.com/ubi8/python-39' - LB = 'registry.access.redhat.com/ubi8/nginx-122' + Build = 'registry.access.redhat.com/ubi8/nodejs-18' + Serve = 'registry.access.redhat.com/ubi8/python-39' } } } - $enginePath = [Io.Path]::Combine('..', 'engine') - $engineDockerFile = Join-Path -Path $enginePath -ChildPath "Dockerfile.$($containerMap[$ContainerType].Extension)" - $functionDockerFile = Join-Path -Path $enginePath -ChildPath 'Dockerfile.func' - - $uiPath = [Io.Path]::Combine('..', 'ui') - $uiDockerFile = Join-Path -Path $uiPath -ChildPath "Dockerfile.$($containerMap[$ContainerType].Extension)" - - $lbPath = [Io.Path]::Combine('..', 'lb') - $lbDockerFile = Join-Path -Path $lbPath -ChildPath "Dockerfile" + $dockerFile = 'Dockerfile.' + $containerMap[$ContainerType].Extension + $dockerFilePath = Join-Path -Path $ROOT_DIR -ChildPath $dockerFile + $dockerFileFunc = Join-Path -Path $ROOT_DIR -ChildPath 'Dockerfile.func' - if($AsFunction) { - # WRITE-HOST "INFO: Building Function container ($ContainerType)..." -ForegroundColor Green - # Write-Verbose -Message "INFO: Building Function container ($ContainerType)..." - - # $funcBuildOutput = $( - # az acr build -r $deployment.Outputs["acrName"].Value ` - # -t ipam-func:latest ` - # -f $functionDockerFile $enginePath ` - # --build-arg PORT=$($containerMap[$ContainerType].Port) ` - # --build-arg BASE_IMAGE=$($containerMap[$ContainerType].Images.Engine) - # ) *>&1 - - WRITE-HOST "INFO: Building Function container..." -ForegroundColor Green - Write-Verbose -Message "INFO: Building Function container..." + if($Function) { + Write-Host "INFO: Building Function container..." -ForegroundColor Green $funcBuildOutput = $( az acr build -r $deployment.Outputs["acrName"].Value ` - -t ipam-func:latest ` - -f $functionDockerFile $enginePath + -t ipamfunc:latest ` + -f $dockerFileFunc $ROOT_DIR ) *>&1 if ($LASTEXITCODE -ne 0) { throw $funcBuildOutput } else { - WRITE-HOST "INFO: Function container image build and push completed successfully" -ForegroundColor Green - Write-Verbose -Message "Function container image build and push completed successfully" + Write-Host "INFO: Function container image build and push completed successfully" -ForegroundColor Green } Write-Host "INFO: Restarting Function App" -ForegroundColor Green - Write-Verbose -Message "Restarting Function App" Restart-AzFunctionApp -Name $deployment.Outputs["appServiceName"].Value -ResourceGroupName $deployment.Outputs["resourceGroupName"].Value -Force | Out-Null } else { - WRITE-HOST "INFO: Building Engine container ($ContainerType)..." -ForegroundColor Green - Write-Verbose -Message "INFO: Building Engine container ($ContainerType)..." - - $engineBuildOutput = $( - az acr build -r $deployment.Outputs["acrName"].Value ` - -t ipam-engine:latest ` - -f $engineDockerFile $enginePath ` - --build-arg PORT=$($containerMap[$ContainerType].Port) ` - --build-arg BASE_IMAGE=$($containerMap[$ContainerType].Images.Engine) - ) *>&1 + Write-Host "INFO: Building App container ($ContainerType)..." -ForegroundColor Green - if ($LASTEXITCODE -ne 0) { - throw $engineBuildOutput - } else { - WRITE-HOST "INFO: Engine container image build and push completed successfully" -ForegroundColor Green - Write-Verbose -Message "Engine container image build and push completed successfully" - } - - WRITE-HOST "INFO: Building UI container ($ContainerType)..." -ForegroundColor Green - Write-Verbose -Message "INFO: Building UI container ($ContainerType)..." - - $uiBuildOutput = $( + $appBuildOutput = $( az acr build -r $deployment.Outputs["acrName"].Value ` - -t ipam-ui:latest ` - -f $uiDockerFile $uiPath ` + -t ipam:latest ` + -f $dockerFilePath $ROOT_DIR ` --build-arg PORT=$($containerMap[$ContainerType].Port) ` - --build-arg BASE_IMAGE=$($containerMap[$ContainerType].Images.UI) - ) *>&1 - - if ($LASTEXITCODE -ne 0) { - throw $uiBuildOutput - } else { - WRITE-HOST "INFO: UI container image build and push completed successfully" -ForegroundColor Green - Write-Verbose -Message "UI container image build and push completed successfully" - } - - WRITE-HOST "INFO: Building Load Balancer container ($ContainerType)..." -ForegroundColor Green - Write-Verbose -Message "INFO: Building Load Balancer container ($ContainerType)..." - - $lbBuildOutput = $( - az acr build -r $deployment.Outputs["acrName"].Value ` - -t ipam-lb:latest ` - -f $lbDockerFile $lbPath ` - --build-arg BASE_IMAGE=$($containerMap[$ContainerType].Images.LB) + --build-arg BUILD_IMAGE=$($containerMap[$ContainerType].Images.Build) ` + --build-arg SERVE_IMAGE=$($containerMap[$ContainerType].Images.Serve) ) *>&1 if ($LASTEXITCODE -ne 0) { - throw $lbBuildOutput + throw $appBuildOutput } else { - WRITE-HOST "INFO: Load Balancer container image build and push completed successfully" -ForegroundColor Green - Write-Verbose -Message "Load Balancer container image build and push completed successfully" + Write-Host "INFO: App container image build and push completed successfully" -ForegroundColor Green } Write-Host "INFO: Restarting App Service" -ForegroundColor Green - Write-Verbose -Message "Restarting App Service" Restart-AzWebApp -Name $deployment.Outputs["appServiceName"].Value -ResourceGroupName $deployment.Outputs["resourceGroupName"].Value | Out-Null } } Write-Host "INFO: Azure IPAM Solution deployed successfully" -ForegroundColor Green - Write-Verbose -Message "Azure IPAM Solution deployed successfully" - if ($($PSCmdlet.ParameterSetName -eq 'TemplateOnly') -and $(-not $AsFunction)) { + if ($($PSCmdlet.ParameterSetName -notin 'AppsOnly') -and (-not $DisableUI) -and $ParameterFile) { $updateUrl = "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Authentication/appId/$($appDetails.UIAppId)" $updateAddr = "https://$($deployment.Outputs["appServiceHostName"].Value)" @@ -1013,10 +1142,17 @@ process { Write-Host $updateAddr -ForegroundColor White Write-Host "##############################################" -ForegroundColor Yellow } + + if ($PSCmdlet.ParameterSetName -in ('App', 'Function')) { + Write-Host + Write-Host "NOTE: Please allow ~5 minutes for the Azure IPAM service to become available" -ForegroundColor Yellow + } + + $script:deploymentSuccess = $true } catch { $_ | Out-File -FilePath $errorLog -Append - Write-Host "ERROR: Unable to deploy Azure IPAM solution due to an exception, see logs for detailed information!" -ForegroundColor red + Write-Host "ERROR: Unable to deploy Azure IPAM solution due to an exception, see logs for detailed information!" -ForegroundColor Red Write-Host "Run Log: $transcriptLog" -ForegroundColor Red Write-Host "Error Log: $errorLog" -ForegroundColor Red @@ -1028,11 +1164,13 @@ process { Write-Host Stop-Transcript | Out-Null - Write-Output "ipamURL=https://$($deployment.Outputs["appServiceHostName"].Value)" >> $Env:GITHUB_OUTPUT - Write-Output "ipamUIAppId=$($appDetails.UIAppId)" >> $Env:GITHUB_OUTPUT - Write-Output "ipamEngineAppId=$($appDetails.EngineAppId)" >> $Env:GITHUB_OUTPUT - Write-Output "ipamSuffix=$($deployment.Outputs["suffix"].Value)" >> $Env:GITHUB_OUTPUT - Write-Output "ipamResourceGroup=$($deployment.Outputs["resourceGroupName"].Value)" >> $Env:GITHUB_OUTPUT + if ($script:deploymentSuccess) { + Write-Output "ipamURL=https://$($deployment.Outputs["appServiceHostName"].Value)" >> $Env:GITHUB_OUTPUT + Write-Output "ipamUIAppId=$($appDetails.UIAppId)" >> $Env:GITHUB_OUTPUT + Write-Output "ipamEngineAppId=$($appDetails.EngineAppId)" >> $Env:GITHUB_OUTPUT + Write-Output "ipamSuffix=$($deployment.Outputs["suffix"].Value)" >> $Env:GITHUB_OUTPUT + Write-Output "ipamResourceGroup=$($deployment.Outputs["resourceGroupName"].Value)" >> $Env:GITHUB_OUTPUT + } exit } diff --git a/deploy/main.bicep b/deploy/main.bicep index fd4ce50..e2d4100 100644 --- a/deploy/main.bicep +++ b/deploy/main.bicep @@ -20,6 +20,9 @@ param privateAcr bool = false @description('Flag to Deploy IPAM as a Function') param deployAsFunc bool = false +@description('Flag to Deploy IPAM as a Container') +param deployAsContainer bool = false + @description('IPAM-UI App Registration Client/App ID') param uiAppId string = '00000000-0000-0000-0000-000000000000' @@ -37,6 +40,7 @@ param tags object = {} param resourceNames object = { functionName: '${namePrefix}-${uniqueString(guid)}' appServiceName: '${namePrefix}-${uniqueString(guid)}' + functionPlanName: '${namePrefix}-asp-${uniqueString(guid)}' appServicePlanName: '${namePrefix}-asp-${uniqueString(guid)}' cosmosAccountName: '${namePrefix}-dbacct-${uniqueString(guid)}' cosmosContainerName: '${namePrefix}-ctr' @@ -58,7 +62,7 @@ resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { } // Log Analytics Workspace -module logAnalyticsWorkspace 'logAnalyticsWorkspace.bicep' ={ +module logAnalyticsWorkspace './modules/logAnalyticsWorkspace.bicep' ={ name: 'logAnalyticsWorkspaceModule' scope: resourceGroup params: { @@ -68,7 +72,7 @@ module logAnalyticsWorkspace 'logAnalyticsWorkspace.bicep' ={ } // Managed Identity for Secure Access to KeyVault -module managedIdentity 'managedIdentity.bicep' = { +module managedIdentity './modules/managedIdentity.bicep' = { name: 'managedIdentityModule' scope: resourceGroup params: { @@ -78,13 +82,14 @@ module managedIdentity 'managedIdentity.bicep' = { } // KeyVault for Secure Values -module keyVault 'keyVault.bicep' = { +module keyVault './modules/keyVault.bicep' = { name: 'keyVaultModule' scope: resourceGroup params: { location: location keyVaultName: resourceNames.keyVaultName - principalId: managedIdentity.outputs.principalId + identityPrincipalId: managedIdentity.outputs.principalId + identityClientId: managedIdentity.outputs.clientId uiAppId: uiAppId engineAppId: engineAppId engineAppSecret: engineAppSecret @@ -93,7 +98,7 @@ module keyVault 'keyVault.bicep' = { } // Cosmos DB for IPAM Database -module cosmos 'cosmos.bicep' = { +module cosmos './modules/cosmos.bicep' = { name: 'cosmosModule' scope: resourceGroup params: { @@ -103,25 +108,23 @@ module cosmos 'cosmos.bicep' = { cosmosDatabaseName: resourceNames.cosmosDatabaseName keyVaultName: keyVault.outputs.keyVaultName workspaceId: logAnalyticsWorkspace.outputs.workspaceId + principalId: managedIdentity.outputs.principalId } } // Storage Account for Nginx Config/Function Metadata -module storageAccount 'storageAccount.bicep' = if (deployAsFunc) { +module storageAccount './modules/storageAccount.bicep' = if (deployAsFunc) { scope: resourceGroup name: 'storageAccountModule' params: { location: location storageAccountName: resourceNames.storageAccountName - // principalId: managedIdentity.outputs.principalId - // managedIdentityId: managedIdentity.outputs.id workspaceId: logAnalyticsWorkspace.outputs.workspaceId - // deployAsFunc: deployAsFunc } } // Container Registry -module containerRegistry 'containerRegistry.bicep' = if (privateAcr) { +module containerRegistry './modules/containerRegistry.bicep' = if (privateAcr) { scope: resourceGroup name: 'containerRegistryModule' params: { @@ -132,14 +135,14 @@ module containerRegistry 'containerRegistry.bicep' = if (privateAcr) { } // App Service w/ Docker Compose + CI -module appService 'appService.bicep' = if (!deployAsFunc) { +module appService './modules/appService.bicep' = if (!deployAsFunc) { scope: resourceGroup name: 'appServiceModule' params: { location: location azureCloud: azureCloud - appServicePlanName: resourceNames.appServicePlanName appServiceName: resourceNames.appServiceName + appServicePlanName: resourceNames.appServicePlanName keyVaultUri: keyVault.outputs.keyVaultUri cosmosDbUri: cosmos.outputs.cosmosDocumentEndpoint databaseName: resourceNames.cosmosDatabaseName @@ -147,20 +150,21 @@ module appService 'appService.bicep' = if (!deployAsFunc) { managedIdentityId: managedIdentity.outputs.id managedIdentityClientId: managedIdentity.outputs.clientId workspaceId: logAnalyticsWorkspace.outputs.workspaceId + deployAsContainer: deployAsContainer privateAcr: privateAcr privateAcrUri: privateAcr ? containerRegistry.outputs.acrUri : '' } } // Function App -module functionApp 'functionApp.bicep' = if (deployAsFunc) { +module functionApp './modules/functionApp.bicep' = if (deployAsFunc) { scope: resourceGroup name: 'functionAppModule' params: { location: location azureCloud: azureCloud - functionAppPlanName: resourceNames.appServicePlanName functionAppName: resourceNames.functionName + functionPlanName: resourceNames.appServicePlanName keyVaultUri: keyVault.outputs.keyVaultUri cosmosDbUri: cosmos.outputs.cosmosDocumentEndpoint databaseName: resourceNames.cosmosDatabaseName @@ -169,6 +173,7 @@ module functionApp 'functionApp.bicep' = if (deployAsFunc) { managedIdentityClientId: managedIdentity.outputs.clientId storageAccountName: resourceNames.storageAccountName workspaceId: logAnalyticsWorkspace.outputs.workspaceId + deployAsContainer: deployAsContainer privateAcr: privateAcr privateAcrUri: privateAcr ? containerRegistry.outputs.acrUri : '' } diff --git a/deploy/main.parameters.example.json b/deploy/main.parameters.example.json index 148348e..e475032 100644 --- a/deploy/main.parameters.example.json +++ b/deploy/main.parameters.example.json @@ -23,8 +23,14 @@ "deployAsFunc": { "value": false }, + "deployAsContainer": { + "value": false + }, "privateAcr": { "value": false + }, + "disableUi": { + "value": false } } } diff --git a/deploy/appService.bicep b/deploy/modules/appService.bicep similarity index 67% rename from deploy/appService.bicep rename to deploy/modules/appService.bicep index 97c9f4b..2ba28fd 100644 --- a/deploy/appService.bicep +++ b/deploy/modules/appService.bicep @@ -31,6 +31,9 @@ param managedIdentityClientId string @description('Log Analytics Worskpace ID') param workspaceId string +@description('Flag to Deploy IPAM as a Container') +param deployAsContainer bool = false + @description('Flag to Deploy Private Container Registry') param privateAcr bool @@ -40,14 +43,6 @@ param privateAcrUri string // ACR Uri Variable var acrUri = privateAcr ? privateAcrUri : 'azureipam.azurecr.io' -// Docker Compose File as Base64 String -// var dockerCompose = loadFileAsBase64('../docker-compose.prod.yml') - -// Load Docker Compose File & Replace ACR Uri (for Private ACR) -var dockerCompose = loadTextContent('../docker-compose.prod.yml') -var dockerComposeReplace = replace(dockerCompose, 'azureipam.azurecr.io', acrUri) -var dockerComposeEncode = base64(dockerComposeReplace) - resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { name: appServicePlanName location: location @@ -66,7 +61,7 @@ resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { resource appService 'Microsoft.Web/sites@2021-02-01' = { name: appServiceName location: location - kind: 'app,linux,container' + kind: deployAsContainer ? 'app,linux,container' : 'app,linux' identity: { type: 'UserAssigned' userAssignedIdentities: { @@ -81,61 +76,72 @@ resource appService 'Microsoft.Web/sites@2021-02-01' = { acrUseManagedIdentityCreds: privateAcr ? true : false acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null alwaysOn: true - linuxFxVersion: 'COMPOSE|${dockerComposeEncode}' - appSettings: [ - { - name: 'AZURE_ENV' - value: azureCloud - } - { - name: 'COSMOS_URL' - value: cosmosDbUri - } - { - name: 'COSMOS_KEY' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/COSMOS-KEY/)' - } - { - name: 'DATABASE_NAME' - value: databaseName - } - { - name: 'CONTAINER_NAME' - value: containerName - } - { - name: 'UI_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' - } - { - name: 'ENGINE_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' - } - { - name: 'ENGINE_APP_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' - } - { - name: 'TENANT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' - } - { - name: 'KEYVAULT_URL' - value: keyVaultUri - } - { - name: 'DOCKER_ENABLE_CI' - value: 'true' - } - { - name: 'DOCKER_REGISTRY_SERVER_URL' - value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' - } - { - name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' - value: 'true' - } - ] + linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipam:latest' : 'PYTHON|3.9' + appCommandLine: !deployAsContainer ? 'init.sh 8000' : null + healthCheckPath: '/api/status' + appSettings: concat( + [ + { + name: 'AZURE_ENV' + value: azureCloud + } + { + name: 'COSMOS_URL' + value: cosmosDbUri + } + { + name: 'DATABASE_NAME' + value: databaseName + } + { + name: 'CONTAINER_NAME' + value: containerName + } + { + name: 'MANAGED_IDENTITY_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + } + { + name: 'UI_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + } + { + name: 'ENGINE_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + } + { + name: 'ENGINE_APP_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + } + { + name: 'TENANT_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + } + { + name: 'KEYVAULT_URL' + value: keyVaultUri + } + { + name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' + value: '2' + } + ], + deployAsContainer ? [ + { + name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' + value: 'true' + } + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + ] : [ + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' + } + ] + ) } } } diff --git a/deploy/containerRegistry.bicep b/deploy/modules/containerRegistry.bicep similarity index 100% rename from deploy/containerRegistry.bicep rename to deploy/modules/containerRegistry.bicep diff --git a/deploy/cosmos.bicep b/deploy/modules/cosmos.bicep similarity index 82% rename from deploy/cosmos.bicep rename to deploy/modules/cosmos.bicep index 19a7081..ed2b893 100644 --- a/deploy/cosmos.bicep +++ b/deploy/modules/cosmos.bicep @@ -16,6 +16,13 @@ param location string = resourceGroup().location @description('Log Analytics Workspace ID') param workspaceId string +@description('Managed Identity PrincipalId') +param principalId string + +var dbContributor = '00000000-0000-0000-0000-000000000002' +var dbContributorId = '${resourceGroup().id}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosAccount.name}/sqlRoleDefinitions/${dbContributor}' +var dbContributorRoleAssignmentId = guid(dbContributor, principalId, cosmosAccount.id) + resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2021-04-15' = { name: cosmosAccountName location: location @@ -32,6 +39,7 @@ resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2021-04-15' = { ] databaseAccountOfferType: 'Standard' enableAutomaticFailover: true + disableKeyBasedMetadataWriteAccess: true } } @@ -150,4 +158,14 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr } } +resource sqlRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = { + name: dbContributorRoleAssignmentId + parent: cosmosAccount + properties: { + roleDefinitionId: dbContributorId + principalId: principalId + scope: cosmosAccount.id + } +} + output cosmosDocumentEndpoint string = cosmosAccount.properties.documentEndpoint diff --git a/deploy/functionApp.bicep b/deploy/modules/functionApp.bicep similarity index 51% rename from deploy/functionApp.bicep rename to deploy/modules/functionApp.bicep index 408dca6..4ac9c20 100644 --- a/deploy/functionApp.bicep +++ b/deploy/modules/functionApp.bicep @@ -1,8 +1,8 @@ @description('Function App Name') param functionAppName string -@description('Function App Plan Name') -param functionAppPlanName string +@description('Function Plan Name') +param functionPlanName string @description('CosmosDB URI') param cosmosDbUri string @@ -34,6 +34,9 @@ param storageAccountName string @description('Log Analytics Workspace ID') param workspaceId string +@description('Flag to Deploy IPAM as a Container') +param deployAsContainer bool = false + @description('Flag to Deploy Private Container Registry') param privateAcr bool @@ -47,8 +50,8 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' existing name: storageAccountName } -resource functionAppPlan 'Microsoft.Web/serverfarms@2021-02-01' = { - name: functionAppPlanName +resource functionPlan 'Microsoft.Web/serverfarms@2021-02-01' = { + name: functionPlanName location: location sku: { name: 'EP1' @@ -72,82 +75,100 @@ resource functionApp 'Microsoft.Web/sites@2021-03-01' = { } properties: { httpsOnly: true - serverFarmId: functionAppPlan.id + serverFarmId: functionPlan.id keyVaultReferenceIdentity: managedIdentityId siteConfig: { acrUseManagedIdentityCreds: privateAcr ? true : false acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null - linuxFxVersion: 'DOCKER|${acrUri}/ipam-func:latest' - appSettings: [ - { - name: 'AZURE_ENV' - value: azureCloud - } - { - name: 'COSMOS_URL' - value: cosmosDbUri - } - { - name: 'COSMOS_KEY' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/COSMOS-KEY/)' - } - { - name: 'DATABASE_NAME' - value: databaseName - } - { - name: 'CONTAINER_NAME' - value: containerName - } - { - name: 'CLIENT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' - } - { - name: 'CLIENT_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' - } - { - name: 'TENANT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' - } - { - name: 'KEYVAULT_URL' - value: keyVaultUri - } - { - name: 'DOCKER_ENABLE_CI' - value: 'true' - } - { - name: 'DOCKER_REGISTRY_SERVER_URL' - value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' - } - { - name: 'AzureWebJobsStorage' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - { - name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - { - name: 'WEBSITE_CONTENTSHARE' - value: toLower(functionAppName) - } - { - name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' - value: 'false' - } - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'APPINSIGHTS_INSTRUMENTATIONKEY' - value: applicationInsights.properties.InstrumentationKey - } - ] + linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipamfunc:latest' : 'Python|3.9' + healthCheckPath: '/api/status' + appSettings: concat( + [ + { + name: 'AZURE_ENV' + value: azureCloud + } + { + name: 'COSMOS_URL' + value: cosmosDbUri + } + { + name: 'DATABASE_NAME' + value: databaseName + } + { + name: 'CONTAINER_NAME' + value: containerName + } + { + name: 'MANAGED_IDENTITY_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + } + { + name: 'UI_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + } + { + name: 'ENGINE_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + } + { + name: 'ENGINE_APP_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + } + { + name: 'TENANT_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + } + { + name: 'KEYVAULT_URL' + value: keyVaultUri + } + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTSHARE' + value: toLower(functionAppName) + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: applicationInsights.properties.InstrumentationKey + } + { + name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' + value: '2' + } + ], + deployAsContainer ? [ + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + { + name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' + value: 'false' + } + ] : [ + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'python' + } + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' + } + ] + ) } } } @@ -185,7 +206,7 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { resource diagnosticSettingsPlan 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { name: 'diagSettings' - scope: functionAppPlan + scope: functionPlan properties: { metrics: [ { diff --git a/deploy/keyVault.bicep b/deploy/modules/keyVault.bicep similarity index 72% rename from deploy/keyVault.bicep rename to deploy/modules/keyVault.bicep index 21cbf74..caba133 100644 --- a/deploy/keyVault.bicep +++ b/deploy/modules/keyVault.bicep @@ -5,7 +5,10 @@ param keyVaultName string param location string = resourceGroup().location @description('Managed Identity PrincipalId') -param principalId string +param identityPrincipalId string + +@description('Managed Identity ClientId') +param identityClientId string @description('AzureAD TenantId') param tenantId string = subscription().tenantId @@ -23,26 +26,17 @@ param engineAppSecret string @description('Log Analytics Worskpace ID') param workspaceId string -// KeyVault Secret Permissions Assigned to Managed Identity -var secretsPermissions = [ - 'get' -] +var keyVaultUser = '4633458b-17de-408a-b874-0445c86b69e6' +var keyVaultUserId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultUser) +var keyVaultUserRoleAssignmentId = guid(keyVaultUser, identityPrincipalId, keyVault.id) resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { name: keyVaultName location: location properties: { enablePurgeProtection: true + enableRbacAuthorization: true tenantId: tenantId - accessPolicies: [ - { - objectId: principalId - tenantId: tenantId - permissions: { - secrets: secretsPermissions - } - } - ] sku: { name: 'standard' family: 'A' @@ -54,6 +48,14 @@ resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { } } +resource identityId 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { + parent: keyVault + name: 'IDENTITY-ID' + properties: { + value: identityClientId + } +} + resource uiId 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { parent: keyVault name: 'UI-ID' @@ -114,5 +116,15 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr } } +resource keyVaultUserAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { + name: keyVaultUserRoleAssignmentId + scope: keyVault + properties: { + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultUserId + principalId: identityPrincipalId + } +} + output keyVaultName string = keyVault.name output keyVaultUri string = keyVault.properties.vaultUri diff --git a/deploy/logAnalyticsWorkspace.bicep b/deploy/modules/logAnalyticsWorkspace.bicep similarity index 100% rename from deploy/logAnalyticsWorkspace.bicep rename to deploy/modules/logAnalyticsWorkspace.bicep diff --git a/deploy/managedIdentity.bicep b/deploy/modules/managedIdentity.bicep similarity index 76% rename from deploy/managedIdentity.bicep rename to deploy/modules/managedIdentity.bicep index e64e2e5..35ec879 100644 --- a/deploy/managedIdentity.bicep +++ b/deploy/modules/managedIdentity.bicep @@ -1,19 +1,15 @@ -@description('Contributor Role Assignment GUID') -param contributorAssignmentName string = newGuid() - @description('Deployment Location') param location string = resourceGroup().location @description('Managed Identity Name') param managedIdentityName string -@description('Managed Identity Operator Role Assignment GUID') -param managedIdentityOperatorAssignmentName string = newGuid() - var contributor = 'b24988ac-6180-42a0-ab88-20f7382dd24c' var contributorId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', contributor) +var contributorRoleAssignmentId = guid(contributor, managedIdentity.id, subscription().id) var managedIdentityOperator = 'f1a07417-d97a-45cb-824c-7a7467783830' var managedIdentityOperatorId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', managedIdentityOperator) +var managedIdentityOperatorRoleAssignmentId = guid(managedIdentityOperator, managedIdentity.id, subscription().id) resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { name: managedIdentityName @@ -21,8 +17,7 @@ resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018- } resource contributorAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - #disable-next-line use-stable-resource-identifiers - name: contributorAssignmentName + name: contributorRoleAssignmentId properties: { principalType: 'ServicePrincipal' roleDefinitionId: contributorId @@ -31,8 +26,7 @@ resource contributorAssignment 'Microsoft.Authorization/roleAssignments@2020-04- } resource managedIdentityOperatorAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - #disable-next-line use-stable-resource-identifiers - name: managedIdentityOperatorAssignmentName + name: managedIdentityOperatorRoleAssignmentId properties: { principalType: 'ServicePrincipal' roleDefinitionId: managedIdentityOperatorId diff --git a/deploy/modules/storageAccount.bicep b/deploy/modules/storageAccount.bicep new file mode 100644 index 0000000..8ea0c42 --- /dev/null +++ b/deploy/modules/storageAccount.bicep @@ -0,0 +1,90 @@ +@description('Deployment Location') +param location string = resourceGroup().location + +@description('Storage Account Name') +param storageAccountName string + +@description('Log Analytics Workspace ID') +param workspaceId string + +resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + } +} + +resource blob 'Microsoft.Storage/storageAccounts/blobServices@2021-09-01' existing = { + name: 'default' + parent: storageAccount +} + +resource diagnosticSettingsAccount 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'diagSettingsAccount' + scope: storageAccount + properties: { + metrics: [ + { + category: 'Transaction' + enabled: true + retentionPolicy: { + days: 0 + enabled: false + } + } + ] + workspaceId: workspaceId + } +} + +resource diagnosticSettingsBlob 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'diagSettingsBlob' + scope: blob + properties: { + logs: [ + { + category: 'StorageRead' + enabled: true + retentionPolicy: { + days: 0 + enabled: false + } + } + { + category: 'StorageWrite' + enabled: true + retentionPolicy: { + days: 0 + enabled: false + } + } + { + category: 'StorageDelete' + enabled: true + retentionPolicy: { + days: 0 + enabled: false + } + } + ] + metrics: [ + { + category: 'Transaction' + enabled: true + retentionPolicy: { + days: 0 + enabled: false + } + } + ] + workspaceId: workspaceId + } +} + +output name string = storageAccount.name diff --git a/deploy/storageAccount.bicep b/deploy/storageAccount.bicep deleted file mode 100644 index 1a63e9b..0000000 --- a/deploy/storageAccount.bicep +++ /dev/null @@ -1,175 +0,0 @@ -@description('Deployment Location') -param location string = resourceGroup().location - -// @description('Blob Container Name') -// param containerName string = 'nginx' - -// @description('Managed Identity Id') -// param managedIdentityId string - -// @description('Managed Identity PrincipalId') -// param principalId string - -// @description('Role Assignment GUID') -// param roleAssignmentName string = newGuid() - -@description('Storage Account Name') -param storageAccountName string - -@description('Log Analytics Workspace ID') -param workspaceId string - -// @description('Flag to Deploy IPAM as a Function') -// param deployAsFunc bool - -// var storageBlobDataContributor = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' -// var storageBlobDataContributorId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributor) - -resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' = { - name: storageAccountName - location: location - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - properties: { - accessTier: 'Hot' - allowBlobPublicAccess: false - } -} - -resource blob 'Microsoft.Storage/storageAccounts/blobServices@2021-09-01' existing = { - name: 'default' - parent: storageAccount -} - -// resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-06-01' = if (!deployAsFunc) { -// name: '${storageAccount.name}/default/${containerName}' -// } - -// resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (!deployAsFunc) { -// #disable-next-line use-stable-resource-identifiers -// name: roleAssignmentName -// scope: blobContainer -// properties: { -// principalType: 'ServicePrincipal' -// roleDefinitionId: storageBlobDataContributorId -// principalId: principalId -// } -// } - -// resource copyNginxConfig 'Microsoft.Resources/deploymentScripts@2020-10-01' = if (!deployAsFunc) { -// name: 'copyNginxConfig' -// location: location -// kind: 'AzurePowerShell' -// identity: { -// type: 'UserAssigned' -// userAssignedIdentities: { -// '${managedIdentityId}': {} -// } -// } -// properties: { -// azPowerShellVersion: '7.5' -// timeout: 'PT1H' -// environmentVariables: [ -// { -// name: 'StorageAccountName' -// value: storageAccount.name -// } -// { -// name: 'ContainerName' -// value: containerName -// } -// { -// name: 'ResourceGroup' -// value: resourceGroup().name -// } -// { -// name: 'DeployScript' -// value: loadTextContent('../default.conf') -// } -// ] -// scriptContent: ''' -// $Env:DeployScript | Out-File -FilePath ./default.conf -// $storageAccount = Get-AzStorageAccount -ResourceGroupName $Env:ResourceGroup -Name $Env:StorageAccountName -// $ctx = $storageAccount.Context -// $container = Get-AzStorageContainer -Name $Env:ContainerName -Context $ctx - -// $NginxConfig = @{ -// File = "./default.conf" -// Container = $Env:ContainerName -// Blob = "default.conf" -// Context = $ctx -// StandardBlobTier = "Hot" -// } - -// Set-AzStorageBlobContent @NginxConfig -// ''' -// cleanupPreference: 'Always' -// retentionInterval: 'PT1H' -// } -// } - -resource diagnosticSettingsAccount 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'diagSettingsAccount' - scope: storageAccount - properties: { - metrics: [ - { - category: 'Transaction' - enabled: true - retentionPolicy: { - days: 0 - enabled: false - } - } - ] - workspaceId: workspaceId - } -} - -resource diagnosticSettingsBlob 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'diagSettingsBlob' - scope: blob - properties: { - logs: [ - { - category: 'StorageRead' - enabled: true - retentionPolicy: { - days: 0 - enabled: false - } - } - { - category: 'StorageWrite' - enabled: true - retentionPolicy: { - days: 0 - enabled: false - } - } - { - category: 'StorageDelete' - enabled: true - retentionPolicy: { - days: 0 - enabled: false - } - } - ] - metrics: [ - { - category: 'Transaction' - enabled: true - retentionPolicy: { - days: 0 - enabled: false - } - } - ] - workspaceId: workspaceId - } -} - -output name string = storageAccount.name diff --git a/deploy/update.ps1 b/deploy/update.ps1 new file mode 100644 index 0000000..5e74cdd --- /dev/null +++ b/deploy/update.ps1 @@ -0,0 +1,355 @@ +############################################################################################################### +## +## Azure IPAM ZIP Deploy Updater Script +## +############################################################################################################### + +# Set minimum version requirements +#Requires -Version 7.2 +#Requires -Modules @{ ModuleName="Az"; ModuleVersion="10.3.0"} + +# Intake and set global parameters +param( + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true)] + [string] + $AppName, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true)] + [string] + $ResourceGroupName +) + +# Root Directory +$ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName + +# Minimum Required Azure CLI Version +$MIN_AZ_CLI_VER = [System.Version]'2.35.0' + +# Azure IPAM Public ACR +$IPAM_PUBLIC_ACR = "azureipam.azurecr.io" + +# Set preference variables +$ErrorActionPreference = "Stop" + +# Set Log File Location +$logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" +New-Item -ItemType Directory -Path $logpath -Force | Out-Null + +$updateLog = Join-Path -Path $logPath -ChildPath "update_$(get-date -format `"yyyyMMddhhmmsstt`").log" + +Function Restart-IpamApp { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$false)] + [switch]$Function + ) + + $restartRetries = 5 + $restartSuccess = $False + + do { + try { + if ($Function) { + Restart-AzFunctionApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable restartErr ` + -ErrorAction SilentlyContinue ` + -Force ` + | Out-Null + } else { + Restart-AzWebApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable restartErr ` + -ErrorAction SilentlyContinue ` + | Out-Null + } + + if ($restartErr) { + throw $restartErr + } + + $restartSuccess = $True + Write-Host "INFO: Application successfuly restarted" -ForegroundColor Green + } catch { + if($restartRetries -gt 0) { + Write-Host "WARNING: Problem while restarting application! Retrying..." -ForegroundColor Yellow + $restartRetries-- + } else { + Write-Host "ERROR: Unable to restart application!" -ForegroundColor Red + throw $_ + } + } + } while ($restartSuccess -eq $False -and $restartRetries -gt 0) +} + +Function Publish-ZipFile { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$false)] + [switch]$UseAPI + ) + + if ($UseAPI) { + Write-Host "INFO: Using Kudu API for ZIP Deploy" -ForegroundColor Green + } + + $zipPath = Join-Path -Path $ROOT_DIR -ChildPath 'assets' -AdditionalChildPath "ipam.zip" + + $publishRetries = 3 + $publishSuccess = $False + + if ($UseAPI) { + $accessToken = (Get-AzAccessToken).Token + $zipContents = Get-Item -Path $zipPath + + $publishProfile = Get-AzWebAppPublishingProfile -Name $AppName -ResourceGroupName $ResourceGroupName + $zipUrl = ([System.uri]($publishProfile | Select-Xml -XPath "//publishProfile[@publishMethod='ZipDeploy']" | Select-Object -ExpandProperty Node).publishUrl).Scheme + } + + do { + try { + if (-not $UseAPI) { + Publish-AzWebApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ArchivePath $zipPath ` + -Restart ` + -Force ` + | Out-Null + } else { + Invoke-RestMethod ` + -Uri "https://${zipUrl}/api/zipdeploy" ` + -Method Post ` + -ContentType "multipart/form-data" ` + -Headers @{ "Authorization" = "Bearer $accessToken" } ` + -Form @{ file = $zipContents } ` + -StatusCodeVariable statusCode ` + | Out-Null + + if ($statusCode -ne 200) { + throw [System.Exception]::New("Error while uploading ZIP Deploy via Kudu API! ($statusCode)") + } + } + + $publishSuccess = $True + Write-Host "INFO: ZIP Deploy archive successfully uploaded" -ForegroundColor Green + } catch { + if($publishRetries -gt 0) { + Write-Host "WARNING: Problem while uploading ZIP Deploy archive! Retrying..." -ForegroundColor Yellow + $publishRetries-- + } else { + Write-Host "ERROR: Unable to upload ZIP Deploy archive!" -ForegroundColor Red + throw $_ + } + } + } while ($publishSuccess -eq $False -and $publishRetries -ge 0) +} + +Start-Transcript -Path $updateLog | Out-Null + +try { + Write-Host + Write-Host "INFO: Verifying application exists" -ForegroundColor Green + + $appType = "" + $isFunction = $false + $privateAcr = $false + $acrName = "" + + $existingApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $AppName -ErrorAction SilentlyContinue + + if($null -eq $existingApp) { + Write-Host "ERROR: Application not found in current subscription!" -ForegroundColor Red + throw "Application does not exist!" + } else { + $appKind = $existingApp.Kind + $appType = $($appKind.Split(",") -contains 'functionapp') ? 'Function' : 'App' + $isFunction = $appType -eq 'Function' ? $true : $false + } + + $appContainer = $existingApp.Kind.Split(",") -contains 'container' + + if ($appContainer) { + $appType += "Container" + } + + Write-Host "INFO: Application exists, detected type is " -ForegroundColor Green -NoNewline + Write-Host $appType -ForegroundColor Cyan + + if ($appContainer) { + $appAcr = $existingApp.SiteConfig.LinuxFxVersion.Split('|')[1].Split('/')[0] + $privateAcr = $appAcr -eq $IPAM_PUBLIC_ACR ? $false : $true + + if (-not $privateAcr) { + Write-Host "INFO: Deployment is using the Azure IPAM public ACR, restarting to update..." -ForegroundColor Green + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName + exit + } + + if($privateAcr) { + $acrName = $appAcr.Split('.')[0] + + Write-Host "INFO: Deployment is using a private ACR (" -ForegroundColor Green -NoNewline + Write-Host "$acrName" -ForegroundColor Cyan -NoNewline + Write-Host ")" -ForegroundColor Green + Write-Host "INFO: Verifying ACR is in current Resource Group" -ForegroundColor Green + + $acrDetails = Get-AzContainerRegistry ` + -Name $acrName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable acrErr ` + -ErrorAction SilentlyContinue + + if ($acrErr) { + Write-Host "ERROR: Private ACR not found in current Resource Group!" -ForegroundColor Red + throw $acrErr + } + + $acrName = $acrDetails.Name + + Write-Host "INFO: Verifying minimum Azure CLI version" -ForegroundColor Green + + # Verify Minimum Azure CLI Version + $azureCliVer = [System.Version](az version | ConvertFrom-Json).'azure-cli' + + if($azureCliVer -lt $MIN_AZ_CLI_VER) { + Write-Host "ERROR: Azure CLI must be version $MIN_AZ_CLI_VER or greater!" -ForegroundColor Red + exit + } + + Write-Host "INFO: Verifying Azure PowerShell and Azure CLI contexts match" -ForegroundColor Green + + # Verify Azure PowerShell and Azure CLI Contexts Match + $azureCliContext = $(az account show | ConvertFrom-Json) 2>$null + + if(-not $azureCliContext) { + Write-Host "ERROR: Azure CLI not logged in or no subscription has been selected!" -ForegroundColor Red + exit + } + + $azureCliSub = $azureCliContext.id + $azurePowerShellSub = (Get-AzContext).Subscription.Id + + if($azurePowerShellSub -ne $azureCliSub) { + Write-Host "ERROR: Azure PowerShell and Azure CLI must be set to the same context!" -ForegroundColor Red + exit + } + } + } + + if ($appContainer) { + if (-not $isFunction) { + Write-Host "INFO: Detecting container distro..." -ForegroundColor Green + + $appUri = $existingApp.HostNames[0] + $statusUri = "https://${appUri}/api/status" + $status = Invoke-RestMethod -Method Get -Uri $statusUri -ErrorVariable statusErr -ErrorAction SilentlyContinue + + if ($statusErr) { + Write-Host "ERROR: Unable to detect container distro!" -ForegroundColor Red + throw $statusErr + } + + $containerType = $status.container.image_id + } + + Write-Host "INFO: Building and pushing container images to Azure Container Registry" -ForegroundColor Green + + $containerMap = @{ + debian = @{ + Extension = 'deb' + Port = 80 + Images = @{ + Build = 'node:18-slim' + Serve = 'python:3.9-slim' + } + } + rhel = @{ + Extension = 'rhel' + Port = 8080 + Images = @{ + Build = 'registry.access.redhat.com/ubi8/nodejs-18' + Serve = 'registry.access.redhat.com/ubi8/python-39' + } + } + } + + $dockerFile = 'Dockerfile.' + $containerMap[$containerType].Extension + $dockerFilePath = Join-Path -Path $ROOT_DIR -ChildPath $dockerFile + $dockerFileFunc = Join-Path -Path $ROOT_DIR -ChildPath 'Dockerfile.func' + + if($isFunction) { + Write-Host "INFO: Building Function container..." -ForegroundColor Green + + $funcBuildOutput = $( + az acr build -r $acrName ` + -t ipamfunc:latest ` + -f $dockerFileFunc $ROOT_DIR + ) *>&1 + + if ($LASTEXITCODE -ne 0) { + throw $funcBuildOutput + } else { + Write-Host "INFO: Function container image build and push completed successfully" -ForegroundColor Green + } + + Write-Host "INFO: Restarting Function App" -ForegroundColor Green + + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName -Function + } else { + Write-Host "INFO: Building App container (" -ForegroundColor Green -NoNewline + Write-Host "$containerType" -ForegroundColor Cyan -NoNewline + Write-Host ")..." -ForegroundColor Green + + $appBuildOutput = $( + az acr build -r $acrName ` + -t ipam:latest ` + -f $dockerFilePath $ROOT_DIR ` + --build-arg PORT=$($containerMap[$ContainerType].Port) ` + --build-arg BUILD_IMAGE=$($containerMap[$containerType].Images.Build) ` + --build-arg SERVE_IMAGE=$($containerMap[$containerType].Images.Serve) + ) *>&1 + + if ($LASTEXITCODE -ne 0) { + throw $appBuildOutput + } else { + Write-Host "INFO: App container image build and push completed successfully" -ForegroundColor Green + } + + Write-Host "INFO: Restarting App Service" -ForegroundColor Green + + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName + } + } else { + Write-Host "INFO: Uploading ZIP Deploy archive..." -ForegroundColor Green + + try { + Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName + } catch { + Write-Host "SWITCH: Retrying ZIP Deploy with Kudu API..." -ForegroundColor Blue + Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName -UseAPI + } + + Write-Host + Write-Host "NOTE: Please allow ~5 minutes for the ZIP Deploy process to complete" -ForegroundColor Yellow + } +} +catch { + $_ | Out-File -FilePath $updateLog -Append + Write-Host "ERROR: Unable to update Azure IPAM application, see log for detailed information!" -ForegroundColor red + Write-Host "Update Log: $updateLog" -ForegroundColor Red +} +finally { + Write-Host + Stop-Transcript | Out-Null +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 9686d6c..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3.8' -services: - ipam-ui: - environment: - VITE_AZURE_ENV: ${AZURE_ENV} - VITE_UI_ID: ${UI_APP_ID} - VITE_ENGINE_ID: ${ENGINE_APP_ID} - VITE_TENANT_ID: ${TENANT_ID} - image: azureipam.azurecr.io/ipam-ui:latest - ipam-engine: - environment: - AZURE_ENV: ${AZURE_ENV} - CLIENT_ID: ${ENGINE_APP_ID} - CLIENT_SECRET: ${ENGINE_APP_SECRET} - TENANT_ID: ${TENANT_ID} - COSMOS_URL: ${COSMOS_URL} - COSMOS_KEY: ${COSMOS_KEY} - KEYVAULT_URL: ${KEYVAULT_URL} - image: azureipam.azurecr.io/ipam-engine:latest - nginx-proxy: - image: azureipam.azurecr.io/ipam-lb:latest - ports: - - "80:8080" diff --git a/docs/README.md b/docs/README.md index fdc9d5e..b9d3ae5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Welcome to IPAM! +# Welcome to Azure IPAM! ## Overview and Architecture -IPAM was developed to give customers a simple, straightforward way to manage their IP address space in Azure. IPAM enables end-to-end planning, deploying, managing and monitoring of your IP address space, with an intuitive user experience. IPAM automatically discovers IP address utilization in your Azure tenant and enables you to manage it all from a centralized UI. You can also interface with IPAM programmatically via a RESTful API to facilitate IP address management at scale via Infrastructure as Code (IaC). IPAM is designed and architected based on the 5 pillars of the [Microsoft Azure Well Architected Framework](https://docs.microsoft.com/en-us/azure/architecture/framework/). +Azure IPAM was developed to give customers a simple, straightforward way to manage their IP address space in Azure. It enables end-to-end planning, deploying, managing and monitoring of your IP address space, with an intuitive user experience. Additionally, it can automatically discover IP address utilization within your Azure tenant and enables you to manage it all from a centralized UI. You can also interface with the Azure IPAM service programmatically via a RESTful API to facilitate IP address management at scale via Infrastructure as Code (IaC) and CI/CD pipelines. Azure IPAM is designed and architected based on the 5 pillars of the [Microsoft Azure Well Architected Framework](https://docs.microsoft.com/en-us/azure/architecture/framework/). -| Full (App Service) | Function | +| App Service | Function | :-----------------------------------------------------------------:|:---------------------------------------------------------------------------: | ![IPAM Architecture](./images/ipam_architecture_full.png ':size=70%') | ![IPAM Architecture](./images/ipam_architecture_function.png ':size=70%') | -## IPAM Infrastructure -The IPAM solution is comprised of containers running on Azure App Services. IPAM can also be deployed in an API-only fashion with an Azure Function if no UI is required (e.g. pure IaC model). The containers are built and published to a public Azure Container Registry (ACR), but you may also choose to build your own containers and host them in a Private Container Registry. More details on this can be found in the [Deployment](./deployment/README.md) section. All of the supporting infrastructure is deployed and runs within your Azure Tenant, none of the resources are shared with other IPAM users (outside of the publicly hosted ACR). +## Azure IPAM Infrastructure +The Azure IPAM solution is delivered via a container running in Azure App Services or as an Azure Function. It can also be deployed in an API-only fashion if no UI is required (e.g. pure IaC model). The container is built and published to a public Azure Container Registry (ACR), but you may also choose to build your own container and host it in a Private Container Registry. More details on this can be found in the [Deployment](./deployment/README.md) section. All of the supporting infrastructure is deployed and runs within your Azure Tenant and none of the resources are shared with other IPAM users (outside of the publicly hosted ACR). Here is a more specific breakdown of the components used: - **App Registrations** - 2x App Registrations - *Engine* App Registration - - Granted **reader** permission to the root management group to facilitate IPAM Admin operations (global visibility) + - Granted **reader** permission to the [Root Management Group](https://learn.microsoft.com/en-us/azure/governance/management-groups/overview#root-management-group-for-each-directory) to facilitate IPAM Admin operations (global visibility) - Authentication point for IPAM API operations ([on-behalf-of](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow) flow) - - *UI* App Registration + - *UI* App Registration *(Optional if no UI is desired)* - Granted **read** permissions for Microsoft Graph API's - Added as a *known client application* for the *Engine* App Registration - Authentication point for the IPAM UI ([auth code](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) flow) - **Resource Group** - - House all Azure infrastructure related resources -- **App Service Plan with App Service** *(Full Deployment only)* - - Run the IPAM Engine, UI, and Load Balancer containers as a multi-container App Service -- **App Service Plan with Function App** *(Function Deployment only)* - - Run IPAM Engine as an Azure Function -- **Storage Account with Blob Container** *(Function Deployment only)* - - This account stores the Function metadata + - Contains all Azure IPAM deployed resources +- **App Service Plan with App Service** *(AppContainer Deployment only)* + - Runs the Azure IPAM solution as a container within App Services +- **App Service Plan with Function App** *(FunctionContainer Deployment only)* + - Runs the Azure IPAM solution as a container within Azure Functions +- **Storage Account with Blob Container** *(FunctionContainer Deployment only)* + - Storage for the Azure Function metadata - **Cosmos DB** - Backend NoSQL datastore for the IPAM application - **KeyVault** - Stores the following secrets: - App Registration application IDs and Secrets (Engine & UI) - - Cosmos DB read-write key + - Managed Identity ID - Azure Tenant ID - **User Assigned Managed Identity** - - Assigned to the App Service to retrieve secrets from KeyVault and NGINX configuration data from the Storage Account + - Assigned to the App Service to retrieve secrets from KeyVault +- **Container Registry** *(Optional)* + - Stores a private copy of the Azure IPAM containers -## How IPAM Works +## How Azure IPAM Works -As mentioned above, the IPAM application is made up of two containers, one that runs the front end user interface, and the other that runs the backend engine. For the *Full* deployment, there is also a Load Balancer container running [NGINX](https://www.nginx.com/) for path-based routing. IPAM has been designed as such to accommodate the following use cases... +Azure IPAM has been designed as such to radically simplify the often daunting task of IP address management within Azure and was built to accommodate use cases such as the following... -- A user interface is not needed or you plan on providing your own user interface (API-only) -- You plan on interfacing with IPAM exclusively via the RESTful API -- You plan on running the backend engine in a lightweight fashion, such as Azure Functions or Azure Container Instances +- Discover + - Identify networks, subnets and endpoints holistically across your Azure tenant + - Visualize misconfigurations such as orphaned endpoints and improperly configured virtual network peers +- Organize + - Group Azure networks into *Spaces* and *Blocks* aligned to internal lines of business and enterprise CIDR assignments + - Track IP and CIDR consumption + - Map external (non-Azure) networks to Azure CIDR ranges +- Plan + - Explore "what if" cases such as how may subnets of a given mask are available within a given CIDR block +- Self-Service + - Allow users to reserve CIDR blocks for new virtual network and subnet creation programatically + - Integration with Azure template deployments (ARM/Bicep), Terraform and CI/CD pipelines ## User Interface diff --git a/docs/_coverpage.md b/docs/_coverpage.md index 610481b..f998a7b 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -2,7 +2,7 @@ ![logo](./images/ipam-logo.png ':size=45%') -# IPAM 1.0.0 +# IPAM 3.0.0 > Azure IP Address Management Made Easy [GitHub](https://github.com/Azure/ipam) diff --git a/docs/api/README.md b/docs/api/README.md index c08c238..ff8f55f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,7 +1,7 @@ -## IPAM REST API Overview -You can interface with the full set of capabilities of IPAM via a REST API. We use Swagger to define API documentation in OpenAPI v3 Specification format. +## Azure IPAM REST API Overview +You can interface with the full set of capabilities of Azure IPAM via a REST API. We use Swagger to define API documentation in OpenAPI v3 Specification format. -API docs can be found at the `/api/docs` path of your IPAM website. Here you will find information on methods, parameters, and request body details for all available APIs. +API docs can be found at the `/api/docs` path of your Azure IPAM website. Here you will find information on methods, parameters, and request body details for all available APIs. ![IPAM openapi specification](./images/openapispec.png) @@ -9,7 +9,7 @@ API docs can be found at the `/api/docs` path of your IPAM website. Here you wil You can interface with the API like you would any other REST API. We'll be using [Postman](https://www.postman.com) and [Azure PowerShell](https://docs.microsoft.com/en-us/powershell/azure/what-is-azure-powershell) for our examples. ## Obtaining an Azure AD Token -First things first, you'll need to obtain an Azure AD token for authentication purposes. You can retrieve one via the IPAM UI at anytime by selecting **Token** from the menu presented when clicking on your user avatar in the upper righthand corner. +First things first, you'll need to obtain an Azure AD token for authentication purposes. You can retrieve one via the Azure IPAM UI at anytime by selecting **Token** from the menu presented when clicking on your user avatar in the upper righthand corner. ![IPAM azure ad token](./images/token.png) @@ -17,7 +17,7 @@ You'll then be presented with a message notifying you that your token has been s ![IPAM azure ad token clipboard](./images/token_clipboard.png) -You can also retrieve an Azure AD token from IPAM via Azure PowerShell by using the [Get-AzAccessToken](https://docs.microsoft.com/en-us/powershell/module/az.accounts/get-azaccesstoken) commandlet. The token is retrieved from the API exposed via the backend engine application registration. This is the **ResourceUrl** you will be making the access token call against via Azure PowerShell. +You can also retrieve an Azure AD token from Azure IPAM via Azure PowerShell by using the [Get-AzAccessToken](https://docs.microsoft.com/en-us/powershell/module/az.accounts/get-azaccesstoken) commandlet. The token is retrieved from the API exposed via the backend engine application registration. This is the **ResourceUrl** you will be making the access token call against via Azure PowerShell. ![IPAM api resource url](./images/ipam_api_resource_url.png) @@ -90,10 +90,10 @@ $response id : ABNsJjXXyTRDTRCdJEJThu cidr : 10.1.5.0/24 -userId : harvey@elnica6yahoo.onmicrosoft.com +userId : user@ipam.onmicrosoft.com createdOn : 1662514052.26623 status : wait tag : @{X-IPAM-RES-ID=ABNsJjXXyTRDTRCdJEJThu} ```` -Take a look at our **Azure Landing Zone integration** example found under the `deploy` directory in the repository for a real work example of how to automate vNET creation by means of Bicep and leveraging the IPAM API. +Take a look at our **Azure Landing Zone integration** example found under the `deploy` directory in the repository for a real work example of how to automate vNET creation by means of Bicep and leveraging the Azure IPAM API. diff --git a/docs/contributing/README.md b/docs/contributing/README.md index fb80b81..f5af5e7 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -12,7 +12,7 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. -## Running an IPAM Development Environment with Docker Compose +## Running an Azure IPAM Development Environment with Docker Compose We have included a Docker Compose file in the root directory of the project (`docker-compose.yml`), to quickly build a fully functional Azure IPAM development environment. The Docker Compose file is also dependant on an `env` file to correctly pass all of the required environment variables into the containers. You can use the `env.example` file, also found at the root directory of the project, as a template to create your own `env` file. To start a development environment of the Azure IPAM solution via Docker Compose, run the following commands from the root directory of the project: @@ -29,32 +29,24 @@ docker compose rm -s -v -f ``` ## Building Production Containers Images and Pushing them to DockerHub -We user Dockerfiles to build the containers for each of the Azure IPAM supporting components including the Engine, UI, and Load Balancer. If you choose, you can build these containers yourself and host them in DockerHub. +We use Dockerfiles to build the containers for the Azure IPAM solution and have two located in the root directory of the project. One is designed for use when running inside a solution such as Azure App Services (as well as other containerized environments) and another specifically designed for running inside Azure Functions. If you choose, you can build these containers yourself and host them in DockerHub. To do so, run the following Docker commands from the root directory of the project: ```shell -# Engine Container -docker build --rm --no-cache -t /ipam-engine:latest -f ./engine/Dockerfile.deb ./engine -docker push /ipam-engine:latest +# App Services Container +docker build --rm --no-cache -t /ipam:latest -f ./Dockerfile . +docker push /ipam:latest # Function Container -docker build --rm --no-cache -t /ipam-func:latest -f ./engine/Dockerfile.func ./engine -docker push /ipam-func:latest - -# UI Container -docker build --rm --no-cache -t /ipam-ui:latest -f ./ui/Dockerfile.deb ./ui -docker push /ipam-ui:latest - -# Load Balancer Container -docker build --rm --no-cache -t /ipam-lb:latest -f ./lb/Dockerfile ./lb -docker push /ipam-lb:latest +docker build --rm --no-cache -t /ipamfunc:latest -f ./Dockerfile.func . +docker push /ipamfunc:latest ``` -## Building Production Containers Images Using a Private ACR +## Building & Updating Production Containers Images Using a Private ACR In addition to the DockerHub option (above), alternatively you may choose to leverage an Azure Container Registry to host your Azure IPAM containers. Also, you may have selected the `-PrivateACR` flag during the deployment of your Azure IPAM environment, and from time to time you will need to update your containers as new code is released. -To do so, run the following Azure CLI commands from the root directory of the project: +Before running the update commands, you'll need to authenticate to the Azure CLI ```shell # Authenicate to Azure CLI @@ -62,7 +54,21 @@ az login # Set Target Azure Subscription az account set --subscription "" +``` + +Next, use the following commands to update the Azure IPAM containers within your private Azure Container Registry +```shell +# App Services Container +az acr build -r -t ipam:latest -f ./Dockerfile . + +# Function Container +az acr build -r -t ipamfunc:latest -f ./Dockerfile.func . +``` + +If you're using the legacy Azure IPAM multi-container deployment (prior to v3.0.0), please use the following commands to update your containers instead + +```shell # Engine Container az acr build -r -t ipam-engine:latest -f ./engine/Dockerfile.deb ./engine diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 23b33a1..db93a1c 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -1,4 +1,4 @@ -# IPAM Deployment Overview +# Azure IPAM Deployment Overview ## Prerequisites @@ -12,31 +12,38 @@ To successfully deploy the solution, the following prerequisites must be met: - [User Access Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#user-access-administrator) - [Custom Role](https://learn.microsoft.com/en-us/azure/role-based-access-control/custom-roles) with *allow* permissions of `Microsoft.Authorization/roleAssignments/write` - [Global Administrator](https://learn.microsoft.com/en-us/azure/active-directory/roles/permissions-reference#global-administrator) (needed to grant admin consent for the App Registration API permissions) +- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed + - Required to clone the Azure IPAM GitHub repository - [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) version 7.2.0 or later installed - [Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/install-az-ps) version 8.0.0 or later installed -- [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation) version 1.9.6 or later installed +- [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation) version 2.0.0 or later installed - Required for *Full* or *Apps Only* deployments to grant [Admin Consent](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/grant-admin-consent) to the App Registrations -- [Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) version 0.10.161 or later installed +- [Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) version 0.21.1 or later installed - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) version 2.35.0 or later installed (optional) - - Required only if you are building your own container images and pushing them to Azure Container Registry (Private ACR) -- Docker (Linux) / Docker Desktop (Windows) installed (optional) - - Required only if you are building your own container images and running them locally for development/testing purposes + - Required only if you are building your own container image and pushing it to a private Azure Container Registry (Private ACR) +- [Docker (Linux)](https://docs.docker.com/engine/install/) / [Docker Desktop (Windows)](https://docs.docker.com/desktop/install/windows-install/) installed (optional) + - Required only if you are building your own container image and running it locally for development/testing purposes ## Deployment Overview The Azure IPAM solution is deployed via a PowerShell deployment script, `deploy.ps1`, found in the `deploy` directory of the project. The infrastructure stack is defined via Azure Bicep files. The deployment can be performed via your local machine or from the development container found in the project. You have the following options for deployment: -- Two-part deployment - - Part 1: App Registrations only +- Two-part deployment *(Azure Identities and Permissions Only)* + - Part 1: Azure Identities only + - App Registrations and Service Principals are created + - Required permissions are assigned to App Registrations and Service Principals - Configuration details are saved to a `parameters.json` file which will be shared with the infrastructure team - - Part 2: Infrastructure Stack only - - UI and Engine containers hosted in App Service or... - - Engine container hosted in an Azure Function -- Deploy the entire solution (App Registrations + Azure Infrastructure) - - UI and Engine containers hosted in App Service or... - - Engine container hosted in an Azure Function - -The two-part deployment option is provided in the event that a single team doesn't have the necessary permissions to deploy both the App Registrations in Azure AD, and the Azure infrastructure stack. In the event that you do have all of the the necessary permissions in Azure AD and on the Azure infrastructure side, then you have the option to deploy the entire solution all at once. + - Part 2: Azure Infrastructure only + - Parameters are read from supplied `parameters.json` file + - Azure infrastructure components are deployed + - Azure App Service is pointed to public or private Azure Container Registry +- Single deployment *(Azure Identities, Permissions and Infrastructure)* + - App Registrations and Service Principals are created + - Required permissions are assigned to App Registrations and Service Principals + - Azure infrastructure components are deployed + - Azure App Service is pointed to public or private Azure Container Registry + +The two-part deployment option is provided in the event that a single team within your organization doesn't have the necessary permissions to deploy both the Azure identities within Entra ID, and the Azure infrastructure stack. If a single group does have all of the the necessary permissions in Entra ID and on the Azure infrastructure side, then you have the option to deploy the complete solution all at once. ## Authenticate to Azure PowerShell @@ -122,15 +129,16 @@ To deploy the full solution, run the following from within the `deploy` director You have the ability to pass optional flags to the deployment script: -| Parameter | Description | -| :---------------------------------------------- | :------------------------------------------------------------------------ | -| `-UIAppName ` | Changes the name of the UI app registration | -| `-EngineAppName ` | Changes the name of the Engine app registration | -| `-Tags @{​​​​​​ = '​'; ​ = '​'}` | Attaches the hashtable as tags on the deployed IPAM resource group | -| `-ResourceNames @{​​​​​​ = '​'; ​ = '​'}` | Overrides default resource names with custom names **1,2** | -| `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | -| `-AsFunction` | Deploys the engine container only to an Azure Function | -| `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| Parameter | Description | +| :----------------------------------------------------------------- | :----------------------------------------------------------------------------------------- | +| `-UIAppName ` | Changes the name of the UI app registration | +| `-EngineAppName ` | Changes the name of the Engine app registration | +| `-Tags @{​​​​​​ = '​'; ​ = '​'}` | Attaches the hashtable as tags on the deployed IPAM resource group | +| `-ResourceNames @{​​​​​​ = '​'; ​ = '​'}` | Overrides default resource names with custom names **1,2** | +| `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | +| `-Function` | Deploys the engine container only to an Azure Function | +| `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| `-DisableUI` | Solution will be deployed without a UI, no UI identities will be created | > **NOTE 1:** The required values will vary based on the deployment type. @@ -168,7 +176,7 @@ You have the ability to pass optional flags to the deployment script: ```powershell ./deploy.ps1 ` -Location "westus3" ` - -AsFunction + -Function ``` **Deploy IPAM solution with a private Container Registry:** @@ -199,7 +207,7 @@ $ResourceNames = @{ -ResourceNames $ResourceNames ``` -**Override default resource names with custom resource names and deploy as an Azure Function:** +**Override default resource names with custom resource names and use a private Container Registry:** ```powershell $ResourceNames = @{ @@ -217,16 +225,16 @@ $ResourceNames = @{ ./deploy.ps1 ` -Location "westus3" ` - -ResourceNames $ResourceNames + -ResourceNames $ResourceNames ` -PrivateACR ``` -**Override default resource names with custom resource names and use a private Container Registry:** +**Override default resource names with custom resource names and deploy as an Azure Function:** ```powershell $ResourceNames = @{ functionName = 'myfunction01' - appServicePlanName = 'myappserviceplan01' + functionPlanName = 'myfunctionplan01' cosmosAccountName = 'mycosmosaccount01' cosmosContainerName = 'mycontainer01' cosmosDatabaseName = 'mydatabase01' @@ -240,12 +248,12 @@ $ResourceNames = @{ ./deploy.ps1 ` -Location "westus3" ` -ResourceNames $ResourceNames - -AsFunction + -Function ``` -## App Registration Only Deployment +## Azure Identities (Only) Deployment -To deploy App Registrations only, run the following from within the `deploy` directory: +To deploy Azure Identities only, run the following from within the `deploy` directory: ```powershell ./deploy.ps1 -AppsOnly @@ -253,11 +261,11 @@ To deploy App Registrations only, run the following from within the `deploy` dir You have the ability to pass optional flags to the deployment script: -| Parameter | Description | -| :---------------------- | :-------------------------------------------------------------------------------------------------- | -| `-UIAppName ` | Changes the name of the UI app registration | -| `-EngineAppName ` | Changes the name of the Engine app registration | -| `-AsFunction` | Indicates that this solution will be deployed as an Azure Function, no UI App Registration required | +| Parameter | Description | +| :---------------------- | :----------------------------------------------------------------------- | +| `-UIAppName ` | Changes the name of the UI app registration | +| `-EngineAppName ` | Changes the name of the Engine app registration | +| `-DisableUI` | Solution will be deployed without a UI, no UI identities will be created | **Customize the name of the App Registrations:** @@ -268,15 +276,15 @@ You have the ability to pass optional flags to the deployment script: -EngineAppName "my-engine-app-reg" ``` -**Deploy IPAM solution as an Azure Function:** +**Deploy IPAM solution without a UI (API-only):** ```powershell ./deploy.ps1 ` -AppsOnly ` - -AsFunction + -DisableUI ``` -As part of the app registration deployment, a `main.parameters.json` file is generated with pre-populated parameters for the app registration IDs as well as the engine app registration secret. This parameter file will then be used to perform the infrastructure deployment. +As part of the app registration deployment, a `main.parameters.json` file is generated with pre-populated parameters for the app registration IDs as well as the engine app registration secret. This parameter file will then be used to perform the infrastructure only deployment. ## Infrastructure Stack (Only) Deployment @@ -292,12 +300,13 @@ Once your parameters file is ready, run the following from within the `deploy` d You have the ability to pass optional flags to the deployment script: -| Parameter | Description | -| :---------------------------------------------- | :------------------------------------------------------------------------ | -| `-Tags @{​​​​​​ = '​'; ​ = '​'}`​ | Attaches the hashtable as tags on the deployed IPAM resource group | -| `-ResourceNames @{​​​​​​ = '​'; ​ = '​'}` | Overrides default resource names with custom names **1,2** | -| `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | -| `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| Parameter | Description | +| :----------------------------------------------------------------- | :----------------------------------------------------------------------------------------- | +| `-Tags @{​​​​​​ = '​'; ​ = '​'}`​ | Attaches the hashtable as tags on the deployed IPAM resource group | +| `-ResourceNames @{​​​​​​ = '​'; ​ = '​'}` | Overrides default resource names with custom names **1,2** | +| `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | +| `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| `-Function` | Deploys the engine container only to an Azure Function | > **NOTE 1:** The required values will vary based on the deployment type. @@ -305,6 +314,15 @@ You have the ability to pass optional flags to the deployment script: > **NOTE 3:** Maximum of seven (7) characters. This is because the prefix is used to generate names for several different Azure resource types with varying maximum lengths. +**Change the name prefix for the Azure resources:** + +```powershell +./deploy.ps1 ` + -Location "westus3" ` + -ParameterFile ./main.parameters.json ` + -NamePrefix "devipam" +``` + **Add custom tags to the Azure resources:** ```powershell @@ -314,13 +332,13 @@ You have the ability to pass optional flags to the deployment script: -Tags @{owner = 'ipamadmin@example.com'; environment = 'development'} ``` -**Change the name prefix for the Azure resources:** +**Deploy IPAM solution as an Azure Function:** ```powershell ./deploy.ps1 ` -Location "westus3" ` -ParameterFile ./main.parameters.json ` - -NamePrefix "devipam" + -Function ``` **Deploy IPAM solution with a private Container Registry:** @@ -372,7 +390,7 @@ $ResourceNames = @{ ./deploy.ps1 ` -Location "westus3" ` -ParameterFile ./main.parameters.json ` - -ResourceNames $ResourceNames + -ResourceNames $ResourceNames ` -PrivateACR ``` @@ -395,7 +413,6 @@ $ResourceNames = @{ ./deploy.ps1 ` -Location "westus3" ` -ParameterFile ./main.parameters.json ` - -ResourceNames $ResourceNames + -ResourceNames $ResourceNames ` + -Function ``` - -> **NOTE:** Use this format when the `-AsFunction` flag was used during the *App Registration Only* step above diff --git a/docs/images/ipam_architecture_full.png b/docs/images/ipam_architecture_full.png index af0ac51..a19675e 100644 Binary files a/docs/images/ipam_architecture_full.png and b/docs/images/ipam_architecture_full.png differ diff --git a/docs/images/ipam_architecture_function.png b/docs/images/ipam_architecture_function.png index 723fb4e..3ee47d5 100644 Binary files a/docs/images/ipam_architecture_function.png and b/docs/images/ipam_architecture_function.png differ diff --git a/docs/questions-comments/README.md b/docs/questions-comments/README.md index 2caf4e3..f5bb639 100644 --- a/docs/questions-comments/README.md +++ b/docs/questions-comments/README.md @@ -1,11 +1,11 @@ # Questions or Comments -The IPAM team welcomes engagement and contributions from the community. +The Azure IPAM team welcomes engagement and contributions from the community. ## Discussions -We have set up a [GitHub Discussions](https://github.com/Azure/ipam/discussions) page to make it easy to engage with the IPAM team without opening an issue. +We have set up a [GitHub Discussions](https://github.com/Azure/ipam/discussions) page to make it easy to engage with the Azure IPAM team without opening an issue. ## Issues -In the event you come across a bug in the IPAM tool, please open a [GitHub Issue](https://github.com/Azure/ipam/issues) and someone from the team will respond as soon ASAP. Please include as much detail as you can about what actions were taken leading up to the discovery of the issue, and how (if possible) it can be reproduced. +In the event you come across a bug in the Azure IPAM tool, please open a [GitHub Issue](https://github.com/Azure/ipam/issues) and someone from the team will respond as soon ASAP. Please include as much detail as you can about what actions were taken leading up to the discovery of the issue, and how (if possible) it can be reproduced. diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md index ac2da3a..30ceb9b 100644 --- a/docs/troubleshooting/README.md +++ b/docs/troubleshooting/README.md @@ -131,3 +131,5 @@ az cosmosdb update --resource-group --name Notes This flag may have been set by [Azure Policy](https://learn.microsoft.com/en-us/azure/governance/policy/overview). You can find more details about this policy [here](https://learn.microsoft.com/en-us/azure/cosmos-db/policy-reference#azure-cosmos-db) under *Azure Cosmos DB key based metadata write access should be disabled*. You may need to contact your policy administrator to request an exception for Azure IPAM. + +Additionally this issue only applies to legacy deployments of Azure IPAM (prior to v3.0.0) as the latest versions use SQL [role-based access control](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) to read/write data from Cosmos DB. diff --git a/engine/Dockerfile.rhel b/engine/Dockerfile.rhel index 6823327..4b3d9e0 100644 --- a/engine/Dockerfile.rhel +++ b/engine/Dockerfile.rhel @@ -22,7 +22,7 @@ RUN pip install --upgrade pip --progress-bar off RUN pip install --no-cache-dir --upgrade -r ./requirements.txt --progress-bar off # Copy Application Scripts & Sources -ADD ./app ./app +ADD ./app ./appDockerfile ADD ./init.sh . # Set Script Execute Permissions diff --git a/engine/app/dependencies.py b/engine/app/dependencies.py index 923e05e..673da2f 100644 --- a/engine/app/dependencies.py +++ b/engine/app/dependencies.py @@ -1,4 +1,5 @@ from fastapi import Request, HTTPException +# from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from requests import Session, adapters from urllib3.util.retry import Retry @@ -14,6 +15,127 @@ from app.globals import globals +from app.logs.logs import ipam_logger as logger + +# class IPAMToken(HTTPBearer): +# _session = None + +# def __init__(self, auto_error: bool = True): +# super(IPAMToken, self).__init__( +# auto_error=auto_error, +# scheme_name='IPAM Token', +# description="Please enter a valid IPAM token (Entra Access Token)
Guide: How to generate an Azure IPAM token" +# ) + +# async def __call__(self, request: Request): +# credentials: HTTPAuthorizationCredentials = await super(IPAMToken, self).__call__(request) +# if credentials: +# if not credentials.scheme == "Bearer": +# raise HTTPException(status_code=403, detail="Invalid authentication scheme.") +# if not self.validate_token(request, credentials.credentials): +# raise HTTPException(status_code=403, detail="Invalid token or expired token.") +# return credentials.credentials +# else: +# raise HTTPException(status_code=403, detail="Invalid authorization code.") + +# async def fetch_jwks_keys(self): +# if self._session is None: +# self._session = Session() + +# retries = Retry( +# total=5, +# backoff_factor=0.1, +# status_forcelist=[ 500, 502, 503, 504 ] +# ) + +# self._session.mount('https://', adapters.HTTPAdapter(max_retries=retries)) +# self._session.mount('http://', adapters.HTTPAdapter(max_retries=retries)) + +# key_url = "https://" + globals.AUTHORITY_HOST + "/" + globals.TENANT_ID + "/discovery/v2.0/keys" + +# jwks = _session.get(key_url).json() + +# return jwks + +# async def check_admin(request: Request, user_oid: str, user_tid: str): +# admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", user_tid) + +# if admin_query: +# admin_data = copy.deepcopy(admin_query[0]) + +# if admin_data['admins']: +# is_admin = next((x for x in admin_data['admins'] if user_oid == x['id']), None) +# else: +# is_admin = True +# else: +# is_admin = True + +# request.state.admin = True if is_admin else False + +# async def validate_token(self, request: Request, token: str) -> bool: +# try: +# jwks = await self.fetch_jwks_keys() +# unverified_header = jwt.get_unverified_header(token) + +# rsa_key = {} + +# for key in jwks["keys"]: +# if key["kid"] == unverified_header["kid"]: +# rsa_key = { +# "kty": key["kty"], +# "kid": key["kid"], +# "use": key["use"], +# "n": key["n"], +# "e": key["e"] +# } +# except Exception: +# raise HTTPException(status_code=401, detail="Unable to parse authorization token.") + +# try: +# token_version = int(jwt.decode(token, options={"verify_signature": False})["ver"].split(".")[0]) +# except Exception: +# raise HTTPException(status_code=401, detail="Unable to decode token version.") + +# if token_version == 1: +# logger.error("Microsoft Identity v1.0 access tokens are not supported!") +# logger.error("https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#token-formats") +# raise HTTPException(status_code=401, detail="Microsoft Identity v1.0 access tokens are not supported.") + +# if rsa_key: +# rsa_pem_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(rsa_key)) +# rsa_pem_key_bytes = rsa_pem_key.public_bytes( +# encoding=serialization.Encoding.PEM, +# format=serialization.PublicFormat.SubjectPublicKeyInfo +# ) + +# try: +# payload = jwt.decode( +# token, +# key=rsa_pem_key_bytes, +# verify=True, +# algorithms=["RS256"], +# audience=globals.CLIENT_ID, +# issuer="https://" + globals.AUTHORITY_HOST + "/" + globals.TENANT_ID + "/v2.0" +# ) +# except jwt.ExpiredSignatureError: +# raise HTTPException(status_code=401, detail="Token has expired.") +# except jwt.MissingRequiredClaimError: +# raise HTTPException(status_code=401, detail="Incorrect token claims, please check the audience and issuer.") +# except jwt.InvalidSignatureError: +# raise HTTPException(status_code=401, detail="Invalid token signature.") +# except Exception: +# raise HTTPException(status_code=401, detail="Unable to decode authorization token.") +# else: +# raise HTTPException(status_code=401, detail="Unable to find appropriate signing key.") + +# request.state.tenant_id = payload['tid'] + +# await check_admin(request, payload['oid'], payload['tid']) + +# return True + +# ipam_security = IPAMToken(auto_error=False) + _session = None async def fetch_jwks_keys(): @@ -76,6 +198,16 @@ async def validate_token(request: Request): except Exception: raise HTTPException(status_code=401, detail="Unable to parse authorization token.") + try: + token_version = int(jwt.decode(token, options={"verify_signature": False})["ver"].split(".")[0]) + except Exception: + raise HTTPException(status_code=401, detail="Unable to decode token version.") + + if token_version == 1: + logger.error("Microsoft Identity v1.0 access tokens are not supported!") + logger.error("https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#token-formats") + raise HTTPException(status_code=401, detail="Microsoft Identity v1.0 access tokens are not supported.") + if rsa_key: rsa_pem_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(rsa_key)) rsa_pem_key_bytes = rsa_pem_key.public_bytes( @@ -99,10 +231,10 @@ async def validate_token(request: Request): except jwt.InvalidSignatureError: raise HTTPException(status_code=401, detail="Invalid token signature.") except Exception: - raise HTTPException(status_code=401, detail="Unable to parse authorization token.") + raise HTTPException(status_code=401, detail="Unable to decode authorization token.") else: raise HTTPException(status_code=401, detail="Unable to find appropriate signing key.") - + request.state.tenant_id = payload['tid'] return payload diff --git a/engine/app/globals.py b/engine/app/globals.py index 994a127..da203d4 100644 --- a/engine/app/globals.py +++ b/engine/app/globals.py @@ -1,30 +1,36 @@ import os +import json import aiohttp -from azure.identity import AzureAuthorityHosts - from azure.core.pipeline.transport import AioHttpTransport +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + AZURE_ENV_MAP = { 'AZURE_PUBLIC': { 'AZURE_ARM': 'management.azure.com', 'AZURE_MGMT': 'management.core.windows.net', - 'AUTH_HOST': AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + 'AUTH_HOST': 'login.microsoftonline.com' }, 'AZURE_US_GOV': { 'AZURE_ARM': 'management.usgovcloudapi.net', 'AZURE_MGMT': 'management.core.usgovcloudapi.net', - 'AUTH_HOST': AzureAuthorityHosts.AZURE_GOVERNMENT + 'AUTH_HOST': 'login.microsoftonline.us' + }, + 'AZURE_US_GOV_SECRET': { + 'AZURE_ARM': 'management.azure.microsoft.scloud', + 'AZURE_MGMT': 'management.azure.microsoft.scloud', + 'AUTH_HOST': 'login.microsoftonline.microsoft.scloud' }, 'AZURE_GERMANY': { 'AZURE_ARM': 'management.microsoftazure.de', 'AZURE_MGMT': 'management.core.cloudapi.de', - 'AUTH_HOST': AzureAuthorityHosts.AZURE_GERMANY + 'AUTH_HOST': 'login.microsoftonline.de' }, 'AZURE_CHINA': { 'AZURE_ARM': 'management.chinacloudapi.cn', 'AZURE_MGMT': 'management.core.chinacloudapi.cn', - 'AUTH_HOST': AzureAuthorityHosts.AZURE_CHINA + 'AUTH_HOST': 'login.chinacloudapi.cn' } } @@ -34,13 +40,21 @@ def __init__(self): session = aiohttp.ClientSession(connector=conn) self.shared_transport = AioHttpTransport(session=session, session_owner=False) + @property + def IPAM_VERSION(self): + return json.load(open(os.path.join(ROOT_DIR, "version.json")))['version'] + + @property + def MANAGED_IDENTITY_ID(self): + return os.environ.get('MANAGED_IDENTITY_ID') + @property def CLIENT_ID(self): - return os.environ.get('CLIENT_ID') + return os.environ.get('CLIENT_ID') or os.environ.get('ENGINE_APP_ID') @property def CLIENT_SECRET(self): - return os.environ.get('CLIENT_SECRET') + return os.environ.get('CLIENT_SECRET') or os.environ.get('ENGINE_APP_SECRET') @property def TENANT_ID(self): diff --git a/engine/app/logs/config.json b/engine/app/logs/config.json index e066486..e806fee 100644 --- a/engine/app/logs/config.json +++ b/engine/app/logs/config.json @@ -1,7 +1,7 @@ { "logger": { "path": "./logs", - "filename": "access.log", + "filename": "ipam.log", "level": "info", "rotation": "30 days", "retention": "12 months", diff --git a/engine/app/logs/logs.py b/engine/app/logs/logs.py index 0faba00..5ec8c80 100644 --- a/engine/app/logs/logs.py +++ b/engine/app/logs/logs.py @@ -2,6 +2,7 @@ import sys import logging import json +import tempfile from pathlib import Path @@ -46,7 +47,7 @@ def make_logger(cls,config_path: Path): logging_config = config.get('logger') logger = cls.customize_logging( - logging_config.get('path') , + os.environ.get('IPAM_LOGFILE_LOCATION', os.path.join(tempfile.gettempdir(), "logs", "ipam.log")), level=logging_config.get('level'), retention=logging_config.get('retention'), rotation=logging_config.get('rotation'), @@ -78,7 +79,7 @@ def customize_logging(cls, retention=retention, enqueue=True, backtrace=True, - level=level.upper(), + level='DEBUG', format=format ) diff --git a/engine/app/main.py b/engine/app/main.py index 4e6d199..fb3ca04 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -7,9 +7,11 @@ from fastapi_restful.tasks import repeat_every from fastapi.encoders import jsonable_encoder +from azure.identity.aio import ManagedIdentityCredential + from azure.cosmos.aio import CosmosClient from azure.cosmos import PartitionKey -from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError +from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError, CosmosHttpResponseError from app.routers import ( azure, @@ -17,7 +19,8 @@ admin, user, space, - tool + tool, + status ) from app.logs.logs import ipam_logger as logger @@ -26,6 +29,10 @@ import re import uuid import copy +import json +import shutil +import tempfile +import traceback from pathlib import Path from urllib.parse import urlparse @@ -37,7 +44,15 @@ cosmos_replace ) -BUILD_DIR = os.path.join(os.getcwd(), "app", "build") +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +BUILD_DIR = os.path.join(os.getcwd(), "dist") + +try: + UI_APP_ID = uuid.UUID(os.environ.get('UI_APP_ID')) + VALID_APP_ID = UI_APP_ID != uuid.UUID(int=0) +except: + UI_APP_ID = None + VALID_APP_ID = False description = """ Azure IPAM is a lightweight solution developed on top of the Azure platform designed to help Azure customers manage their enterprise IP Address space easily and effectively. @@ -46,7 +61,7 @@ app = FastAPI( title = "Azure IPAM", description = description, - version = "2.1.0", + version = globals.IPAM_VERSION, contact = { "name": "Azure IPAM Team", "url": "https://github.com/azure/ipam", @@ -91,6 +106,11 @@ prefix = "/api" ) +app.include_router( + status.router, + prefix = "/api" +) + @app.get( "/api/{full_path:path}", include_in_schema = False @@ -128,10 +148,10 @@ async def serve_react_app(request: Request): minimum_size = 500 ) -if os.path.isdir(BUILD_DIR): +if os.path.isdir(BUILD_DIR) and UI_APP_ID and VALID_APP_ID: app.mount( - "/static/", - StaticFiles(directory = Path(BUILD_DIR) / "static"), + "/assets/", + StaticFiles(directory = Path(BUILD_DIR) / "assets"), name = "static" ) @@ -151,19 +171,27 @@ def read_index(request: Request): def read_index(request: Request, full_path: str): target_file = BUILD_DIR + "/" + full_path - # print('look for: ', full_path, target_file) + print('look for: ', full_path, target_file) if os.path.exists(target_file): return FileResponse(target_file) return FileResponse(BUILD_DIR + "/index.html") async def db_upgrade(): - cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) + managed_identity_credential = ManagedIdentityCredential( + client_id = globals.MANAGED_IDENTITY_ID + ) + + cosmos_client = CosmosClient( + globals.COSMOS_URL, + credential=globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential, + transport=globals.SHARED_TRANSPORT + ) - database_name = "ipam-db" + database_name = globals.DATABASE_NAME database = cosmos_client.get_database_client(database_name) - container_name = "ipam-container" + container_name = globals.CONTAINER_NAME container = database.get_container_client(container_name) try: @@ -352,47 +380,106 @@ async def db_upgrade(): # logger.info("No existing Virtual Hubs to patch...") await cosmos_client.close() + await managed_identity_credential.close() @app.on_event("startup") -async def set_globals(): - client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) +async def ipam_startup(): + global BUILD_DIR + + if os.path.exists(BUILD_DIR): + if(os.environ.get('FUNCTIONS_WORKER_RUNTIME')): + new_build_dir = os.path.join(tempfile.gettempdir(), "dist") + + shutil.copytree(BUILD_DIR, new_build_dir) + + BUILD_DIR = new_build_dir + + release_data = {} + + path = '/etc/os-release' if os.path.exists('/etc/os-release') else '/usr/lib/os-release' + + release_info = open(path, 'r') + release_values = release_info.read().splitlines() + cleaned_values = [i for i in release_values if i] + + for value in cleaned_values: + clean_value = value.strip() + value_parts = clean_value.split('=') + release_data[value_parts[0]] = value_parts[1].replace('"', '') + + os.environ['VITE_CONTAINER_IMAGE_ID'] = release_data['ID'] + os.environ['VITE_CONTAINER_IMAGE_VERSION'] = release_data['VERSION_ID'] + os.environ['VITE_CONTAINER_IMAGE_CODENAME'] = release_data['VERSION'].split(" ")[1][1:-1].lower() + os.environ['VITE_CONTAINER_IMAGE_PRETTY_NAME'] = release_data['PRETTY_NAME'] + + env_data = { + 'VITE_AZURE_ENV': os.environ.get('AZURE_ENV'), + 'VITE_UI_ID': os.environ.get('UI_APP_ID'), + 'VITE_ENGINE_ID': os.environ.get('ENGINE_APP_ID'), + 'VITE_TENANT_ID': os.environ.get('TENANT_ID'), + 'VITE_OS_NAME': release_data['PRETTY_NAME'] + } + + env_data_js = "window.env = " + json.dumps(env_data, indent=4) + "\n" + + env_file = os.path.join(BUILD_DIR, "env.js") + + with open(env_file, "w") as env_file: + env_file.write(env_data_js) + + managed_identity_credential = ManagedIdentityCredential( + client_id = globals.MANAGED_IDENTITY_ID + ) + + cosmos_client = CosmosClient( + globals.COSMOS_URL, + credential=globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential, + transport=globals.SHARED_TRANSPORT + ) database_name = globals.DATABASE_NAME try: - logger.info('Creating Database...') - database = await client.create_database( + logger.info('Verifying Database Exists...') + database = await cosmos_client.create_database_if_not_exists( id = database_name ) - except CosmosResourceExistsError: - logger.warning('Database exists! Using existing database...') - database = client.get_database_client(database_name) + except CosmosHttpResponseError as e: + logger.error('Cosmos database does not exist, error initializing Azure IPAM!') + raise e + + + database = cosmos_client.get_database_client(database_name) container_name = globals.CONTAINER_NAME try: - logger.info('Creating Container...') - container = await database.create_container( + logger.info('Verifying Container Exists...') + container = await database.create_container_if_not_exists( id = container_name, partition_key = PartitionKey(path = "/tenant_id") ) - except CosmosResourceExistsError: - logger.warning('Container exists! Using existing container...') - container = database.get_container_client(container_name) + except CosmosHttpResponseError as e: + logger.error('Cosmos container does not exist, error initializing Azure IPAM!') + raise e + + container = database.get_container_client(container_name) - await client.close() + await cosmos_client.close() + await managed_identity_credential.close() await db_upgrade() -# https://github.com/yuval9313/FastApi-RESTful/issues/138 @app.on_event("startup") -@repeat_every(seconds = 60, wait_first = True) # , wait_first=True +@repeat_every(seconds = 60, wait_first = True) async def find_reservations() -> None: if not os.environ.get("FUNCTIONS_WORKER_RUNTIME"): try: await azure.match_resv_to_vnets() except Exception as e: logger.error('Error running network check loop!') + tb = traceback.format_exc() + logger.debug(tb) raise e @app.exception_handler(StarletteHTTPException) diff --git a/engine/app/models.py b/engine/app/models.py index c740c62..5bc930a 100644 --- a/engine/app/models.py +++ b/engine/app/models.py @@ -577,3 +577,22 @@ class CIDRCheckRes(BaseModel): tenant_id: UUID prefixes: List[IPv4Network] containers: List[CIDRContainer] + +##################### +# STATUS MODELS # +##################### + +class ImageDetails(BaseModel): + """DOCSTRING""" + + image_id: str + image_version: str + image_codename: str + image_pretty_name: str + +class Status(BaseModel): + """DOCSTRING""" + + status: str + version: str + container: ImageDetails diff --git a/engine/app/routers/azure.py b/engine/app/routers/azure.py index 9b2caed..ee798b1 100644 --- a/engine/app/routers/azure.py +++ b/engine/app/routers/azure.py @@ -8,7 +8,10 @@ from azure.core.exceptions import ClientAuthenticationError, HttpResponseError from azure.mgmt.compute.aio import ComputeManagementClient from azure.mgmt.network.aio import NetworkManagementClient +from azure.mgmt.datafactory.aio import DataFactoryManagementClient +from azure.mgmt.resourcegraph.aio import ResourceGraphClient from azure.mgmt.resource.subscriptions.aio import SubscriptionClient +from azure.mgmt.resourcegraph.models import QueryRequest, QueryRequestOptions, ResultFormat from typing import List @@ -286,6 +289,51 @@ async def get_vmss_interfaces_sdk_helper(credentials, vmss, list): await network_client.close() +async def get_factory_map_sdk(credentials): + SUB_QUERY = "Resources | where type =~ 'Microsoft.DataFactory/factories' | project id, name, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId" + + data_factory_map = {} + + resource_graph_client = ResourceGraphClient(credentials) + + query = QueryRequest( + query=SUB_QUERY, + options=QueryRequestOptions( + result_format=ResultFormat.object_array + ) + ) + + poll = await resource_graph_client.resources(query) + + await resource_graph_client.close() + + subscription_set = set([x['subscription_id'] for x in poll.data]) + + for subscription in subscription_set: + data_factory_map[subscription] = list(filter(lambda x: x['subscription_id'] == subscription, poll.data)) + + return data_factory_map + +async def get_factory_endpoints_sdk(credentials, factory_map): + factory_list = [] + + for subscription in factory_map.keys(): + data_factory_client = DataFactoryManagementClient(credentials, subscription) + + for factory in factory_map[subscription]: + async for poll in data_factory_client.private_end_point_connections.list_by_factory(factory['resource_group'], factory['name']): + factory_data = { + "name": factory['name'], + "id": factory['id'], + "private_endpoint_id": poll.properties.private_endpoint.id, + } + + factory_list.append(factory_data) + + await data_factory_client.close() + + return factory_list + @router.get( "/subscription", summary = "Get All Subscriptions" @@ -522,6 +570,64 @@ async def pe( return results +@router.get( + "/df", + summary = "Get All Data Factories" +) +async def df( + authorization: str = Header(None), + admin: str = Depends(get_admin) +): + """ + Get a list of Azure Data Factories. + """ + + if admin: + creds = await get_client_credentials() + else: + user_assertion=authorization.split(' ')[1] + creds = await get_obo_credentials(user_assertion) + + data_factory_map = await get_factory_map_sdk(creds) + data_factory_list = await get_factory_endpoints_sdk(creds, data_factory_map) + + await creds.close() + + return data_factory_list + +@router.get( + "/endpoint", + summary = "Get All Azure Private Endpoints (PE's & Data Factories)" +) +async def endpoint( + authorization: str = Header(None), + tenant_id: str = Depends(get_tenant_id), + admin: str = Depends(get_admin) +): + """ + Get a list of Azure Private Endpoints (PE's & Data Factories). + """ + + tasks = [ + asyncio.create_task(pe(authorization, admin)), + asyncio.create_task(df(authorization, admin)) + ] + + endpoints = await asyncio.gather(*tasks) + + private_endpoints = copy.deepcopy(endpoints[0]) + data_factories = copy.deepcopy(endpoints[1]) + + for factory in data_factories: + df_pe = next((x for x in private_endpoints if x['metadata']['pe_id'] == factory['private_endpoint_id']), None) + + if df_pe: + df_pe['id'] = factory['id'] + df_pe['name'] = factory['name'] + df_pe['metadata']['orphaned'] = False + + return private_endpoints + @router.get( "/vm", summary = "Get All Virtual Machines" @@ -706,7 +812,7 @@ async def multi( tasks = [] result_list = [] - tasks.append(asyncio.create_task(multi_helper(pe, result_list, authorization, admin))) + tasks.append(asyncio.create_task(multi_helper(endpoint, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(vm, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(vmss, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(fwvnet, result_list, authorization, admin))) diff --git a/engine/app/routers/common/helper.py b/engine/app/routers/common/helper.py index da51855..6ec4f64 100644 --- a/engine/app/routers/common/helper.py +++ b/engine/app/routers/common/helper.py @@ -1,6 +1,6 @@ from fastapi import HTTPException -from azure.identity.aio import OnBehalfOfCredential, ClientSecretCredential +from azure.identity.aio import OnBehalfOfCredential, ManagedIdentityCredential, ClientSecretCredential from azure.core import MatchConditions from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ServiceRequestError @@ -18,9 +18,13 @@ from app.globals import globals +managed_identity_credential = ManagedIdentityCredential( + client_id = globals.MANAGED_IDENTITY_ID +) + cosmos_client = CosmosClient( url=globals.COSMOS_URL, - credential=globals.COSMOS_KEY, + credential=(globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential), transport=globals.SHARED_TRANSPORT ) diff --git a/engine/app/routers/space.py b/engine/app/routers/space.py index c81291b..3d10100 100644 --- a/engine/app/routers/space.py +++ b/engine/app/routers/space.py @@ -273,6 +273,10 @@ async def get_spaces( net['used'] += IPNetwork(subnet['prefix']).size subnet['size'] = IPNetwork(subnet['prefix']).size + for ext in block['externals']: + space['used'] += IPNetwork(ext['cidr']).size + block['used'] += IPNetwork(ext['cidr']).size + if not is_admin: user_name = get_username_from_jwt(user_assertion) block['resv'] = list(filter(lambda x: x['createdBy'] == user_name, block['resv'])) @@ -417,6 +421,10 @@ async def get_space( net['used'] += IPNetwork(subnet['prefix']).size subnet['size'] = IPNetwork(subnet['prefix']).size + for ext in block['externals']: + space['used'] += IPNetwork(ext['cidr']).size + block['used'] += IPNetwork(ext['cidr']).size + if not is_admin: user_name = get_username_from_jwt(user_assertion) block['resv'] = list(filter(lambda x: x['createdBy'] == user_name, block['resv'])) @@ -711,6 +719,9 @@ async def get_blocks( net['used'] += IPNetwork(subnet['prefix']).size subnet['size'] = IPNetwork(subnet['prefix']).size + for ext in block['externals']: + block['used'] += IPNetwork(ext['cidr']).size + if not is_admin: user_name = get_username_from_jwt(user_assertion) block['resv'] = list(filter(lambda x: x['createdBy'] == user_name, block['resv'])) @@ -868,6 +879,9 @@ async def get_block( net['used'] += IPNetwork(subnet['prefix']).size subnet['size'] = IPNetwork(subnet['prefix']).size + for ext in target_block['externals']: + target_block['used'] += IPNetwork(ext['cidr']).size + if not is_admin: user_name = get_username_from_jwt(user_assertion) target_block['resv'] = list(filter(lambda x: x['createdBy'] == user_name, target_block['resv'])) diff --git a/engine/app/routers/status.py b/engine/app/routers/status.py new file mode 100644 index 0000000..40f4006 --- /dev/null +++ b/engine/app/routers/status.py @@ -0,0 +1,43 @@ +from fastapi.responses import JSONResponse + +from fastapi import APIRouter + +import os + +from app.models import * + +from app.globals import globals + +router = APIRouter( + prefix="/status", + tags=["status"] +) + +@router.get( + "", + summary="Get Azure IPAM Status", + response_model=Status, + status_code = 200 +) +async def get_status(): + is_container = os.environ.get('WEBSITE_STACK') == 'DOCKER' + is_function = os.environ.get('FUNCTIONS_WORKER_RUNTIME') is not None + + stack_type = 'Function' if is_function else 'App' + stack_type += 'Container' if is_container else '' + + status_message = { + "status": "OK", + "version": globals.IPAM_VERSION, + "stack": stack_type + } + + if (is_container): + status_message["container"] = { + "image_id": os.environ.get('VITE_CONTAINER_IMAGE_ID'), + "image_version": os.environ.get('VITE_CONTAINER_IMAGE_VERSION'), + "image_codename": os.environ.get('VITE_CONTAINER_IMAGE_CODENAME'), + "image_pretty_name": os.environ.get('VITE_CONTAINER_IMAGE_PRETTY_NAME') + } + + return JSONResponse(status_message) diff --git a/engine/app/version.json b/engine/app/version.json new file mode 100644 index 0000000..f27700c --- /dev/null +++ b/engine/app/version.json @@ -0,0 +1,3 @@ +{ + "version": "3.0.0" +} diff --git a/engine/function_app.py b/engine/function_app.py new file mode 100644 index 0000000..d5fd0de --- /dev/null +++ b/engine/function_app.py @@ -0,0 +1,33 @@ +import logging +import traceback +from datetime import datetime, timezone + +import azure.functions as func + +from app.main import app as ipam +from app.logs.logs import ipam_logger as logger +from app.routers.azure import match_resv_to_vnets + +azureLogger = logging.getLogger('azure') +azureLogger.setLevel(logging.ERROR) + +app = func.AsgiFunctionApp(app=ipam, http_auth_level=func.AuthLevel.ANONYMOUS) + +# @app.function_name(name="ipam-sentinel") +# @app.schedule(schedule="0 * * * * *", arg_name="mytimer", run_on_startup=True, use_monitor=False) +@app.timer_trigger(schedule="0 * * * * *", arg_name="mytimer", run_on_startup=True, use_monitor=False) +async def ipam_sentinel(mytimer: func.TimerRequest) -> None: + utc_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() + + logger.info('Azure IPAM Sentinel function was triggered') + + if mytimer.past_due: + logger.debug('The timer is past due ({})!'.format(utc_timestamp)) + + try: + await match_resv_to_vnets() + except Exception as e: + logger.error('Error running network check loop!') + tb = traceback.format_exc() + logger.debug(tb) + raise e diff --git a/engine/host.json b/engine/host.json index f292709..95ba23d 100644 --- a/engine/host.json +++ b/engine/host.json @@ -6,6 +6,9 @@ } }, "logging": { + "logLevel": { + "default": "Information" + }, "applicationInsights": { "samplingSettings": { "isEnabled": true, @@ -15,6 +18,6 @@ }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[2.*, 3.0.0)" + "version": "[4.*, 5.0.0)" } } diff --git a/engine/init.sh b/engine/init.sh index a01df21..ffc351f 100644 --- a/engine/init.sh +++ b/engine/init.sh @@ -2,4 +2,5 @@ PORT=$1 +# Start the Uvicorn Server uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} diff --git a/engine/ipam-func/__init__.py b/engine/ipam-func/__init__.py deleted file mode 100644 index 36bbfd9..0000000 --- a/engine/ipam-func/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -import azure.functions as func -from azure.functions._http_asgi import AsgiResponse, AsgiRequest - -# https://github.com/Azure-Samples/fastapi-on-azure-functions/issues/4 -# https://github.com/Azure/azure-functions-python-library/pull/143 -# import nest_asyncio - -import sys -import logging - -from app.main import app as ipam - -# https://github.com/Azure-Samples/fastapi-on-azure-functions/issues/4 -# https://github.com/Azure/azure-functions-python-library/pull/143 -# nest_asyncio.apply() - -logger = logging.getLogger('azure') -logger.setLevel(logging.ERROR) - -IS_INITED = False - -async def run_setup(app): - """Workaround to run Starlette startup events on Azure Function Workers.""" - - global IS_INITED - - if not IS_INITED: - await app.router.startup() - IS_INITED = True - -async def handle_asgi_request(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: - asgi_request = AsgiRequest(req, context) - scope = asgi_request.to_asgi_http_scope() - asgi_response = await AsgiResponse.from_app(ipam, scope, req.get_body()) - - return asgi_response.to_func_response() - -async def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: - await run_setup(ipam) - - return await handle_asgi_request(req, context) - -# Current issue where FastAPI startup is ignored: -# https://github.com/Azure/azure-functions-python-worker/issues/911 -# -# Target function format after fix: -# -# async def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: -# """Each request is redirected to the ASGI handler.""" -# return await func.AsgiMiddleware(ipam).handle_async(req, context) - -# Keeping an eye on this fix as well: -# https://github.com/Azure/azure-functions-python-library/pull/148 - -# Log Stream Flood Issue(s) -# https://github.com/Azure/azure-functions-dotnet-worker/issues/796 -# https://github.com/Azure/azure-functions-host/issues/8973 -# See logging.getLogger('azure') workaround above... diff --git a/engine/ipam-func/function.json b/engine/ipam-func/function.json deleted file mode 100644 index 01051a4..0000000 --- a/engine/ipam-func/function.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "scriptFile": "__init__.py", - "bindings": [ - { - "authLevel": "anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "get", - "post", - "patch", - "put", - "delete" - ], - "route": "/{*route}" - }, - { - "type": "http", - "direction": "out", - "name": "$return" - } - ] -} diff --git a/engine/ipam-sentinel/__init__.py b/engine/ipam-sentinel/__init__.py deleted file mode 100644 index bab1c8a..0000000 --- a/engine/ipam-sentinel/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -import datetime - -import azure.functions as func - -from app.logs.logs import ipam_logger as logger - -from app.routers.azure import match_resv_to_vnets - -async def main(mytimer: func.TimerRequest) -> None: - utc_timestamp = datetime.datetime.utcnow().replace( - tzinfo=datetime.timezone.utc).isoformat() - - if mytimer.past_due: - logger.info('The timer is past due!') - - logger.info('Python timer trigger function ran at %s', utc_timestamp) - - await match_resv_to_vnets() diff --git a/engine/ipam-sentinel/function.json b/engine/ipam-sentinel/function.json deleted file mode 100644 index 76d99ba..0000000 --- a/engine/ipam-sentinel/function.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "scriptFile": "__init__.py", - "bindings": [ - { - "name": "mytimer", - "type": "timerTrigger", - "direction": "in", - "schedule": "0 * * * * *" - } - ] -} diff --git a/engine/requirements.txt b/engine/requirements.txt index 83495d3..13b4754 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -20,6 +20,7 @@ azure-mgmt-network azure-mgmt-resource azure-mgmt-resourcegraph azure-mgmt-managementgroups +azure-mgmt-datafactory azure-keyvault-secrets azure-cosmos azure-functions diff --git a/init.sh b/init.sh index dc71502..48c7365 100644 --- a/init.sh +++ b/init.sh @@ -1,12 +1,11 @@ #!/bin/bash +PORT=$1 -export REACT_APP_CLIENT_ID=$CLIENT_ID -export REACT_APP_TENANT_ID=$TENANT_ID - +# Pull Environment Variables from Parent Shell eval $(printenv | sed -n "s/^\([^=]\+\)=\(.*\)$/export \1=\2/p" | sed 's/"/\\\"/g' | sed '/=/s//="/' | sed 's/$/"/' >> /etc/profile) +# Start the SSH Service /usr/sbin/sshd -npx --yes react-inject-env set -d /code/app/build & - -uvicorn "app.main:app" --reload --host "0.0.0.0" --port 80 +# Start the Uvicorn Server +uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} diff --git a/sshd_config b/sshd_config index 6449279..34ab5f3 100644 --- a/sshd_config +++ b/sshd_config @@ -1,7 +1,7 @@ -Port 2222 -ListenAddress 0.0.0.0 -LoginGraceTime 180 -X11Forwarding yes +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr MACs hmac-sha1,hmac-sha1-96 StrictModes yes diff --git a/tools/build.ps1 b/tools/build.ps1 new file mode 100644 index 0000000..f326760 --- /dev/null +++ b/tools/build.ps1 @@ -0,0 +1,142 @@ +############################################################################################################### +## +## Azure IPAM Zip Deploy Archive Creation Script +## +############################################################################################################### + +# Set minimum version requirements +#Requires -Version 7.2 + +# Intake and set global parameters +param( + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true)] + [ValidateScript({ + if (Test-Path -LiteralPath $_ -PathType Container) { + return $true + } + elseif (Test-Path -LiteralPath $_ -PathType Leaf) { + throw 'The Path parameter must be a folder, file paths are not allowed.' + } + throw 'Invalid File Path' + })] + [string] + $Path, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false)] + [ValidateScript({ + if ($_.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -eq -1) { + return $true + } + throw 'File name contains invalid characters' + })] + [string] + $FileName = 'ipam.zip' +) + +# Root Directory +$ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName + +# Define minimum NodeJS and NPM versions required to build the Azure IPAM UI solution +$MIN_NODE_VERSION = [System.Version]'18.0.0' +$MIN_NPM_VERSION = [System.Version]'8.6.0' + +# Set preference variables +$ErrorActionPreference = "Stop" + +# Set Log File Location +$logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" +New-Item -ItemType Directory -Path $logpath -Force | Out-Null + +$buildLog = Join-Path -Path $logPath -ChildPath "build_$(get-date -format `"yyyyMMddhhmmsstt`").log" + +Start-Transcript -Path $buildLog | Out-Null + +try { + Write-Host + Write-Host "INFO: Verifying NodeJS is present and has the correct version" -ForegroundColor Green + + # Check for NodeJS and NPM and fetch their current versions + $npmErr = $( + $npmDetails = npm version --json + ) 2>&1 + + # Extract NodeJs and NPM versions and exit if either is not detected + if($null -eq $npmErr) { + $npmDetailsJson = [string]$npmDetails | ConvertFrom-Json + + $npmVersion = [version]$npmDetailsJson.npm + $nodeVersion = [version]$npmDetailsJson.node + } else { + Write-Host "ERROR: NodeJS not detected!" -ForegroundColor red + Write-Host "ERROR: NodeJS is required to build the Azure IPAM code package!" -ForegroundColor red + exit + } + + # Check for required NodeJS version + if($nodeVersion -lt $MIN_NODE_VERSION) { + Write-Host "ERROR: NodeJS must be version $MIN_NODE_VERSION or greater!" -ForegroundColor red + } + + # Check for required NPM version + if($npmVersion -lt $MIN_NPM_VERSION) { + Write-Host "ERROR: NPM must be version $MIN_NPM_VERSION or greater!" -ForegroundColor red + } + + # Exit if NodeJS or NPM versions do not meet the minimum version requirements + if(($nodeVersion -lt $MIN_NODE_VERSION) -or ($npmVersion -lt $MIN_NPM_VERSION)) { + exit + } + + Write-Host "INFO: Building application creating ZIP Deploy package" -ForegroundColor Green + + # Create path to UI dir from script file location + $uiDir = Join-Path -Path $ROOT_DIR -ChildPath "ui" + + # Switch to UI dir for build process + Push-Location -Path $uiDir + + Write-Host "INFO: Running NPM Build..." -ForegroundColor Green + + # Build Azure IPAM UI + $npmBuildErr = $( + $npmBuild = npm run build --no-update-notifier + ) 2>&1 + + # Switch back to original dir + Pop-Location + + # Create the Azure IPAM ZIP Deploy archive if NPM Build was successful + if(-not $npmBuildErr) { + Write-Host "INFO: Creating ZIP Deploy archive..." -ForegroundColor Green + + $FilePath = Join-Path -Path $Path -ChildPath $FileName + + Compress-Archive -Path ..\engine\app -DestinationPath $FilePath -Force + Compress-Archive -Path ..\engine\function_app.py -DestinationPath $FilePath -Update + Compress-Archive -Path ..\engine\requirements.txt -DestinationPath $FilePath -Update + Compress-Archive -Path ..\engine\host.json -DestinationPath $FilePath -Update + Compress-Archive -Path ..\ui\dist -DestinationPath $FilePath -Update + Compress-Archive -Path ..\init.sh -DestinationPath $FilePath -Update + } else { + Write-Host "ERROR: Cannot create ZIP Deploy archive!" -ForegroundColor red + throw $npmBuildErr + } + + Write-Host "INFO: Azure IPAM Zip Deploy archive successfully created" -ForegroundColor Green + + $fullPath = (Resolve-Path -Path $FilePath).Path + + Write-Host + Write-Host "ZIP Asset Path: $fullPath" -ForegroundColor Yellow +} +catch { + $_ | Out-File -FilePath $buildLog -Append + Write-Host "ERROR: Unable to build Azure IPAM Zip assets due to an exception, see log for detailed information!" -ForegroundColor red + Write-Host "Build Log: $buildLog" -ForegroundColor Red +} +finally { + Write-Host + Stop-Transcript | Out-Null +} diff --git a/tools/version.ps1 b/tools/version.ps1 new file mode 100644 index 0000000..234591a --- /dev/null +++ b/tools/version.ps1 @@ -0,0 +1,167 @@ +############################################################################################################### +## +## Azure IPAM Version Update Script +## +############################################################################################################### + +# Set minimum version requirements +#Requires -Version 7.2 + +# Intake and set global parameters +[CmdletBinding(DefaultParameterSetName = 'Explicit')] +param( + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true, + ParameterSetName = 'Explicit')] + [System.Version] + $Version, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'Implicit')] + [switch] + $BumpMajor, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'Implicit')] + [switch] + $BumpMinor, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false, + ParameterSetName = 'Implicit')] + [switch] + $BumpBuild +) + +# Root Directory +$ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName + +# Set preference variables +$ErrorActionPreference = "Stop" + +# Set Log File Location +$logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" +New-Item -ItemType Directory -Path $logpath -Force | Out-Null + +$versionLog = Join-Path -Path $logPath -ChildPath "version_$(get-date -format `"yyyyMMddhhmmsstt`").log" + +$versionSuccess = $false + +Start-Transcript -Path $versionLog | Out-Null + +try { + Write-Host + Write-Host "NOTE: Version Update Type: $($PSCmdlet.ParameterSetName)" -ForegroundColor Magenta + + # Create path to adjacent directories from script file location + $scriptPath = $MyInvocation.MyCommand.Path + $parentDir = (Get-Item $scriptPath).Directory.Parent.FullName + $uiDir = Join-Path -Path $parentDir -ChildPath "ui" + $engineDir = Join-Path -Path $parentDir -ChildPath "engine" -AdditionalChildPath "app" + $docsDir = Join-Path -Path $parentDir -ChildPath "docs" + + Write-Host "INFO: Reading version from UI package.json file" -ForegroundColor Green + + # Read version from UI package.json + $packageJsonFile = Join-Path -Path $uiDir -ChildPath "package.json" + $packageJsonContent = Get-Content -Path $packageJsonFile | ConvertFrom-Json + $packageJsonVersion = $packageJsonContent.version + + Write-Host "INFO: Reading version from Engine version.json file" -ForegroundColor Green + + # Read version from Engine version.json + $engineVersionFile = Join-Path -Path $engineDir -ChildPath "version.json" + $engineVersionContent = Get-Content -Path $engineVersionFile | ConvertFrom-Json + $engineVersion = $engineVersionContent.version + + Write-Host "INFO: Reading version from Docs _coverpage.md file" -ForegroundColor Green + + # Read version from Docs coverpage.md + $versionPattern = "(?<=).*(?=<\/small>)" + $coverpageFile = Join-Path -Path $docsDir -ChildPath "_coverpage.md" + $coverpageContent = Get-Content -Path $coverpageFile + $coverpageVersion = ($coverpageContent | Select-String $versionPattern).Matches.Groups[0].Value + + if ($PSCmdlet.ParameterSetName -eq 'Explicit') { + $updatedVersion = "{0}.{1}.{2}" -f $Version.Major, $Version.Minor, $Version.Build + } + + if ($PSCmdlet.ParameterSetName -eq 'Implicit') { + Write-Host "INFO: Calculating new version number" -ForegroundColor Green + + $currentVersions = @($packageJsonVersion, $engineVersion, $coverpageVersion) + $notEqual = $currentVersions -ne $currentVersions[0] + + if($notEqual) { + Write-Host "ERROR: The current versions do not match" -ForegroundColor Red + Write-Host "ERROR: UI: $packageJsonVersion" -ForegroundColor Red + Write-Host "ERROR: Engine: $engineVersion" -ForegroundColor Red + Write-Host "ERROR: Docs: $coverpageVersion" -ForegroundColor Red + Write-Host "ERROR: Cannot implicitly bump versions" -ForegroundColor Red + + throw [System.ArgumentException]::New("Current file versions do not match.") + } else { + $currVer = [System.Version]$currentVersions[0] + } + + $majorVersion = $currVer.Major + $minorVersion = $currVer.Minor + $buildVersion = $currVer.Build + + if($BumpMajor) { + $majorVersion += 1 + $minorVersion = 0 + $buildVersion = 0 + } + + if($BumpMinor) { + $minorVersion += 1 + $buildVersion = 0 + } + + if($BumpBuild) { + $buildVersion += 1 + } + + $updatedVersion = "{0}.{1}.{2}" -f $majorVersion, $minorVersion, $buildVersion + } + + Write-Host "INFO: Updating version for UI package.json file" -ForegroundColor Green + + # Update version for UI package.json + $packageJsonContent.version = $updatedVersion + $packageJsonContent | ConvertTo-Json | Set-Content -Path $packageJsonFile + + Write-Host "INFO: Updating version for Engine version.json file" -ForegroundColor Green + + # Update version for Engine version.json + $engineVersionContent.version = $updatedVersion + $engineVersionContent | ConvertTo-Json | Set-Content -Path $engineVersionFile + + Write-Host "INFO: Updating version for Docs coverpage.md file" -ForegroundColor Green + + # Update version for Docs coverpage.md + $coverpageContent = $coverpageContent -replace $versionPattern, $updatedVersion + $coverpageContent | Set-Content -Path $coverpageFile + + Write-Host "INFO: Azure IPAM versions successfully updated" -ForegroundColor Green + Write-Host + Write-Host "Updated Version -> v$updatedVersion" -ForegroundColor Yellow + + $script:versionSuccess = $true +} +catch { + $_ | Out-File -FilePath $versionLog -Append + Write-Host "ERROR: Unable to update Azure IPAM component versions due to an exception, see log for detailed information!" -ForegroundColor red + Write-Host "Version Log: $versionLog" -ForegroundColor Red +} +finally { + Write-Host + Stop-Transcript | Out-Null + + if ($script:versionSuccess) { + Write-Output "ipamVersion=$updatedVersion" >> $Env:GITHUB_OUTPUT + } +} diff --git a/ui/.devcontainer/base.Dockerfile b/ui/.devcontainer/base.Dockerfile index 56599e9..7b28a78 100644 --- a/ui/.devcontainer/base.Dockerfile +++ b/ui/.devcontainer/base.Dockerfile @@ -1,5 +1,5 @@ # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster -ARG VARIANT=16-bullseye +ARG VARIANT=18-bullseye FROM node:${VARIANT} # [Option] Install zsh diff --git a/ui/.devcontainer/devcontainer.json b/ui/.devcontainer/devcontainer.json index 43a0819..3ff8051 100644 --- a/ui/.devcontainer/devcontainer.json +++ b/ui/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ // Update 'VARIANT' to pick a Node version: 18, 16, 14. // Append -bullseye or -buster to pin to an OS version. // Use -bullseye variants on local arm64/Apple Silicon. - "args": { "VARIANT": "16-bullseye" }, + "args": { "VARIANT": "18-bullseye" }, }, // Remove container when terminated diff --git a/ui/art/admins.svg b/ui/art/admins.svg new file mode 100644 index 0000000..6eb446c --- /dev/null +++ b/ui/art/admins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/art/settings.svg b/ui/art/settings.svg index e40c849..ce2dc8d 100644 --- a/ui/art/settings.svg +++ b/ui/art/settings.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index 710337a..405d015 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,39 +1,39 @@ { "name": "azure-ipam-ui", - "version": "2.1.0", + "version": "3.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "azure-ipam-ui", - "version": "2.1.0", + "version": "3.0.0", "dependencies": { - "@azure/msal-browser": "^3.6.0", - "@azure/msal-react": "^2.0.8", - "@emotion/react": "^11.11.1", + "@azure/msal-browser": "^3.10.0", + "@azure/msal-react": "^2.0.12", + "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@inovua/reactdatagrid-community": "^5.10.2", - "@mui/icons-material": "^5.15.1", - "@mui/lab": "^5.0.0-alpha.157", - "@mui/material": "^5.15.1", - "@reduxjs/toolkit": "^2.0.1", - "@testing-library/jest-dom": "^6.1.5", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.1", - "axios": "^1.6.2", - "echarts": "^5.4.3", + "@mui/icons-material": "^5.15.10", + "@mui/lab": "^5.0.0-alpha.165", + "@mui/material": "^5.15.10", + "@reduxjs/toolkit": "^2.2.1", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "axios": "^1.6.7", + "echarts": "^5.5.0", "echarts-for-react": "^3.0.2", "lodash": "^4.17.21", - "moment": "^2.29.4", + "moment": "^2.30.1", "notistack": "^3.0.1", "pluralize": "^8.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", - "react-redux": "^9.0.4", - "react-router-dom": "^6.21.1", + "react-redux": "^9.1.0", + "react-router-dom": "^6.22.1", "spinners-react": "^1.0.7", - "web-vitals": "^3.5.0" + "web-vitals": "^3.5.2" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.1", @@ -41,7 +41,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "serve": "^14.2.1", - "vite": "^5.0.10", + "vite": "^5.1.3", "vite-plugin-eslint2": "^4.3.1" } }, @@ -55,9 +55,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==" }, "node_modules/@ampproject/remapping": { "version": "2.2.1", @@ -73,33 +73,33 @@ } }, "node_modules/@azure/msal-browser": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.6.0.tgz", - "integrity": "sha512-FrFBJXRJMyWXjAjg4cUNZwEKktzfzD/YD9+S1kj2ors67hKoveam4aL0bZuCZU/jTiHTn0xDQGQh2ksCMXTXtA==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.10.0.tgz", + "integrity": "sha512-mnmi8dCXVNZI+AGRq0jKQ3YiodlIC4W9npr6FCB9WN6NQT+6rq+cIlxgUb//BjLyzKsnYo+i4LROGeMyU+6v1A==", "dependencies": { - "@azure/msal-common": "14.5.0" + "@azure/msal-common": "14.7.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "14.5.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.5.0.tgz", - "integrity": "sha512-Gx5rZbiZV/HiZ2nEKfjfAF/qDdZ4/QWxMvMo2jhIFVz528dVKtaZyFAOtsX2Ak8+TQvRsGCaEfuwJFuXB6tu1A==", + "version": "14.7.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.7.1.tgz", + "integrity": "sha512-v96btzjM7KrAu4NSEdOkhQSTGOuNUIIsUdB8wlyB9cdgl5KqEKnTonHUZ8+khvZ6Ap542FCErbnTyDWl8lZ2rA==", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-react": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.0.8.tgz", - "integrity": "sha512-Kmbq2Mm6vrXfemxf8+q1PWU7dhx8Ck4lB6gXFFDR+Bn1odoLzxksOqm8CKEk+y9Bq1iR54H0raTQ3Avan43oMw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.0.12.tgz", + "integrity": "sha512-23HKdajBWQ5SSzcwwFKHAWaOCpq4UCthoOBgKpab3UoHx0OuFMQiq6CrNBzBKtBFdyxCjadBGzWshRgl0Nvk1g==", "engines": { "node": ">=10" }, "peerDependencies": { - "@azure/msal-browser": "^3.6.0", + "@azure/msal-browser": "^3.10.0", "react": "^16.8.0 || ^17 || ^18" } }, @@ -511,9 +511,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", - "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -618,14 +618,14 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", - "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", + "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", + "@emotion/serialize": "^1.1.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", "@emotion/utils": "^1.2.1", "@emotion/weak-memoize": "^0.3.1", @@ -641,9 +641,9 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", "dependencies": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -1154,28 +1154,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", "dependencies": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", - "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", "dependencies": { - "@floating-ui/dom": "^1.5.1" + "@floating-ui/dom": "^1.6.1" }, "peerDependencies": { "react": ">=16.8.0", @@ -1183,9 +1183,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", @@ -1290,16 +1290,16 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-beta.28", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.28.tgz", - "integrity": "sha512-KIoSc5sUFceeCaZTq5MQBapFzhHqMo4kj+4azWaCAjorduhcRQtN+BCgVHmo+gvEjix74bUfxwTqGifnu2fNTg==", - "dependencies": { - "@babel/runtime": "^7.23.5", - "@floating-ui/react-dom": "^2.0.4", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", + "version": "5.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.36.tgz", + "integrity": "sha512-6A8fYiXgjqTO6pgj31Hc8wm1M3rFYCxDRh09dBVk0L0W4cb2lnurRJa3cAyic6hHY+we1S58OdGYRbKmOsDpGQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", "@popperjs/core": "^2.11.8", - "clsx": "^2.0.0", + "clsx": "^2.1.0", "prop-types": "^15.8.1" }, "engines": { @@ -1321,20 +1321,20 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.1.tgz", - "integrity": "sha512-y/nUEsWHyBzaKYp9zLtqJKrLod/zMNEWpMj488FuQY9QTmqBiyUhI2uh7PVaLqLewXRtdmG6JV0b6T5exyuYRw==", + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.10.tgz", + "integrity": "sha512-qPv7B+LeMatYuzRjB3hlZUHqinHx/fX4YFBiaS19oC02A1e9JFuDKDvlyRQQ5oRSbJJt0QlaLTlr0IcauVcJRQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.1.tgz", - "integrity": "sha512-VPJdBSyap6uOxCb5BLbWbkvd6aeJCp1pQZm8DcZBITCH0NOSv8Mz9c8Zvo8xr4Od7+xyWHUAgvRSL4047pL2WQ==", + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.10.tgz", + "integrity": "sha512-9cF8oUHZKo9oQ7EQ3pxPELaZuZVmphskU4OI6NiJNDVN7zcuvrEsuWjYo1Zh4fLiC39Nrvm30h/B51rcUjvSGA==", "dependencies": { - "@babel/runtime": "^7.23.5" + "@babel/runtime": "^7.23.9" }, "engines": { "node": ">=12.0.0" @@ -1355,16 +1355,16 @@ } }, "node_modules/@mui/lab": { - "version": "5.0.0-alpha.157", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.157.tgz", - "integrity": "sha512-gY7UM2kNSxiVLfsm0o6HG2G5rM2Vr47prJhDCazY+VG/NOSRc8CG7la6dpL9WDTJhotEZdWwfj1FOUxTonmuQA==", - "dependencies": { - "@babel/runtime": "^7.23.5", - "@mui/base": "5.0.0-beta.28", - "@mui/system": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", - "clsx": "^2.0.0", + "version": "5.0.0-alpha.165", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.165.tgz", + "integrity": "sha512-8/zJStT10nh9yrAzLOPTICGhpf5YiGp/JpM0bdTP7u5AE+YT+X2u6QwMxuCrVeW8/WVLAPFg0vtzyfgPcN5T7g==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.36", + "@mui/system": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", + "clsx": "^2.1.0", "prop-types": "^15.8.1" }, "engines": { @@ -1377,7 +1377,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material": ">=5.10.11", + "@mui/material": ">=5.15.0", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" @@ -1395,19 +1395,19 @@ } }, "node_modules/@mui/material": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.1.tgz", - "integrity": "sha512-WA5DVyvacxDakVyAhNqu/rRT28ppuuUFFw1bLpmRzrCJ4uw/zLTATcd4WB3YbB+7MdZNEGG/SJNWTDLEIyn3xQ==", - "dependencies": { - "@babel/runtime": "^7.23.5", - "@mui/base": "5.0.0-beta.28", - "@mui/core-downloads-tracker": "^5.15.1", - "@mui/system": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.10.tgz", + "integrity": "sha512-YJJGHjwDOucecjDEV5l9ISTCo+l9YeWrho623UajzoHRYxuKUmwrGVYOW4PKwGvCx9SU9oklZnbbi2Clc5XZHw==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.36", + "@mui/core-downloads-tracker": "^5.15.10", + "@mui/system": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", "@types/react-transition-group": "^4.4.10", - "clsx": "^2.0.0", - "csstype": "^3.1.2", + "clsx": "^2.1.0", + "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" @@ -1439,12 +1439,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.1.tgz", - "integrity": "sha512-wTbzuy5KjSvCPE9UVJktWHJ0b/tD5biavY9wvF+OpYDLPpdXK52vc1hTDxSbdkHIFMkJExzrwO9GvpVAHZBnFQ==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.9.tgz", + "integrity": "sha512-/aMJlDOxOTAXyp4F2rIukW1O0anodAMCkv1DfBh/z9vaKHY3bd5fFf42wmP+0GRmwMinC5aWPpNfHXOED1fEtg==", "dependencies": { - "@babel/runtime": "^7.23.5", - "@mui/utils": "^5.15.1", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.9", "prop-types": "^15.8.1" }, "engines": { @@ -1465,13 +1465,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.1.tgz", - "integrity": "sha512-7WDZTJLqGexWDjqE9oAgjU8ak6hEtUw2yQU7SIYID5kLVO2Nj/Wi/KicbLsXnTsJNvSqePIlUIWTBSXwWJCPZw==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.9.tgz", + "integrity": "sha512-NRKtYkL5PZDH7dEmaLEIiipd3mxNnQSO+Yo8rFNBNptY8wzQnQ+VjayTq39qH7Sast5cwHKYFusUrQyD+SS4Og==", "dependencies": { - "@babel/runtime": "^7.23.5", + "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -1496,17 +1496,17 @@ } }, "node_modules/@mui/system": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.1.tgz", - "integrity": "sha512-LAnP0ls69rqW9eBgI29phIx/lppv+WDGI7b3EJN7VZIqw0RezA0GD7NRpV12BgEYJABEii6z5Q9B5tg7dsX0Iw==", - "dependencies": { - "@babel/runtime": "^7.23.5", - "@mui/private-theming": "^5.15.1", - "@mui/styled-engine": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", - "clsx": "^2.0.0", - "csstype": "^3.1.2", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.9.tgz", + "integrity": "sha512-SxkaaZ8jsnIJ77bBXttfG//LUf6nTfOcaOuIgItqfHv60ZCQy/Hu7moaob35kBb+guxVJnoSZ+7vQJrA/E7pKg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.9", + "@mui/styled-engine": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", + "clsx": "^2.1.0", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -1535,9 +1535,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.11", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.11.tgz", - "integrity": "sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==", + "version": "7.2.13", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -1548,11 +1548,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.1.tgz", - "integrity": "sha512-V1/d0E3Bju5YdB59HJf2G0tnHrFEvWLN+f8hAXp9+JSNy/LC2zKyqUfPPahflR6qsI681P8G9r4mEZte/SrrYA==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.9.tgz", + "integrity": "sha512-yDYfr61bCYUz1QtwvpqYy/3687Z8/nS4zv7lv/ih/6ZFGMl1iolEvxRmR84v2lOYxlds+kq1IVYbXxDKh8Z9sg==", "dependencies": { - "@babel/runtime": "^7.23.5", + "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -1619,12 +1619,12 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.0.1.tgz", - "integrity": "sha512-fxIjrR9934cmS8YXIGd9e7s1XRsEU++aFc9DVNMFMRTM5Vtsg2DCRMj21eslGtDt43IUf9bJL3h5bwUlZleibA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz", + "integrity": "sha512-8CREoqJovQW/5I4yvvijm/emUiCCmcs4Ev4XPWd4mizSO+dD3g5G6w34QK5AGeNrSH7qM8Fl66j4vuV7dpOdkw==", "dependencies": { "immer": "^10.0.3", - "redux": "^5.0.0", + "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.0.1" }, @@ -1642,9 +1642,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.1.tgz", - "integrity": "sha512-Qg4DMQsfPNAs88rb2xkdk03N3bjK4jgX5fR24eHCTR9q6PrhZQZ4UJBPzCHJkIpTRN1UKxx2DzjZmnC+7Lj0Ow==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", + "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", "engines": { "node": ">=14.0.0" } @@ -1859,16 +1859,16 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.5.tgz", - "integrity": "sha512-3y04JLW+EceVPy2Em3VwNr95dOKqA8DhR0RJHhHKDZNYXcVXnEK7WIrpj4eYU8SVt/qYZ2aRWt/WgQ+grNES8g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", + "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", "dependencies": { - "@adobe/css-tools": "^4.3.1", + "@adobe/css-tools": "^4.3.2", "@babel/runtime": "^7.9.2", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", + "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.15", "redent": "^3.0.0" }, @@ -1879,6 +1879,7 @@ }, "peerDependencies": { "@jest/globals": ">= 28", + "@types/bun": "latest", "@types/jest": ">= 28", "jest": ">= 28", "vitest": ">= 0.32" @@ -1887,6 +1888,9 @@ "@jest/globals": { "optional": true }, + "@types/bun": { + "optional": true + }, "@types/jest": { "optional": true }, @@ -1910,10 +1914,15 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" + }, "node_modules/@testing-library/react": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.2.tgz", - "integrity": "sha512-z4p7DVBTPjKM5qDZ0t5ZjzkpSNb+fZy1u6bzO7kk8oeGagpPCAtgh4cx1syrfp7a+QWkM021jGqjJaxJJnXAZg==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz", + "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", @@ -1928,9 +1937,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", - "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "engines": { "node": ">=12", "npm": ">=6" @@ -2388,11 +2397,11 @@ } }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -2675,9 +2684,9 @@ } }, "node_modules/clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", "engines": { "node": ">=6" } @@ -2809,9 +2818,9 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/debug": { "version": "4.3.4", @@ -2943,12 +2952,12 @@ "dev": true }, "node_modules/echarts": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz", - "integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.0.tgz", + "integrity": "sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==", "dependencies": { "tslib": "2.3.0", - "zrender": "5.4.4" + "zrender": "5.5.0" } }, "node_modules/echarts-for-react": { @@ -3551,9 +3560,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -4630,9 +4639,9 @@ } }, "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "engines": { "node": "*" } @@ -5036,9 +5045,9 @@ } }, "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "funding": [ { @@ -5230,9 +5239,9 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/react-redux": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.0.4.tgz", - "integrity": "sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz", + "integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==", "dependencies": { "@types/use-sync-external-store": "^0.0.3", "use-sync-external-store": "^1.0.0" @@ -5265,11 +5274,11 @@ } }, "node_modules/react-router": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.1.tgz", - "integrity": "sha512-W0l13YlMTm1YrpVIOpjCADJqEUpz1vm+CMo47RuFX4Ftegwm6KOYsL5G3eiE52jnJpKvzm6uB/vTKTPKM8dmkA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz", + "integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==", "dependencies": { - "@remix-run/router": "1.14.1" + "@remix-run/router": "1.15.1" }, "engines": { "node": ">=14.0.0" @@ -5279,12 +5288,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.1.tgz", - "integrity": "sha512-QCNrtjtDPwHDO+AO21MJd7yIcr41UetYt5jzaB9Y1UYaPTCnVuJq6S748g1dE11OQlCFIQg+RtAA1SEZIyiBeA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz", + "integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==", "dependencies": { - "@remix-run/router": "1.14.1", - "react-router": "6.21.1" + "@remix-run/router": "1.15.1", + "react-router": "6.22.1" }, "engines": { "node": ">=14.0.0" @@ -5334,9 +5343,9 @@ } }, "node_modules/redux": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.0.tgz", - "integrity": "sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6190,13 +6199,13 @@ } }, "node_modules/vite": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz", - "integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "dev": true, "dependencies": { "esbuild": "^0.19.3", - "postcss": "^8.4.32", + "postcss": "^8.4.35", "rollup": "^4.2.0" }, "bin": { @@ -6270,9 +6279,9 @@ } }, "node_modules/web-vitals": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", - "integrity": "sha512-f5YnCHVG9Y6uLCePD4tY8bO/Ge15NPEQWtvm3tPzDKygloiqtb4SVqRHBcrIAqo2ztqX5XueqDn97zHF0LdT6w==" + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz", + "integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==" }, "node_modules/which": { "version": "2.0.2", @@ -6466,9 +6475,9 @@ } }, "node_modules/zrender": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.4.tgz", - "integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.5.0.tgz", + "integrity": "sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==", "dependencies": { "tslib": "2.3.0" } @@ -6482,9 +6491,9 @@ "dev": true }, "@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==" }, "@ampproject/remapping": { "version": "2.2.1", @@ -6497,22 +6506,22 @@ } }, "@azure/msal-browser": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.6.0.tgz", - "integrity": "sha512-FrFBJXRJMyWXjAjg4cUNZwEKktzfzD/YD9+S1kj2ors67hKoveam4aL0bZuCZU/jTiHTn0xDQGQh2ksCMXTXtA==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.10.0.tgz", + "integrity": "sha512-mnmi8dCXVNZI+AGRq0jKQ3YiodlIC4W9npr6FCB9WN6NQT+6rq+cIlxgUb//BjLyzKsnYo+i4LROGeMyU+6v1A==", "requires": { - "@azure/msal-common": "14.5.0" + "@azure/msal-common": "14.7.1" } }, "@azure/msal-common": { - "version": "14.5.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.5.0.tgz", - "integrity": "sha512-Gx5rZbiZV/HiZ2nEKfjfAF/qDdZ4/QWxMvMo2jhIFVz528dVKtaZyFAOtsX2Ak8+TQvRsGCaEfuwJFuXB6tu1A==" + "version": "14.7.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.7.1.tgz", + "integrity": "sha512-v96btzjM7KrAu4NSEdOkhQSTGOuNUIIsUdB8wlyB9cdgl5KqEKnTonHUZ8+khvZ6Ap542FCErbnTyDWl8lZ2rA==" }, "@azure/msal-react": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.0.8.tgz", - "integrity": "sha512-Kmbq2Mm6vrXfemxf8+q1PWU7dhx8Ck4lB6gXFFDR+Bn1odoLzxksOqm8CKEk+y9Bq1iR54H0raTQ3Avan43oMw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.0.12.tgz", + "integrity": "sha512-23HKdajBWQ5SSzcwwFKHAWaOCpq4UCthoOBgKpab3UoHx0OuFMQiq6CrNBzBKtBFdyxCjadBGzWshRgl0Nvk1g==", "requires": {} }, "@babel/code-frame": { @@ -6820,9 +6829,9 @@ } }, "@babel/runtime": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", - "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "requires": { "regenerator-runtime": "^0.14.0" } @@ -6915,14 +6924,14 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "@emotion/react": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", - "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", + "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", + "@emotion/serialize": "^1.1.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", "@emotion/utils": "^1.2.1", "@emotion/weak-memoize": "^0.3.1", @@ -6930,9 +6939,9 @@ } }, "@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", "requires": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -7197,34 +7206,34 @@ "dev": true }, "@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "requires": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.1" } }, "@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", "requires": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" } }, "@floating-ui/react-dom": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", - "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", "requires": { - "@floating-ui/dom": "^1.5.1" + "@floating-ui/dom": "^1.6.1" } }, "@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "@humanwhocodes/config-array": { "version": "0.11.13", @@ -7305,113 +7314,113 @@ } }, "@mui/base": { - "version": "5.0.0-beta.28", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.28.tgz", - "integrity": "sha512-KIoSc5sUFceeCaZTq5MQBapFzhHqMo4kj+4azWaCAjorduhcRQtN+BCgVHmo+gvEjix74bUfxwTqGifnu2fNTg==", - "requires": { - "@babel/runtime": "^7.23.5", - "@floating-ui/react-dom": "^2.0.4", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", + "version": "5.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.36.tgz", + "integrity": "sha512-6A8fYiXgjqTO6pgj31Hc8wm1M3rFYCxDRh09dBVk0L0W4cb2lnurRJa3cAyic6hHY+we1S58OdGYRbKmOsDpGQ==", + "requires": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", "@popperjs/core": "^2.11.8", - "clsx": "^2.0.0", + "clsx": "^2.1.0", "prop-types": "^15.8.1" } }, "@mui/core-downloads-tracker": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.1.tgz", - "integrity": "sha512-y/nUEsWHyBzaKYp9zLtqJKrLod/zMNEWpMj488FuQY9QTmqBiyUhI2uh7PVaLqLewXRtdmG6JV0b6T5exyuYRw==" + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.10.tgz", + "integrity": "sha512-qPv7B+LeMatYuzRjB3hlZUHqinHx/fX4YFBiaS19oC02A1e9JFuDKDvlyRQQ5oRSbJJt0QlaLTlr0IcauVcJRQ==" }, "@mui/icons-material": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.1.tgz", - "integrity": "sha512-VPJdBSyap6uOxCb5BLbWbkvd6aeJCp1pQZm8DcZBITCH0NOSv8Mz9c8Zvo8xr4Od7+xyWHUAgvRSL4047pL2WQ==", + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.10.tgz", + "integrity": "sha512-9cF8oUHZKo9oQ7EQ3pxPELaZuZVmphskU4OI6NiJNDVN7zcuvrEsuWjYo1Zh4fLiC39Nrvm30h/B51rcUjvSGA==", "requires": { - "@babel/runtime": "^7.23.5" + "@babel/runtime": "^7.23.9" } }, "@mui/lab": { - "version": "5.0.0-alpha.157", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.157.tgz", - "integrity": "sha512-gY7UM2kNSxiVLfsm0o6HG2G5rM2Vr47prJhDCazY+VG/NOSRc8CG7la6dpL9WDTJhotEZdWwfj1FOUxTonmuQA==", - "requires": { - "@babel/runtime": "^7.23.5", - "@mui/base": "5.0.0-beta.28", - "@mui/system": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", - "clsx": "^2.0.0", + "version": "5.0.0-alpha.165", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.165.tgz", + "integrity": "sha512-8/zJStT10nh9yrAzLOPTICGhpf5YiGp/JpM0bdTP7u5AE+YT+X2u6QwMxuCrVeW8/WVLAPFg0vtzyfgPcN5T7g==", + "requires": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.36", + "@mui/system": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", + "clsx": "^2.1.0", "prop-types": "^15.8.1" } }, "@mui/material": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.1.tgz", - "integrity": "sha512-WA5DVyvacxDakVyAhNqu/rRT28ppuuUFFw1bLpmRzrCJ4uw/zLTATcd4WB3YbB+7MdZNEGG/SJNWTDLEIyn3xQ==", - "requires": { - "@babel/runtime": "^7.23.5", - "@mui/base": "5.0.0-beta.28", - "@mui/core-downloads-tracker": "^5.15.1", - "@mui/system": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", + "version": "5.15.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.10.tgz", + "integrity": "sha512-YJJGHjwDOucecjDEV5l9ISTCo+l9YeWrho623UajzoHRYxuKUmwrGVYOW4PKwGvCx9SU9oklZnbbi2Clc5XZHw==", + "requires": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.36", + "@mui/core-downloads-tracker": "^5.15.10", + "@mui/system": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", "@types/react-transition-group": "^4.4.10", - "clsx": "^2.0.0", - "csstype": "^3.1.2", + "clsx": "^2.1.0", + "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" } }, "@mui/private-theming": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.1.tgz", - "integrity": "sha512-wTbzuy5KjSvCPE9UVJktWHJ0b/tD5biavY9wvF+OpYDLPpdXK52vc1hTDxSbdkHIFMkJExzrwO9GvpVAHZBnFQ==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.9.tgz", + "integrity": "sha512-/aMJlDOxOTAXyp4F2rIukW1O0anodAMCkv1DfBh/z9vaKHY3bd5fFf42wmP+0GRmwMinC5aWPpNfHXOED1fEtg==", "requires": { - "@babel/runtime": "^7.23.5", - "@mui/utils": "^5.15.1", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.9", "prop-types": "^15.8.1" } }, "@mui/styled-engine": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.1.tgz", - "integrity": "sha512-7WDZTJLqGexWDjqE9oAgjU8ak6hEtUw2yQU7SIYID5kLVO2Nj/Wi/KicbLsXnTsJNvSqePIlUIWTBSXwWJCPZw==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.9.tgz", + "integrity": "sha512-NRKtYkL5PZDH7dEmaLEIiipd3mxNnQSO+Yo8rFNBNptY8wzQnQ+VjayTq39qH7Sast5cwHKYFusUrQyD+SS4Og==", "requires": { - "@babel/runtime": "^7.23.5", + "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" } }, "@mui/system": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.1.tgz", - "integrity": "sha512-LAnP0ls69rqW9eBgI29phIx/lppv+WDGI7b3EJN7VZIqw0RezA0GD7NRpV12BgEYJABEii6z5Q9B5tg7dsX0Iw==", - "requires": { - "@babel/runtime": "^7.23.5", - "@mui/private-theming": "^5.15.1", - "@mui/styled-engine": "^5.15.1", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.1", - "clsx": "^2.0.0", - "csstype": "^3.1.2", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.9.tgz", + "integrity": "sha512-SxkaaZ8jsnIJ77bBXttfG//LUf6nTfOcaOuIgItqfHv60ZCQy/Hu7moaob35kBb+guxVJnoSZ+7vQJrA/E7pKg==", + "requires": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.9", + "@mui/styled-engine": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", + "clsx": "^2.1.0", + "csstype": "^3.1.3", "prop-types": "^15.8.1" } }, "@mui/types": { - "version": "7.2.11", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.11.tgz", - "integrity": "sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==", + "version": "7.2.13", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", "requires": {} }, "@mui/utils": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.1.tgz", - "integrity": "sha512-V1/d0E3Bju5YdB59HJf2G0tnHrFEvWLN+f8hAXp9+JSNy/LC2zKyqUfPPahflR6qsI681P8G9r4mEZte/SrrYA==", + "version": "5.15.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.9.tgz", + "integrity": "sha512-yDYfr61bCYUz1QtwvpqYy/3687Z8/nS4zv7lv/ih/6ZFGMl1iolEvxRmR84v2lOYxlds+kq1IVYbXxDKh8Z9sg==", "requires": { - "@babel/runtime": "^7.23.5", + "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -7449,20 +7458,20 @@ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, "@reduxjs/toolkit": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.0.1.tgz", - "integrity": "sha512-fxIjrR9934cmS8YXIGd9e7s1XRsEU++aFc9DVNMFMRTM5Vtsg2DCRMj21eslGtDt43IUf9bJL3h5bwUlZleibA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz", + "integrity": "sha512-8CREoqJovQW/5I4yvvijm/emUiCCmcs4Ev4XPWd4mizSO+dD3g5G6w34QK5AGeNrSH7qM8Fl66j4vuV7dpOdkw==", "requires": { "immer": "^10.0.3", - "redux": "^5.0.0", + "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.0.1" } }, "@remix-run/router": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.1.tgz", - "integrity": "sha512-Qg4DMQsfPNAs88rb2xkdk03N3bjK4jgX5fR24eHCTR9q6PrhZQZ4UJBPzCHJkIpTRN1UKxx2DzjZmnC+7Lj0Ow==" + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", + "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==" }, "@rollup/pluginutils": { "version": "5.0.5", @@ -7582,16 +7591,16 @@ } }, "@testing-library/jest-dom": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.5.tgz", - "integrity": "sha512-3y04JLW+EceVPy2Em3VwNr95dOKqA8DhR0RJHhHKDZNYXcVXnEK7WIrpj4eYU8SVt/qYZ2aRWt/WgQ+grNES8g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", + "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", "requires": { - "@adobe/css-tools": "^4.3.1", + "@adobe/css-tools": "^4.3.2", "@babel/runtime": "^7.9.2", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", + "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.15", "redent": "^3.0.0" }, @@ -7604,13 +7613,18 @@ "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } + }, + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" } } }, "@testing-library/react": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.2.tgz", - "integrity": "sha512-z4p7DVBTPjKM5qDZ0t5ZjzkpSNb+fZy1u6bzO7kk8oeGagpPCAtgh4cx1syrfp7a+QWkM021jGqjJaxJJnXAZg==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz", + "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==", "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", @@ -7618,9 +7632,9 @@ } }, "@testing-library/user-event": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", - "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "requires": {} }, "@types/aria-query": { @@ -7991,11 +8005,11 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "requires": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -8173,9 +8187,9 @@ } }, "clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==" }, "color-convert": { "version": "2.0.1", @@ -8285,9 +8299,9 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" }, "csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "debug": { "version": "4.3.4", @@ -8390,12 +8404,12 @@ "dev": true }, "echarts": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz", - "integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.0.tgz", + "integrity": "sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==", "requires": { "tslib": "2.3.0", - "zrender": "5.4.4" + "zrender": "5.5.0" } }, "echarts-for-react": { @@ -8872,9 +8886,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "for-each": { "version": "0.3.3", @@ -9626,9 +9640,9 @@ "dev": true }, "moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" }, "ms": { "version": "2.1.2", @@ -9902,9 +9916,9 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" }, "postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "requires": { "nanoid": "^3.3.7", @@ -10039,9 +10053,9 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "react-redux": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.0.4.tgz", - "integrity": "sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz", + "integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==", "requires": { "@types/use-sync-external-store": "^0.0.3", "use-sync-external-store": "^1.0.0" @@ -10054,20 +10068,20 @@ "dev": true }, "react-router": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.1.tgz", - "integrity": "sha512-W0l13YlMTm1YrpVIOpjCADJqEUpz1vm+CMo47RuFX4Ftegwm6KOYsL5G3eiE52jnJpKvzm6uB/vTKTPKM8dmkA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz", + "integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==", "requires": { - "@remix-run/router": "1.14.1" + "@remix-run/router": "1.15.1" } }, "react-router-dom": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.1.tgz", - "integrity": "sha512-QCNrtjtDPwHDO+AO21MJd7yIcr41UetYt5jzaB9Y1UYaPTCnVuJq6S748g1dE11OQlCFIQg+RtAA1SEZIyiBeA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz", + "integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==", "requires": { - "@remix-run/router": "1.14.1", - "react-router": "6.21.1" + "@remix-run/router": "1.15.1", + "react-router": "6.22.1" } }, "react-transition-group": { @@ -10100,9 +10114,9 @@ } }, "redux": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.0.tgz", - "integrity": "sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "redux-thunk": { "version": "3.1.0", @@ -10720,14 +10734,14 @@ "dev": true }, "vite": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz", - "integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "dev": true, "requires": { "esbuild": "^0.19.3", "fsevents": "~2.3.3", - "postcss": "^8.4.32", + "postcss": "^8.4.35", "rollup": "^4.2.0" } }, @@ -10743,9 +10757,9 @@ } }, "web-vitals": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", - "integrity": "sha512-f5YnCHVG9Y6uLCePD4tY8bO/Ge15NPEQWtvm3tPzDKygloiqtb4SVqRHBcrIAqo2ztqX5XueqDn97zHF0LdT6w==" + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz", + "integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==" }, "which": { "version": "2.0.2", @@ -10878,9 +10892,9 @@ "dev": true }, "zrender": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.4.tgz", - "integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.5.0.tgz", + "integrity": "sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==", "requires": { "tslib": "2.3.0" } diff --git a/ui/package.json b/ui/package.json index 7ec3340..63f2352 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,34 +1,35 @@ { "name": "azure-ipam-ui", - "version": "2.1.0", + "version": "3.0.0", + "type": "module", "private": true, "dependencies": { - "@azure/msal-browser": "^3.6.0", - "@azure/msal-react": "^2.0.8", - "@emotion/react": "^11.11.1", + "@azure/msal-browser": "^3.10.0", + "@azure/msal-react": "^2.0.12", + "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@inovua/reactdatagrid-community": "^5.10.2", - "@mui/icons-material": "^5.15.1", - "@mui/lab": "^5.0.0-alpha.157", - "@mui/material": "^5.15.1", - "@reduxjs/toolkit": "^2.0.1", - "@testing-library/jest-dom": "^6.1.5", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.1", - "axios": "^1.6.2", - "echarts": "^5.4.3", + "@mui/icons-material": "^5.15.10", + "@mui/lab": "^5.0.0-alpha.165", + "@mui/material": "^5.15.10", + "@reduxjs/toolkit": "^2.2.1", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "axios": "^1.6.7", + "echarts": "^5.5.0", "echarts-for-react": "^3.0.2", "lodash": "^4.17.21", - "moment": "^2.29.4", + "moment": "^2.30.1", "notistack": "^3.0.1", "pluralize": "^8.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", - "react-redux": "^9.0.4", - "react-router-dom": "^6.21.1", + "react-redux": "^9.1.0", + "react-router-dom": "^6.22.1", "spinners-react": "^1.0.7", - "web-vitals": "^3.5.0" + "web-vitals": "^3.5.2" }, "scripts": { "start": "vite", @@ -52,7 +53,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "serve": "^14.2.1", - "vite": "^5.0.10", + "vite": "^5.1.3", "vite-plugin-eslint2": "^4.3.1" } } diff --git a/ui/src/features/admin/admin.jsx b/ui/src/features/admin/admin.jsx index c34d0bc..fcb248c 100644 --- a/ui/src/features/admin/admin.jsx +++ b/ui/src/features/admin/admin.jsx @@ -123,53 +123,6 @@ const gridStyle = { fontFamily: 'Roboto, Helvetica, Arial, sans-serif' }; -// const UserAppSwitch = styled(Switch)(({ theme }) => ({ -// width: 62, -// height: 34, -// padding: 7, -// '& .MuiSwitch-switchBase': { -// margin: 1, -// padding: 0, -// transform: 'translateX(6px)', -// '&.Mui-checked': { -// color: '#fff', -// transform: 'translateX(22px)', -// '& .MuiSwitch-thumb:before': { -// backgroundImage: `url('data:image/svg+xml;utf8,')`, -// }, -// '& + .MuiSwitch-track': { -// opacity: 0.5, -// backgroundColor: theme.palette.mode === 'dark' ? 'rgb(144, 202, 249)' : 'rgb(25, 118, 210)', -// }, -// }, -// }, -// '& .MuiSwitch-thumb': { -// backgroundColor: theme.palette.mode === 'dark' ? 'rgb(144, 202, 249)' : 'rgb(25, 118, 210)', -// width: 32, -// height: 32, -// '&:before': { -// content: "''", -// position: 'absolute', -// width: '100%', -// height: '100%', -// left: -2, -// top: -2, -// backgroundRepeat: 'no-repeat', -// backgroundPosition: 'center', -// backgroundImage: `url('data:image/svg+xml;utf8,')`, -// }, -// }, -// '& .MuiSwitch-track': { -// opacity: 0.5, -// backgroundColor: theme.palette.mode === 'dark' ? 'rgb(144, 202, 249)' : 'rgb(25, 118, 210)', -// borderRadius: 20 / 2, -// }, -// })); - function HeaderMenu(props) { const { setting } = props; const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.useContext(AdminContext); @@ -779,7 +732,7 @@ export default function Administration() { )} /> - IPAM Admins + Admin Users ({ + display: "flex", + flexGrow: 1, + height: "calc(100vh - 160px)" +})); + +const MainBody = styled("div")({ + display: "flex", + height: "100%", + width: "100%", + flexDirection: "column", +}); + +const FloatingHeader = styled("div")(({ theme }) => ({ + ...theme.typography.h6, + display: "flex", + flexDirection: "row", + height: "7%", + width: "100%", + border: "1px solid rgba(224, 224, 224, 1)", + borderRadius: "4px", + marginBottom: theme.spacing(3) +})); + +const HeaderTitle = styled("div")(({ theme }) => ({ + ...theme.typography.h6, + width: "80%", + textAlign: "center", + alignSelf: "center", +})); + +const DataSection = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + height: "100%", + width: "100%", + // border: "1px solid rgba(224, 224, 224, 1)" +})); + +// Grid Style(s) +const GridBody = styled("div")(({ theme }) => ({ + height: "100%", + width: "100%" +})); + +const Item = styled(Paper)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff', + ...theme.typography.body2, + padding: theme.spacing(1), + textAlign: 'center', + color: theme.palette.text.secondary, +})); + +export default function AdminSettings() { + const { enqueueSnackbar } = useSnackbar(); + + const [loading, setLoading] = React.useState(true); + const [sending, setSending] = React.useState(false); + const [selected, setSelected] = React.useState({}); + const [loadedExclusions, setLoadedExclusions] = React.useState(null); + + const dispatch = useDispatch(); + + const unchanged = isEqual(selected, loadedExclusions); + + function onSave() { + (async () => { + try { + setSending(true); + let selectedValues = Object.values(selected); + let update = selectedValues.map(item => item.subscription_id); + // await replaceExclusions(update); + enqueueSnackbar("Successfully updated exclusions", { variant: "success" }); + // setLoadedExclusions(selected); + // dispatch(refreshAllAsync()) + } catch (e) { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + enqueueSnackbar(e.message, { variant: "error" }); + } finally { + setSending(false); + } + })(); + } + + return ( + + + + + + Admin Settings + + + + + + + + + + + + + + + + Automatic Updates + + + } label="Enabled" /> + + + + + xs=4 + + + + + + + + + ); +} diff --git a/ui/src/features/drawer/about.jsx b/ui/src/features/drawer/about.jsx new file mode 100644 index 0000000..bdeb504 --- /dev/null +++ b/ui/src/features/drawer/about.jsx @@ -0,0 +1,215 @@ +import * as React from "react"; +// import { useSelector, useDispatch } from 'react-redux'; + +// import { isEqual } from 'lodash'; + +// import { useSnackbar } from "notistack"; + +import Draggable from 'react-draggable'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, + // ToggleButton, + // ToggleButtonGroup, + Paper +} from "@mui/material"; + +// import { +// WbSunnyOutlined, +// DarkModeOutlined, +// } from "@mui/icons-material"; + +// import LoadingButton from '@mui/lab/LoadingButton'; + +// import { +// getMeAsync, +// getRefreshInterval, +// getDarkMode, +// setDarkMode +// } from "../ipam/ipamSlice"; + +// import { updateMe } from "../ipam/ipamAPI"; + +function DraggablePaper(props) { + const nodeRef = React.useRef(null); + + return ( + + + + ); +} + +export default function About(props) { + const { open, handleClose } = props; + + // const { enqueueSnackbar } = useSnackbar(); + + // const [prevOpen, setPrevOpen] = React.useState(false); + // const [openState, setOpenState] = React.useState({}); + // const [refreshValue, setRefreshValue] = React.useState(); + // const [darkModeValue, setDarkModeValue] = React.useState(false); + const [sending, setSending] = React.useState(false); + + // const darkModeSetting = useSelector(getDarkMode); + // const refreshInterval = useSelector(getRefreshInterval); + + // const dispatch = useDispatch(); + + // const changed = React.useMemo(() => { + // const currentState = { + // darkMode: darkModeValue, + // apiRefresh: refreshValue + // }; + + // return(!isEqual(openState, currentState)); + // },[darkModeValue, refreshValue, openState]); + + // React.useEffect(()=>{ + // if(!open && (open !== prevOpen)) { + // dispatch(setDarkMode(openState.darkMode)); + // setRefreshValue(openState.apiRefresh); + // setPrevOpen(open); + // } + // }, [open, prevOpen, openState, dispatch]); + + // React.useEffect(()=>{ + // if(open && (open !== prevOpen)) { + // setDarkModeValue(darkModeSetting); + // setRefreshValue(refreshInterval); + + // setOpenState( + // { + // darkMode: darkModeSetting, + // apiRefresh: refreshInterval + // } + // ); + + // setPrevOpen(open); + // } + // }, [open, prevOpen, darkModeSetting, refreshInterval]); + + function onSubmit() { + console.log("Submit"); + // var body = [ + // { "op": "replace", "path": "/apiRefresh", "value": refreshValue }, + // { "op": "replace", "path": "/darkMode", "value": darkModeValue } + // ]; + + // (async () => { + // try { + // setSending(true); + // await updateMe(body); + // enqueueSnackbar("User settings updated", { variant: "success" }); + // setOpenState( + // { + // darkMode: darkModeValue, + // apiRefresh: refreshValue + // } + // ); + // dispatch(getMeAsync()); + // handleClose(); + // } catch (e) { + // console.log("ERROR"); + // console.log("------------------"); + // console.log(e); + // console.log("------------------"); + // enqueueSnackbar("Error updating user settings", { variant: "error" }); + // } finally { + // setSending(false); + // } + // })(); + } + + return ( +
+ + + About + + + + IPAM DETAILS + + + Current Version: v2.0.0 + + + Runtime: Native Code + + + Framework: Azure App Service + + + + + + + + {/* */} + {/* + Apply + */} + + +
+ ); +} diff --git a/ui/src/features/drawer/drawer.jsx b/ui/src/features/drawer/drawer.jsx index a11e7f4..3c7fb9c 100644 --- a/ui/src/features/drawer/drawer.jsx +++ b/ui/src/features/drawer/drawer.jsx @@ -38,6 +38,10 @@ import { MoreVert as MoreIcon, Token as TokenIcon, Logout as LogoutIcon, + // Info as InfoIcon, + // Close as CloseIcon, + AccountCircle as AccountCircleIcon, + // CloudDownloadOutlined as CloudDownloadIcon } from "@mui/icons-material"; // Imports for the Drawer @@ -51,6 +55,7 @@ import { Divider, Collapse, Avatar, + Badge } from "@mui/material"; import { @@ -73,14 +78,16 @@ import Configure from "../../img/Configure"; import Admin from "../../img/Admin"; import Visualize from "../../img/Visualize"; import Peering from "../../img/Peering"; -import Person from "../../img/Person"; +import Admins from "../../img/Admins"; import Rule from "../../img/Rule"; import Tools from "../../img/Tools"; import Planner from "../../img/Planner"; +// import Settings from "../../img/Settings"; import Help from "../../img/Help"; import VWan from "../../img/VWan"; import UserSettings from "./userSettings"; +import About from "./about"; import Welcome from "../welcome/Welcome"; import DiscoverTabs from "../tabs/discoverTabs"; @@ -120,9 +127,49 @@ const Search = styled("div")(({ theme }) => ({ }, })); +// const Update = styled(Typography)(({ theme }) => ({ +// display: 'flex', +// justifyContent: 'center', +// alignItems: 'center', +// width: '100vw', +// fontSize: '12px', +// color: theme.palette.mode == 'dark' ? 'black' : 'white', +// backgroundColor: theme.palette.warning.light, +// height: '100%' +// })); + +// const StyledBadge = styled(Badge)(({ theme }) => ({ +// '& .MuiBadge-badge': { +// backgroundColor: theme.palette.warning.light, +// color: theme.palette.warning.light, +// boxShadow: `0 0 0 2px ${theme.palette.background.paper}`, +// '&::after': { +// position: 'absolute', +// top: 0, +// left: 0, +// width: '100%', +// height: '100%', +// borderRadius: '50%', +// animation: 'ripple 1.2s infinite ease-in-out', +// border: '1px solid currentColor', +// content: '""', +// }, +// }, +// '@keyframes ripple': { +// '0%': { +// transform: 'scale(.8)', +// opacity: 1, +// }, +// '100%': { +// transform: 'scale(2.4)', +// opacity: 0, +// }, +// }, +// })); + export default function NavDrawer() { const { instance, accounts } = useMsal(); - const { enqueueSnackbar } = useSnackbar(); + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); const [mobileMenuAnchorEl, setMobileMenuAnchorEl] = React.useState(null); @@ -131,6 +178,7 @@ export default function NavDrawer() { const [navChildOpen, setNavChildOpen] = React.useState({}); const [drawerState, setDrawerState] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); + const [aboutOpen, setAboutOpen] = React.useState(false); const [searchData, setSearchData] = React.useState([]); const [searchInput, setSearchInput] = React.useState(''); const [searchValue, setSearchValue] = React.useState(null); @@ -249,7 +297,7 @@ export default function NavDrawer() { children: [ { title: "Admins", - icon: Person, + icon: Admins, link: "admin/admins", admin: true }, @@ -258,7 +306,13 @@ export default function NavDrawer() { icon: Rule, link: "admin/subscriptions", admin: true - } + }, + // { + // title: "Settings", + // icon: Settings, + // link: "admin/settings", + // admin: true + // } ] } ] @@ -309,6 +363,20 @@ export default function NavDrawer() { setDrawerState(open); }; + // React.useEffect(() => { + // const action = snackbarId => ( + // <> + // { closeSnackbar(snackbarId) }}> + // + // + // + // ); + + // if(meLoaded && graphData) { + // enqueueSnackbar("Update available! See menu for details.", { action, variant: "info", preventDuplicate: true, persist: true }); + // } + // }, [meLoaded, graphData, enqueueSnackbar, closeSnackbar]); + React.useEffect(() => { function getTitleCase(str) { const titleCase = str @@ -571,10 +639,20 @@ export default function NavDrawer() { handleMobileMenuClose(); }; + const handleAboutOpen = () => { + setAboutOpen(true); + handleMenuClose(); + handleMobileMenuClose(); + }; + const handleSettingsClose = () => { setSettingsOpen(false); }; + const handleAboutClose = () => { + setAboutOpen(false); + }; + const menuId = "primary-search-account-menu"; const renderMenu = ( handleSettingsOpen()}> - + - Settings + Profile RequestToken()}> - Token + Token + {/* handleAboutOpen()}> + + + + About + + */} handleLogout()}> - Logout + Logout ); @@ -691,22 +776,29 @@ export default function NavDrawer() { > handleSettingsOpen()}> - + - Settings + Profile RequestToken()}> - Token + Token + {/* handleAboutOpen()}> + + + + About + + */} handleLogout()}> - Logout + Logout ); @@ -795,12 +887,18 @@ export default function NavDrawer() { onClick={handleProfileMenuOpen} color="inherit" > + {/* */} { graphData ? graphPhoto ? : : } + {/* */}
@@ -845,6 +943,10 @@ export default function NavDrawer() { open={settingsOpen} handleClose={handleSettingsClose} /> + } /> } /> @@ -859,8 +961,24 @@ export default function NavDrawer() { } /> } /> } /> + {/* } /> */} } /> + {/* + + Update Available (v3.0.0) + + */} ); diff --git a/ui/src/features/drawer/userSettings.jsx b/ui/src/features/drawer/userSettings.jsx index ef2d0bf..a4204a6 100644 --- a/ui/src/features/drawer/userSettings.jsx +++ b/ui/src/features/drawer/userSettings.jsx @@ -141,7 +141,7 @@ export default function UserSettings(props) { fullWidth > - Settings + Profile + {/* */} + {/* */} ); diff --git a/ui/src/features/tools/planner.jsx b/ui/src/features/tools/planner.jsx index a1b882a..e3ab96f 100644 --- a/ui/src/features/tools/planner.jsx +++ b/ui/src/features/tools/planner.jsx @@ -229,16 +229,18 @@ const Planner = () => { React.useEffect(() => { if (selectedVNet && selectedPrefix && selectedMask) { - let prefixParts = selectedPrefix.split("/"); - let currentMask = parseInt(prefixParts[1], 10); + // let prefixParts = selectedPrefix.split("/"); + // let currentMask = parseInt(prefixParts[1], 10); + + // let query = { + // address: prefixParts[0], + // netmask: currentMask, + // netmaskRange: { max: selectedMask.value, min: currentMask || 16 }, + // }; - let query = { - address: prefixParts[0], - netmask: currentMask, - netmaskRange: { max: selectedMask.value, min: currentMask || 16 }, - }; + // let subnetsObj = availableSubnets(query, exclusions); - let subnetsObj = availableSubnets(query, exclusions); + let subnetsObj = availableSubnets(selectedPrefix, selectedMask.value, exclusions); setSubnetData(subnetsObj); } else { @@ -388,13 +390,13 @@ const Planner = () => { { subnetData && - [...new Set(subnetData.subnets.map((x) => x.mask))].map((mask) => { + [...new Set(subnetData.map((x) => x.mask))].map((mask) => { return ( - x.mask === mask).length} used={subnetData.subnets.filter((x) => x.mask === mask && x.overlap).length} /> + x.mask === mask).length} used={subnetData.filter((x) => x.mask === mask && x.overlap).length} /> { - subnetData?.subnets.filter((x) => x.mask === mask).map((item) => { + subnetData?.filter((x) => x.mask === mask).map((item) => { return ( >> 24) + '.' + ((ipInt >> 16) & 255) + '.' + ((ipInt >> 8) & 255) + '.' + (ipInt & 255)); } -function ip2Integer(ip) { +function ip2int(ip) { return ip.split('.').reduce((ipInt, octet) => (ipInt << 8) + parseInt(octet, 10), 0) >>> 0; } -function probabalCombinations(arr, addressBytes, position) { - var res = []; - - for (var i = 0; i < Math.pow(2, arr.length); i++) { - var bin = (i).toString(2); - var set = []; - - bin = new Array((arr.length - bin.length) + 1).join("0") + bin; - - for (var j = 0; j < bin.length; j++) { - if (bin[j] === "1") { - set.push(arr[j]); - } - } - - try { - var sum = set.reduce((a, b) => a + b); - res.push(sum); - } catch(e) { - continue; - } - } - - res = res.filter(n => n >= addressBytes[position]); - - if(arr.indexOf(0) !== -1) { - res.push(0); - } - - res = [...new Set(res)]; - - return res; -} - -function getIpRangeForSubnet(subnetCIDR) { - var address = subnetCIDR.split('/')[0].split('.'); - var netmask = parseInt(subnetCIDR.split('/')[1], 10); - var allowed = allowedOctets(); - var pos = Math.ceil(netmask / 8) - 1; - var endAddress = [...address]; - - endAddress[pos] = parseInt(endAddress[pos], 10) + ((!allowed[(netmask % 8) - 1]) ? 0 : allowed[(netmask % 8) - 1] - 1); - - if(pos === 0 && endAddress[1] < 255) { - endAddress[1] = 255; - endAddress[2] = 255; - endAddress[3] = 255; - } - - if(pos === 1 && endAddress[2] < 255) { - endAddress[2] = 255; - endAddress[3] = 255; - } - - if(pos === 2 && endAddress[3] < 255) { - endAddress[3] = 255; - } - - return { - start: address.join('.'), - end: endAddress.join('.') +function getIpRangeForSubnet(cidr) { + // Split CIDR into Network & Mask + var cidrParts = cidr.split('/'); + // Get Address as a 32-bit Unsigned Integer + var addr32 = ip2int(cidrParts[0]); + // Create Bitmask Representing Subnet + var mask = ((~0 << (32 - +cidrParts[1])) >>> 0); + + // Apply Bitmask to 32-bit Addresses + var results = { + start: int2ip((addr32 & mask) >>> 0), + end: int2ip((addr32 | ~mask) >>> 0) }; -} - -export function isSubnetOf(childCIDR, parentCIDR) { - const parentRange = getIpRangeForSubnet(parentCIDR); - const childRange = getIpRangeForSubnet(childCIDR); - const parentStart = ip2Integer(parentRange.start); - const parentEnd = ip2Integer(parentRange.end); - - const childStart = ip2Integer(childRange.start); - const childEnd = ip2Integer(childRange.end); - - const isSubnet = (parentStart <= childStart) && (parentEnd >= childEnd); - - return isSubnet; + return results; } export function isSubnetOverlap(subnetCIDR, existingSubnetCIDR) { @@ -91,7 +29,7 @@ export function isSubnetOverlap(subnetCIDR, existingSubnetCIDR) { var isOverlap = existingSubnetCIDR.map(subnet => { var ipRange = getIpRangeForSubnet(subnet); - if((ip2Integer(ipRangeforCurrent.start) >= ip2Integer(ipRange.start) && ip2Integer(ipRangeforCurrent.start) <= ip2Integer(ipRange.end)) || (ip2Integer(ipRangeforCurrent.end) >= ip2Integer(ipRange.start) && ip2Integer(ipRangeforCurrent.end) <= ip2Integer(ipRange.end)) || (ip2Integer(ipRange.start) >= ip2Integer(ipRangeforCurrent.start) && ip2Integer(ipRange.start) <= ip2Integer(ipRangeforCurrent.end)) || (ip2Integer(ipRange.end) >= ip2Integer(ipRangeforCurrent.start) && ip2Integer(ipRange.end) <= ip2Integer(ipRangeforCurrent.end))) { + if((ip2int(ipRangeforCurrent.start) >= ip2int(ipRange.start) && ip2int(ipRangeforCurrent.start) <= ip2int(ipRange.end)) || (ip2int(ipRangeforCurrent.end) >= ip2int(ipRange.start) && ip2int(ipRangeforCurrent.end) <= ip2int(ipRange.end)) || (ip2int(ipRange.start) >= ip2int(ipRangeforCurrent.start) && ip2int(ipRange.start) <= ip2int(ipRangeforCurrent.end)) || (ip2int(ipRange.end) >= ip2int(ipRangeforCurrent.start) && ip2int(ipRange.end) <= ip2int(ipRangeforCurrent.end))) { return true; } @@ -101,93 +39,80 @@ export function isSubnetOverlap(subnetCIDR, existingSubnetCIDR) { return isOverlap; } -function possibleSubnets(obj, index, existingSubnetCIDR) { - var sliceTo = ((index % 8) === 0) ? 8 : (index % 8); - var filteredOctets = []; - var pos = Math.ceil(index / 8) - 1; - var subnets = []; - var subnetsExcluded = []; - var allowed = allowedOctets(); - var addressBytes = obj.address.split('.', 4).map(num => parseInt(num, 10)); - - if((obj.netmask % 8 === 0) && (index % 8 === 0) && (index === obj.netmask)) { - filteredOctets.push(addressBytes[pos]); - } else if((obj.netmask % 8) <= sliceTo && index <= 24) { - filteredOctets = allowed.slice((obj.netmask % 8), sliceTo); - filteredOctets.push(addressBytes[2]); - } else if(index >= 24 && addressBytes[2] === 0) { - filteredOctets = allowed.slice((obj.netmask % 8), sliceTo); - filteredOctets.push(addressBytes[3]); - } else if(index >= 24 && addressBytes[3] === 0) { - filteredOctets = allowed.slice(0, sliceTo); - filteredOctets.push(addressBytes[3]); - } else { - let newArr = addressBytes[pos].toString(2).padStart(8, '0').substring(0, (obj.netmask % 8)).padEnd(8, '1').split(''); - - filteredOctets = allowed.slice(0, sliceTo).filter((d, ind) => newArr[ind] === '1'); - } +export function isSubnetOf(childCIDR, parentCIDR) { + const parentRange = getIpRangeForSubnet(parentCIDR); + const childRange = getIpRangeForSubnet(childCIDR); - var allowedCombinations = probabalCombinations(filteredOctets, addressBytes, pos); + const parentStart = ip2int(parentRange.start); + const parentEnd = ip2int(parentRange.end); - allowedCombinations.forEach(function(octet) { - let range = (index >= 25 & obj.netmask < 24) ? { - from: addressBytes[2], - to: addressBytes[2] + ((allowed[(obj.netmask % 8) - 1] === undefined) ? 256 : allowed[(obj.netmask % 8) - 1]) - 1 - } : { - from: addressBytes[2], - to: addressBytes[2] - } + const childStart = ip2int(childRange.start); + const childEnd = ip2int(childRange.end); - for (let i = range.from; i <= range.to; i++) { - var subnetBytes = [...addressBytes]; + const isSubnet = (parentStart <= childStart) && (parentEnd >= childEnd); - subnetBytes[2] = i; - subnetBytes[pos] = octet; + return isSubnet; +} - var subnetObject = { - network: subnetBytes.join('.'), - mask: index, - cidr: subnetBytes.join('.') + '/' + index, - ipRange: getIpRangeForSubnet(subnetBytes.join('.') + '/' + index) - }; +function findSubnets(targetCidr, targetMask, existingSubnets) { + var address = targetCidr.split('/')[0]; + var currentMask = parseInt(targetCidr.split('/')[1], 10); + var addressBytes = address.split('.'); + var pos = Math.floor(currentMask / 8) + var posInt = parseInt(addressBytes[pos], 10); + var max = Math.pow(2, 8 - (currentMask % 8)); + var step = max / 2; - var doesOverlap = isSubnetOverlap(subnetObject.cidr, existingSubnetCIDR); + var results = []; - if(!doesOverlap) { - subnetObject.overlap = false; - subnets.push(subnetObject); - } else { - subnetObject.overlap = true; - subnets.push(subnetObject); - subnetsExcluded.push(subnetObject.cidr); - } + for (var i = posInt; i < (max + posInt); i += step) { + addressBytes[pos] = i; + + var newNetwork = addressBytes.join('.'); + var newMask = (currentMask + 1); + var newCidr = newNetwork + '/' + newMask; + + var newAddress = { + cidr: newCidr, + network: newNetwork, + mask: newMask, + overlap: isSubnetOverlap(newCidr, existingSubnets) + }; + + results.push(newAddress); + + if((currentMask + 1) < targetMask) { + results = results.concat(findSubnets(newAddress.cidr, targetMask, existingSubnets)); } - }); + } - return { - subnets: subnets, - subnetsExcluded: subnetsExcluded - }; + return results; } -export function availableSubnets(obj, existingSubnetCIDR) { - var subnetsObj = { - subnets: [], - subnetsExcluded: [] - }; +export function availableSubnets(targetCidr, targetMask, existingSubnets) { + var cidrNetwork = targetCidr.split('/')[0]; + var cidrMask = parseInt(targetCidr.split('/')[1], 10); - var startIndex = obj.netmaskRange.min; + var subnets = []; - for(var i = startIndex; i <= obj.netmaskRange.max; i++) { - var res = possibleSubnets(obj, i, existingSubnetCIDR); + if (targetMask >= cidrMask) { + subnets.push( + { + cidr: targetCidr, + network: cidrNetwork, + mask: cidrMask, + overlap: isSubnetOverlap(targetCidr, existingSubnets) + } + ); + } - subnetsObj.subnets = [...subnetsObj.subnets, ...res.subnets]; - subnetsObj.subnetsExcluded = [...subnetsObj.subnetsExcluded, ...res.subnetsExcluded]; + if (targetMask > cidrMask) { + subnets = subnets.concat(findSubnets(targetCidr, targetMask, existingSubnets)); } - subnetsObj.subnets = subnetsObj.subnets.sort((a, b) => { - let netA = ip2Integer(a.network); - let netB = ip2Integer(b.network); + return subnets.sort((a, b) => { + let netA = ip2int(a.network); + let netB = ip2int(b.network); if (a.mask < b.mask) return -1 @@ -201,6 +126,4 @@ export function availableSubnets(obj, existingSubnetCIDR) { return 0; }); - - return subnetsObj; } diff --git a/ui/src/features/welcome/Welcome.jsx b/ui/src/features/welcome/Welcome.jsx index aeef8b3..ea59552 100644 --- a/ui/src/features/welcome/Welcome.jsx +++ b/ui/src/features/welcome/Welcome.jsx @@ -10,7 +10,7 @@ import ipamLogo from '../../img/logo/logo.png'; const Welcome = () => { return ( - + Welcome to Azure IPAM! Welcome to Azure IPAM! diff --git a/ui/src/global/azureClouds.jsx b/ui/src/global/azureClouds.jsx index 553d047..3ad2a7a 100644 --- a/ui/src/global/azureClouds.jsx +++ b/ui/src/global/azureClouds.jsx @@ -13,6 +13,12 @@ const AZURE_ENV_MAP = { AZURE_PORTAL: "portal.azure.us", MS_GRAPH: "graph.microsoft.us" }, + AZURE_US_GOV_SECRET: { + AZURE_AD: "login.microsoftonline.microsoft.scloud", + AZURE_ARM: "management.azure.microsoft.scloud", + AZURE_PORTAL: "portal.azure.microsoft.scloud", + MS_GRAPH: "graph.cloudapi.microsoft.scloud" + }, AZURE_GERMANY: { AZURE_AD: "login.microsoftonline.de", AZURE_ARM: "management.microsoftazure.de", diff --git a/ui/src/img/Admins.jsx b/ui/src/img/Admins.jsx new file mode 100644 index 0000000..00668f5 --- /dev/null +++ b/ui/src/img/Admins.jsx @@ -0,0 +1,17 @@ +import React from "react"; + +function Admins() { + return ( + + + + + ); +} + +export default Admins; diff --git a/ui/src/img/Settings.jsx b/ui/src/img/Settings.jsx index e7d3f65..7a041b0 100644 --- a/ui/src/img/Settings.jsx +++ b/ui/src/img/Settings.jsx @@ -9,7 +9,7 @@ function Settings() { viewBox="0 0 24 24" > - + ); } diff --git a/ui/src/msal/graph.jsx b/ui/src/msal/graph.jsx index 0b74ce7..0dff29b 100644 --- a/ui/src/msal/graph.jsx +++ b/ui/src/msal/graph.jsx @@ -1,6 +1,6 @@ import axios from 'axios'; -import { InteractionRequiredAuthError } from "@azure/msal-browser"; +import { InteractionRequiredAuthError, BrowserAuthError } from "@azure/msal-browser"; import { msalInstance } from '../index'; import { graphConfig } from "./authConfig"; @@ -25,7 +25,7 @@ async function generateToken() { return response.accessToken; } catch (e) { - if (e instanceof InteractionRequiredAuthError) { + if (e instanceof InteractionRequiredAuthError || e instanceof BrowserAuthError) { const response = await msalInstance.acquireTokenRedirect(request); return response.accessToken; diff --git a/ui/vite.config.js b/ui/vite.config.js index 084b7e8..17046bd 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -45,6 +45,9 @@ export default () => { define: { IPAM_VERSION: JSON.stringify(process.env.npm_package_version), }, + build: { + chunkSizeWarningLimit: 5120 + }, logLevel: 'warn' }) }