Continuous Integration and Continuous Deployment with JetBrains Space

I was recently asked to implement a simple Continuous Integration (CI) and Continuous Deployment (CD) process for a customer. The aim was to set up an automated build and an automated deployment job for a Blazor application, whereby the deployment job includes the execution of the Infrastructure as Code (IaC) changes and EFCore migrations. I have already implemented such scenarios several times and especially in different tools:

  • Azure DevOps (YAML pipelines)
  • GitHub Actions
  • JetBrains TeamCity
  • Jenkins
  • GitLab
  • Atlassian Bamboo
  • Bitbucket

The challenging part was, that the tool I needed to implement the pipelines for was JetBrains Space and I had never used it before. JetBrains advertises Space as “The Intelligent Code Collaboration Platform”. The platform provides a pretty huge bunch of features that support the development workflow. From Software Development features like source code management, code reviews, cloud development environments and automation (CI/CD) over project management features to collaboration and communication features. It’s an all in one place, extensible solution which is fully integrated with JetBrains IDEs.

I already worked with JetBrains products like IDE, ReSharper, TeamCity and PyCharm. Except TeamCity, I love all of the before mentioned products as they are reliable, performant and really developer friendly. In my opinion, back then TeamCity was a little awkward to use in comparison to other CI/CD tools – but to be fair, I have to say that TeamCity worked well too.

Curious as I am, I was looking forward to learning something new. As usual, I first took a look at the JetBrains Space documentation. As always with JetBrains, the documentation is very good. I was particularly interested in the automation feature – so the following sections are mainly dedicated to the automation function of JetBrains Space.

I started by creating the .space.kts file in the repository root and used the official .NET and .NET Core example out of the documentation as first version. After two days, many tryouts and more than hundred failed runs I ended up with the following solution.

Of course there is still a lot of room for improvement, such as moving script code to script files, but for now it’s good.

val buildContainerImage = "mcr.microsoft.com/dotnet/sdk:8.0"
val ubuntuImage = "ubuntu:22.04"

job("Continuous integration build") {
    startOn {
        gitPush {
            enabled = true

            anyBranch()
        }
    }

    requirements {
        // https://www.jetbrains.com/help/space/cloud-workers.html
        workerPool = WorkerPools.SPACE_CLOUD
        workerType = WorkerTypes.SPACE_CLOUD_UBUNTU_LTS_LARGE
    }

    container(displayName = "Build and test ExampleApplication", image = buildContainerImage) {
        // https://www.jetbrains.com/help/space/using-service-containers.html#setting-service-container-resources
        resources {
            cpu = 4.cpu
            memory = 8.gb
        }
        
        shellScript {
            content = """
                echo Run .NET build...
                dotnet build ExampleApplication.sln --configuration Release
                if [ $? -ne 0 ]; then
                    echo "Build failed"
                    exit 1
                fi

                echo Run .NET tests...
                dotnet test ExampleApplication.sln --configuration Release
                if [ $? -ne 0 ]; then
                    echo "Tests failed"
                    exit 1
                fi
            """
        }
    }
}

job("Continuous deployment") {
    startOn {
        gitPush {
            enabled = true

            anyBranchMatching {
                +"dev"
                +"test"
                +"main"
            }
        }
    }

    requirements {
        // https://www.jetbrains.com/help/space/cloud-workers.html
        workerPool = WorkerPools.SPACE_CLOUD
        workerType = WorkerTypes.SPACE_CLOUD_UBUNTU_LTS_LARGE
    }

    container(displayName = "Build, test and publish ExampleApplication", image = buildContainerImage) {
        // https://www.jetbrains.com/help/space/using-service-containers.html#setting-service-container-resources
        resources {
            cpu = 4.cpu
            memory = 8.gb
        }
        
        shellScript {
            content = """
                echo Run .NET build...
                dotnet build ExampleApplication.sln --configuration Release
                if [ $? -ne 0 ]; then
                    echo "Build failed"
                    exit 1
                fi

                echo Run .NET tests...
                dotnet test ExampleApplication.sln --configuration Release
                if [ $? -ne 0 ]; then
                    echo "Tests failed"
                    exit 1
                fi

                echo Run .NET publish...
                dotnet publish src/Server/ExampleApplication.Server.csproj --configuration Release --output build/publish/app/ --no-build
                if [ $? -ne 0 ]; then
                    echo "Publishing ExampleApplication failed"
                    exit 1
                fi

                echo Run .NET publish...
                dotnet publish src/Domain.Migrations/ExampleApplication.Domain.Migrations.csproj --configuration Release --output build/publish/migrations/ --no-build
                if [ $? -ne 0 ]; then
                    echo "Publishing ExampleApplication.Domain.Migrations failed"
                    exit 1
                fi
            """
        }

        fileArtifacts {
            localPath = "build/publish/app/"
            // Fail job if build/publish/app/ is not found
            optional = false
            remotePath = "{{ run:number }}/ExampleApplication.gz"
            archive = true
            onStatus = OnStatus.SUCCESS
        }

        fileArtifacts {
            localPath = "build/publish/migrations/"
            // Fail job if build/publish/migrations/ is not found
            optional = false
            remotePath = "{{ run:number }}/ExampleApplicationMigrations.gz"
            archive = true
            onStatus = OnStatus.SUCCESS
        }
    }

    host(displayName = "Create job parameters") {
        env["DB_CONNECTION_STRING_DEV"] = "{{ project:db_connection_string_dev }}"
        env["AZURE_CLIENT_ID_DEV"] = "{{ project:az_clientid_dev }}"
        env["AZURE_CLIENT_SECRET_DEV"] = "{{ project:az_client_secret_dev }}"

        kotlinScript { api ->
            api.parameters["Environment"] = when (api.gitBranch()) {
                "refs/heads/main" -> "prod"
                "refs/heads/test" -> "test"
                "refs/heads/dev" -> "dev"
                else -> ""
            }

            api.parameters["EnvironmentCode"] = when (api.gitBranch()) {
                "refs/heads/main" -> "p1"
                "refs/heads/test" -> "t1"
                "refs/heads/dev" -> "d1"
                else -> ""
            }

            api.parameters["AzureTenant"] = when (api.gitBranch()) {
                "refs/heads/main" -> "PROD_TENANT_ID_HERE"
                "refs/heads/test" -> "TEST_TENANT_ID_HERE"
                "refs/heads/dev" -> "DEV_TENANT_ID_HERE"
                else -> ""
            }

            api.parameters["AzureClientId"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("AZURE_CLIENT_ID_DEV")
                else -> ""
            }

            api.parameters["AzureClientSecret"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("AZURE_CLIENT_SECRET_DEV")
                else -> ""
            }

            api.parameters["DbConnectionString"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("DB_CONNECTION_STRING_DEV")
                else -> ""
            }
        }
    }

    container(displayName = "Start deployment", image = "amazoncorretto:17-alpine") {
        env["TARGET_ENV"] = "{{ Environment }}"

        kotlinScript { api ->
            api.space().projects.automation.deployments.start(
                project = api.projectIdentifier(),
                targetIdentifier = TargetIdentifier.Key(System.getenv("TARGET_ENV")),
                version = System.getenv("JB_SPACE_EXECUTION_NUMBER"),
                syncWithAutomationJob = true
            )
        }
    }

    host(displayName = "Run terraform apply and apply EF Core migrations") {
        // https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/service_principal_client_secret
        env["ARM_CLIENT_ID"] = "{{ AzureClientId }}"
        env["ARM_CLIENT_SECRET"] = "{{ AzureClientSecret }}"
        env["ARM_TENANT_ID"] = "{{ AzureTenant }}"
        env["AZURE_CLIENT_ID"] = "{{ AzureClientId }}"
        env["AZURE_CLIENT_SECRET"] = "{{ AzureClientSecret }}"
        env["AZURE_TENANT_ID"] = "{{ AzureTenant }}"
        env["DB_CONNECTION_STRING"] = "{{ DbConnectionString }}"
        env["ENVIRONMENT"] = "{{ Environment }}"
        env["ENVIRONMENT_CODE"] = "{{ EnvironmentCode }}"

        fileInput {
            source = FileSource.FileArtifact(
                "{{ run:file-artifacts.default-repository }}/{{ run:file-artifacts.default-base-path }}",
                "{{ run:number }}/ExampleApplicationMigrations.gz",
                extract = true
            )
            localPath = "build"
        }

        shellScript {
            content = """
                # disable unattended upgrades to avoid unexpected locks
                sudo apt remove unattended-upgrade

                # Az CLI - https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt#option-1-install-with-one-command
                curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

                # PoSH - https://learn.microsoft.com/en-us/powershell/scripting/install/install-ubuntu?view=powershell-7.4
                sudo apt-get update
                sudo apt-get install -y wget apt-transport-https software-properties-common
                source /etc/os-release
                wget -q https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb
                sudo dpkg -i packages-microsoft-prod.deb
                rm packages-microsoft-prod.deb
                sudo apt-get update
                sudo apt-get install -y powershell

                # Terraform - https://computingforgeeks.com/how-to-install-terraform-on-ubuntu/
                sudo apt update
                sudo apt install  software-properties-common gnupg2 curl
                curl https://apt.releases.hashicorp.com/gpg | gpg --dearmor > hashicorp.gpg
                sudo install -o root -g root -m 644 hashicorp.gpg /etc/apt/trusted.gpg.d/
                sudo apt-add-repository "deb [arch=$(dpkg --print-architecture)] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
                sudo apt install terraform=1.6.6-*

                cd infra/iac
                terraform init -reconfigure --backend-config=./backend/${'$'}ENVIRONMENT.backend.tfvars  -no-color
                terraform workspace select ${'$'}ENVIRONMENT_CODE -no-color

                if [ "${'$'}ENVIRONMENT_CODE" == "d1" ]; then
                    terraform apply -auto-approve -var-file="./vars/${'$'}ENVIRONMENT.${'$'}ENVIRONMENT_CODE.tfvars" -no-color
                else
                    terraform apply -auto-approve -var-file="./vars/${'$'}ENVIRONMENT.${'$'}ENVIRONMENT_CODE.tfvars" -no-color -replace azuread_application_password.aadapppwd
                fi

                # .NET Core SDK - https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-2204#install-the-sdk
                wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
                sudo dpkg -i packages-microsoft-prod.deb
                rm packages-microsoft-prod.deb

                sudo apt-get update
                sudo apt-get install -y dotnet-host
                sudo apt-get install -y dotnet-sdk-8.0

                az login --service-principal -u ${'$'}AZURE_CLIENT_ID -p ${'$'}AZURE_CLIENT_SECRET --tenant ${'$'}AZURE_TENANT_ID
                cd ../../build
                dotnet "ExampleApplication.Domain.Migrations.dll" --Migration:IsProduction=true --Migration:ConnectionString="${'$'}DB_CONNECTION_STRING"
            """
        }
    }

    host(displayName = "Run deploy script") {
        fileInput {
            source = FileSource.FileArtifact(
                "{{ run:file-artifacts.default-repository }}/{{ run:file-artifacts.default-base-path }}",
                "{{ run:number }}/ExampleApplication.gz",
                extract = true
            )
            localPath = "build"
        }

        env["AZURE_CLIENT_ID"] = "{{ AzureClientId }}"
        env["AZURE_CLIENT_SECRET"] = "{{ AzureClientSecret }}"
        env["AZURE_TENANT_ID"] = "{{ AzureTenant }}"
        env["ENVIRONMENT_CODE"] = "{{ EnvironmentCode }}"

        shellScript {
            content = """
                sudo apt install  zip
                cd build
                zip -r ExampleApplication.zip .

                # Az CLI - https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt#option-1-install-with-one-command
                curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
                az login --service-principal -u ${'$'}AZURE_CLIENT_ID -p ${'$'}AZURE_CLIENT_SECRET --tenant ${'$'}AZURE_TENANT_ID
                az webapp deploy --resource-group cp-${'$'}ENVIRONMENT_CODE-rg-exampleapplication --name cp-${'$'}ENVIRONMENT_CODE-appsrv-exampleapplication --src-path ExampleApplication.zip
            """
        }
    }
}

Let’s have a look a the most important parts.

To reduce the execution time of host steps, I updated the worker requirements to large (4 vCPU and 15600 MB):

    requirements {
        // https://www.jetbrains.com/help/space/cloud-workers.html
        workerPool = WorkerPools.SPACE_CLOUD
        workerType = WorkerTypes.SPACE_CLOUD_UBUNTU_LTS_LARGE
    }

Job parameters can be used to pass data between automation steps. The host step “Create job parameters” creates such job parameters and conditionally assigns environment specific values to them. The values (mainly secrets) are stored as project-wide secrets (i.e. `az_client_secret_dev`).

    host(displayName = "Create job parameters") {
        env["DB_CONNECTION_STRING_DEV"] = "{{ project:db_connection_string_dev }}"
        env["AZURE_CLIENT_ID_DEV"] = "{{ project:az_clientid_dev }}"
        env["AZURE_CLIENT_SECRET_DEV"] = "{{ project:az_client_secret_dev }}"

        kotlinScript { api ->
            api.parameters["Environment"] = when (api.gitBranch()) {
                "refs/heads/main" -> "prod"
                "refs/heads/test" -> "test"
                "refs/heads/dev" -> "dev"
                else -> ""
            }

            api.parameters["EnvironmentCode"] = when (api.gitBranch()) {
                "refs/heads/main" -> "p1"
                "refs/heads/test" -> "t1"
                "refs/heads/dev" -> "d1"
                else -> ""
            }

            api.parameters["AzureTenant"] = when (api.gitBranch()) {
                "refs/heads/main" -> "PROD_TENANT_ID_HERE"
                "refs/heads/test" -> "TEST_TENANT_ID_HERE"
                "refs/heads/dev" -> "DEV_TENANT_ID_HERE"
                else -> ""
            }

            api.parameters["AzureClientId"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("AZURE_CLIENT_ID_DEV")
                else -> ""
            }

            api.parameters["AzureClientSecret"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("AZURE_CLIENT_SECRET_DEV")
                else -> ""
            }

            api.parameters["DbConnectionString"] = when (api.gitBranch()) {
                "refs/heads/main" -> ""
                "refs/heads/test" -> ""
                "refs/heads/dev" -> System.getenv("DB_CONNECTION_STRING_DEV")
                else -> ""
            }
        }
    }

The last two steps took quite a lot of time and nerves. The main challenges were to install the various Linux packages correctly, to specify the paths correctly and to create the ZIP file for the deployment without zipping the build folder.

Conclusion

The idea behind JetBrains Space is great and the documentation is good. Nevertheless, I’m a little disappointed with the implementation of the CI/CD features (Automation (CI/CD)). The approach is super flexible but I miss predefined steps, tasks or containers for standard cases in context of popular languages (i.e. build .NET application or deploy .NET solution to Azure App Service) as it’s too much unnecessary effort to set up such a job like the above from scratch. To make matters worse, the community feels small and consequently there are only a few examples.

One thing I don’t like is the fact that project-wide secrets get exposed as plain text in runtime parameters of job runs if they get passed to the host as environment variable as suggested by the official docs.

Leave a Reply

Powered by WordPress.com.

Up ↑

Discover more from blog.rufer.be

Subscribe now to keep reading and get access to the full archive.

Continue reading