mirror of
https://github.com/microsoft/onefuzz.git
synced 2025-06-15 19:38:11 +00:00
port arm template to bicep (#1724)
* port template to bicep * Update src/deployment/azuredeploy.bicep Co-authored-by: Teo Voinea <58236992+tevoinea@users.noreply.github.com> * port template to bicep * adding type annotation * apply changes from #1679 Co-authored-by: stas <statis@microsoft.com> Co-authored-by: Teo Voinea <58236992+tevoinea@users.noreply.github.com>
This commit is contained in:
@ -38,7 +38,7 @@ While viewing an open workbook instance:
|
|||||||
Each workbook is stored as a serialized JSON string value in
|
Each workbook is stored as a serialized JSON string value in
|
||||||
`deployments/workbook-data.json`.
|
`deployments/workbook-data.json`.
|
||||||
|
|
||||||
The serialized workbook data will be referenced in `azuredeploy.json` using the
|
The serialized workbook data will be referenced in `azuredeploy.json` or `azuredeploy.bicep` using the
|
||||||
property in `workbook-data.json`.
|
property in `workbook-data.json`.
|
||||||
|
|
||||||
The value must be the exact string you copied from the example ARM Template in
|
The value must be the exact string you copied from the example ARM Template in
|
||||||
@ -47,10 +47,10 @@ the Advanced Editor view.
|
|||||||
If adding a new workbook, add a new property and value. If editing a workbook,
|
If adding a new workbook, add a new property and value. If editing a workbook,
|
||||||
overwrite the existing value.
|
overwrite the existing value.
|
||||||
|
|
||||||
4. Ensure the resource is deployed in `azuredeploy.json`
|
4. Ensure the resource is deployed in `azuredeploy.json` or `azuredeploy.bicep`
|
||||||
|
|
||||||
To actually deploy a workbook instance, you must include it as a resource in
|
To actually deploy a workbook instance, you must include it as a resource in
|
||||||
`azuredeploy.json`.
|
`azuredeploy.json` or `azuredeploy.bicep`.
|
||||||
|
|
||||||
It should be a child resource of the Log Analytics workspace resource
|
It should be a child resource of the Log Analytics workspace resource
|
||||||
(`Microsoft.Insights/components` component).
|
(`Microsoft.Insights/components` component).
|
||||||
|
@ -18,6 +18,11 @@ There are currently two uses of Managed Identities within OneFuzz:
|
|||||||
See [azuredeploy.json](../src/deployment/azuredeploy.json) for the specific
|
See [azuredeploy.json](../src/deployment/azuredeploy.json) for the specific
|
||||||
implementation of these role assignments.
|
implementation of these role assignments.
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
See [azuredeploy.bicep](../src/deployment/azuredeploy.bicep) for the specific
|
||||||
|
implementation of these role assignments.
|
||||||
|
|
||||||
1. VMs created by OneFuzz are created using the Managed Identities without roles
|
1. VMs created by OneFuzz are created using the Managed Identities without roles
|
||||||
assigned in order to enable the OneFuzz agent running in the VMs to
|
assigned in order to enable the OneFuzz agent running in the VMs to
|
||||||
authenticate to the service itself.
|
authenticate to the service itself.
|
||||||
|
701
src/deployment/azuredeploy.bicep
Normal file
701
src/deployment/azuredeploy.bicep
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
param name string
|
||||||
|
param owner string
|
||||||
|
param clientId string
|
||||||
|
param clientSecret string
|
||||||
|
param signedExpiry string
|
||||||
|
param app_func_issuer string
|
||||||
|
param app_func_audiences array
|
||||||
|
param multi_tenant_domain string
|
||||||
|
|
||||||
|
param location string = resourceGroup().location
|
||||||
|
|
||||||
|
@description('Azure monitor workbook definitions.')
|
||||||
|
param workbookData object
|
||||||
|
|
||||||
|
@description('The degree of severity for diagnostics logs.')
|
||||||
|
@allowed([
|
||||||
|
'Verbose'
|
||||||
|
'Information'
|
||||||
|
'Warning'
|
||||||
|
'Error'
|
||||||
|
])
|
||||||
|
param diagnosticsLogLevel string = 'Verbose'
|
||||||
|
|
||||||
|
var suffix = uniqueString(resourceGroup().id)
|
||||||
|
var tenantId = subscription().tenantId
|
||||||
|
|
||||||
|
var autoscale_name = 'onefuzz-autoscale-${suffix}'
|
||||||
|
var log_retention = 30
|
||||||
|
var monitorAccountName = name
|
||||||
|
var scaleset_identity = '${name}-scalesetid'
|
||||||
|
var signalr_name = 'onefuzz-${suffix}'
|
||||||
|
var storage_account_sas = {
|
||||||
|
signedExpiry: signedExpiry
|
||||||
|
signedPermission: 'rwdlacup'
|
||||||
|
signedResourceTypes: 'sco'
|
||||||
|
signedServices: 'bfqt'
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageAccountName = 'fuzz${suffix}'
|
||||||
|
var storageAccountNameFunc = 'func${suffix}'
|
||||||
|
var telemetry = 'd7a73cf4-5a1a-4030-85e1-e5b25867e45a'
|
||||||
|
var StorageBlobDataReader = '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
|
||||||
|
var keyVaultName = 'of-kv-${suffix}'
|
||||||
|
var fuzz_blob_topic_name ='fuzz-blob-topic-${suffix}'
|
||||||
|
|
||||||
|
var roleAssignmentsParams = [
|
||||||
|
{
|
||||||
|
suffix: '-vmss'
|
||||||
|
role: '9980e02c-c2be-4d73-94e8-173b1dc7cf3c' //VirtualMachineContributor
|
||||||
|
}
|
||||||
|
{
|
||||||
|
suffix: '-storage'
|
||||||
|
role:'17d1049b-9a84-46fb-8f53-869881c3d3ab' //StorageAccountContributor
|
||||||
|
}
|
||||||
|
{
|
||||||
|
suffix: '-network'
|
||||||
|
role: '4d97b98b-1d4f-4787-a291-c67834d212e7'//NetworkContributor
|
||||||
|
}
|
||||||
|
{
|
||||||
|
suffix: '-logs'
|
||||||
|
role: '92aaf0da-9dab-42b6-94a3-d43ce8d16293'//LogAnalyticsContributor
|
||||||
|
}
|
||||||
|
{
|
||||||
|
suffix: '-user_managed_identity'
|
||||||
|
role: 'f1a07417-d97a-45cb-824c-7a7467783830'//ManagedIdentityOperator
|
||||||
|
}
|
||||||
|
{
|
||||||
|
suffix: '-contributor'
|
||||||
|
role: 'b24988ac-6180-42a0-ab88-20f7382dd24c'//Contributor
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
var onefuzz = {
|
||||||
|
severitiesAtMostInfo: [
|
||||||
|
{
|
||||||
|
severity: 'emerg'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'alert'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'crit'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'err'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'warning'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'notice'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
severity: 'info'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource scalesetIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = {
|
||||||
|
name: scaleset_identity
|
||||||
|
location: location
|
||||||
|
}
|
||||||
|
|
||||||
|
resource keyVault 'Microsoft.KeyVault/vaults@2021-10-01' = {
|
||||||
|
name: keyVaultName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
enabledForDiskEncryption: false
|
||||||
|
enabledForTemplateDeployment: true
|
||||||
|
sku: {
|
||||||
|
family: 'A'
|
||||||
|
name: 'standard'
|
||||||
|
}
|
||||||
|
networkAcls: {
|
||||||
|
defaultAction: 'Allow'
|
||||||
|
bypass: 'AzureServices'
|
||||||
|
}
|
||||||
|
tenantId: tenantId
|
||||||
|
accessPolicies: [
|
||||||
|
{
|
||||||
|
objectId: reference(resourceId('Microsoft.Web/sites', name), '2019-08-01', 'full').identity.principalId
|
||||||
|
tenantId: tenantId
|
||||||
|
permissions: {
|
||||||
|
secrets: [
|
||||||
|
'get'
|
||||||
|
'list'
|
||||||
|
'set'
|
||||||
|
'delete'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource serverFarms 'Microsoft.Web/serverfarms@2021-03-01' = {
|
||||||
|
name: name
|
||||||
|
location: location
|
||||||
|
kind: 'linux'
|
||||||
|
properties: {
|
||||||
|
reserved: true
|
||||||
|
}
|
||||||
|
sku: {
|
||||||
|
name: 'P2v2'
|
||||||
|
tier: 'PremiumV2'
|
||||||
|
family: 'Pv2'
|
||||||
|
capacity: 1
|
||||||
|
}
|
||||||
|
tags: {
|
||||||
|
OWNER: owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource autoscaleSettings 'Microsoft.Insights/autoscalesettings@2015-04-01' = {
|
||||||
|
name: autoscale_name
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
name: autoscale_name
|
||||||
|
enabled: true
|
||||||
|
targetResourceUri: serverFarms.id
|
||||||
|
targetResourceLocation: location
|
||||||
|
notifications: []
|
||||||
|
profiles:[
|
||||||
|
{
|
||||||
|
name: 'Auto scale condition'
|
||||||
|
capacity: {
|
||||||
|
default: '1'
|
||||||
|
maximum: '20'
|
||||||
|
minimum: '1'
|
||||||
|
}
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
metricTrigger: {
|
||||||
|
metricName: 'CpuPercentage'
|
||||||
|
metricResourceUri: serverFarms.id
|
||||||
|
operator: 'GreaterThanOrEqual'
|
||||||
|
statistic: 'Average'
|
||||||
|
threshold: 20
|
||||||
|
timeAggregation: 'Average'
|
||||||
|
timeGrain: 'PT1M'
|
||||||
|
timeWindow: 'PT1M'
|
||||||
|
}
|
||||||
|
scaleAction: {
|
||||||
|
cooldown: 'PT1M'
|
||||||
|
direction: 'Increase'
|
||||||
|
type: 'ChangeCount'
|
||||||
|
value: '5'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
metricTrigger: {
|
||||||
|
metricName: 'CpuPercentage'
|
||||||
|
metricResourceUri: serverFarms.id
|
||||||
|
operator: 'LessThan'
|
||||||
|
statistic: 'Average'
|
||||||
|
threshold: 20
|
||||||
|
timeAggregation:'Average'
|
||||||
|
timeGrain: 'PT1M'
|
||||||
|
timeWindow: 'PT1M'
|
||||||
|
}
|
||||||
|
scaleAction: {
|
||||||
|
cooldown: 'PT5M'
|
||||||
|
direction: 'Decrease'
|
||||||
|
type: 'ChangeCount'
|
||||||
|
value: '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
tags: {
|
||||||
|
OWNER: owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var linuxDataSources = [
|
||||||
|
{
|
||||||
|
name: 'syslogDataSourcesKern'
|
||||||
|
syslogName: 'kern'
|
||||||
|
kind: 'LinuxSyslog'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'syslogDataSourcesUser'
|
||||||
|
syslogName: 'user'
|
||||||
|
kind: 'LinuxSyslog'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'syslogDataSourcesCron'
|
||||||
|
syslogName: 'cron'
|
||||||
|
kind: 'LinuxSyslog'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'syslogDataSourcesDaemon'
|
||||||
|
syslogName: 'daemon'
|
||||||
|
kind: 'LinuxSyslog'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
var windowsDataSources = [
|
||||||
|
{
|
||||||
|
name: 'windowsEventSystem'
|
||||||
|
eventLogName: 'System'
|
||||||
|
kind: 'WindowsEvent'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'windowsEventApplication'
|
||||||
|
eventLogName: 'Application'
|
||||||
|
kind: 'WindowsEvent'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
resource insightsMonitorAccount 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
|
||||||
|
name: monitorAccountName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
sku: {
|
||||||
|
name: 'PerGB2018'
|
||||||
|
}
|
||||||
|
retentionInDays: log_retention
|
||||||
|
features: {
|
||||||
|
enableLogAccessUsingOnlyResourcePermissions: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resource linux 'dataSources@2020-08-01' = [for d in linuxDataSources : {
|
||||||
|
name: d.name
|
||||||
|
kind: d.kind
|
||||||
|
properties: {
|
||||||
|
syslogName: d.syslogName
|
||||||
|
syslogSeverities: onefuzz.severitiesAtMostInfo
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
|
resource linuxCollection 'dataSources@2020-08-01' = {
|
||||||
|
name: 'syslogDataSourceCollection'
|
||||||
|
kind: 'LinuxSyslogCollection'
|
||||||
|
properties: {
|
||||||
|
state: 'Enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource windows 'dataSources@2020-08-01' = [for d in windowsDataSources : {
|
||||||
|
name: d.name
|
||||||
|
kind: d.kind
|
||||||
|
properties: {
|
||||||
|
eventLogName: d.eventLogName
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
eventType: 'Error'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
eventType: 'Warning'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
eventType: 'Information'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource vmInsights 'Microsoft.OperationsManagement/solutions@2015-11-01-preview' = {
|
||||||
|
name: 'VMInsights(${monitorAccountName})'
|
||||||
|
location: location
|
||||||
|
dependsOn: [
|
||||||
|
insightsMonitorAccount
|
||||||
|
]
|
||||||
|
properties: {
|
||||||
|
workspaceResourceId: resourceId('Microsoft.OperationalInsights/workspaces', monitorAccountName)
|
||||||
|
}
|
||||||
|
plan: {
|
||||||
|
name: 'VMInsights(${monitorAccountName})'
|
||||||
|
publisher: 'Microsoft'
|
||||||
|
product: 'OMSGallery/VMInsights'
|
||||||
|
promotionCode: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource insightsComponents 'Microsoft.Insights/components@2020-02-02' = {
|
||||||
|
name: name
|
||||||
|
location: location
|
||||||
|
kind: ''
|
||||||
|
properties: {
|
||||||
|
Application_Type: 'other'
|
||||||
|
RetentionInDays: log_retention
|
||||||
|
WorkspaceResourceId: insightsMonitorAccount.id
|
||||||
|
}
|
||||||
|
tags: {
|
||||||
|
OWNER: owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource insightsWorkbooks 'Microsoft.Insights/workbooks@2021-08-01' = {
|
||||||
|
name: 'df20765c-ed5b-46f9-a47b-20f4aaf7936d'
|
||||||
|
location: location
|
||||||
|
kind: 'shared'
|
||||||
|
properties: {
|
||||||
|
displayName: 'Libfuzzer Job Dashboard'
|
||||||
|
serializedData: workbookData.libFuzzerJob
|
||||||
|
version: '1.0'
|
||||||
|
sourceId: insightsComponents.id
|
||||||
|
category: 'tsg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageAccountFuncContainersParams = [
|
||||||
|
'vm-scripts'
|
||||||
|
'repro-scripts'
|
||||||
|
'proxy-configs'
|
||||||
|
'task-configs'
|
||||||
|
'app-logs'
|
||||||
|
]
|
||||||
|
|
||||||
|
var storageAccountFuncQueuesParams = [
|
||||||
|
'file-chages'
|
||||||
|
'task-heartbeat'
|
||||||
|
'node-heartbeat'
|
||||||
|
'proxy'
|
||||||
|
'update-queue'
|
||||||
|
'webhooks'
|
||||||
|
'signalr-events'
|
||||||
|
]
|
||||||
|
|
||||||
|
var fileChangesIndex = 0
|
||||||
|
|
||||||
|
resource storageAccount 'Microsoft.Storage/storageAccounts@2021-08-01' = {
|
||||||
|
name: storageAccountName
|
||||||
|
location: location
|
||||||
|
sku: {
|
||||||
|
name: 'Standard_LRS'
|
||||||
|
}
|
||||||
|
kind: 'StorageV2'
|
||||||
|
properties: {
|
||||||
|
supportsHttpsTrafficOnly: true
|
||||||
|
accessTier: 'Hot'
|
||||||
|
allowBlobPublicAccess: false
|
||||||
|
}
|
||||||
|
tags: {
|
||||||
|
OWNER: owner
|
||||||
|
}
|
||||||
|
|
||||||
|
resource blobServices 'blobServices' = {
|
||||||
|
name: 'default'
|
||||||
|
properties: {
|
||||||
|
deleteRetentionPolicy: {
|
||||||
|
enabled: true
|
||||||
|
days: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource storageAccountFunc 'Microsoft.Storage/storageAccounts@2021-08-01' = {
|
||||||
|
name: storageAccountNameFunc
|
||||||
|
location: location
|
||||||
|
sku: {
|
||||||
|
name: 'Standard_LRS'
|
||||||
|
}
|
||||||
|
kind: 'StorageV2'
|
||||||
|
properties: {
|
||||||
|
supportsHttpsTrafficOnly: true
|
||||||
|
accessTier: 'Hot'
|
||||||
|
allowBlobPublicAccess: false
|
||||||
|
}
|
||||||
|
tags: {
|
||||||
|
OWNER: owner
|
||||||
|
}
|
||||||
|
|
||||||
|
resource blobServices 'blobServices' = {
|
||||||
|
name: 'default'
|
||||||
|
properties: {
|
||||||
|
deleteRetentionPolicy: {
|
||||||
|
enabled: true
|
||||||
|
days: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource storageAccountFuncQueues 'Microsoft.Storage/storageAccounts/queueServices/queues@2021-08-01' = [for q in storageAccountFuncQueuesParams: {
|
||||||
|
name: '${storageAccountNameFunc}/default/${q}'
|
||||||
|
dependsOn: [
|
||||||
|
storageAccountFunc
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
|
||||||
|
resource storageAccountFunBlobContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-08-01' = [for c in storageAccountFuncContainersParams: {
|
||||||
|
name: '${storageAccountNameFunc}/default/${c}'
|
||||||
|
dependsOn: [
|
||||||
|
storageAccountFunc
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
|
||||||
|
// try to make role assignments to deploy as late as possible in order to has principalId ready
|
||||||
|
resource roleAssigments 'Microsoft.Authorization/roleAssignments@2020-10-01-preview' = [for r in roleAssignmentsParams: {
|
||||||
|
name: guid('${resourceGroup().id}${r.suffix}')
|
||||||
|
properties: {
|
||||||
|
roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${r.role}'
|
||||||
|
principalId: reference(pythonFunction.id, pythonFunction.apiVersion, 'Full').identity.principalId
|
||||||
|
}
|
||||||
|
dependsOn: [
|
||||||
|
eventSubscriptions
|
||||||
|
keyVault
|
||||||
|
serverFarms
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
|
||||||
|
// try to make role assignments to deploy as late as possible in order to has principalId ready
|
||||||
|
resource readBlobUserAssignment 'Microsoft.Authorization/roleAssignments@2020-10-01-preview' = {
|
||||||
|
name: guid('${resourceGroup().id}-user_managed_idenity_read_blob')
|
||||||
|
properties: {
|
||||||
|
roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${StorageBlobDataReader}'
|
||||||
|
principalId: reference(scalesetIdentity.id, scalesetIdentity.apiVersion, 'Full').properties.principalId
|
||||||
|
}
|
||||||
|
dependsOn: [
|
||||||
|
eventSubscriptions
|
||||||
|
keyVault
|
||||||
|
serverFarms
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource signalR 'Microsoft.SignalRService/signalR@2021-10-01' = {
|
||||||
|
name: signalr_name
|
||||||
|
location: location
|
||||||
|
sku: {
|
||||||
|
name: 'Standard_S1'
|
||||||
|
tier: 'Standard'
|
||||||
|
capacity: 1
|
||||||
|
}
|
||||||
|
properties: {
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
flag: 'ServiceMode'
|
||||||
|
value: 'Serverless'
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
flag: 'EnableConnectivityLogs'
|
||||||
|
value: 'True'
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
flag: 'EnableMessagingLogs'
|
||||||
|
value: 'False'
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource eventGridSystemTopics 'Microsoft.EventGrid/systemTopics@2021-12-01' = {
|
||||||
|
name: fuzz_blob_topic_name
|
||||||
|
dependsOn: [
|
||||||
|
storageAccountFuncQueues[fileChangesIndex]
|
||||||
|
storageAccountFunc
|
||||||
|
]
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
source: storageAccount.id
|
||||||
|
topicType: 'microsoft.storage.storageaccounts'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource eventSubscriptions 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2021-12-01' = {
|
||||||
|
name: 'onefuzz1_subscription'
|
||||||
|
parent: eventGridSystemTopics
|
||||||
|
dependsOn: [
|
||||||
|
storageAccountFuncQueues[fileChangesIndex]
|
||||||
|
storageAccount
|
||||||
|
]
|
||||||
|
properties: {
|
||||||
|
destination: {
|
||||||
|
properties: {
|
||||||
|
resourceId: storageAccountFunc.id
|
||||||
|
queueName: storageAccountFuncQueuesParams[fileChangesIndex]
|
||||||
|
}
|
||||||
|
endpointType: 'StorageQueue'
|
||||||
|
}
|
||||||
|
filter: {
|
||||||
|
includedEventTypes: [
|
||||||
|
'Microsoft.Storage.BlobCreated'
|
||||||
|
'Microsoft.Storage.BlobDeleted'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
eventDeliverySchema: 'EventGridSchema'
|
||||||
|
retryPolicy: {
|
||||||
|
maxDeliveryAttempts: 30
|
||||||
|
eventTimeToLiveInMinutes: 1440
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource funcLogs 'Microsoft.Web/sites/config@2021-03-01' = {
|
||||||
|
name: 'logs'
|
||||||
|
properties: {
|
||||||
|
applicationLogs: {
|
||||||
|
azureBlobStorage: {
|
||||||
|
level: diagnosticsLogLevel
|
||||||
|
retentionInDays: log_retention
|
||||||
|
sasUrl: '${storageAccountFunc.properties.primaryEndpoints.blob}app-logs?${storageAccountFunc.listAccountSas('2021-08-01', storage_account_sas).accountSasToken}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent: pythonFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
resource funcAuthSettings 'Microsoft.Web/sites/config@2021-03-01' = {
|
||||||
|
name: 'authsettingsV2'
|
||||||
|
properties: {
|
||||||
|
login:{
|
||||||
|
tokenStore: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalValidation: {
|
||||||
|
unauthenticatedClientAction: 'RedirectToLoginPage'
|
||||||
|
requireAuthentication: true
|
||||||
|
}
|
||||||
|
httpSettings: {
|
||||||
|
requireHttps: true
|
||||||
|
}
|
||||||
|
identityProviders: {
|
||||||
|
azureActiveDirectory: {
|
||||||
|
enabled: true
|
||||||
|
isAutoProvisioned: false
|
||||||
|
registration: {
|
||||||
|
clientId: clientId
|
||||||
|
openIdIssuer: app_func_issuer
|
||||||
|
clientSecretSettingName: 'ONEFUZZ_CLIENT_SECRET'
|
||||||
|
}
|
||||||
|
validation: {
|
||||||
|
allowedAudiences: app_func_audiences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent: pythonFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
resource pythonFunction 'Microsoft.Web/sites@2021-03-01' = {
|
||||||
|
name: name
|
||||||
|
location: location
|
||||||
|
kind: 'functionapp,linux'
|
||||||
|
tags: {
|
||||||
|
'OWNER': owner
|
||||||
|
}
|
||||||
|
identity: {
|
||||||
|
type: 'SystemAssigned'
|
||||||
|
}
|
||||||
|
properties: {
|
||||||
|
siteConfig: {
|
||||||
|
appSettings: [
|
||||||
|
{
|
||||||
|
name: 'FUNCTIONS_EXTENSION_VERSION'
|
||||||
|
value: '~3'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'FUNCTIONS_WORKER_RUNTIME'
|
||||||
|
value: 'python'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'FUNCTIONS_WORKER_PROCESS_COUNT'
|
||||||
|
value: '1'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
|
||||||
|
value: insightsComponents.properties.InstrumentationKey
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'APPINSIGHTS_APPID'
|
||||||
|
value: insightsComponents.properties.AppId
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_TELEMETRY'
|
||||||
|
value: telemetry
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'AzureWebJobsStorage'
|
||||||
|
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountFunc.name};AccountKey=${storageAccountFunc.listKeys().keys[0].value};EndpointSuffix=core.windows.net'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'MULTI_TENANT_DOMAIN'
|
||||||
|
value: multi_tenant_domain
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'AzureWebJobsDisableHomepage'
|
||||||
|
value: 'true'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'AzureSignalRConnectionString'
|
||||||
|
value: signalR.listKeys().primaryConnectionString
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'AzureSignalRServiceTransportType'
|
||||||
|
value: 'Transient'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_INSTANCE_NAME'
|
||||||
|
value: name
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_INSTANCE'
|
||||||
|
value: 'https://${name}.azurewebsites.net'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_RESOURCE_GROUP'
|
||||||
|
value: resourceGroup().id
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_DATA_STORAGE'
|
||||||
|
value: storageAccount.id
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_FUNC_STORAGE'
|
||||||
|
value: storageAccountFunc.id
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_MONITOR'
|
||||||
|
value: monitorAccountName
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_KEYVAULT'
|
||||||
|
value: keyVaultName
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_OWNER'
|
||||||
|
value: owner
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: 'ONEFUZZ_CLIENT_SECRET'
|
||||||
|
value: clientSecret
|
||||||
|
}
|
||||||
|
]
|
||||||
|
linuxFxVersion: 'Python|3.8'
|
||||||
|
alwaysOn: true
|
||||||
|
defaultDocuments: []
|
||||||
|
httpLoggingEnabled: true
|
||||||
|
logsDirectorySizeLimit: 100
|
||||||
|
detailedErrorLoggingEnabled: true
|
||||||
|
http20Enabled: true
|
||||||
|
ftpsState: 'Disabled'
|
||||||
|
}
|
||||||
|
httpsOnly: true
|
||||||
|
serverFarmId: serverFarms.id
|
||||||
|
clientAffinityEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fuzz_key = storageAccount.listKeys().keys[0].value
|
||||||
|
output fuzz_storage string = storageAccount.id
|
||||||
|
output fuzz_name string = storageAccountName
|
||||||
|
output fuzz_key string = fuzz_key
|
||||||
|
|
||||||
|
var func_key = storageAccountFunc.listKeys().keys[0].value
|
||||||
|
output func_storage string = storageAccountFunc.id
|
||||||
|
output func_name string = storageAccountNameFunc
|
||||||
|
output func_key string = func_key
|
||||||
|
|
||||||
|
output scaleset_identity string = scaleset_identity
|
||||||
|
output tenant_id string = tenantId
|
@ -921,31 +921,31 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"fuzz-storage": {
|
"fuzz_storage": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
|
"value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
|
||||||
},
|
},
|
||||||
"fuzz-name": {
|
"fuzz_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[variables('storageAccountName')]"
|
"value": "[variables('storageAccountName')]"
|
||||||
},
|
},
|
||||||
"fuzz-key": {
|
"fuzz_key": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value]"
|
"value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value]"
|
||||||
},
|
},
|
||||||
"func-name": {
|
"func_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[variables('storageAccountNameFunc')]"
|
"value": "[variables('storageAccountNameFunc')]"
|
||||||
},
|
},
|
||||||
"func-storage": {
|
"func_storage": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc'))]"
|
"value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc'))]"
|
||||||
},
|
},
|
||||||
"func-key": {
|
"func_key": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc')), '2019-06-01').keys[0].value]"
|
"value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc')), '2019-06-01').keys[0].value]"
|
||||||
},
|
},
|
||||||
"scaleset-identity": {
|
"scaleset_identity": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "[variables('scaleset_identity')]"
|
"value": "[variables('scaleset_identity')]"
|
||||||
},
|
},
|
||||||
|
@ -101,6 +101,31 @@ def gen_guid() -> str:
|
|||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def bicep_to_arm(bicep_template: str) -> str:
|
||||||
|
from azure.cli.core import get_default_cli
|
||||||
|
|
||||||
|
az_cli = get_default_cli()
|
||||||
|
az_cli.invoke(["bicep", "install"])
|
||||||
|
az_cli.invoke(
|
||||||
|
[
|
||||||
|
"bicep",
|
||||||
|
"build",
|
||||||
|
"--file",
|
||||||
|
bicep_template,
|
||||||
|
"--outfile",
|
||||||
|
"azuredeploy-bicep.json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
from importlib import reload
|
||||||
|
|
||||||
|
# az_cli hijacks logging, so need to reset it
|
||||||
|
logging.shutdown()
|
||||||
|
reload(logging)
|
||||||
|
global logger
|
||||||
|
logger = logging.getLogger("deploy")
|
||||||
|
return "azuredeploy-bicep.json"
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -116,7 +141,7 @@ class Client:
|
|||||||
tools: str,
|
tools: str,
|
||||||
instance_specific: str,
|
instance_specific: str,
|
||||||
third_party: str,
|
third_party: str,
|
||||||
arm_template: str,
|
arm_or_bicep_template: str,
|
||||||
workbook_data: str,
|
workbook_data: str,
|
||||||
create_registration: bool,
|
create_registration: bool,
|
||||||
migrations: List[str],
|
migrations: List[str],
|
||||||
@ -129,7 +154,6 @@ class Client:
|
|||||||
):
|
):
|
||||||
self.subscription_id = subscription_id
|
self.subscription_id = subscription_id
|
||||||
self.resource_group = resource_group
|
self.resource_group = resource_group
|
||||||
self.arm_template = arm_template
|
|
||||||
self.location = location
|
self.location = location
|
||||||
self.application_name = application_name
|
self.application_name = application_name
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
@ -158,6 +182,13 @@ class Client:
|
|||||||
self.admins = admins
|
self.admins = admins
|
||||||
self.allowed_aad_tenants = allowed_aad_tenants
|
self.allowed_aad_tenants = allowed_aad_tenants
|
||||||
|
|
||||||
|
if arm_or_bicep_template:
|
||||||
|
file_name, file_extension = os.path.splitext(arm_or_bicep_template)
|
||||||
|
if file_extension == ".bicep":
|
||||||
|
self.arm_template = bicep_to_arm(arm_or_bicep_template)
|
||||||
|
else:
|
||||||
|
self.arm_template = arm_or_bicep_template
|
||||||
|
|
||||||
machine = platform.machine()
|
machine = platform.machine()
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
|
|
||||||
@ -211,23 +242,28 @@ class Client:
|
|||||||
client = ResourceManagementClient(
|
client = ResourceManagementClient(
|
||||||
credential, subscription_id=self.get_subscription_id()
|
credential, subscription_id=self.get_subscription_id()
|
||||||
)
|
)
|
||||||
providers = {x.namespace: x for x in client.providers.list()}
|
providers = {x.namespace.lower(): x for x in client.providers.list()}
|
||||||
|
|
||||||
unsupported = []
|
unsupported = []
|
||||||
|
|
||||||
|
# we cannot validate site/config resources since they require resource group
|
||||||
|
# to exist. check_region only validates subscription level resources.
|
||||||
|
resource_group_level_resources = ["sites/config"]
|
||||||
|
|
||||||
for resource in arm["resources"]:
|
for resource in arm["resources"]:
|
||||||
namespace, name = resource["type"].split("/", 1)
|
namespace, name = resource["type"].lower().split("/", 1)
|
||||||
|
|
||||||
# resource types are in the form of a/b/c....
|
# resource types are in the form of a/b/c....
|
||||||
# only the top two are listed as resource types within providers
|
# only the top two are listed as resource types within providers
|
||||||
name = "/".join(name.split("/")[:2])
|
name = "/".join(name.split("/")[:2])
|
||||||
|
|
||||||
if namespace not in providers:
|
if namespace not in providers:
|
||||||
unsupported.append("Unsupported provider: %s" % namespace)
|
unsupported.append("Unsupported provider: %s" % namespace)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
provider = providers[namespace]
|
provider = providers[namespace]
|
||||||
resource_types = {x.resource_type: x for x in provider.resource_types}
|
resource_types = {
|
||||||
|
x.resource_type.lower(): x for x in provider.resource_types
|
||||||
|
}
|
||||||
|
if name not in resource_group_level_resources:
|
||||||
if name not in resource_types:
|
if name not in resource_types:
|
||||||
unsupported.append(
|
unsupported.append(
|
||||||
"Unsupported resource type: %s/%s" % (namespace, name)
|
"Unsupported resource type: %s/%s" % (namespace, name)
|
||||||
@ -605,7 +641,7 @@ class Client:
|
|||||||
logger.info("assigning the user managed identity role")
|
logger.info("assigning the user managed identity role")
|
||||||
assign_instance_app_role(
|
assign_instance_app_role(
|
||||||
self.application_name,
|
self.application_name,
|
||||||
self.results["deploy"]["scaleset-identity"]["value"],
|
self.results["deploy"]["scaleset_identity"]["value"],
|
||||||
self.get_subscription_id(),
|
self.get_subscription_id(),
|
||||||
OnefuzzAppRole.ManagedNode,
|
OnefuzzAppRole.ManagedNode,
|
||||||
)
|
)
|
||||||
@ -646,15 +682,15 @@ class Client:
|
|||||||
|
|
||||||
def apply_migrations(self) -> None:
|
def apply_migrations(self) -> None:
|
||||||
logger.info("applying database migrations")
|
logger.info("applying database migrations")
|
||||||
name = self.results["deploy"]["func-name"]["value"]
|
name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
table_service = TableService(account_name=name, account_key=key)
|
table_service = TableService(account_name=name, account_key=key)
|
||||||
migrate(table_service, self.migrations)
|
migrate(table_service, self.migrations)
|
||||||
|
|
||||||
def set_instance_config(self) -> None:
|
def set_instance_config(self) -> None:
|
||||||
logger.info("setting instance config")
|
logger.info("setting instance config")
|
||||||
name = self.results["deploy"]["func-name"]["value"]
|
name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
tenant = UUID(self.results["deploy"]["tenant_id"]["value"])
|
tenant = UUID(self.results["deploy"]["tenant_id"]["value"])
|
||||||
table_service = TableService(account_name=name, account_key=key)
|
table_service = TableService(account_name=name, account_key=key)
|
||||||
|
|
||||||
@ -747,8 +783,8 @@ class Client:
|
|||||||
|
|
||||||
container_name = "base-config"
|
container_name = "base-config"
|
||||||
blob_name = "instance_id"
|
blob_name = "instance_id"
|
||||||
account_name = self.results["deploy"]["func-name"]["value"]
|
account_name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
account_url = "https://%s.blob.core.windows.net" % account_name
|
account_url = "https://%s.blob.core.windows.net" % account_name
|
||||||
client = BlobServiceClient(account_url, credential=key)
|
client = BlobServiceClient(account_url, credential=key)
|
||||||
if container_name not in [x["name"] for x in client.list_containers()]:
|
if container_name not in [x["name"] for x in client.list_containers()]:
|
||||||
@ -773,8 +809,8 @@ class Client:
|
|||||||
container_name = "app-insights"
|
container_name = "app-insights"
|
||||||
|
|
||||||
logger.info("adding appinsight log export")
|
logger.info("adding appinsight log export")
|
||||||
account_name = self.results["deploy"]["func-name"]["value"]
|
account_name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
account_url = "https://%s.blob.core.windows.net" % account_name
|
account_url = "https://%s.blob.core.windows.net" % account_name
|
||||||
client = BlobServiceClient(account_url, credential=key)
|
client = BlobServiceClient(account_url, credential=key)
|
||||||
if container_name not in [x["name"] for x in client.list_containers()]:
|
if container_name not in [x["name"] for x in client.list_containers()]:
|
||||||
@ -833,8 +869,8 @@ class Client:
|
|||||||
|
|
||||||
def upload_tools(self) -> None:
|
def upload_tools(self) -> None:
|
||||||
logger.info("uploading tools from %s", self.tools)
|
logger.info("uploading tools from %s", self.tools)
|
||||||
account_name = self.results["deploy"]["func-name"]["value"]
|
account_name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
account_url = "https://%s.blob.core.windows.net" % account_name
|
account_url = "https://%s.blob.core.windows.net" % account_name
|
||||||
client = BlobServiceClient(account_url, credential=key)
|
client = BlobServiceClient(account_url, credential=key)
|
||||||
if "tools" not in [x["name"] for x in client.list_containers()]:
|
if "tools" not in [x["name"] for x in client.list_containers()]:
|
||||||
@ -870,8 +906,8 @@ class Client:
|
|||||||
|
|
||||||
def upload_instance_setup(self) -> None:
|
def upload_instance_setup(self) -> None:
|
||||||
logger.info("uploading instance-specific-setup from %s", self.instance_specific)
|
logger.info("uploading instance-specific-setup from %s", self.instance_specific)
|
||||||
account_name = self.results["deploy"]["func-name"]["value"]
|
account_name = self.results["deploy"]["func_name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func_key"]["value"]
|
||||||
account_url = "https://%s.blob.core.windows.net" % account_name
|
account_url = "https://%s.blob.core.windows.net" % account_name
|
||||||
client = BlobServiceClient(account_url, credential=key)
|
client = BlobServiceClient(account_url, credential=key)
|
||||||
if "instance-specific-setup" not in [
|
if "instance-specific-setup" not in [
|
||||||
@ -916,8 +952,8 @@ class Client:
|
|||||||
|
|
||||||
def upload_third_party(self) -> None:
|
def upload_third_party(self) -> None:
|
||||||
logger.info("uploading third-party tools from %s", self.third_party)
|
logger.info("uploading third-party tools from %s", self.third_party)
|
||||||
account_name = self.results["deploy"]["fuzz-name"]["value"]
|
account_name = self.results["deploy"]["fuzz_name"]["value"]
|
||||||
key = self.results["deploy"]["fuzz-key"]["value"]
|
key = self.results["deploy"]["fuzz_key"]["value"]
|
||||||
account_url = "https://%s.blob.core.windows.net" % account_name
|
account_url = "https://%s.blob.core.windows.net" % account_name
|
||||||
|
|
||||||
client = BlobServiceClient(account_url, credential=key)
|
client = BlobServiceClient(account_url, credential=key)
|
||||||
@ -1067,9 +1103,9 @@ def main() -> None:
|
|||||||
parser.add_argument("owner")
|
parser.add_argument("owner")
|
||||||
parser.add_argument("nsg_config")
|
parser.add_argument("nsg_config")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--arm-template",
|
"--arm-or-bicep-template",
|
||||||
type=arg_file,
|
type=arg_file,
|
||||||
default="azuredeploy.json",
|
default="azuredeploy.bicep",
|
||||||
help="(default: %(default)s)",
|
help="(default: %(default)s)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@ -1180,7 +1216,7 @@ def main() -> None:
|
|||||||
tools=args.tools,
|
tools=args.tools,
|
||||||
instance_specific=args.instance_specific,
|
instance_specific=args.instance_specific,
|
||||||
third_party=args.third_party,
|
third_party=args.third_party,
|
||||||
arm_template=args.arm_template,
|
arm_or_bicep_template=args.arm_or_bicep_template,
|
||||||
workbook_data=args.workbook_data,
|
workbook_data=args.workbook_data,
|
||||||
create_registration=args.create_pool_registration,
|
create_registration=args.create_pool_registration,
|
||||||
migrations=args.apply_migrations,
|
migrations=args.apply_migrations,
|
||||||
|
Reference in New Issue
Block a user