Container Registry, Managed Identity, Container App Environment and Container App
Now that the database and key vault is set up with the secrets ready to go, I can start creating the app. I am going to need container registry to hold my docker image, a container app environment for the container app to sit in, and a container app to run my docker container.
Container Registry
Since I am trying to cut cost I need to use a basic sku container registry. This sku has one major downside, which is it blocks you from setting firewall rules and using private endpoints. Which means I cannot restrict the traffic to my container app subnet.
Because of this I have no choice but to keep it open. To secure this right, the best thing to do would be to use the premium service and restrict the traffic to the registry. But since I can’t lets get started. This piece is pretty small so I will include all of the code right away.
import pulumi
from pulumi_azure_native import containerregistry
import pulumi_docker as docker
def create_reg(rg_name):
registry = containerregistry.Registry("registry",
resource_group_name=rg_name,
registry_name="cybauerrg",
sku=containerregistry.SkuArgs(name="Basic"),
admin_user_enabled=True)
credentials = pulumi.Output.all(rg_name, registry.name).apply(
lambda args: containerregistry.list_registry_credentials(resource_group_name=args[0],
registry_name=args[1]))
admin_username = credentials.username
admin_password = credentials.passwords[0]["value"]
docker_file_path = r"c:\Users\Brett\OneDrive\Documents\Scripting\BauerWebApp\Bauer_Cyber_Services"
cybauer_image = docker.Image("cybauer",
image_name=registry.login_server.apply(
lambda login_server: f"{login_server}/cybauer:v2.0.0"),
build=docker.DockerBuildArgs(context=f"{docker_file_path}"),
registry=docker.RegistryArgs(
server=registry.login_server,
username=admin_username,
password=admin_password))
return registry, credentials, cybauer_image
Couple things here to explain. The first is the credentials. I need the credentials so my container app can pull the image from the registry. I use Pulumi output to grab the registry name and resource group, and then I pass those to the list_registry_credentials method to get the credentials. Once I have the credential, I access the username and password through it and break them up into two variables.
The second is the docker file path. This is the path to my docker file on my computer. For me the docker file was in my Django app directory so that is where I pointed the path towards.
Once I have those two things I am able to create the image inside the registry. I set the image name, build the image with the docker file path, and then login to then use the container app registry I created above with the login_server and the username and password. At the end I return the image, credentials, and registry because I might need them later. Now I can go and use the function in then main.py file.
from resource_group import create_resourcegroup
from network import create_network
from key_vault import create_kv
from storage_account import create_sa
from post_gresql import create_postgres
from container_registry import create_reg
from nsg import create_nsg
cybauer_rg = create_resourcegroup()
vnet, postgres_subnet, container_apps_subnet, private_dns_zone, subnet_dependencies = create_network(rg_name=cybauer_rg.name)
account, blob_service_properties_resource, static_container, media_container, sas_token = create_sa(rg_name=cybauer_rg.name, container_apps_sub_id=container_apps_subnet.id)
key_vault = create_kv(rg_name=cybauer_rg.name, container_subnet_id=container_apps_subnet.id, depends_on=subnet_dependencies)
password, postgres_server, cybauer_db, postgres_password_secret, postgres_fqdn_secret = create_postgres(rg_name=cybauer_rg.name,
postgres_subnet_id=postgres_subnet.id, key_vault_name=key_vault.name, priv_dns_zone=private_dns_zone)
registry, credentials, cybaer_image = create_reg(rg_name=cybauer_rg.name)
Managed Identity
Now I need an identity for my container app to access some of the resources I have created. If I remember correctly I need the app to be able to access both key vaults I created. I also am going to give the identity a role for the storage account and container registry.
def create_identity(rg_name, kv_id, sa_account_id, registry_id):
managed_identity = managedidentity.UserAssignedIdentity("cybauerAppIdentity",
resource_group_name=rg_name
)
captcha_key_role_assignment = authorization.RoleAssignment(
"captcha_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
scope="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/resourceGroups/AZLabs/providers/Microsoft.KeyVault/vaults/cybauer-capchta-email"
)
cybauer_key_role_assignment = authorization.RoleAssignment(
"cyaberkey_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
scope=kv_id
)
sa_role_assignment = authorization.RoleAssignment(
"sa_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe",
scope=sa_account_id
)
acr_pull_role_assignment = authorization.RoleAssignment(
"acr_pull_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d",
scope=registry_id
)
pulumi.export("app_Container_identity", managed_identity.id)
pulumi.export("app_Container_identity_client_id", managed_identity.client_id)
return managed_identity, captcha_key_role_assignment, cybauer_key_role_assignment, sa_role_assignment, acr_pull_role_assignment
I created the id and assigned roles with the role definition id and the scope of the resource I wanted the role to be on.
Container App Environment
Now lets create the environment. The app needs this to be created first.
def create_app_env(rg_name, container_subnet_id):
# Create an Azure Container App Environment
app_env = app.ManagedEnvironment(
'cybauerAppEnv',
resource_group_name=rg_name,
environment_name="cybauerAppEnv",
sku=app.EnvironmentSkuPropertiesArgs(
name="Consumption",
),
vnet_configuration=app.VnetConfigurationArgs(
infrastructure_subnet_id=container_subnet_id,
internal=True
)
)
return app_env
Container Application
Now with the registry, identity, and the environment set up lets create the app.
def create_app(rg_name, app_env, managed_identity, registry, credential, image, kv_name, depends_on, sas_token, vnet):
pulumi.ResourceOptions(depends_on=depends_on)
user_assigned_identities = managed_identity.id.apply(lambda id: {id: {}})
admin_username = credential.username
admin_password = credential.passwords[0].value
pulumi.export("acr_username", admin_username)
pulumi.export("acr_pass", admin_password)
pulumi.export("registry-url", registry.login_server)
container_app = app.ContainerApp(
'cybauer-app',
resource_group_name=rg_name,
environment_id=app_env.id,
identity=app.ManagedServiceIdentityArgs(
type=app.ManagedServiceIdentityType.USER_ASSIGNED,
user_assigned_identities=user_assigned_identities
),
configuration=app.ConfigurationArgs(
ingress=app.IngressArgs(
external=True,
target_port=8000, # Default port for HTTP, modify as per your container configuration.
allow_insecure=False, # Ensures HTTPS is enforced.
),
registries=[
app.RegistryCredentialsArgs(
server=registry.login_server,
username=admin_username,
password_secret_ref='pwd'
)
],
secrets=[
app.SecretArgs(
name="pwd",
value=admin_password
),
app.SecretArgs(
name="managed-identity-clientid",
value=managed_identity.client_id
),
app.SecretArgs(
name="sas",
value=sas_token
)],
),
template=app.TemplateArgs(
containers=[
app.ContainerArgs(
name='cybauer',
image=image.image_name,
env=[
app.EnvironmentVarArgs(
name="AZURE_CLIENT_ID",
secret_ref="managed-identity-clientid"
),
app.EnvironmentVarArgs(name="SAS", secret_ref="sas")
]
)
],
scale=app.ScaleArgs(
max_replicas=1,
min_replicas=0
),
),
)
# Get the FQDN of the container app
fqdn = container_app.configuration.apply(lambda config: config.ingress.fqdn)
# Create a secret for the FQDN in Key Vault
container_app_fqdn_secret = keyvault.Secret(
"containerAppFQDNSecret",
secret_name="containerurl",
vault_name=kv_name,
resource_group_name=rg_name,
properties=keyvault.SecretPropertiesArgs(
value=fqdn
)
)
return container_app, container_app_fqdn_secret, fqdn
The first things needed are the container registry, credentials, and the managed identity just created. Once those are created I can pass them into the container app creation.
The next piece is the app configuration. I need to create the ingress, registry, and secret settings. I need to allow external traffic on port 8000, but I need to make sure insecure connections are not allowed. The registry uses the password secret I create in the secrets list. The other two secrets are the storage account Shared Access Signature and the managed identity client id. That client id is need inside the Django app because I am using the python managed identity authentication inside my app and it needs the client id of the identity.
The last part is the template. For this I grab the image from the container registry, set the environment variables with the secrets I just created, and then since this is a small app and should not have much traffic, I am setting the max scale at 1 and minimum at 0 to allow the app to shut down when it is not being used. I now need the container url so I grab the fully qualified domain name off the container app and create a key vault secret with it.
Private DNS Zone
I need to be able to access the app through the VNET so I need a DNS zone for this. I need a private DNS zone, two records, and then I am going to link the zone to the VNET.
def app_create_dns(rg_name, app_env, vnet):
# Create a Private DNS Zone for the Container App
app_private_dns_zone = app_env.id.apply(lambda _: network.PrivateZone("acaprivateDnsZone",
resource_group_name=rg_name,
private_zone_name=app_env.default_domain.apply(lambda domain: f"{domain}"),
location="Global"
))
# Create A Records in the Private DNS Zone
arecord = network.PrivateRecordSet("acaARecord",
resource_group_name=rg_name,
private_zone_name=app_private_dns_zone.name,
record_type="A",
ttl=300,
a_records=[network.ARecordArgs(ipv4_address=app_env.static_ip)],
relative_record_set_name="*"
)
atrecord = network.PrivateRecordSet("acaatRecord",
resource_group_name=rg_name,
private_zone_name=app_private_dns_zone.name,
record_type="A",
ttl=300,
a_records=[network.ARecordArgs(ipv4_address=app_env.static_ip)],
relative_record_set_name="@"
)
# Create a Virtual Network Link for the Private DNS Zone
dns_zone_link = app_private_dns_zone.apply(lambda zone: network.VirtualNetworkLink("dnsZoneLink",
resource_group_name=rg_name,
private_zone_name=zone.name,
virtual_network=network.SubResourceArgs(
id=vnet.id
),
registration_enabled=True,
location="Global"
))
return app_private_dns_zone, arecord, atrecord, dns_zone_link
I used the static ip of the app environment for the record creations.
Now with everything created, lets combine those functions together and use them in the main file.
Container_app.py:
import pulumi
from pulumi_azure_native import app, keyvault, network
from pulumi_azure_native import managedidentity, authorization
def create_identity(rg_name, kv_id, sa_account_id, registry_id):
managed_identity = managedidentity.UserAssignedIdentity("cybauerAppIdentity",
resource_group_name=rg_name
)
captcha_key_role_assignment = authorization.RoleAssignment(
"captcha_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
scope="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/resourceGroups/AZLabs/providers/Microsoft.KeyVault/vaults/cybauer-capchta-email"
)
cybauer_key_role_assignment = authorization.RoleAssignment(
"cyaberkey_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
scope=kv_id
)
sa_role_assignment = authorization.RoleAssignment(
"sa_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe",
scope=sa_account_id
)
acr_pull_role_assignment = authorization.RoleAssignment(
"acr_pull_roleAssignment",
principal_id=managed_identity.principal_id,
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id="/subscriptions/3b8667c6-8f75-42ea-b301-bf27c9db8674/providers/Microsoft.Authorization/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d",
scope=registry_id
)
pulumi.export("app_Container_identity", managed_identity.id)
pulumi.export("app_Container_identity_client_id", managed_identity.client_id)
return managed_identity, captcha_key_role_assignment, cybauer_key_role_assignment, sa_role_assignment, acr_pull_role_assignment
def create_app_env(rg_name, container_subnet_id):
# Create an Azure Container App Environment
app_env = app.ManagedEnvironment(
'cybauerAppEnv',
resource_group_name=rg_name,
environment_name="cybauerAppEnv",
sku=app.EnvironmentSkuPropertiesArgs(
name="Consumption",
),
vnet_configuration=app.VnetConfigurationArgs(
infrastructure_subnet_id=container_subnet_id,
internal=True
)
)
return app_env
def create_app(rg_name, app_env, managed_identity, registry, credential, image, kv_name, depends_on, sas_token, vnet):
pulumi.ResourceOptions(depends_on=depends_on)
user_assigned_identities = managed_identity.id.apply(lambda id: {id: {}})
admin_username = credential.username
admin_password = credential.passwords[0].value
pulumi.export("acr_username", admin_username)
pulumi.export("acr_pass", admin_password)
pulumi.export("registry-url", registry.login_server)
container_app = app.ContainerApp(
'cybauer-app',
resource_group_name=rg_name,
environment_id=app_env.id,
identity=app.ManagedServiceIdentityArgs(
type=app.ManagedServiceIdentityType.USER_ASSIGNED,
user_assigned_identities=user_assigned_identities
),
configuration=app.ConfigurationArgs(
ingress=app.IngressArgs(
external=True,
target_port=8000, # Default port for HTTP, modify as per your container configuration.
allow_insecure=False, # Ensures HTTPS is enforced.
),
registries=[
app.RegistryCredentialsArgs(
server=registry.login_server,
username=admin_username,
password_secret_ref='pwd'
)
],
secrets=[
app.SecretArgs(
name="pwd",
value=admin_password
),
app.SecretArgs(
name="managed-identity-clientid",
value=managed_identity.client_id
),
app.SecretArgs(
name="sas",
value=sas_token
)],
),
template=app.TemplateArgs(
containers=[
app.ContainerArgs(
name='cybauer',
image=image.image_name,
env=[
app.EnvironmentVarArgs(
name="AZURE_CLIENT_ID",
secret_ref="managed-identity-clientid"
),
app.EnvironmentVarArgs(name="SAS", secret_ref="sas")
]
)
],
scale=app.ScaleArgs(
max_replicas=1,
min_replicas=0
),
),
)
# Get the FQDN of the container app
fqdn = container_app.configuration.apply(lambda config: config.ingress.fqdn)
# Create a secret for the FQDN in Key Vault
container_app_fqdn_secret = keyvault.Secret(
"containerAppFQDNSecret",
secret_name="containerurl",
vault_name=kv_name,
resource_group_name=rg_name,
properties=keyvault.SecretPropertiesArgs(
value=fqdn
)
)
return container_app, container_app_fqdn_secret, fqdn
def app_create_dns(rg_name, app_env, vnet):
# Create a Private DNS Zone for the Container App
app_private_dns_zone = app_env.id.apply(lambda _: network.PrivateZone("acaprivateDnsZone",
resource_group_name=rg_name,
private_zone_name=app_env.default_domain.apply(lambda domain: f"{domain}"),
location="Global"
))
# Create A Records in the Private DNS Zone
arecord = network.PrivateRecordSet("acaARecord",
resource_group_name=rg_name,
private_zone_name=app_private_dns_zone.name,
record_type="A",
ttl=300,
a_records=[network.ARecordArgs(ipv4_address=app_env.static_ip)],
relative_record_set_name="*"
)
atrecord = network.PrivateRecordSet("acaatRecord",
resource_group_name=rg_name,
private_zone_name=app_private_dns_zone.name,
record_type="A",
ttl=300,
a_records=[network.ARecordArgs(ipv4_address=app_env.static_ip)],
relative_record_set_name="@"
)
# Create a Virtual Network Link for the Private DNS Zone
dns_zone_link = app_private_dns_zone.apply(lambda zone: network.VirtualNetworkLink("dnsZoneLink",
resource_group_name=rg_name,
private_zone_name=zone.name,
virtual_network=network.SubResourceArgs(
id=vnet.id
),
registration_enabled=True,
location="Global"
))
return app_private_dns_zone, arecord, atrecord, dns_zone_link
Now here is what the main.py file looks like now.
main.py:
from resource_group import create_resourcegroup
from network import create_network
from key_vault import create_kv
from storage_account import create_sa
from post_gresql import create_postgres
from container_registry import create_reg
from container_app import create_identity, create_app, create_app_env, app_create_dns
cybauer_rg = create_resourcegroup()
vnet, postgres_subnet, container_apps_subnet, private_dns_zone, subnet_dependencies = create_network(rg_name=cybauer_rg.name)
account, blob_service_properties_resource, static_container, media_container, sas_token = create_sa(rg_name=cybauer_rg.name, container_apps_sub_id=container_apps_subnet.id)
key_vault = create_kv(rg_name=cybauer_rg.name, container_subnet_id=container_apps_subnet.id, depends_on=subnet_dependencies)
password, postgres_server, cybauer_db, postgres_password_secret, postgres_fqdn_secret = create_postgres(rg_name=cybauer_rg.name,
postgres_subnet_id=postgres_subnet.id, key_vault_name=key_vault.name, priv_dns_zone=private_dns_zone)
registry, credentials, cybaer_image = create_reg(rg_name=cybauer_rg.name)
managed_identity, captcha_key_role_assignment, cybauer_key_role_assignment, sa_role_assignment, acr_pull_role_assignmnet = create_identity(rg_name=cybauer_rg.name, kv_id=key_vault.id, sa_account_id=account.id, registry_id=registry.id)
app_env1 = create_app_env(rg_name=cybauer_rg.name, container_subnet_id=container_apps_subnet.id)
container_app, container_app_fqdn_secret, fqdn, dns_zone, a_record, txt_record, certificate = create_app(rg_name=cybauer_rg.name, app_env=app_env1, managed_identity=managed_identity,
registry=registry, credential=credentials, image=cybaer_image, kv_name=key_vault.name, depends_on=postgres_server,
sas_token=sas_token, static_ip=app_env1.static_ip)
app_private_dns_zone, arecord, atrecord, dns_zone_link = app_create_dns(rg_name=cybauer_rg.name, app_env=app_env1, vnet=vnet)
Conclusion
This is it for part 4. In this part I got the container registry set up with the image loaded into it. I then created a user assigned managed identity and assigned roles to it. I then created the app environment and the container app. The next part is the last in the series. I am going create an application gateway with a public ip and then I am going to point that gateway to my container app. I am going to need a pfx certificate so I can use my custom domain cybauer.com with the app gateway and my container app. The last thing that I will need to do is create the Network Security Groups for each subnet so I can restrict the traffic that comes in and out of each subnet.