Azure / bicep

Bicep is a declarative language for describing and deploying Azure resources
MIT License
3.22k stars 746 forks source link

Template interfaces (suggestion for modularity) #10882

Open slavizh opened 1 year ago

slavizh commented 1 year ago

Is your feature request related to a problem? Please describe. With user defined types you currently define those in the Bicep template file. One of the cases of user defined types is to serve as schema for complex (and not so complex) input parameters. At the same time if you create Bicep parameters file you need to import the the Bicep template via using to get intellisense for the parameters. This kind of implementation does not seems very modular to me. As you have a template that you version the end user needs to download locally every version and reference it. My proposal is to be able to define a file that can contain only parameters and types. In that file you will define the parameters and the types for your main.bicep template. In the main.bicep template you could be using 'using' or 'import' key word to import the the file that contains the parameters and the types. On the other hand when you have end user with Bicep parameters file they can just be referencing via using just the file that contains the parameters and the types. Additionally the bicep parameters file should allow using to point to file located on HTTP link along to just pointing locally. The benefits of this approach are:

For even greater flexibility you could allow a file containing parameters and types to be able to reference (using/import) another parameters and types file. This could be important if you are re-using types on different Bicep templates files for the same solution.

In summary, we can think of this parameters and types files as schema that you can modify along with your Bicep templates or separately - depending on the needs. Users who use the parameters file just reference the schema instead of referencing a Bicep solution which in many cases won't be just one file named main.bicep.

alex-frankel commented 1 year ago

Can you share some pseudo-code of what you want the different files and what they look like/how they are connected?

slavizh commented 1 year ago

Yep,

So the parameters and types files will be 'types-params.bicep':

@sealed()
type resourceGroupsType = {
  name: string
  location: string
  create: bool?
  tags: tagsType?
  azureMonitorWorkspace: azureMonitorWorkspace
  actionGroups: actionGroups[]?
  ruleGroups: ruleGroupsType[]?
}

type tagsType = {
  *: string?
}

@sealed()
type azureMonitorWorkspace = {
  subscriptionId: string?
  resourceGroup: string
  name: string
}

@sealed()
type actionGroups = {
  subscriptionId: string?
  resourceGroup: string
  name: string
}

@sealed()
type ruleGroupsType = {
  name: string
  description: string?
  tags: tagsType?
  enabled: bool?
  clusterName: string?
  @minValue(1)
  @maxValue(15)
  frequencyInMinutes: int
  recordingRules: recordingRules[]?
  alertRules: alertRules[]?
}

@sealed()
type recordingRules = {
  recordName: string
  expression: string
  enabled: bool?
  labels: object?
}

@sealed()
type alertRules = {
  name: string
  expression: string
  enabled: bool?
  labels: object?
  @minValue(1)
  forInMinutes: int?
  @minValue(0)
  @maxValue(4)
  severity: int
  annotations: object?
  autoMitigate: bool?
  @minValue(1)
  @maxValue(15)
  timeToMitigateInMinutes: int?
  customProperties: object?
}

type allowedRegions = 'West Europe' | 'North Europe'

param resourceGroups resourceGroupsType[]
param region allowedRegions

File extension type of that file could be different if needed

main.bicep will be:

targetScope = 'subscription'

import  'types-params.bicep'

resource resourceGroupsRes 'Microsoft.Resources/resourceGroups@2022-09-01' = [for resourceGroup in resourceGroups: {
  name: resourceGroup.name
  location: resourceGroup.location
  tags: resourceGroup.tags
  properties: {}
}]

module ruleGroups 'modules/rule-groups.bicep' = [for (resourceGroup, i) in resourceGroups: {
  name: 'ruleGroups-${i}'
  scope: resourceGroupsRes[i]
  params: {
    ruleGroups: resourceGroup.ruleGroups
    azureMonitorWorkspace: resourceGroup.azureMonitorWorkspace
    actionGroups: resourceGroup.actionGroups
    tags: resourceGroup.tags
  }
}]

There are more modules beneath main.bicep but they are irrelevant.

And the parameters file would look like this with option when using is specified to use local file or public HTTP endpoint.

using 'types-params.bicep'
// or by using public http endpoint
// using 'https://someblob.blob.core.windows.net/container1/solutionfoo/1.1.0/types-params.bicep'

param region = 'West Europe'
param resourceGroups = [
  {
    name: 'rg1'
    create: true
    location: 'West Europe'
    azureMonitorWorkspace: {
      name: 'workspace1'
      resourceGroup: 'group'
    }
    actionGroups: [
      {
        resourceGroup: 'group'
        name: 'action1'
      }
    ]
    ruleGroups: [
      {
        name: 'group 1'
        frequencyInMinutes: 1
        clusterName: 'test'
        recordingRules: [
          {
            recordName: 'instance:node_num_cpu:sum'
            expression: 'count without (cpu, mode) (  node_cpu_seconds_total{job="node",mode="idle"})'
          }
        ]
        alertRules: [
          {
            name: 'KubePodCrashLooping'
            expression: 'max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff", job="kube-state-metrics"}[5m]) >= 1',
            forInMinutes: 15
            labels: {
              severity: 'warning'
            }
            severity: 3
            timeToMitigateInMinutes: 10
          }
        ]
      }
    ]
  }
]

Let me know if there are some questions.

jeskew commented 1 year ago

Would outputs be omitted from the auxiliary file? I usually think of those as part of a template's public interface.

slavizh commented 1 year ago

@jeskew Yea probably output would be good to be part of the types and parameters file as the output probably will have output schema as well.

jeskew commented 1 year ago

There's some discussion along the same lines in https://github.com/Azure/bicep/issues/10121#issuecomment-1515410447.

I'm not opposed to the idea of "interface" files, but I think many param statements and all output statements would need to be duplicated between the interface file and the template using it. The value of an output statement (and the default value of a param statement) seem like part of the template, not the interface.

slavizh commented 1 year ago

@jeskew ok, I am ok if I have to duplicate param and output as long as we have this kind of modular approach as we provide our templates in a way that the templates and configuration files are separate thing and not in one repository. You may take it as similar approach of how ARM RPs have their schemas published at GitHub. Do you want me to comment on the other discussion or by just linking it is fine? Is there something more that I can do to convince you about having this implemented? I am fine if there is some other approach as long as I can achieve the same end result/experience.

jeskew commented 1 year ago

I think linking the issues is fine.

The action item coming out of #10121 that should be shipping soon is import statements, which I think will help achieve some of what you've described here in terms of type reuse. At some point in the future, Bicep could also add some form of "interface" artifact that could take advantage of imports.

For example, assume you have a type definition used in an optional parameter and output. You could define everything in the same template:

type foo = {
  bar: string
  baz: {
    buzz: string
    pop: string?
  }
}

param fooParam foo = {
  bar: 'bar'
  baz: {
    buzz: 'buzz'
  }
}

output fooOutput foo = fooParam

// some resources are defined below

If Bicep had some kind of interface artifact (a .ibicep file?), the shared types could be defined in one template and shared where needed via import:

types.bicep

type foo = {
  bar: string
  baz: {
    buzz: string
    pop: string?
  }
}

foo.ibicep

import {foo} from 'types.bicep'

param fooParam foo?

output fooOutput foo

foo.bicep

fulfills 'foo.ibicep'  // <-- or some other way to declare that the template should match the declared interface

import {foo} from 'types.bicep'

param fooParam foo = {
  bar: 'bar'
  baz: {
    buzz: 'buzz'
  }
}

output fooOutput foo = fooParam

// some resources are defined below

At the moment, I don't really understand how the interface artifact would be used. There's no way to pass modules around as values, so it wouldn't add any form of substitutability. (I could see Bicep adding something along those lines in the future, though.)

slavizh commented 1 year ago

@jeskew I think I have described the usage of such interface file. Such file will be referenced in the bicepparam file either via local file or http. That avoids referencing bicep template file which could contain references to other modules thus you will have to maintaining the whole module locally just to get intellisesense in bicepparam. I think it is essential that we do not stop with only import function as that is just doing the bare minimum without reflecting real world scenarios like the one I have described. I think it is important to clarify that Bicep templates and bicep parameters files are not always located in the same repository. We use CI/CD where the bicep templates are published as artifacts and they are downloaded when the pipeline runs. End users they just modify configurations and tell which bicep template to use and which version when the pipeline runs.

jeskew commented 1 year ago

Understood. It sounds like the scenario you describe would require some way to replace module paths, which is what I meant by substitutability in my previous comment. Something along the lines of:

foo.ibicep

import {foo} from 'types.bicep'

param fooParam foo?

output fooOutput foo

foo.bicep

fulfills 'foo.ibicep'  // <-- or some other way to declare that the template should match the declared interface

import {foo} from 'types.bicep'

param fooParam foo = {
  bar: 'bar'
  baz: {
    buzz: 'buzz'
  }
}

output fooOutput foo = fooParam

// some resources are defined below

main.bicep

module mod 'foo.ibicep' = {
  ...
}

bicepconfig.json

{
  "implementations": {
    "foo.ibicep": "foo.bicep"
  }
}

That would be conceptually similar to how you specify a concrete implementation for an interface in a dependency injection container.

slavizh commented 1 year ago

@jeskew thanks! If you want we can work together to make sure all these things are fulfilled. We have quite big experience in Bicep templates and large number of Bicep solutions that we can help you in testing certain things.

slavizh commented 1 year ago

For anyone that is annoyed like me about this constrain you can do workaround by putting the only the parameters and types from your main.bicep file into another file and reference that file in your Bicep parameters file. That way if your Bicep deployment consists of multiple templates and not just a single main.bicep file you can avoid copying all these template files just to get intellisense in your parameters file. Of course even the workaround is just partial as using function in parameters file does not support http endpoint which makes this a little bit annoying. And of course when you make changes to main.bicep in the types and parameters you need to make those changes in that file as well.

jeskew commented 1 year ago

Were you thinking of this feature more in the context of .bicepparam files, specifically to support using the same parameters file for multiple templates? That wouldn't need the kind of substitutability mechanism described above.

Maybe something like the following?

foo.ibicep

import {foo} from 'types.bicep'

param fooParam foo?

output fooOutput foo

main.bicep

fulfills 'foo.ibicep'  // <-- or some other way to declare that the template should match the declared interface

import {foo} from 'types.bicep'

param fooParam foo = {
  bar: 'bar'
  baz: {
    buzz: 'buzz'
  }
}

output fooOutput foo = fooParam

// some resources are defined below

main.bicepparam

using 'foo.ibicep'

param fooParam = ...

CLI command

az deployment group create \
  --name ExampleDeployment \
  --resource-group ExampleGroup \
  --template-file main.bicep \
  --parameters main.bicepparam
slavizh commented 1 year ago

No, I am not looking at having one parameters file for multiple templates. Imagine having one deployment that consists of multiple bicep templates. In typical scenario you need to have modules to achieve deployment of several resource. For example if you want to pass secret from key vault you are forced to start deployment so you can get the secret and pass it securely. The main file for that deployment is main.bicep. Let's call the the main.bicep file and its all modules a single solution. Now imagine have 10 such solutions each deploying different resources and serving different purposes. When end user creates bicep parameters file for one of these solutions I do not want to force the user to download all the files for that solution just to get intellisense in VSC for the parameters file. In fact I would just want in their Bicep parameters file to point to public endpoint containing the parameters and types instead of referring to local file. Now imagine that user needs to create separate parameters file for 5 of those solutions. In VSC they will need to just refer to the public endpoint where the foo.ibicep is stored for the version of that solution they want to use rather having to download all solutions just to create parameters file. Once they create their parameters files they push to Git, some pipeline runs that based on their pipeline.yml definition downloads the needed solutions for each parameters file from artifacts repository and runs the deployment with that parameters file and the solution downloaded. If needed we can have a meeting as I have been testing this kind of scenario with the capabilities currently available.

jeskew commented 1 year ago

Mind if I rename this issue to "Template interfaces"? It sounds like the scenario you describe isn't really related to user-defined types, since a template with 30 int/bool/string parameters would benefit from an abstraction mechanism just as much as would a template with a single parameter of a user-defined type.

slavizh commented 1 year ago

no problem. Yes it is a cross feature that spans on different parts of bicep.