r

restlessmodem

Hi everyone! My name is Christopher and I am a software developer in Düsseldorf, Germany. Currently in my professional life, I am mostly focused on data engineering and business intelligence, however on my own time I like to explore different things and technologies, whatever seems fun to me. While working as a developer and learning new technologies e.g. recently SwiftUI, I have always found random blog posts on the internet to be incredibly valuable to see real life experience of fellow human beings working on the same thing as me and suffering the same issues as me. So I thought I would try to collect my thoughts from time to time whenever I struggle with some confusing thing for a while and finally understand it, so maybe someone else in the same situation can get to the solution a bit quicker.

Passing values down the view hierarchy in SwiftUI

Introduction

So recently I have been trying to get into iOS App Development with Swift and SwiftUI, not because I had some genius app idea thats going to make me rich, much more because at work I exclusively develop internal stuff, even if my colleagues are very happy about it, it would be nice to create something that I can publish on the App Store and that might end up on someones iPhone or iPad and helps them in some way.

As my first simple project I chose an unofficial client for the Databricks platform. Databricks has a great web app however in my daily life at work I am responsible not only for developing these ETL-workflows but to also make sure they run successfully everyday and to intervene if something goes wrong, so naturally often I wish I could just check if everything is alright on the go and maybe even start a job again or restart a cluster quickly from my phone without needed to log in to the web app.
Simply enough, Databricks offers a great set of API endpoints that should make this a great first project to start on and learn the fundamentals of Swift and SwiftUI.

Before we get into Jobs (or Workflows, how they are called now), let us start with Clusters which should be a bit simpler. For those of you who do not use databricks, a cluster is just a collection of VMs from the cloud provider of your choice (Azure in our case) that runs pyspark, scala or SQL code.

So in my app I would like to see and overview of all my clusters in the workspace and also be able to select a single cluster to learn more about it. The basic view structure I came up with looks like the following:

ContentView -> ClusterView -> ClusterInfoView.

The important thing here is, that I want to be able to update the values from every view, even the child views, so the user always has current data without going all the way back to the overview. Either that happens automatically, or with a pull-to-refresh gesture. For this post I just want to talk about how to get the child views to update, I will talk into how to call the refresh from the child views in another post, for now just keep in mind we want to be able to refresh the cluster data even if we are currently viewing a cluster in a ClusterInfoView.

Another thing to keep in mind is that the Cluster object in this example needs a stable identity. I solved this with the Identifiable protocol and using a unique ID provided by the API. Using a UUID will not work because that will change every time a new cluster response is downloaded from the API and a "new" Cluster() instance is created. This will lead to all the child views being fully destroyed and redrawn on refresh, which is not what we want. So instead we use cluster.cluster_id.

Creating the parent view

After going though Apples excellent fundamentals course, I thought I had understood how to pass data around my view hierarchy. Let us ignore how I get the data from the API for now and just look at how to pass the data through my views. As a first step I need a view that takes the clusters and displays all of them in a list for the overview page.

struct ClusterView: View {
    var clusters = [Cluster]()

    var body: some View {
        VStack {
            Text("Databricks Clusters").font(.headline)
            List(self.clusters) { cluster in
                HStack {
                    Text(cluster.cluster_name)
                }
            }.task {
                // load clusters from API
            }
        }
    }
}

We are not passing any data yet and SwiftUI is not watching the clusters for any changes. Even if the clusters variable changes, the names in the displayed view would remain the same. So if we want to tell SwiftUI to watch this value, we use the @State wrapper.

@State var clusters = [Cluster]()

Now should we reload our cluster data, e.g. if we call the API in refreshable{} on the list, we can get the names in the list to update simply by performing a pull-to-refresh gesture.

Creating the child view

Now it is time to create our child view ClusterInfoView and get it to open as a second screen. For this we can use NavigationView and NavigationLink. In my ContentView I wrap the call to ClusterView in NavigationView{} and now I can use NavigationLink inside of it.

struct ContentView: View {
    var body: some View {
        NavigationView {
            ClusterView()
        }
    }
}

struct ClusterView: View {
    @State var clusters = [Cluster]()

    var body: some View {
        VStack {
            Text("Databricks Clusters").font(.headline)
            List(self.clusters) { cluster in
                NavigationLink(destination: ClusterInfoView(cluster: cluster)) {
                    HStack {
                        Text(cluster.cluster_name)
                    }
                }
            }
        }
    }
}

Every list item is wrapped in a NavigationLink so that it can be tapped and a new page is opened, in this case ClusterInfoView. Because I want to show the details of one particular cluster, I have to pass that as a parameter and expect said parameter in my ClusterInfoView.

struct ClusterInfoView: View {
    @State var cluster: Cluster

    var body: some View {
        List {
            HStack {
                Label(self.label, systemImage: self.icon)
                Spacer()
                Text(self.value)
            }
        }
    }
}

And here I met my first problem. I ran the code on my phone and to my immense disappointment, the ClusterInfoView is not actually updating, it keeps the values it has when it is first rendered.
This is because by applying the @State wrapper to ClusterInfoViews cluster I established a second source of information or "truth" how Apple likes to call it.

So away with the @State:

var cluster: Cluster

Actually I have to intention of altering the cluster inside of my child view so why even make it mutable?

let cluster: Cluster

Fully expecting everything to work now I start the App on my iPhone and find: Still nothing. I must have spend hours that felt like days figuring out what went wrong and if I still fundamentally misunderstood something about SwiftUI.

Alternatively I tried @Binding in the child view and that worked! The child views values are updating! But as I understand, Binding is really only designed for when the ChildView needs to update its parent, which might be needed in the future when I make the cluster editable. But for now this should work without Bindings!
So why is the ClusterDetailView not updating?

I explained the problem to my plush Bert from Sesame Street sitting on my desk and while he did not offer any insights - he rarely does - I had one more idea: When I implemented my Codable model of Cluster to translate the API responses into struct values, I did not just use Identifiable but also Equatable. I did not really need it back then yet but it seemed like a good idea to be able to equate my Clusters with their stable identity. However it seems like SwiftUI is using Equatable to determine if a value changed, while ignoring all other possible changes of other values. Of course my API-provided cluster "id" never changed, so SwiftUI never thought to redraw the Views.

This was the Equatable definition that I came up with.

extension Cluster: Equatable {
    static func == (lhs: Cluster, rhs: Cluster) -> Bool {
        return lhs.id == rhs.id
    }
}

I removed it from the model and I audibly cheer as my child view is now updating automatically when the clusters change.

Invite Azure Active Directory Guest Users via terraform

Introduction

At work one of my systems that I built and maintain is a collection of PowerBI datasets and reports that our external clients consume to view their performance in our portfolio. To make this possible we invite the external users to our Azure Active Directory tenant and give them access to a PowerBI app we created for them. This way they can consume reports but not edit - essentially look but don't touch!

So far that process of inviting users is fairly manual and time-consuming: First we need to invite the users individually from the GUI of Azure Portal, then we have to send them each an email to explain the invite and include the link to the PowerBI app. Seems fine for one or two users but as soon as I have to invite 6 or 7 users at once I find myself pushing it off my to-do list day after day because it takes so long.

I hope to replace all of this with terraform! Alternatively there could absolutely be a fancy PowerShell Script that automates the guest invites, but the great thing about terraform is that it expresses a state rather than an action. This is what our infrastructure should look like and if it doesn't, then please make it that way, whatever the deployment path to that state may be. Also we already manage our Azure subscription fully via terraform and less spots where something is configured are generally preferable, in my opinion.

Creating AAD invited via terraform

Let's see what an aad invite looks like in terraform:

resource "azuread_invitation" "externalClientUser" {
    user_email_address = "niceperson@client.de"
}

It is that simple! At this point I already save time, anytime I have to invite 5 or 6 users at once. But of course now we do not want to just copy those three lines for every single guest user. Instead we can use the for_each argument.

resource "azuread_invitation" "externalClientUsers" {
    for_each = toset(["niceperson@client.de", "otherniceperson@client2.de"])
    user_email_address = each.key
}

This is what makes terraform so incredibly powerful! We only have to define this one resource and terraform understands we actually need two invites.

Now with currently around 80 external users in our system, this set would get so long that it would probably violate some data structure laws, I don't know, I am not a lawyer. So we can put that into its own file with the .tfvars ending.

tf-aad-mandanten.tfvars:

externalClientUsers = [
    "niceperson@client.de",
    "otherniceperson@client2.de"
]

This also needs a variable definition in our .tf file:
tf-aad-mandanten.tf:

variable "externalClientUsers" {
    type = list(string)
}

resource "azuread_invitation" "externalClientUsers" {
    for_each = toset(var.externalClientUsers)
    user_email_address = each.key
}

Much cleaner! The .tfvars file needs to be specified on apply or we can rename it to .auto.tfvars and terraform will look for it, as the name suggests, automatically.

Refining the resource configuration

Now remember the additional steps I was facing? Why do I send our clients an additional email after the invite? Well I want to be sure they can make sense of the invite. However now I can just include the message that needs to be sent to every client when they receive the invite:

tf-aad-mandanten.tf:

variable "externalClientUsers" {
    type = list(string)
}

resource "azuread_invitation" "externalClientUsers" {
    for_each = toset(var.externalClientUsers)
    user_email_address = each.key

    message {
        body = "Hey Client! Please accept this invite to access our PowerBI-Reports! Sincerely, restlessmodem"
    }
}

This works great, the email now includes this message. However the big one line seems a bit messy to me, especially because I would like to actually have a much longer message here to provide some more context. I thought about inserting \n line breaks, however it turns out terraform easily supports multiline strings.

message {
    body = <<EOH
    Hey Client!

    Please accept this invite to access our PowerBI-Reports! 

    Sincerely, restlessmodem
    EOH
}

I can also make sure I get a copy of every sent out invite to be confident everything went according to plan.

message {
    additional_recipients = ["mail@christopherpfister.de"]
    body = "<message>"
}

My final problem was that I still needed to inform the client users on where to find our app. The invite just sent them to the Office 365 homepage. Fortunately the redirect url is configurable.

resource "azuread_invitation" "externalClientUsers" {
    for_each = toset(var.externalClientUsers)
    user_email_address = each.key
    redirect_url       = "<link to our PowerBI app>"

    message {
        additional_recipients = ["mail@christopherpfister.de"]
        body = "<message>"
    }
}

Oh no! Something doesn't work!

Applying the changes fully expecting this to work, I was suddenly greeted with an error. At the same time Azure actually sent out the invite email as confirmed by a notification on my phone at the same time. The error message was:

Error: Failed to patch guest user after creating invitation

What went wrong? After discovering this issue on Github I realised it seems like not only the User.Invite.All permission was required but also write access to all users!
As we are part of a medium-sized corporation, we are (understandably) not granted such a broad access for a single service account that should not need it.

What do we do now? The user has been invited successfully, however terraform still things it has to do that again. Why? Because it has marked the resource as tainted meaning it is a sick little resource that needs help, in this case help is recreation.

As a solution I suggest a messy fix: We can untaint resources manually, either though the terraform cli, or if we are feeling brave and know there should not be any tainted modules in our configuration

sed -i '' "/\"status\": \"tainted\"\,/d" terraform.tfstate

with one line we can untaint them all.