Azure / bicep

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

Re-export imported elements #13670

Open jrobins-tfa opened 7 months ago

jrobins-tfa commented 7 months ago

I've run into an issue around user-defined types, which is that I can only export them up one level of consumption, and that's not always enough. As an example, suppose I have the following modules:

firstModule.bicep:

@export()
type firstType = {
  propX: string
  propY: int
}
param firstParam firstType

....

secondModule.bicep:

@export()
type secondType = {
  propA: string
  propB: int
}
param secondParam secondType

....

compositeModule.bicep:

import {firstType} from 'firstModule.bicep'
import {secondType} from 'secondModule.bicep'

param firstParam firstType
param secondParam secondType

module firstMod 'firstModule.bicep' = {
  name: 'firstMod'
  params: {
    firstParam: firstParam
  }
}

module secondMod 'secondModule.bicep' = {
  name: 'secondMod'
  params: {
    secondParam: secondParam
  }
}

So far, so good. I reuse the types to ensure that the params passed into my composite module have the same type as the individual modules those params will be passed along to.

The problem is that I want to share compositeModule.bicep with my consumers by publishing it to a private registry, and I don't want to publish firstModule.bicep or secondModule.bicep (because nobody should be directly using those modules). I want my consumers to be able to reuse the same user-defined types, but they don't have access to them - they're not exported from compositeModule.bicep, and the consumer doesn't have access to firstModule.bicep or secondModule.bicep.

I know that I could separate out the types into a separate file or two, and publish that/those file(s) to the registry, but this decouples the type from the content it's related to, which I don't really like as a solution.

What I would love to see is the ability to re-export imported elements. If I could do something like this in compositeModule.bicep:

@export()
import {firstType} from 'firstModule.bicep'
@export()
import {secondType} from 'secondModule.bicep'

And then my consumer could get those types from compositeModule.bicep without even needing to know that those types originated in another module. This would make these types more reusable without having to completely detach them from the content they're related to. It also provides a level of encapsulation because the consumer doesn't need to know what's going on under the hood in compositeModule.bicep.

I'm sure there's some complexity that will come into play with aliases for imports, especially if multiple imports with different aliases have same-named elements, but that feels like it should be solvable.

sgebb commented 6 months ago

Had the exact same need, so far solving it by having a separate types.bicep file for each module. In my registry i'll have bicep/servicebus:module and bicep/servicebus:types to make it clear that these are the types you need to import to use the module.

marsontret commented 6 months ago

This sounds interesting. What's a scenario where a user would reuse the custom types declared in the published module?

jrobins-tfa commented 6 months ago

This sounds interesting. What's a scenario where a user would reuse the custom types declared in the published module?

Our scenario is that our consumers (development teams who are deploying applications to Azure) generally create a Bicep template that pulls together all of the components they need, and then set up parameter files for each environment. So a team deploying a webapp might have a template myApp.bicep something like:

param foo object
param bar object

module kv 'br:...keyvault' = {
  params: {
    foo: foo
  }
}

module appService 'br:...appservice' = {
  params: {
    bar: bar
  }
}

And then they could have param files myApp.qa.bicepparam, myApp.prod.bicepparam, etc.

The problem is that because foo and bar are just object type in myApp.bicep, they don't get any of the realtime validation, tooltips, and other benefits that come from having a custom type while working on the param files. If instead of objects, we had:

import {fooType} from 'br:...keyvault'
import {barType} from 'br:...appservice'
param foo fooType
param bar barType

Then the custom type would propagate out and would help out the developers creating the param files.

sgebb commented 6 months ago

Just to add on my reason for wanting this. I'm making a module for creating a service bus, where the service bus takes a list of Topics, each Topic consists of a list of Subscribers, and each Subscriber has a name plus the name of their keyvault so I can put the connection string in it. Because of how nested looping (doesn't) work in bicep I have to spread this out over three separate files to be able to create multiple resources per Subscriber.

This means I have Servicebus.bicep, referencing Topic.bicep, which references Subscription.bicep. I'm only publishing Servicebus.bicep to ACR. I can't dump all my types into Servicebus.bicep as that would create a cyclical reference (Subscription.bicep would have to reference Servicebus.bicep to gee Subscriber-type).

Now I want the consumers of my module to be able to create Topic and Subscriber objects when using the module.

param examplesub Subscriber = { name: 'example' vault: 'vault' }

param exampletopicTopic = { name: 'example' subscribers: [mysub] }

module sb '..' = { name: 'sb' params: { topics: [exampletopic] } }

This way they can reference examplesub in multiple topics, and they get the benefits of a custom type. examplesub could be created as a var instead, but then you lose the assistance the type brings. i could also create the topic/subscriber directly under the module, then I get type assistance, but then I have no way of referencing the same object multiple times. Like I said I'm now putting the types in a separate types.bicep file which is published on it's own, but I would prefer if the types could be exposed directly through the module.

Adunaphel commented 6 months ago

My workaround for this is to store all user-defined types in their own files and importing those files whenever needed

Xitric commented 3 months ago

Our go-to workaround is to import types via an alias, and then re-export them using the original name. Using the example from the first post, this becomes:

import {firstType as _firstType} from 'firstModule.bicep'
import {secondType as _secondType} from 'secondModule.bicep'

@export()
type firstType = _firstType
@export()
type secondType = _secondType

param firstParam firstType
param secondParam secondType

module firstMod 'firstModule.bicep' = {
  name: 'firstMod'
  params: {
    firstParam: firstParam
  }
}

module secondMod 'secondModule.bicep' = {
  name: 'secondMod'
  params: {
    secondParam: secondParam
  }
}

As an alternative workaround, we tried to manually modify the imported types in our ARM template to re-export them by adding __bicep_export! to the metadata property:

{
  ...
  "definitions": {
    "firstType": {
      "type": "object",
      ...
      "metadata": {
        "__bicep_export!": true,
        "__bicep_imported_from!": {
          "sourceTemplate": "modules/types.bicep"
        }
      }
    }
  },
  ...
}

This works out of the box, but requires a manual modification to the ARM template, which is a no-go for us. But it suggests that an implementation in Bicep maybe isn't that difficult?

I would however like to mention that IntelliSense in my VS Code instance is rather broken when using these two workarounds outlined above, but it is able to tell me if I misconfigure something, so it provides some value.