diff --git a/.dockerignore b/.dockerignore index 4d72b4f..c9a9cb6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,8 @@ **/npm-debug.log **/obj **/secrets.dev.yaml +**/terraform.tfstate +**/terraform.tfstate.backup **/values.dev.yaml LICENSE README.md diff --git a/ExtensionAppDataIO/Containerfile b/ExtensionAppDataIO/Containerfile new file mode 100644 index 0000000..67872e9 --- /dev/null +++ b/ExtensionAppDataIO/Containerfile @@ -0,0 +1,96 @@ +# Global arguments +ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 +ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 +ARG APP_DIR=/app +ARG SRC_DIR=/src +ARG UID=1000 +ARG GID=1000 +ARG PORT=8080 +ARG BUILD_CONFIGURATION=Release +ARG PROJECT_NAME=ExtensionAppDataIO +ARG PROJECT_FILE=ExtensionAppDataIO.csproj +ARG PROJECT_CONTEXT_DIR=ExtensionAppDataIO + +# Base stage +FROM ${RUNTIME_IMAGE} AS base +ARG APP_DIR +ARG UID +ARG GID +ARG PORT + +USER root +RUN mkdir -p ${APP_DIR} && \ + chown -R ${UID}:${GID} ${APP_DIR} + +USER ${UID}:${GID} +WORKDIR ${APP_DIR} + +ENV PORT=${PORT} +ENV ASPNETCORE_HTTP_PORTS=${PORT} +EXPOSE ${PORT} + +# Dependencies stage +FROM ${SDK_IMAGE} AS dependencies +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG PROJECT_CONTEXT_DIR + +USER root +RUN mkdir -p ${SRC_DIR}/${PROJECT_NAME} /tmp/nuget && \ + chown -R ${UID}:${GID} ${SRC_DIR} /tmp/nuget + +USER ${UID}:${GID} +WORKDIR ${SRC_DIR} + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/${PROJECT_FILE} ./${PROJECT_NAME}/ + +RUN dotnet restore ./${PROJECT_NAME}/${PROJECT_FILE} --packages /tmp/nuget + +ENV NUGET_PACKAGES=/tmp/nuget + +# Development stage +FROM dependencies AS development +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG PORT +ARG PROJECT_CONTEXT_DIR + +ENV ASPNETCORE_ENVIRONMENT=Development +ENV ASPNETCORE_URLS=http://+:${PORT} + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/ ${SRC_DIR}/${PROJECT_NAME}/ +WORKDIR ${SRC_DIR}/${PROJECT_NAME} + +CMD ["dotnet", "watch", "run", "--project", "ExtensionAppDataIO.csproj", "--no-launch-profile", "--urls", "http://+:8080"] + +# Build stage +FROM dependencies AS build +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG BUILD_CONFIGURATION +ARG PROJECT_CONTEXT_DIR + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/ ${SRC_DIR}/${PROJECT_NAME}/ +WORKDIR ${SRC_DIR}/${PROJECT_NAME} + +RUN dotnet publish ./${PROJECT_FILE} -c ${BUILD_CONFIGURATION} -o ${SRC_DIR}/publish /p:UseAppHost=false --no-restore + +# Production stage +FROM base AS production +ARG UID +ARG GID + +ENV ASPNETCORE_ENVIRONMENT=Production + +COPY --chown=${UID}:${GID} --from=build /src/publish . + +ENTRYPOINT ["dotnet", "ExtensionAppDataIO.dll"] diff --git a/ExtensionAppDataIO/Dockerfile b/ExtensionAppDataIO/Dockerfile index f5cf58a..9ae0b9e 100644 --- a/ExtensionAppDataIO/Dockerfile +++ b/ExtensionAppDataIO/Dockerfile @@ -1,30 +1,96 @@ -# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. +# Global arguments +ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 +ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 +ARG APP_DIR=/app +ARG SRC_DIR=/src +ARG UID=1000 +ARG GID=1000 +ARG PORT=8080 +ARG BUILD_CONFIGURATION=Release +ARG PROJECT_NAME=ExtensionAppDataIO +ARG PROJECT_FILE=ExtensionAppDataIO.csproj +ARG PROJECT_CONTEXT_DIR=ExtensionAppDataIO -# This stage is used when running from VS in fast mode (Default for Debug configuration) -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 +# Base stage +FROM ${RUNTIME_IMAGE} AS base +ARG APP_DIR +ARG UID +ARG GID +ARG PORT +USER root +RUN mkdir -p ${APP_DIR} && \ + chown -R ${UID}:${GID} ${APP_DIR} -# This stage is used to build the service project -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["ExtensionAppDataIO/ExtensionAppDataIO.csproj", "ExtensionAppDataIO/"] -RUN dotnet restore "./ExtensionAppDataIO/ExtensionAppDataIO.csproj" -COPY . . -WORKDIR "/src/ExtensionAppDataIO" -RUN dotnet build "./ExtensionAppDataIO.csproj" -c $BUILD_CONFIGURATION -o /app/build - -# This stage is used to publish the service project to be copied to the final stage -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./ExtensionAppDataIO.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +USER ${UID}:${GID} +WORKDIR ${APP_DIR} + +ENV PORT=${PORT} +ENV ASPNETCORE_HTTP_PORTS=${PORT} +EXPOSE ${PORT} + +# Dependencies stage +FROM ${SDK_IMAGE} AS dependencies +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG PROJECT_CONTEXT_DIR + +USER root +RUN mkdir -p ${SRC_DIR}/${PROJECT_NAME} /tmp/nuget && \ + chown -R ${UID}:${GID} ${SRC_DIR} /tmp/nuget + +USER ${UID}:${GID} +WORKDIR ${SRC_DIR} + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/${PROJECT_FILE} ./${PROJECT_NAME}/ + +RUN dotnet restore ./${PROJECT_NAME}/${PROJECT_FILE} --packages /tmp/nuget + +ENV NUGET_PACKAGES=/tmp/nuget + +# Development stage +FROM dependencies AS development +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG PORT +ARG PROJECT_CONTEXT_DIR + +ENV ASPNETCORE_ENVIRONMENT=Development +ENV ASPNETCORE_URLS=http://+:${PORT} + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/ ${SRC_DIR}/${PROJECT_NAME}/ +WORKDIR ${SRC_DIR}/${PROJECT_NAME} + +CMD ["dotnet", "watch", "run", "--project", "ExtensionAppDataIO.csproj", "--no-launch-profile", "--urls", "http://+:8080"] + +# Build stage +FROM dependencies AS build +ARG SRC_DIR +ARG UID +ARG GID +ARG PROJECT_NAME +ARG PROJECT_FILE +ARG BUILD_CONFIGURATION +ARG PROJECT_CONTEXT_DIR + +COPY --chown=${UID}:${GID} ${PROJECT_CONTEXT_DIR}/ ${SRC_DIR}/${PROJECT_NAME}/ +WORKDIR ${SRC_DIR}/${PROJECT_NAME} + +RUN dotnet publish ./${PROJECT_FILE} -c ${BUILD_CONFIGURATION} -o ${SRC_DIR}/publish /p:UseAppHost=false --no-restore + +# Production stage +FROM base AS production +ARG UID +ARG GID + +ENV ASPNETCORE_ENVIRONMENT=Production + +COPY --chown=${UID}:${GID} --from=build /src/publish . -# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "ExtensionAppDataIO.dll"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0b7192 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +name: extension-app-data-io-csharp + +services: + dataio-dev: + profiles: ["dev"] + build: + context: . + dockerfile: ExtensionAppDataIO/Dockerfile + target: development + args: + PORT: 8080 + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://+:8080 + ASPNETCORE_HTTP_PORTS: "8080" + AuthSettings__JwtSecretKey: ${AUTH_JWT_SECRET_KEY:-b48f0046467d1d2ef8f6c2a0a4007e596199b43fd491f1a2f223c58686df3b1a6c0e295dadc80811cf633981d1cbf36560e0ee91f87de36e27acd98306ebeff7} + AuthSettings__OAuthClientId: ${AUTH_OAUTH_CLIENT_ID:-31d040b29254174fb3afe9720c0dcfca43662f7d35eb9b17f0eb20456701515c789d33ee78acd3cfdf75d0a9cd3cdf7dd0866b8ef022f98caf6b7a2929fd25e9} + AuthSettings__OAuthClientSecret: ${AUTH_OAUTH_CLIENT_SECRET:-46c907643b229e602e5e2121adb3f0fdfb4b1ba0502b8da7ff41faf14477253e4049c33e99e8b4c7a23b3fbcf67b4c8d74244e5e1f88a43c324cd8dae960e991} + AuthSettings__AuthorizationCode: ${AUTH_AUTHORIZATION_CODE:-4cf62718907d84bc5874d4d128403072a3acfaf87df935bc65459684306a2b0fab52b7de5850b176522ad2e330d7ff21f3bd47b60cf13d046d36813570abc834} + ports: + - "5245:8080" + volumes: + - ./ExtensionAppDataIO:/src/ExtensionAppDataIO + - nuget_cache:/tmp/nuget + + dataio-prod: + profiles: ["prod"] + build: + context: . + dockerfile: ExtensionAppDataIO/Dockerfile + target: production + args: + PORT: 8080 + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://+:8080 + ASPNETCORE_HTTP_PORTS: "8080" + AuthSettings__JwtSecretKey: ${AUTH_JWT_SECRET_KEY:-b48f0046467d1d2ef8f6c2a0a4007e596199b43fd491f1a2f223c58686df3b1a6c0e295dadc80811cf633981d1cbf36560e0ee91f87de36e27acd98306ebeff7} + AuthSettings__OAuthClientId: ${AUTH_OAUTH_CLIENT_ID:-31d040b29254174fb3afe9720c0dcfca43662f7d35eb9b17f0eb20456701515c789d33ee78acd3cfdf75d0a9cd3cdf7dd0866b8ef022f98caf6b7a2929fd25e9} + AuthSettings__OAuthClientSecret: ${AUTH_OAUTH_CLIENT_SECRET:-46c907643b229e602e5e2121adb3f0fdfb4b1ba0502b8da7ff41faf14477253e4049c33e99e8b4c7a23b3fbcf67b4c8d74244e5e1f88a43c324cd8dae960e991} + AuthSettings__AuthorizationCode: ${AUTH_AUTHORIZATION_CODE:-4cf62718907d84bc5874d4d128403072a3acfaf87df935bc65459684306a2b0fab52b7de5850b176522ad2e330d7ff21f3bd47b60cf13d046d36813570abc834} + ports: + - "8080:8080" + restart: unless-stopped + +volumes: + nuget_cache: diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..59dc2d2 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,110 @@ +# Created by https://www.toptal.com/developers/gitignore/api/terraform,osx,linux,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=terraform,osx,linux,windows + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### OSX ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Terraform ### +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/terraform,osx,linux,windows \ No newline at end of file diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..f795203 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,263 @@ +# Deploying an extension app to the cloud with Terraform + +Hashicorp Terraform is an Infrastructure as code (IaC) tool that lets you provision and manage cloud infrastructure. Terraform provides plugins called providers that allow you to interact with cloud providers and other APIs (e.g. Docker). + +This guide describes how to use Terraform to deploy your extension app to different cloud providers: +- [Amazon Web Services](https://aws.amazon.com/) +- [Microsoft Azure](https://azure.microsoft.com/) +- [Google Cloud Platform](https://cloud.google.com/) + +## Prerequisites + +To get started, you need to: +- **Install any container management tool**: Make sure that you have a container management tool installed on your local machine. You can download it from: + - [Docker Desktop](https://docs.docker.com/desktop/) based on [Docker](https://docs.docker.com/engine/install/) + - **Note:** See [What else should I check if I run Docker Desktop?](#what-else-should-i-check-if-i-run-docker-desktop) + - [Rancher Desktop](https://docs.rancherdesktop.io/getting-started/installation/) based on [Moby](https://mobyproject.org/) + - [Podman Desktop](https://podman-desktop.io/docs/installation) based on [Podman](https://podman.io/) +- **Install Terraform**: Make sure that you have Terraform installed on your local machine. You can download it from the [official Terraform website](https://developer.hashicorp.com/terraform/install). +- **Meet the specific cloud prerequisites**: + - [AWS](aws/README.md#specific-cloud-prerequisites) + - [Azure](azure/README.md#specific-cloud-prerequisites) + - [GCP](gcp/README.md#specific-cloud-prerequisites) + +## Deploying resources + +To deploy your extension app using Terraform, follow these steps: + +1. **Navigate to the directory of the cloud provider you want to deploy to:** + ```sh + cd aws # For Amazon Web Services + cd azure # For Microsoft Azure + cd gcp # For Google Cloud Platform + ``` + +1. **Initialize Terraform:** + ```sh + terraform init -upgrade + ``` + + _Key points_: + + - The `-upgrade` parameter upgrades the necessary provider plugins to the newest version that complies with the configuration's version constraints. + +1. **Generate and review the execution plan:** + ```sh + terraform plan -out app.tfplan + ``` + + _Key points_: + + - The `terraform plan` command creates an execution plan but doesn't execute it. Instead, it determines the necessary actions to create the configuration specified in your configuration files. This pattern allows you to verify whether the execution plan matches your expectations before making any changes to actual resources. + - The optional `-out` parameter allows you to specify an output file for the plan. Using the `-out` parameter ensures that the plan you reviewed is exactly what is applied. + +1. **Apply the configuration to provision the infrastructure:** + ```sh + terraform apply app.tfplan + ``` + + _Key points_: + + - The example `terraform apply` command assumes you previously ran `terraform plan -out app.tfplan`. + - If you specified a different filename for the `-out` parameter, use that same filename in the call to `terraform apply`. + - If you didn't use the `-out` parameter, call `terraform apply` without any parameters. + - You may add the `-auto-approve` parameter to automatically confirm. If not please type `yes` when prompted. + +Following these steps will deploy your extension app to the selected cloud provider using Terraform. + +## Getting results + +After running the `terraform apply` command and confirming the action, Terraform will begin provisioning the infrastructure as defined in configuration files. This process involves: + +1. **Creating Resources:** Terraform will create the necessary resources (e.g., Docker image, cloud container registry, cloud web application service, etc.) by making API calls to providers. +1. **Configuring Resources:** Terraform will configure the resources according to the specifications in the configuration files. +1. **Updating State File:** Terraform will update the state file to reflect the current state of the infrastructure. This file is crucial for tracking the resources and their configurations. + +Once the process is complete, Terraform will output the results, including the cloud web application service URL and paths for the generated manifests. Upload these manifests to the [DocuSign Developer Console](https://devconsole.docusign.com/apps) to add your extension app. You can always get the results later by running the `terraform output` command. + +## Cleaning up + +When you no longer need the resources created via Terraform, do the following steps: + +1. **Generate and review the execution plan:** + + ```sh + terraform plan -destroy -out app.destroy.tfplan + ``` + + _Key points_: + + - The `-destroy` parameter activates destroy mode that creates a plan whose goal is to destroy all remote objects that currently exist, leaving an empty Terraform state. It is the same as running `terraform destroy`. Destroy mode can be useful for situations like transient development environments, where the managed objects cease to be useful once the development task is complete. + +1. **Apply the configuration to remove the infrastructure:** + + ```sh + terraform apply app.destroy.tfplan + ``` + + _Key points_: + + - Optionally, you can run `terraform destroy` instead of generating and applying the destroy plan in the above two steps. + - You may add `-auto-approve` parameter to automatically confirm. If not please type `yes` when prompted. + + +## Benefits of using Terraform + +This section explains some of the benefits of using Terraform to provision and manage extension app cloud deployment: +- Terraform is the most commonly used tool to provision and automate cloud infrastructure. You can use the different cloud providers to configure and manage all cloud resources using the same declarative syntax and tooling. +- Terraform lets you specify your preferred end state for your infrastructure. You can then deploy the same configuration multiple times to create reproducible development, test, and production environments. +- Terraform lets you generate an execution plan that shows what Terraform will do when you apply your configuration. This lets you avoid any surprises when you modify your infrastructure through Terraform. +- Terraform lets you package and reuse common code in the form of [modules](https://developer.hashicorp.com/terraform/language/modules). Modules present standard interfaces for creating resources. They simplify projects by increasing readability and allow teams to organize infrastructure in readable blocks. +- Terraform records the current state of your infrastructure and lets you manage the state effectively. The Terraform state file keeps track of all resources in a deployment. + +## Using Terraform + +Terraform has a declarative and configuration-oriented syntax, which you can use to [author the infrastructure](https://developer.hashicorp.com/terraform/language) that you want to provision. Using this syntax, you'll define your preferred end-state for your infrastructure in a _Terraform configuration file_. You'll then use the [Terraform CLI](https://developer.hashicorp.com/terraform/cli/commands) to provision infrastructure based on the configuration file. + +The following steps explain how Terraform works: + +1. You describe the cloud infrastructure you want to provision in a Terraform configuration file. You don't need to author code describing _how_ to provision this configuration. +1. You run the `terraform plan` command, which evaluates your configuration and generates an execution plan. You can review the plan and make changes as needed. +1. Then, you run the `terraform apply` command, which performs the following actions: + - It provisions your infrastructure based on your execution plan by invoking the corresponding APIs in the background. + - It creates a _Terraform state file_, which is a JSON formatted mapping of resources in your configuration file to the resources in the real-world infrastructure. Terraform uses this file to know the latest state of your infrastructure, and to determine when to create, update, and destroy resources. +1. Subsequently, when you run `terraform apply`, Terraform uses the mapping in the state file to compare the existing infrastructure to the code, and make updates as necessary: + - If a resource object defined in the configuration file does not exist in the state file, Terraform creates it. + - If a resource object exists in the state file, but has a different configuration from your configuration file, Terraform updates the resource to match your configuration file. + - If a resource object in the state file matches your configuration file, Terraform leaves the resource unchanged. + +## Directory structure + +The directory structure is organized to support deploying the extension app to different cloud providers using Terraform. Here's an overview of the structure: + +- `README.md`: The main documentation file. +- `aws/`: Contains Terraform configuration files for deploying to Amazon Web Services. + - `README.md`: Documentation specific to AWS deployment. + - `apprunner.tf`, `ecr.tf`, `image.tf`, `main.tf`, `outputs.tf`, `providers.tf`, `terraform.tf`, `variables.tf`: Various Terraform configuration files for AWS resources. +- `azure/`: Contains Terraform configuration files for deploying to Microsoft Azure. + - `README.md`: Documentation specific to Azure deployment. + - `acr.tf`, `image.tf`, `main.tf`, `outputs.tf`, `providers.tf`, `resource_group.tf`, `terraform.tf`, `variables.tf`, `webapp.tf`: Various Terraform configuration files for Azure resources. +- `gcp/`: Contains Terraform configuration files for deploying to the Google Cloud Platform. + - `README.md`: Documentation specific to GCP deployment. + - `cloudrun.tf`, `gar.tf`, `image.tf`, `main.tf`, `outputs.tf`, `providers.tf`, `sa.tf`, `terraform.tf`, `variables.tf`: Various Terraform configuration files for GCP resources. +- `common/`: Contains reusable modules that can be shared across different cloud providers. + - `modules/`: Directory for common modules. + - `docker/`: Module for Docker-related resources. + - `README.md`: Documentation specific to the `docker` module. + - `main.tf`, `outputs.tf`, `terraform.tf`, `variables.tf`: Terraform configuration files for the `docker` module. + - `generate/`: Module for generating secret values. + - `README.md`: Documentation specific to the `generate` module. + - `main.tf`, `outputs.tf`, `terraform.tf`, `variables.tf`: Terraform configuration files for the `generate` module. + - `template/`: Module for templating manifests. + - `README.md`: Documentation specific to the `template` module + - `main.tf`, `outputs.tf`, `terraform.tf`, `variables.tf`: Terraform configuration files for the `template` module. + +``` +├── README.md +├── aws +│   ├── README.md +│   ├── apprunner.tf +│   ├── ecr.tf +│   ├── image.tf +│   ├── main.tf +│   ├── outputs.tf +│   ├── providers.tf +│   ├── terraform.tf +│   └── variables.tf +├── azure +│   ├── README.md +│   ├── acr.tf +│   ├── image.tf +│   ├── main.tf +│   ├── outputs.tf +│   ├── providers.tf +│   ├── resource_group.tf +│   ├── terraform.tf +│   ├── variables.tf +│   └── webapp.tf +├── common +│   └── modules +│   ├── docker +│   │   ├── README.md +│   │   ├── main.tf +│   │   ├── outputs.tf +│   │   ├── terraform.tf +│   │   └── variables.tf +│   ├── generate +│   │   ├── README.md +│   │   ├── main.tf +│   │   ├── outputs.tf +│   │   ├── terraform.tf +│   │   └── variables.tf +│   └── template +│   ├── README.md +│   ├── main.tf +│   ├── outputs.tf +│   ├── terraform.tf +│   └── variables.tf +└── gcp + ├── README.md + ├── cloudrun.tf + ├── gar.tf + ├── image.tf + ├── main.tf + ├── outputs.tf + ├── providers.tf + ├── sa.tf + ├── terraform.tf + └── variables.tf +``` + +## Frequently asked questions + +### What if I use a non-standard Docker daemon socket (Moby, Podman, etc.)? + +You can configure Terraform to use a non-standard Docker daemon socket by setting the `DOCKER_HOST` environment variable or by assigning the `docker_host` Terraform variable. For example, if you are using Podman, you can set the `DOCKER_HOST` to point to the Podman socket: + +```sh +export DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock +``` + +Make sure to replace the socket path with the correct path for your environment. This will direct Terraform to use the specified Docker daemon socket for all Docker-related operations. + + +If you use Docker Desktop on Windows, you need to enable the TCP socket to allow Terraform to communicate with Docker. Follow these steps: + +1. Open Docker Desktop and go to the **Settings**. +1. Navigate to the [**General**](https://docs.docker.com/desktop/settings-and-maintenance/settings/#general) tab. +1. Enable the option **Expose daemon on tcp://localhost:2375 without TLS**. +1. Apply the changes and restart Docker Desktop if necessary. + +After enabling the TCP socket, set the `DOCKER_HOST` environment variable to point to the TCP socket: + +```powershell +$env:DOCKER_HOST="tcp://localhost:2375" +``` + +This configuration will allow Terraform to interact with Docker Desktop on Windows. + +### What else should I check if I run Docker Desktop? + +If you run Docker Desktop, ensure the following: + +1. **Use containerd for pulling and storing images**: Turn off the containerd image store. It should bring new features like faster container startup performance by lazy-pulling images, and the ability to run Wasm applications with Docker, but unfortunately `docker` Terraform provider [doesn't support it](https://github.com/kreuzwerker/terraform-provider-docker/issues/534#issuecomment-1483798237). You can check this in Docker Desktop settings under the [**General**](https://docs.docker.com/desktop/settings-and-maintenance/settings/#general) tab. + +1. **WSL 2 Backend:** Verify that Docker Desktop is configured to use the WSL 2 backend for better performance and compatibility. You can check this in Docker Desktop settings under the [**General**](https://docs.docker.com/desktop/settings-and-maintenance/settings/#general) tab. + +1. **Resource Allocation:** Adjust the resource allocation for Docker Desktop to make sure it has enough CPU and memory to run your containers efficiently. This can be configured in the [**Resources**](https://docs.docker.com/desktop/settings-and-maintenance/settings/#resources) tab of Docker Desktop settings. + +1. **Network Configuration:** If you encounter network issues, check the network settings in Docker Desktop. Make sure that the network mode is correctly configured and that there are no conflicts with other network adapters. + +1. **Windows Firewall:** Make sure that Windows Firewall or any other security software is not blocking Docker's network traffic. You may need to create exceptions for Docker. + +By checking these settings, you can ensure that Docker Desktop runs smoothly on your machine. + +### What if I use Podman as a container tool? + +If you use Podman as a container tool, you can configure Terraform to use it by [setting](https://developer.hashicorp.com/terraform/language/values/variables#assigning-values-to-root-module-variables) the `container_tool` Terraform variable to `podman`. For instance: + +```sh +export TF_VAR_container_tool=podman +``` + +This will ensure that Terraform uses `podman` binary for all container-related operations. diff --git a/terraform/aws/README.md b/terraform/aws/README.md new file mode 100644 index 0000000..56d8bf2 --- /dev/null +++ b/terraform/aws/README.md @@ -0,0 +1,168 @@ +# Terraform configuration for deploying the C# reference implementation to AWS + +This Terraform root deploys the C# Data IO reference implementation to AWS App Runner. It builds the application image from `ExtensionAppDataIO/Containerfile`, pushes it to Amazon ECR, provisions the App Runner service, and renders hosted manifest files into `.terraform/` for later upload to the DocuSign Developer Console. + +## Specific cloud prerequisites + +Before deploying on AWS, complete the following setup: + +1. Sign in to an AWS account that can create App Runner, ECR, and IAM resources. +1. Install the AWS CLI. +1. Configure AWS credentials locally: + + ```sh + aws configure + ``` + +1. Install Terraform. +1. Install and start Docker so Terraform can build and push the application image. + +If you prefer Podman, expose a Docker-compatible API endpoint and pass that endpoint through the `docker_host` Terraform input. This configuration still uses the Docker provider and related Docker resources, so Podman is not a drop-in local runtime. + +The Terraform AWS provider can use the shared AWS credentials created by `aws configure` or any other supported AWS authentication method. + +## What this configuration deploys + +The AWS Terraform root provisions: + +- An Amazon ECR repository for the application image +- An AWS App Runner service for the C# web application +- Generated hosted manifest files written into `.terraform/` + +The containerized application listens on port `8080` inside the container. App Runner is configured with `ASPNETCORE_URLS=http://+:8080` so the deployed service binds to the expected port. + +## Application sources used by Terraform + +Terraform builds from the repository root and uses `ExtensionAppDataIO/Containerfile` as the container build file. + +The generated hosted manifests are based on these source manifest trees: + +- `manifests/authorizationCode/ReadOnlyManifest.json` +- `manifests/authorizationCode/ReadWriteManifest.json` +- `manifests/clientCredentials/ReadOnlyManifest.json` +- `manifests/clientCredentials/ReadWriteManifest.json` + +During `terraform apply`, the rendered hosted manifests are emitted into `.terraform/` with filenames that include the manifest source directory name. + +## Runtime configuration injected by Terraform + +Terraform injects the ASP.NET Core authentication settings that the C# app binds from configuration: + +- `AuthSettings__JwtSecretKey` +- `AuthSettings__OAuthClientId` +- `AuthSettings__OAuthClientSecret` +- `AuthSettings__AuthorizationCode` + +If you do not provide explicit values for these variables, Terraform generates them for the App Runner runtime configuration. + +The generated hosted manifest files are templated separately. They only substitute these hosted manifest placeholders: + +- `CLIENT_ID` +- `CLIENT_SECRET` +- `PROXY_BASE_URL` + +The hosted manifest outputs do not receive `AuthSettings__JwtSecretKey` or `AuthSettings__AuthorizationCode` values directly. + +## Deploying + +From the C# repository root: + +```sh +cd terraform/aws +terraform init -upgrade +terraform plan -out app.tfplan +terraform apply app.tfplan +``` + +Use `terraform output` after apply to retrieve: + +- `application_service_url` +- `output_manifest_files_paths` + +Upload the generated hosted manifest files from `.terraform/` to the DocuSign Developer Console. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.0, < 2.0.0 | +| [aws](#requirement\_aws) | ~> 5.0 | +| [docker](#requirement\_docker) | ~> 3.0 | +| [local](#requirement\_local) | ~> 2.5 | +| [random](#requirement\_random) | ~> 3.6 | +| [time](#requirement\_time) | ~> 0.12 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | ~> 5.0 | +| [time](#provider\_time) | ~> 0.12 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [generate\_authorization\_code](#module\_generate\_authorization\_code) | ../common/modules/generate | n/a | +| [generate\_jwt\_secret\_key](#module\_generate\_jwt\_secret\_key) | ../common/modules/generate | n/a | +| [generate\_oauth\_client\_id](#module\_generate\_oauth\_client\_id) | ../common/modules/generate | n/a | +| [generate\_oauth\_client\_secret](#module\_generate\_oauth\_client\_secret) | ../common/modules/generate | n/a | +| [image](#module\_image) | ../common/modules/docker | n/a | +| [manifest](#module\_manifest) | ../common/modules/template | n/a | + +## Resources + +| Name | Type | +|------|------| +| [aws_apprunner_service.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apprunner_service) | resource | +| [aws_ecr_repository.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository) | resource | +| [aws_ecr_repository_policy.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository_policy) | resource | +| [aws_iam_role.access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.instance](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.apprunner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [time_sleep.access_iam_role_propagation](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_ecr_authorization_token.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ecr_authorization_token) | data source | +| [aws_iam_policy_document.app_role_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.apprunner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ecr](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [application\_authorization\_code](#input\_application\_authorization\_code) | The authorization code for the application. If empty, a random code will be generated. | `string` | `""` | no | +| [application\_build\_base\_image\_name](#input\_application\_build\_base\_image\_name) | Deprecated compatibility alias for the runtime base image. If non-empty, it overrides application_build_runtime_image_name. | `string` | `""` | no | +| [application\_build\_context](#input\_application\_build\_context) | The relative path to the build context for the application. The build context is the directory from which the Dockerfile is read. If it is empty the current working directory will be used. | `string` | `"../.."` | no | +| [application\_build\_image\_tag](#input\_application\_build\_image\_tag) | The tag to apply to the application build image. If empty the timestamp tag will be used. | `string` | `""` | no | +| [application\_build\_labels](#input\_application\_build\_labels) | The labels to apply to the application build image | `map(string)` |
{
"org.opencontainers.image.authors": "DocuSign Inc.",
"org.opencontainers.image.description": "C# reference implementation for data input and output extension app workflows.",
"org.opencontainers.image.licenses": "MIT",
"org.opencontainers.image.source": "https://github.com/docusign/extension-app-data-io-reference-implementation-csharp",
"org.opencontainers.image.title": "Data IO Extension App Reference Implementation (C#)",
"org.opencontainers.image.vendor": "DocuSign Inc."
}
| no | +| [application\_build\_paths](#input\_application\_build\_paths) | Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset). | `list(string)` |
[
".dockerignore",
"ExtensionAppDataIO/**/*.cs",
"ExtensionAppDataIO/**/*.cshtml",
"ExtensionAppDataIO/**/*.json",
"ExtensionAppDataIO/**/*.http",
"ExtensionAppDataIO/Controllers/**",
"ExtensionAppDataIO/Data/**",
"ExtensionAppDataIO/DataModels/**",
"ExtensionAppDataIO/Models/**",
"ExtensionAppDataIO/Properties/**",
"ExtensionAppDataIO/Services/**",
"ExtensionAppDataIO/Views/**",
"ExtensionAppDataIO/wwwroot/**",
"ExtensionAppDataIO/ExtensionAppDataIO.csproj",
"ExtensionAppDataIO/Containerfile",
"manifests/**"
]
| no | +| [application\_build\_runtime\_image\_name](#input\_application\_build\_runtime\_image\_name) | The runtime image to use for the final application container stage | `string` | `"mcr.microsoft.com/dotnet/aspnet:10.0"` | no | +| [application\_build\_sdk\_image\_name](#input\_application\_build\_sdk\_image\_name) | The SDK image to use for the application build stages | `string` | `"mcr.microsoft.com/dotnet/sdk:10.0"` | no | +| [application\_environment\_mode](#input\_application\_environment\_mode) | The environment mode for the application | `string` | `"production"` | no | +| [application\_instance\_cpu](#input\_application\_instance\_cpu) | The number of CPU units to allocate to the application instance | `string` | `"256"` | no | +| [application\_instance\_memory](#input\_application\_instance\_memory) | The amount of memory to allocate to the application instance | `string` | `"512"` | no | +| [application\_jwt\_secret\_key](#input\_application\_jwt\_secret\_key) | The secret key to use for signing JWT tokens. If empty, a random key will be generated. | `string` | `""` | no | +| [application\_name](#input\_application\_name) | The name of the application | `string` | `"extension-app-data-io-cs"` | no | +| [application\_oauth\_client\_id](#input\_application\_oauth\_client\_id) | The OAuth client ID for the application. If empty, a random client ID will be generated. | `string` | `""` | no | +| [application\_oauth\_client\_secret](#input\_application\_oauth\_client\_secret) | The OAuth client secret for the application. If empty, a random client secret will be generated. | `string` | `""` | no | +| [application\_port](#input\_application\_port) | The port the application listens on | `number` | `8080` | no | +| [container\_tool](#input\_container\_tool) | The container tool to use for building and pushing images | `string` | `"docker"` | no | +| [do\_force\_delete\_repository](#input\_do\_force\_delete\_repository) | Whether to delete the ECR repository even if it contains images | `bool` | `true` | no | +| [do\_scan\_images](#input\_do\_scan\_images) | Whether images are scanned after being pushed to the ECR repository | `bool` | `true` | no | +| [docker\_host](#input\_docker\_host) | The Docker host (e.g. 'tcp://127.0.0.1:2376' or 'unix:///var/run/docker.sock') to connect to. If empty, the default Docker host will be used | `string` | `null` | no | +| [manifest\_files\_paths](#input\_manifest\_files\_paths) | The list of manifest files relative paths to generate | `list(string)` |
[
"../../manifests/authorizationCode/ReadOnlyManifest.json",
"../../manifests/authorizationCode/ReadWriteManifest.json",
"../../manifests/clientCredentials/ReadOnlyManifest.json",
"../../manifests/clientCredentials/ReadWriteManifest.json"
]
| no | +| [output\_manifest\_files\_directory](#input\_output\_manifest\_files\_directory) | The directory to output the generated manifest files | `string` | `".terraform"` | no | +| [region](#input\_region) | The AWS region | `string` | `"us-east-1"` | no | +| [repository\_image\_tag\_mutability](#input\_repository\_image\_tag\_mutability) | The image tag mutability setting for the ECR repository | `string` | `"MUTABLE"` | no | +| [tags](#input\_tags) | A map of the tags to apply to various resources | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [application\_service\_url](#output\_application\_service\_url) | The base URL of the application service | +| [output\_manifest\_files\_paths](#output\_output\_manifest\_files\_paths) | The absolute paths to the output manifest files | + diff --git a/terraform/aws/apprunner.tf b/terraform/aws/apprunner.tf new file mode 100644 index 0000000..75b636e --- /dev/null +++ b/terraform/aws/apprunner.tf @@ -0,0 +1,88 @@ +locals { + iam_role_name_separator = "-" + application_service_protocol = "https" + application_service_url = join("://", [local.application_service_protocol, aws_apprunner_service.this.service_url]) +} + +data "aws_iam_policy_document" "app_role_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["tasks.apprunner.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "instance" { + name = join(local.iam_role_name_separator, compact([var.application_name, local.region, "instance"])) + assume_role_policy = data.aws_iam_policy_document.app_role_assume_role_policy.json +} + +data "aws_iam_policy_document" "apprunner" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = [ + "build.apprunner.amazonaws.com", + "tasks.apprunner.amazonaws.com", + ] + } + } +} + +resource "aws_iam_role" "access" { + name = join(local.iam_role_name_separator, [var.application_name, local.region, "access"]) + assume_role_policy = data.aws_iam_policy_document.apprunner.json +} + +# workaround for https://github.com/hashicorp/terraform-provider-aws/issues/6566 +resource "time_sleep" "access_iam_role_propagation" { + create_duration = "10s" + + triggers = { + name = aws_iam_role.access.name + } +} + +resource "aws_iam_role_policy_attachment" "apprunner" { + role = time_sleep.access_iam_role_propagation.triggers["name"] + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess" +} + +resource "aws_apprunner_service" "this" { + service_name = var.application_name + + source_configuration { + auto_deployments_enabled = false + + authentication_configuration { + access_role_arn = aws_iam_role.access.arn + } + + image_repository { + image_repository_type = "ECR" + image_identifier = module.image.app_image_name + image_configuration { + port = var.application_port + runtime_environment_variables = { + ASPNETCORE_FORWARDEDHEADERS_ENABLED = "true" + ASPNETCORE_URLS = "http://+:${var.application_port}" + AuthSettings__JwtSecretKey = local.application_jwt_secret_key + AuthSettings__OAuthClientId = local.application_oauth_client_id + AuthSettings__OAuthClientSecret = local.application_oauth_client_secret + AuthSettings__AuthorizationCode = local.application_authorization_code + } + } + } + } + + instance_configuration { + instance_role_arn = aws_iam_role.instance.arn + cpu = var.application_instance_cpu + memory = var.application_instance_memory + } +} diff --git a/terraform/aws/ecr.tf b/terraform/aws/ecr.tf new file mode 100644 index 0000000..66a749b --- /dev/null +++ b/terraform/aws/ecr.tf @@ -0,0 +1,50 @@ +resource "aws_ecr_repository" "this" { + name = var.application_name + force_delete = var.do_force_delete_repository + + image_tag_mutability = var.repository_image_tag_mutability + + image_scanning_configuration { + scan_on_push = var.do_scan_images + } +} + +data "aws_iam_policy_document" "ecr" { + statement { + actions = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:DescribeRepositories", + "ecr:GetRepositoryPolicy", + "ecr:ListImages", + "ecr:DescribeImages", + "ecr:DeleteRepository", + "ecr:BatchDeleteImage", + "ecr:SetRepositoryPolicy", + "ecr:DeleteRepositoryPolicy", + "ecr:GetLifecyclePolicy", + "ecr:PutLifecyclePolicy", + "ecr:DeleteLifecyclePolicy", + "ecr:GetLifecyclePolicyPreview", + "ecr:StartLifecyclePolicyPreview", + ] + + principals { + type = "AWS" + + identifiers = [ + data.aws_caller_identity.current.id, + ] + } + } +} + +resource "aws_ecr_repository_policy" "this" { + repository = aws_ecr_repository.this.name + policy = data.aws_iam_policy_document.ecr.json +} diff --git a/terraform/aws/image.tf b/terraform/aws/image.tf new file mode 100644 index 0000000..cfa1f3c --- /dev/null +++ b/terraform/aws/image.tf @@ -0,0 +1,33 @@ +locals { + application_build_context = abspath(join(local.file_path_separator, compact([path.cwd, var.application_build_context]))) + application_image_name = join(":", compact([aws_ecr_repository.this.repository_url, var.application_build_image_tag])) + application_runtime_image = var.application_build_base_image_name != "" ? var.application_build_base_image_name : var.application_build_runtime_image_name + + application_build_arguments = { + PORT = tostring(var.application_port) + SDK_IMAGE = var.application_build_sdk_image_name + RUNTIME_IMAGE = local.application_runtime_image + PROJECT_CONTEXT_DIR = "ExtensionAppDataIO" + PROJECT_FILE = "ExtensionAppDataIO.csproj" + PROJECT_NAME = "ExtensionAppDataIO" + } + + application_build_dockerfile = { + docker = "ExtensionAppDataIO/Containerfile" + podman = "ExtensionAppDataIO/Containerfile" + } +} + +module "image" { + source = "../common/modules/docker" + + base_image_name = local.application_runtime_image + + app_image_name = local.application_image_name + app_image_build_context = local.application_build_context + app_image_build_dockerfile = lookup(local.application_build_dockerfile, var.container_tool, null) + app_image_build_target_stage = var.application_environment_mode + app_image_build_paths = var.application_build_paths + app_image_build_args = local.application_build_arguments + app_image_build_labels = var.application_build_labels +} diff --git a/terraform/aws/main.tf b/terraform/aws/main.tf new file mode 100644 index 0000000..facf8eb --- /dev/null +++ b/terraform/aws/main.tf @@ -0,0 +1,66 @@ +data "aws_region" "current" { + count = var.region == null ? 1 : 0 +} +data "aws_caller_identity" "current" {} +data "aws_ecr_authorization_token" "current" {} + +locals { + region = var.region != null ? var.region : data.aws_region.current[0].name + + application_jwt_secret_key = var.application_jwt_secret_key != "" ? var.application_jwt_secret_key : module.generate_jwt_secret_key[0].random_bytes + application_oauth_client_id = var.application_oauth_client_id != "" ? var.application_oauth_client_id : module.generate_oauth_client_id[0].random_bytes + application_oauth_client_secret = var.application_oauth_client_secret != "" ? var.application_oauth_client_secret : module.generate_oauth_client_secret[0].random_bytes + application_authorization_code = var.application_authorization_code != "" ? var.application_authorization_code : module.generate_authorization_code[0].random_bytes + + file_path_separator = "/" + + output_manifest_files_directory = abspath(join(local.file_path_separator, compact([path.cwd, var.output_manifest_files_directory]))) + output_manifest_files_paths = module.manifest[*].output_file_path + + tags = merge( + { + application = var.application_name + }, + var.tags + ) +} + +module "generate_jwt_secret_key" { + count = var.application_jwt_secret_key == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_id" { + count = var.application_oauth_client_id == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_secret" { + count = var.application_oauth_client_secret == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_authorization_code" { + count = var.application_authorization_code == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "manifest" { + count = length(var.manifest_files_paths) + + source = "../common/modules/template" + + input_file_path = abspath(join(local.file_path_separator, [path.cwd, var.manifest_files_paths[count.index]])) + output_file_path = join(local.file_path_separator, [ + local.output_manifest_files_directory, + "${basename(dirname(var.manifest_files_paths[count.index]))}.${basename(var.manifest_files_paths[count.index])}" + ]) + + client_id = local.application_oauth_client_id + client_secret = local.application_oauth_client_secret + base_url = local.application_service_url +} diff --git a/terraform/aws/outputs.tf b/terraform/aws/outputs.tf new file mode 100644 index 0000000..f0f4c7f --- /dev/null +++ b/terraform/aws/outputs.tf @@ -0,0 +1,9 @@ +output "application_service_url" { + description = "The base URL of the application service" + value = local.application_service_url +} + +output "output_manifest_files_paths" { + description = "The absolute paths to the output manifest files" + value = local.output_manifest_files_paths +} diff --git a/terraform/aws/providers.tf b/terraform/aws/providers.tf new file mode 100644 index 0000000..2fdf470 --- /dev/null +++ b/terraform/aws/providers.tf @@ -0,0 +1,17 @@ +provider "aws" { + region = var.region + + default_tags { + tags = local.tags + } +} + +provider "docker" { + host = var.docker_host + + registry_auth { + address = data.aws_ecr_authorization_token.current.proxy_endpoint + username = data.aws_ecr_authorization_token.current.user_name + password = data.aws_ecr_authorization_token.current.password + } +} diff --git a/terraform/aws/terraform.tf b/terraform/aws/terraform.tf new file mode 100644 index 0000000..3019134 --- /dev/null +++ b/terraform/aws/terraform.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/terraform/aws/variables.tf b/terraform/aws/variables.tf new file mode 100644 index 0000000..e3848a3 --- /dev/null +++ b/terraform/aws/variables.tf @@ -0,0 +1,263 @@ +variable "region" { + description = "The AWS region" + type = string + nullable = true + default = "us-east-1" +} + +variable "docker_host" { + description = "The Docker host (e.g. 'tcp://127.0.0.1:2376' or 'unix:///var/run/docker.sock') to connect to. If empty, the default Docker host will be used" + type = string + nullable = true + default = null +} + +variable "container_tool" { + description = "The container tool to use for building and pushing images" + type = string + nullable = false + default = "docker" + + validation { + condition = contains(["docker", "podman"], var.container_tool) + error_message = "The container tool must be one of 'docker' or 'podman'" + } +} + +variable "application_name" { + description = "The name of the application" + type = string + nullable = false + default = "extension-app-data-io-cs" + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.application_name)) && length(var.application_name) <= 32 + error_message = "The application name must contain only alphanumeric characters and hyphens and be at most 32 characters long" + } +} + +variable "application_port" { + description = "The port the application listens on" + type = number + nullable = false + default = 8080 +} + +variable "application_build_base_image_name" { + description = "Deprecated compatibility alias for the runtime base image. If non-empty, it overrides application_build_runtime_image_name." + type = string + nullable = false + default = "" +} + +variable "application_build_sdk_image_name" { + description = "The SDK image to use for the application build stages" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/sdk:10.0" +} + +variable "application_build_runtime_image_name" { + description = "The runtime image to use for the final application container stage" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/aspnet:10.0" +} + +variable "application_build_image_tag" { + description = "The tag to apply to the application build image. If empty the timestamp tag will be used." + type = string + nullable = false + default = "" +} + +variable "application_build_context" { + description = "The relative path to the build context for the application. The build context is the directory from which the Dockerfile is read. If it is empty the current working directory will be used." + type = string + nullable = false + default = "../.." +} + +variable "application_build_paths" { + description = "Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset)." + type = list(string) + default = [ + ".dockerignore", + "ExtensionAppDataIO/**/*.cs", + "ExtensionAppDataIO/**/*.cshtml", + "ExtensionAppDataIO/**/*.json", + "ExtensionAppDataIO/**/*.http", + "ExtensionAppDataIO/Controllers/**", + "ExtensionAppDataIO/Data/**", + "ExtensionAppDataIO/DataModels/**", + "ExtensionAppDataIO/Models/**", + "ExtensionAppDataIO/Properties/**", + "ExtensionAppDataIO/Services/**", + "ExtensionAppDataIO/Views/**", + "ExtensionAppDataIO/wwwroot/**", + "ExtensionAppDataIO/ExtensionAppDataIO.csproj", + "ExtensionAppDataIO/Containerfile", + "manifests/**" + ] +} + +variable "application_environment_mode" { + description = "The environment mode for the application" + type = string + nullable = true + default = "production" + + validation { + condition = contains(["development", "production"], var.application_environment_mode) + error_message = "The environment mode must be one of 'development' or 'production'" + } +} + +variable "application_build_labels" { + description = "The labels to apply to the application build image" + type = map(string) + default = { + "org.opencontainers.image.title" = "Data IO Extension App Reference Implementation (C#)" + "org.opencontainers.image.description" = "C# reference implementation for data input and output extension app workflows." + "org.opencontainers.image.source" = "https://github.com/docusign/extension-app-data-io-reference-implementation-csharp" + "org.opencontainers.image.licenses" = "MIT" + "org.opencontainers.image.authors" = "DocuSign Inc." + "org.opencontainers.image.vendor" = "DocuSign Inc." + } +} + +variable "application_instance_cpu" { + description = "The number of CPU units to allocate to the application instance" + type = string + nullable = false + default = "256" + + validation { + condition = contains( + [ + "256", + "512", + "1024", + "2048", + "4096", + "0.25 vCPU", + "0.5 vCPU", + "1 vCPU", + "2 vCPU", + "4 vCPU", + ], var.application_instance_cpu) + error_message = "The number of CPU units must be one of '256', '512', '1024', '2048', '4096', '0.25 vCPU', '0.5 vCPU', '1 vCPU', '2 vCPU', or '4 vCPU'" + } +} + +variable "application_instance_memory" { + description = "The amount of memory to allocate to the application instance" + type = string + nullable = false + default = "512" + + validation { + condition = contains( + [ + "512", + "1024", + "2048", + "3072", + "4096", + "6144", + "8192", + "10240", + "12288", + "0.5 GB", + "1 GB", + "2 GB", + "3 GB", + "4 GB", + "6 GB", + "8 GB", + "10 GB", + "12 GB", + ], var.application_instance_memory) + error_message = "The amount of memory must be one of '512', '1024', '2048', '3072', '4096', '6144', '8192', '10240', '12288', '0.5 GB', '1 GB', '2 GB', '3 GB', '4 GB', '6 GB', '8 GB', '10 GB', or '12 GB'" + } +} + +variable "application_jwt_secret_key" { + description = "The secret key to use for signing JWT tokens. If empty, a random key will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_id" { + description = "The OAuth client ID for the application. If empty, a random client ID will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_secret" { + description = "The OAuth client secret for the application. If empty, a random client secret will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_authorization_code" { + description = "The authorization code for the application. If empty, a random code will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "manifest_files_paths" { + description = "The list of manifest files relative paths to generate" + type = list(string) + default = [ + "../../manifests/authorizationCode/ReadOnlyManifest.json", + "../../manifests/authorizationCode/ReadWriteManifest.json", + "../../manifests/clientCredentials/ReadOnlyManifest.json", + "../../manifests/clientCredentials/ReadWriteManifest.json", + ] +} + +variable "output_manifest_files_directory" { + description = "The directory to output the generated manifest files" + type = string + nullable = false + default = ".terraform" +} + +variable "do_force_delete_repository" { + description = "Whether to delete the ECR repository even if it contains images" + type = bool + default = true +} + +variable "repository_image_tag_mutability" { + description = "The image tag mutability setting for the ECR repository" + type = string + nullable = false + default = "MUTABLE" + + validation { + condition = contains(["IMMUTABLE", "MUTABLE"], var.repository_image_tag_mutability) + error_message = "The image tag mutability setting must be either 'IMMUTABLE' or 'MUTABLE'" + } +} + +variable "do_scan_images" { + description = "Whether images are scanned after being pushed to the ECR repository" + type = bool + default = true +} + +variable "tags" { + type = map(string) + description = "A map of the tags to apply to various resources" + default = {} +} diff --git a/terraform/azure/README.md b/terraform/azure/README.md new file mode 100644 index 0000000..bfb0c79 --- /dev/null +++ b/terraform/azure/README.md @@ -0,0 +1,187 @@ +# Terraform configuration for deploying the C# reference implementation to Microsoft Azure + +This Terraform root deploys the C# Data IO reference implementation to Azure App Service. It builds the application image from `ExtensionAppDataIO/Containerfile`, pushes it to Azure Container Registry, provisions an Azure Linux Web App, and renders hosted manifest files into `.terraform/` for later upload to the DocuSign Developer Console. + +## Specific cloud prerequisites + +Before deploying your extension app on Azure, complete the following setup steps: + +1. [Sign up for an Azure Free Account](https://azure.microsoft.com/free/) (if you don’t already have one). + +1. **Configure Azure CLI**: Install and configure the Azure CLI to interact with your Azure account. You can follow the instructions [here](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). + +1. **Authenticate Azure CLI**: Log in to your Azure account using the Azure CLI: + ```sh + az login + ``` + +1. **Set Subscription**: Set the Azure subscription you want to use: + ```sh + az account set --subscription "your-subscription-id" + ``` + It's also necessary to set the Azure subscription when running the `azurerm` Terraform provider with version 4.0 or above. This can be done by specifying the `subscription_id` Terraform variable or by exporting the `ARM_SUBSCRIPTION_ID` environment variable. For instance, + ```sh + export ARM_SUBSCRIPTION_ID="your-subscription-id" + ``` + +1. Make sure you have the correct permissions + Terraform needs sufficient permissions to create resources. Make sure your Azure user has the Contributor role: + ```sh + az role assignment list --assignee --output table + ``` + If needed, assign the correct role: + ```sh + az role assignment create --assignee --role "Contributor" --scope "/subscriptions/your-subscription-id" + ``` + + +In that case `azurerm` Terraform provider is [authenticated to Azure using the Azure CLI](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/azure_cli), but you may use other methods for [authenticating to Azure](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#authenticating-to-azure). + +Now that you’ve set up your Azure environment, continue with the [Terraform deployment guide](../README.md) to provision your infrastructure. +## What this configuration deploys + +The Azure Terraform root provisions: + +- An Azure Container Registry for the application image +- An Azure App Service Plan and Linux Web App for the C# web application +- Generated hosted manifest files written into `.terraform/` + +The containerized application listens on port `8080` inside the container. The web app is configured with `ASPNETCORE_URLS=http://+:8080` so the deployed service binds to the expected port. + +## Application sources used by Terraform + +Terraform builds from the repository root and uses `ExtensionAppDataIO/Containerfile` as the container build file. + +The generated hosted manifests are based on these source manifest trees: + +- `manifests/authorizationCode/ReadOnlyManifest.json` +- `manifests/authorizationCode/ReadWriteManifest.json` +- `manifests/clientCredentials/ReadOnlyManifest.json` +- `manifests/clientCredentials/ReadWriteManifest.json` + +During `terraform apply`, the rendered hosted manifests are emitted into `.terraform/` with filenames that include the manifest source directory name. + +## Runtime configuration injected by Terraform + +Terraform injects the ASP.NET Core authentication settings that the C# app binds from configuration: + +- `AuthSettings__JwtSecretKey` +- `AuthSettings__OAuthClientId` +- `AuthSettings__OAuthClientSecret` +- `AuthSettings__AuthorizationCode` + +If you do not provide explicit values for these variables, Terraform generates them for the App Service runtime configuration. + +The generated hosted manifest files are templated separately. They only substitute these hosted manifest placeholders: + +- `CLIENT_ID` +- `CLIENT_SECRET` +- `PROXY_BASE_URL` + +## Deploying + +From the C# repository root: + +```sh +cd terraform/azure +terraform init -upgrade +terraform plan -out app.tfplan +terraform apply app.tfplan +``` + +Use `terraform output` after apply to retrieve: + +- `application_service_url` +- `output_manifest_files_paths` + +Upload the generated hosted manifest files from `.terraform/` to the DocuSign Developer Console. + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.0, < 2.0.0 | +| [azurerm](#requirement\_azurerm) | ~> 4.16 | +| [docker](#requirement\_docker) | ~> 3.0 | +| [local](#requirement\_local) | ~> 2.5 | +| [random](#requirement\_random) | ~> 3.6 | +| [time](#requirement\_time) | ~> 0.12 | + +## Providers + +| Name | Version | +|------|---------| +| [azurerm](#provider\_azurerm) | ~> 4.16 | +| [random](#provider\_random) | ~> 3.6 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [generate\_authorization\_code](#module\_generate\_authorization\_code) | ../common/modules/generate | n/a | +| [generate\_jwt\_secret\_key](#module\_generate\_jwt\_secret\_key) | ../common/modules/generate | n/a | +| [generate\_oauth\_client\_id](#module\_generate\_oauth\_client\_id) | ../common/modules/generate | n/a | +| [generate\_oauth\_client\_secret](#module\_generate\_oauth\_client\_secret) | ../common/modules/generate | n/a | +| [image](#module\_image) | ../common/modules/docker | n/a | +| [manifest](#module\_manifest) | ../common/modules/template | n/a | + +## Resources + +| Name | Type | +|------|------| +| [azurerm_container_registry.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_registry) | resource | +| [azurerm_linux_web_app.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app) | resource | +| [azurerm_resource_group.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [azurerm_role_assignment.pull_image](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_service_plan.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/service_plan) | resource | +| [random_id.container_registry](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [random_id.web_app](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [terraform_data.login_container_registry](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.push_docker_image](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [azurerm_subscription.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [application\_authorization\_code](#input\_application\_authorization\_code) | The authorization code for the application. If empty, a random code will be generated. | `string` | `""` | no | +| [application\_build\_base\_image\_name](#input\_application\_build\_base\_image\_name) | Deprecated compatibility alias for the runtime base image. If non-empty, it overrides application_build_runtime_image_name. | `string` | `""` | no | +| [application\_build\_runtime\_image\_name](#input\_application\_build\_runtime\_image\_name) | The runtime image to use for the final application container stage | `string` | `"mcr.microsoft.com/dotnet/aspnet:10.0"` | no | +| [application\_build\_sdk\_image\_name](#input\_application\_build\_sdk\_image\_name) | The SDK image to use for the application build stages | `string` | `"mcr.microsoft.com/dotnet/sdk:10.0"` | no | +| [application\_build\_context](#input\_application\_build\_context) | The relative path to the build context for the application. The build context is the directory from which the Dockerfile is read. If it is empty the current working directory will be used. | `string` | `"../.."` | no | +| [application\_build\_image\_tag](#input\_application\_build\_image\_tag) | The tag to apply to the application build image. If empty the timestamp tag will be used. | `string` | `""` | no | +| [application\_build\_labels](#input\_application\_build\_labels) | The labels to apply to the application build image | `map(string)` |
{
"org.opencontainers.image.authors": "DocuSign Inc.",
"org.opencontainers.image.description": "C# reference implementation for data input and output extension app workflows.",
"org.opencontainers.image.licenses": "MIT",
"org.opencontainers.image.source": "https://github.com/docusign/extension-app-data-io-reference-implementation-csharp",
"org.opencontainers.image.title": "Data IO Extension App Reference Implementation (C#)",
"org.opencontainers.image.vendor": "DocuSign Inc."
}
| no | +| [application\_build\_paths](#input\_application\_build\_paths) | Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset). | `list(string)` |
[
".dockerignore",
"ExtensionAppDataIO/**/*.cs",
"ExtensionAppDataIO/**/*.cshtml",
"ExtensionAppDataIO/**/*.json",
"ExtensionAppDataIO/**/*.http",
"ExtensionAppDataIO/Controllers/**",
"ExtensionAppDataIO/Data/**",
"ExtensionAppDataIO/DataModels/**",
"ExtensionAppDataIO/Models/**",
"ExtensionAppDataIO/Properties/**",
"ExtensionAppDataIO/Services/**",
"ExtensionAppDataIO/Views/**",
"ExtensionAppDataIO/wwwroot/**",
"ExtensionAppDataIO/ExtensionAppDataIO.csproj",
"ExtensionAppDataIO/Containerfile",
"manifests/**"
]
| no | +| [application\_environment\_mode](#input\_application\_environment\_mode) | The environment mode for the application | `string` | `"production"` | no | +| [application\_jwt\_secret\_key](#input\_application\_jwt\_secret\_key) | The secret key to use for signing JWT tokens. If empty, a random key will be generated. | `string` | `""` | no | +| [application\_name](#input\_application\_name) | The name of the application | `string` | `"extension-app-data-io-cs"` | no | +| [application\_oauth\_client\_id](#input\_application\_oauth\_client\_id) | The OAuth client ID for the application. If empty, a random client ID will be generated. | `string` | `""` | no | +| [application\_oauth\_client\_secret](#input\_application\_oauth\_client\_secret) | The OAuth client secret for the application. If empty, a random client secret will be generated. | `string` | `""` | no | +| [application\_port](#input\_application\_port) | The port the application listens on | `number` | `8080` | no | +| [application\_service\_plan\_name](#input\_application\_service\_plan\_name) | The name of the application service plan. If it is not defined, the prefixed application name will be used | `string` | `null` | no | +| [application\_service\_plan\_sku\_name](#input\_application\_service\_plan\_sku\_name) | The SKU name of the application service plan | `string` | `"F1"` | no | +| [application\_service\_plan\_worker\_count](#input\_application\_service\_plan\_worker\_count) | The number of workers to allocate for the application service plan | `number` | `1` | no | +| [application\_webapp\_name](#input\_application\_webapp\_name) | The name of the application web app. If it is not defined, the prefixed application name will be used | `string` | `null` | no | +| [container\_registry\_name](#input\_container\_registry\_name) | The name of the container registry. If it is not defined, the prefixed application name will be used | `string` | `null` | no | +| [container\_registry\_sku](#input\_container\_registry\_sku) | The SKU of the container registry | `string` | `"Basic"` | no | +| [container\_tool](#input\_container\_tool) | The container tool to use for building and pushing images | `string` | `"docker"` | no | +| [do\_enable\_admin\_access](#input\_do\_enable\_admin\_access) | Whether to enable admin access to the container registry | `bool` | `true` | no | +| [do\_randomize\_resource\_names](#input\_do\_randomize\_resource\_names) | Whether to randomize the resource names that should be globally unique | `bool` | `true` | no | +| [docker\_host](#input\_docker\_host) | The Docker host (e.g. 'tcp://127.0.0.1:2376' or 'unix:///var/run/docker.sock') to connect to. If empty, the default Docker host will be used | `string` | `null` | no | +| [is\_application\_webapp\_always\_on](#input\_is\_application\_webapp\_always\_on) | Whether the application web app should always be on | `bool` | `false` | no | +| [location](#input\_location) | The location/region where the resources will be created | `string` | `"West Europe"` | no | +| [manifest\_files\_paths](#input\_manifest\_files\_paths) | The list of manifest files relative paths to generate | `list(string)` |
[
"../../manifests/authorizationCode/ReadOnlyManifest.json",
"../../manifests/authorizationCode/ReadWriteManifest.json",
"../../manifests/clientCredentials/ReadOnlyManifest.json",
"../../manifests/clientCredentials/ReadWriteManifest.json"
]
| no | +| [output\_manifest\_files\_directory](#input\_output\_manifest\_files\_directory) | The directory to output the generated manifest files | `string` | `".terraform"` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | The name of the resource group. If it is not defined, the prefixed application name will be used | `string` | `null` | no | +| [subscription\_id](#input\_subscription\_id) | The Azure subscription ID | `string` | `null` | no | +| [tags](#input\_tags) | A map of the tags to apply to various resources | `map(string)` | `{}` | no | +| [tenant\_id](#input\_tenant\_id) | The Azure tenant ID | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [application\_service\_url](#output\_application\_service\_url) | The base URL of the application service | +| [output\_manifest\_files\_paths](#output\_output\_manifest\_files\_paths) | The absolute paths to the output manifest files | + diff --git a/terraform/azure/acr.tf b/terraform/azure/acr.tf new file mode 100644 index 0000000..6d50d3b --- /dev/null +++ b/terraform/azure/acr.tf @@ -0,0 +1,39 @@ +locals { + container_registry_name = coalesce( + var.container_registry_name, + replace(lower(join(local.resource_name_separator, compact(["cr", var.application_name, one(random_id.container_registry[*].hex)]))), "-", "") + ) + + created_container_registry_id = azurerm_container_registry.this.id + created_container_registry_name = azurerm_container_registry.this.name + created_container_registry_resource_group_name = azurerm_container_registry.this.resource_group_name + created_container_registry_login_server = azurerm_container_registry.this.login_server + + container_registry_protocol = "https" + created_container_registry_url = join("://", compact([ + local.container_registry_protocol, + local.created_container_registry_login_server + ])) +} + +resource "random_id" "container_registry" { + count = var.do_randomize_resource_names ? 1 : 0 + + byte_length = 2 + + keepers = { + resource_group_id = local.created_resource_group_id + } +} + +resource "azurerm_container_registry" "this" { + name = local.container_registry_name + + resource_group_name = local.created_resource_group_name + location = local.created_resource_group_location + + sku = var.container_registry_sku + admin_enabled = var.do_enable_admin_access + + tags = local.tags +} diff --git a/terraform/azure/image.tf b/terraform/azure/image.tf new file mode 100644 index 0000000..229e43e --- /dev/null +++ b/terraform/azure/image.tf @@ -0,0 +1,104 @@ +locals { + application_build_context = abspath(join(local.file_path_separator, compact([path.cwd, var.application_build_context]))) + application_runtime_image = var.application_build_base_image_name != "" ? var.application_build_base_image_name : var.application_build_runtime_image_name + + application_image_name = join(local.docker_registry_separator, compact([ + local.created_container_registry_login_server, + join(local.docker_image_tag_separator, compact([ + var.application_name, + var.application_build_image_tag + ])), + ])) + + pushed_application_image_name_without_registry = trimprefix( + module.image.app_image_name, + join("", compact([ + local.created_container_registry_login_server, + local.docker_registry_separator, + ])) + ) + + application_build_arguments = { + PORT = tostring(var.application_port) + SDK_IMAGE = var.application_build_sdk_image_name + RUNTIME_IMAGE = local.application_runtime_image + PROJECT_CONTEXT_DIR = "ExtensionAppDataIO" + PROJECT_FILE = "ExtensionAppDataIO.csproj" + PROJECT_NAME = "ExtensionAppDataIO" + } + + application_build_dockerfile = { + docker = "ExtensionAppDataIO/Containerfile" + podman = "ExtensionAppDataIO/Containerfile" + } +} + +module "image" { + source = "../common/modules/docker" + + base_image_name = local.application_runtime_image + + app_image_name = local.application_image_name + app_image_build_context = local.application_build_context + app_image_build_dockerfile = lookup(local.application_build_dockerfile, var.container_tool, null) + app_image_build_target_stage = var.application_environment_mode + app_image_build_paths = var.application_build_paths + app_image_build_args = local.application_build_arguments + app_image_build_labels = var.application_build_labels + + do_push_app_image = false +} + +resource "terraform_data" "login_container_registry" { + input = { + subscription_id = local.subscription_id + + container_registry_name = local.created_container_registry_name + container_registry_resource_group_name = local.created_container_registry_resource_group_name + container_registry_login_server = local.created_container_registry_login_server + + container_tool = var.container_tool + } + + triggers_replace = [ + azurerm_container_registry.this.id, + ] + + provisioner "local-exec" { + environment = { + DOCKER_COMMAND = self.input.container_tool + } + command = "az acr login --name ${self.input.container_registry_name} --resource-group ${self.input.container_registry_resource_group_name} --subscription ${self.input.subscription_id}" + } + + provisioner "local-exec" { + when = destroy + on_failure = continue + command = "${self.input.container_tool} logout ${self.input.container_registry_login_server}" + } +} + +resource "terraform_data" "push_docker_image" { + input = { + application_image_name = module.image.app_image_name + application_image_name_without_registry = local.pushed_application_image_name_without_registry + + container_tool = var.container_tool + container_registry_name = local.created_container_registry_name + } + + triggers_replace = [ + terraform_data.login_container_registry.id, + module.image.app_image_repo_digest, + ] + + provisioner "local-exec" { + command = "${self.input.container_tool} push ${self.input.application_image_name}" + } + + provisioner "local-exec" { + when = destroy + on_failure = continue + command = "az acr repository delete --name ${self.input.container_registry_name} --image ${self.input.application_image_name_without_registry} --yes" + } +} diff --git a/terraform/azure/main.tf b/terraform/azure/main.tf new file mode 100644 index 0000000..26e5a5b --- /dev/null +++ b/terraform/azure/main.tf @@ -0,0 +1,67 @@ +data "azurerm_subscription" "current" { + count = var.subscription_id == null ? 1 : 0 +} + +locals { + subscription_id = coalesce(var.subscription_id, one(data.azurerm_subscription.current[*].subscription_id)) + + application_jwt_secret_key = coalesce(var.application_jwt_secret_key, one(module.generate_jwt_secret_key[*].random_bytes)) + application_oauth_client_id = coalesce(var.application_oauth_client_id, one(module.generate_oauth_client_id[*].random_bytes)) + application_oauth_client_secret = coalesce(var.application_oauth_client_secret, one(module.generate_oauth_client_secret[*].random_bytes)) + application_authorization_code = coalesce(var.application_authorization_code, one(module.generate_authorization_code[*].random_bytes)) + + resource_name_separator = "-" + file_path_separator = "/" + docker_registry_separator = "/" + docker_image_tag_separator = ":" + + output_manifest_files_directory = abspath(join(local.file_path_separator, compact([path.cwd, var.output_manifest_files_directory]))) + output_manifest_files_paths = module.manifest[*].output_file_path + + tags = merge( + { + application = var.application_name + }, + var.tags + ) +} + +module "generate_jwt_secret_key" { + count = var.application_jwt_secret_key == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_id" { + count = var.application_oauth_client_id == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_secret" { + count = var.application_oauth_client_secret == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_authorization_code" { + count = var.application_authorization_code == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "manifest" { + count = length(var.manifest_files_paths) + + source = "../common/modules/template" + + input_file_path = abspath(join(local.file_path_separator, [path.cwd, var.manifest_files_paths[count.index]])) + output_file_path = join(local.file_path_separator, [ + local.output_manifest_files_directory, + "${basename(dirname(var.manifest_files_paths[count.index]))}.${basename(var.manifest_files_paths[count.index])}" + ]) + + client_id = local.application_oauth_client_id + client_secret = local.application_oauth_client_secret + base_url = local.application_service_url +} diff --git a/terraform/azure/outputs.tf b/terraform/azure/outputs.tf new file mode 100644 index 0000000..f0f4c7f --- /dev/null +++ b/terraform/azure/outputs.tf @@ -0,0 +1,9 @@ +output "application_service_url" { + description = "The base URL of the application service" + value = local.application_service_url +} + +output "output_manifest_files_paths" { + description = "The absolute paths to the output manifest files" + value = local.output_manifest_files_paths +} diff --git a/terraform/azure/providers.tf b/terraform/azure/providers.tf new file mode 100644 index 0000000..f128290 --- /dev/null +++ b/terraform/azure/providers.tf @@ -0,0 +1,10 @@ +provider "azurerm" { + features {} + + subscription_id = var.subscription_id + tenant_id = var.tenant_id +} + +provider "docker" { + host = var.docker_host +} diff --git a/terraform/azure/resource_group.tf b/terraform/azure/resource_group.tf new file mode 100644 index 0000000..abe7b9a --- /dev/null +++ b/terraform/azure/resource_group.tf @@ -0,0 +1,18 @@ +locals { + resource_group_name = coalesce( + var.resource_group_name, + lower(join(local.resource_name_separator, compact(["rg", var.application_name]))) + ) + + created_resource_group_id = azurerm_resource_group.this.id + created_resource_group_name = azurerm_resource_group.this.name + created_resource_group_location = azurerm_resource_group.this.location +} + +resource "azurerm_resource_group" "this" { + name = local.resource_group_name + + location = var.location + + tags = local.tags +} diff --git a/terraform/azure/terraform.tf b/terraform/azure/terraform.tf new file mode 100644 index 0000000..cf5ab7d --- /dev/null +++ b/terraform/azure/terraform.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.16" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/terraform/azure/variables.tf b/terraform/azure/variables.tf new file mode 100644 index 0000000..bae222d --- /dev/null +++ b/terraform/azure/variables.tf @@ -0,0 +1,301 @@ +variable "subscription_id" { + description = "The Azure subscription ID" + type = string + nullable = true + default = null +} + +variable "tenant_id" { + description = "The Azure tenant ID" + type = string + nullable = true + default = null +} + +variable "location" { + description = "The location/region where the resources will be created" + type = string + nullable = false + default = "West Europe" +} + +variable "docker_host" { + description = "The Docker host (e.g. 'tcp://127.0.0.1:2376' or 'unix:///var/run/docker.sock') to connect to. If empty, the default Docker host will be used" + type = string + nullable = true + default = null +} + +variable "container_tool" { + description = "The container tool to use for building and pushing images" + type = string + nullable = false + default = "docker" + + validation { + condition = contains(["docker", "podman"], var.container_tool) + error_message = "The container tool must be one of 'docker' or 'podman'" + } +} + +variable "application_name" { + description = "The name of the application" + type = string + nullable = false + default = "extension-app-data-io-cs" + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.application_name)) && length(var.application_name) <= 32 + error_message = "The application name must contain only alphanumeric characters and hyphens and be at most 32 characters long" + } +} + +variable "application_port" { + description = "The port the application listens on" + type = number + nullable = false + default = 8080 +} + +variable "application_build_base_image_name" { + description = "Deprecated compatibility alias for the runtime base image. If non-empty, it overrides application_build_runtime_image_name." + type = string + nullable = false + default = "" +} + +variable "application_build_sdk_image_name" { + description = "The SDK image to use for the application build stages" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/sdk:10.0" +} + +variable "application_build_runtime_image_name" { + description = "The runtime image to use for the final application container stage" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/aspnet:10.0" +} + +variable "application_build_image_tag" { + description = "The tag to apply to the application build image. If empty the timestamp tag will be used." + type = string + nullable = false + default = "" +} + +variable "application_build_context" { + description = "The relative path to the build context for the application. The build context is the directory from which the Dockerfile is read. If it is empty the current working directory will be used." + type = string + nullable = false + default = "../.." +} + +variable "application_build_paths" { + description = "Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset)." + type = list(string) + default = [ + ".dockerignore", + "ExtensionAppDataIO/**/*.cs", + "ExtensionAppDataIO/**/*.cshtml", + "ExtensionAppDataIO/**/*.json", + "ExtensionAppDataIO/**/*.http", + "ExtensionAppDataIO/Controllers/**", + "ExtensionAppDataIO/Data/**", + "ExtensionAppDataIO/DataModels/**", + "ExtensionAppDataIO/Models/**", + "ExtensionAppDataIO/Properties/**", + "ExtensionAppDataIO/Services/**", + "ExtensionAppDataIO/Views/**", + "ExtensionAppDataIO/wwwroot/**", + "ExtensionAppDataIO/ExtensionAppDataIO.csproj", + "ExtensionAppDataIO/Containerfile", + "manifests/**" + ] +} + +variable "application_environment_mode" { + description = "The environment mode for the application" + type = string + nullable = true + default = "production" + + validation { + condition = contains(["development", "production"], var.application_environment_mode) + error_message = "The environment mode must be one of 'development' or 'production'" + } +} + +variable "application_build_labels" { + description = "The labels to apply to the application build image" + type = map(string) + default = { + "org.opencontainers.image.title" = "Data IO Extension App Reference Implementation (C#)" + "org.opencontainers.image.description" = "C# reference implementation for data input and output extension app workflows." + "org.opencontainers.image.source" = "https://github.com/docusign/extension-app-data-io-reference-implementation-csharp" + "org.opencontainers.image.licenses" = "MIT" + "org.opencontainers.image.authors" = "DocuSign Inc." + "org.opencontainers.image.vendor" = "DocuSign Inc." + } +} + +variable "do_randomize_resource_names" { + description = "Whether to randomize the resource names that should be globally unique" + type = bool + nullable = false + default = true +} + +variable "resource_group_name" { + description = "The name of the resource group. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "container_registry_name" { + description = "The name of the container registry. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null + + validation { + condition = can(regex("^[a-zA-Z0-9]+$", coalesce(var.container_registry_name, "cr"))) + error_message = "The container registry name must contain only alphanumeric characters" + } +} + +variable "container_registry_sku" { + description = "The SKU of the container registry" + type = string + nullable = false + default = "Basic" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.container_registry_sku) + error_message = "The container registry SKU must be one of 'Basic', 'Standard', or 'Premium'" + } +} + +variable "do_enable_admin_access" { + description = "Whether to enable admin access to the container registry" + type = bool + default = true +} + +variable "application_service_plan_name" { + description = "The name of the application service plan. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "application_service_plan_sku_name" { + description = "The SKU name of the application service plan" + type = string + nullable = false + default = "F1" + + validation { + condition = contains([ + "F1", + "B1", + "B2", + "B3", + "S1", + "S2", + "S3", + "P1v2", + "P2v2", + "P3v2", + "P0v3", + "P1v3", + "P2v3", + "P3v3", + "P1mv3", + "P2mv3", + "P3mv3", + "P4mv3", + "P5mv3", + ], var.application_service_plan_sku_name) + error_message = "The application service plan SKU name must be one of 'F1', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1v2', 'P2v2', 'P3v2', 'P0v3', 'P1v3', 'P2v3', 'P3v3', 'P1mv3', 'P2mv3', 'P3mv3', 'P4mv3', or 'P5mv3'" + } +} + +variable "application_service_plan_worker_count" { + description = "The number of workers to allocate for the application service plan" + type = number + nullable = false + default = 1 +} + +variable "application_webapp_name" { + description = "The name of the application web app. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "is_application_webapp_always_on" { + description = "Whether the application web app should always be on" + type = bool + nullable = false + default = false +} + +variable "application_jwt_secret_key" { + description = "The secret key to use for signing JWT tokens. If empty, a random key will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_id" { + description = "The OAuth client ID for the application. If empty, a random client ID will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_secret" { + description = "The OAuth client secret for the application. If empty, a random client secret will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_authorization_code" { + description = "The authorization code for the application. If empty, a random code will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "manifest_files_paths" { + description = "The list of manifest files relative paths to generate" + type = list(string) + default = [ + "../../manifests/authorizationCode/ReadOnlyManifest.json", + "../../manifests/authorizationCode/ReadWriteManifest.json", + "../../manifests/clientCredentials/ReadOnlyManifest.json", + "../../manifests/clientCredentials/ReadWriteManifest.json", + ] +} + +variable "output_manifest_files_directory" { + description = "The directory to output the generated manifest files" + type = string + nullable = false + default = ".terraform" +} + +variable "tags" { + type = map(string) + description = "A map of the tags to apply to various resources" + default = {} +} diff --git a/terraform/azure/webapp.tf b/terraform/azure/webapp.tf new file mode 100644 index 0000000..5e23b3c --- /dev/null +++ b/terraform/azure/webapp.tf @@ -0,0 +1,98 @@ +locals { + application_service_plan_name = coalesce( + var.application_service_plan_name, + lower(join(local.resource_name_separator, compact(["asp", var.application_name]))) + ) + + application_service_plan_os_type = "Linux" + + created_application_service_plan_id = azurerm_service_plan.this.id + + application_webapp_name = coalesce( + var.application_webapp_name, + lower(join(local.resource_name_separator, compact(["app", var.application_name, one(random_id.web_app[*].hex)]))) + ) + + application_webapp_system_settings = { + DOCKER_ENABLE_CI = true + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + WEBSITES_PORT = var.application_port + ASPNETCORE_FORWARDEDHEADERS_ENABLED = "true" + ASPNETCORE_URLS = "http://+:${var.application_port}" + } + + application_webapp_container_settings = { + AuthSettings__JwtSecretKey = local.application_jwt_secret_key + AuthSettings__OAuthClientId = local.application_oauth_client_id + AuthSettings__OAuthClientSecret = local.application_oauth_client_secret + AuthSettings__AuthorizationCode = local.application_authorization_code + } + + application_webapp_settings = merge( + local.application_webapp_system_settings, + local.application_webapp_container_settings + ) + + created_application_webapp_principal_id = one(azurerm_linux_web_app.this.identity[*].principal_id) + + application_service_protocol = "https" + application_service_url = join("://", [local.application_service_protocol, azurerm_linux_web_app.this.default_hostname]) +} + +resource "azurerm_service_plan" "this" { + name = local.application_service_plan_name + + resource_group_name = local.created_resource_group_name + location = local.created_resource_group_location + + os_type = local.application_service_plan_os_type + sku_name = var.application_service_plan_sku_name + + worker_count = var.application_service_plan_worker_count + + tags = local.tags +} + +resource "random_id" "web_app" { + count = var.do_randomize_resource_names ? 1 : 0 + + byte_length = 2 + + keepers = { + resource_group_id = local.created_resource_group_id + } +} + +resource "azurerm_linux_web_app" "this" { + name = local.application_webapp_name + + resource_group_name = local.created_resource_group_name + location = local.created_resource_group_location + + service_plan_id = local.created_application_service_plan_id + + identity { + type = "SystemAssigned" + } + + site_config { + application_stack { + docker_image_name = terraform_data.push_docker_image.output.application_image_name_without_registry + docker_registry_url = local.created_container_registry_url + } + always_on = var.is_application_webapp_always_on + container_registry_use_managed_identity = true + } + + app_settings = local.application_webapp_settings + + tags = local.tags +} + +resource "azurerm_role_assignment" "pull_image" { + role_definition_name = "AcrPull" + scope = local.created_container_registry_id + principal_id = local.created_application_webapp_principal_id +} + + diff --git a/terraform/common/modules/docker/README.md b/terraform/common/modules/docker/README.md new file mode 100644 index 0000000..1734f26 --- /dev/null +++ b/terraform/common/modules/docker/README.md @@ -0,0 +1,64 @@ +# Shared reusable module for Docker-related resources + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.0, < 2.0.0 | +| [docker](#requirement\_docker) | ~> 3.0 | +| [time](#requirement\_time) | ~> 0.12 | + +## Providers + +| Name | Version | +|------|---------| +| [docker](#provider\_docker) | ~> 3.0 | +| [time](#provider\_time) | ~> 0.12 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [docker_image.app](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs/resources/image) | resource | +| [docker_image.base](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs/resources/image) | resource | +| [docker_registry_image.app](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs/resources/registry_image) | resource | +| [time_static.app_docker_image](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/static) | resource | +| [docker_registry_image.base](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs/data-sources/registry_image) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [app\_image\_build\_args](#input\_app\_image\_build\_args) | The build arguments to pass to the build process. | `map(string)` | `{}` | no | +| [app\_image\_build\_context](#input\_app\_image\_build\_context) | The absolute path to the build context. If it is empty, the application image will not be built. | `string` | `""` | no | +| [app\_image\_build\_dockerfile](#input\_app\_image\_build\_dockerfile) | The path to the Dockerfile to use for building the image. | `string` | `null` | no | +| [app\_image\_build\_labels](#input\_app\_image\_build\_labels) | The labels to apply to the image. | `map(string)` | `{}` | no | +| [app\_image\_build\_paths](#input\_app\_image\_build\_paths) | Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset). | `list(string)` | `[]` | no | +| [app\_image\_build\_platform](#input\_app\_image\_build\_platform) | The platform to build the image for. | `string` | `"linux/amd64"` | no | +| [app\_image\_build\_tags](#input\_app\_image\_build\_tags) | The tags to apply to the image. | `list(string)` | `[]` | no | +| [app\_image\_build\_target\_stage](#input\_app\_image\_build\_target\_stage) | The target build stage. | `string` | `null` | no | +| [app\_image\_name](#input\_app\_image\_name) | The name of the application image to build. If it is empty, the application image will not be built. | `string` | `""` | no | +| [base\_image\_name](#input\_base\_image\_name) | The name of the base image to use. If it is empty, the base image will not be pulled. | `string` | `""` | no | +| [do\_build\_app\_image](#input\_do\_build\_app\_image) | Whether to build the application image. | `bool` | `true` | no | +| [do\_force\_remove\_images](#input\_do\_force\_remove\_images) | Whether to force remove images when the Terrafrom resources are destroyed. | `bool` | `false` | no | +| [do\_keep\_images\_locally](#input\_do\_keep\_images\_locally) | Whether to keep images locally after destroy operation. | `bool` | `true` | no | +| [do\_keep\_images\_remotely](#input\_do\_keep\_images\_remotely) | Whether to keep images remotely after destroy operation. | `bool` | `true` | no | +| [do\_pull\_base\_image](#input\_do\_pull\_base\_image) | Whether to pull the base image. | `bool` | `true` | no | +| [do\_push\_app\_image](#input\_do\_push\_app\_image) | Whether to push the application image. | `bool` | `true` | no | +| [do\_use\_build\_cache](#input\_do\_use\_build\_cache) | Whether to use the build cache. | `bool` | `true` | no | +| [do\_use\_timestamp\_tag](#input\_do\_use\_timestamp\_tag) | Whether to add a timestamp tag to the image. | `bool` | `true` | no | +| [enabled](#input\_enabled) | Whether the module should be enabled. If it is false, the module will not do anything. | `bool` | `true` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [app\_image\_name](#output\_app\_image\_name) | The name of the built application image. | +| [app\_image\_repo\_digest](#output\_app\_image\_repo\_digest) | The digest of the built application image. | +| [base\_image\_name](#output\_base\_image\_name) | The name of the used base image. | + diff --git a/terraform/common/modules/docker/main.tf b/terraform/common/modules/docker/main.tf new file mode 100644 index 0000000..aee52f2 --- /dev/null +++ b/terraform/common/modules/docker/main.tf @@ -0,0 +1,105 @@ +locals { + do_pull_base_image = alltrue([ + var.enabled, + var.do_pull_base_image + ]) + do_build_app_image = alltrue([ + var.enabled, + var.do_build_app_image + ]) + do_push_app_image = alltrue([ + var.enabled, + var.do_push_app_image + ]) + + app_image_tag = strcontains(var.app_image_name, ":") && !strcontains(var.app_image_name, "@sha256") ? element(split(":", var.app_image_name), 1) : null + app_image_name_without_tag = element(split(":", var.app_image_name), 0) + + timestamp_tag = formatdate("YYYYMMDDHHmmss", one(time_static.app_docker_image[*].rfc3339)) + + app_image_name_with_timestamp_tag = join(":", compact([local.app_image_name_without_tag, local.timestamp_tag])) + app_image_build_name = var.do_use_timestamp_tag && local.app_image_tag == null ? local.app_image_name_with_timestamp_tag : var.app_image_name + app_image_build_tags = var.do_use_timestamp_tag && local.app_image_tag != null ? concat(var.app_image_build_tags, [local.app_image_name_with_timestamp_tag]) : var.app_image_build_tags + + app_image_build_labels = merge( + var.app_image_build_labels, + { + "org.opencontainers.image.created" = one(time_static.app_docker_image[*].rfc3339) + } + ) + + file_path_separator = "/" + app_image_build_paths_md5 = length(var.app_image_build_paths) == 0 ? null : md5(join("", [for path in var.app_image_build_paths : md5(join("", [for file in fileset(var.app_image_build_context, path) : filemd5(join(local.file_path_separator, [var.app_image_build_context, file]))]))])) + + base_image_name = one(data.docker_registry_image.base[*].name) + base_image_sha256_digest = one(data.docker_registry_image.base[*].sha256_digest) + + app_image_name = try(coalesce(one(docker_registry_image.app[*].name), one(docker_image.app[*].name)), null) + app_image_repo_digest = one(docker_image.app[*].repo_digest) +} + +data "docker_registry_image" "base" { + count = local.do_pull_base_image ? 1 : 0 + + name = var.base_image_name +} + +resource "docker_image" "base" { + count = local.do_pull_base_image ? 1 : 0 + + name = local.base_image_name + + keep_locally = var.do_keep_images_locally + force_remove = var.do_force_remove_images + + pull_triggers = [ + local.base_image_sha256_digest, + ] +} + +resource "time_static" "app_docker_image" { + count = local.do_build_app_image ? 1 : 0 + + triggers = { + base_image_id = one(docker_image.base[*].id) + paths_md5 = local.app_image_build_paths_md5 + } +} + +resource "docker_image" "app" { + count = local.do_build_app_image ? 1 : 0 + + name = local.app_image_build_name + + keep_locally = var.do_keep_images_locally + force_remove = var.do_force_remove_images + + build { + context = var.app_image_build_context + dockerfile = var.app_image_build_dockerfile + platform = var.app_image_build_platform + target = var.app_image_build_target_stage + build_args = var.app_image_build_args + no_cache = !var.do_use_build_cache + tag = local.app_image_build_tags + label = local.app_image_build_labels + } + + triggers = { + base_image_id = one(docker_image.base[*].id) + paths_md5 = local.app_image_build_paths_md5 + } +} + +resource "docker_registry_image" "app" { + count = local.do_push_app_image ? 1 : 0 + + keep_remotely = var.do_keep_images_remotely + + name = coalesce(one(docker_image.app[*].name), var.app_image_name) + + triggers = { + app_image_id = one(docker_image.app[*].id) + } +} + diff --git a/terraform/common/modules/docker/outputs.tf b/terraform/common/modules/docker/outputs.tf new file mode 100644 index 0000000..64ae259 --- /dev/null +++ b/terraform/common/modules/docker/outputs.tf @@ -0,0 +1,14 @@ +output "base_image_name" { + description = "The name of the used base image." + value = local.base_image_name +} + +output "app_image_name" { + description = "The name of the built application image." + value = local.app_image_name +} + +output "app_image_repo_digest" { + description = "The digest of the built application image." + value = local.app_image_repo_digest +} diff --git a/terraform/common/modules/docker/terraform.tf b/terraform/common/modules/docker/terraform.tf new file mode 100644 index 0000000..47a7d6d --- /dev/null +++ b/terraform/common/modules/docker/terraform.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/terraform/common/modules/docker/variables.tf b/terraform/common/modules/docker/variables.tf new file mode 100644 index 0000000..ac747ca --- /dev/null +++ b/terraform/common/modules/docker/variables.tf @@ -0,0 +1,124 @@ +variable "enabled" { + description = "Whether the module should be enabled. If it is false, the module will not do anything." + type = bool + default = true +} + +variable "do_pull_base_image" { + description = "Whether to pull the base image." + type = bool + default = true +} + +variable "do_build_app_image" { + description = "Whether to build the application image." + type = bool + default = true +} + +variable "do_push_app_image" { + description = "Whether to push the application image." + type = bool + default = true +} + +variable "do_force_remove_images" { + description = "Whether to force remove images when the Terrafrom resources are destroyed." + type = bool + default = false +} + +variable "do_keep_images_locally" { + description = "Whether to keep images locally after destroy operation." + type = bool + default = true +} + +variable "do_keep_images_remotely" { + description = "Whether to keep images remotely after destroy operation." + type = bool + default = true +} + +variable "base_image_name" { + description = "The name of the base image to use. If it is empty, the base image will not be pulled." + type = string + nullable = false + default = "" +} + +variable "app_image_name" { + description = "The name of the application image to build. If it is empty, the application image will not be built." + type = string + nullable = false + default = "" +} + +variable "app_image_build_context" { + description = "The absolute path to the build context. If it is empty, the application image will not be built." + type = string + nullable = false + default = "" +} + +variable "app_image_build_dockerfile" { + description = "The path to the Dockerfile to use for building the image." + type = string + nullable = true + default = null +} + +variable "app_image_build_platform" { + description = "The platform to build the image for." + type = string + nullable = false + default = "linux/amd64" + + validation { + condition = can(regex("^[a-z0-9]+/[a-z0-9]+$", var.app_image_build_platform)) + error_message = "The platform must be in the format 'os/arch'." + } +} + +variable "app_image_build_target_stage" { + description = "The target build stage." + type = string + nullable = true + default = null +} + +variable "app_image_build_args" { + description = "The build arguments to pass to the build process." + type = map(string) + default = {} +} + +variable "app_image_build_labels" { + description = "The labels to apply to the image." + type = map(string) + default = {} +} + +variable "app_image_build_tags" { + description = "The tags to apply to the image." + type = list(string) + default = [] +} + +variable "do_use_timestamp_tag" { + description = "Whether to add a timestamp tag to the image." + type = bool + default = true +} + +variable "app_image_build_paths" { + description = "Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset)." + type = list(string) + default = [] +} + +variable "do_use_build_cache" { + description = "Whether to use the build cache." + type = bool + default = true +} diff --git a/terraform/common/modules/generate/README.md b/terraform/common/modules/generate/README.md new file mode 100644 index 0000000..1b0f128 --- /dev/null +++ b/terraform/common/modules/generate/README.md @@ -0,0 +1,44 @@ +# Shared reusable module for generating secret values + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.0, < 2.0.0 | +| [random](#requirement\_random) | ~> 3.6 | +| [time](#requirement\_time) | ~> 0.12 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | ~> 3.6 | +| [time](#provider\_time) | ~> 0.12 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [random_bytes.this](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/bytes) | resource | +| [time_rotating.this](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [do\_rotate](#input\_do\_rotate) | Whether to rotate the random bytes | `bool` | `true` | no | +| [random\_bytes\_length](#input\_random\_bytes\_length) | The length of the random bytes | `number` | `64` | no | +| [random\_bytes\_type](#input\_random\_bytes\_type) | The type of the random bytes | `string` | `"hex"` | no | +| [rotation\_days](#input\_rotation\_days) | The number of days after which the random bytes should be rotated | `number` | `30` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [random\_bytes](#output\_random\_bytes) | The generated random bytes | + diff --git a/terraform/common/modules/generate/main.tf b/terraform/common/modules/generate/main.tf new file mode 100644 index 0000000..dbd8590 --- /dev/null +++ b/terraform/common/modules/generate/main.tf @@ -0,0 +1,20 @@ +locals { + random_bytes = { + "hex" = one(random_bytes.this[*].hex) + "base64" = one(random_bytes.this[*].base64) + } +} + +resource "time_rotating" "this" { + count = var.do_rotate ? 1 : 0 + + rotation_days = var.rotation_days +} + +resource "random_bytes" "this" { + length = var.random_bytes_length + + keepers = { + rotation = one(time_rotating.this[*].id) + } +} diff --git a/terraform/common/modules/generate/outputs.tf b/terraform/common/modules/generate/outputs.tf new file mode 100644 index 0000000..4217e2b --- /dev/null +++ b/terraform/common/modules/generate/outputs.tf @@ -0,0 +1,4 @@ +output "random_bytes" { + description = "The generated random bytes" + value = local.random_bytes[var.random_bytes_type] +} diff --git a/terraform/common/modules/generate/terraform.tf b/terraform/common/modules/generate/terraform.tf new file mode 100644 index 0000000..f3c23f1 --- /dev/null +++ b/terraform/common/modules/generate/terraform.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/terraform/common/modules/generate/variables.tf b/terraform/common/modules/generate/variables.tf new file mode 100644 index 0000000..611d78c --- /dev/null +++ b/terraform/common/modules/generate/variables.tf @@ -0,0 +1,36 @@ +variable "random_bytes_length" { + description = "The length of the random bytes" + type = number + nullable = false + default = 64 +} + +variable "random_bytes_type" { + description = "The type of the random bytes" + type = string + nullable = false + default = "hex" + + validation { + condition = contains( + [ + "hex", + "base64", + ], var.random_bytes_type) + error_message = "The type of the random bytes must be one of 'hex' or 'base64'" + } +} + +variable "do_rotate" { + description = "Whether to rotate the random bytes" + type = bool + nullable = false + default = true +} + +variable "rotation_days" { + description = "The number of days after which the random bytes should be rotated" + type = number + nullable = false + default = 30 +} diff --git a/terraform/common/modules/template/README.md b/terraform/common/modules/template/README.md new file mode 100644 index 0000000..e4ad063 --- /dev/null +++ b/terraform/common/modules/template/README.md @@ -0,0 +1,40 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.0, < 2.0.0 | +| [local](#requirement\_local) | ~> 2.5 | + +## Providers + +| Name | Version | +|------|---------| +| [local](#provider\_local) | ~> 2.5 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [local_sensitive_file.this](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/sensitive_file) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [base\_url](#input\_base\_url) | The base URL. If it is not provided, the module will not do anything | `string` | `""` | no | +| [client\_id](#input\_client\_id) | The OAuth client ID. If it is not provided, the module will not do anything | `string` | `""` | no | +| [client\_secret](#input\_client\_secret) | The OAuth client secret. If it is not provided, the module will not do anything | `string` | `""` | no | +| [input\_file\_path](#input\_input\_file\_path) | The absolute path to the input file. If it doesn't exist, the module will not do anything | `string` | `""` | no | +| [output\_file\_path](#input\_output\_file\_path) | The absolute path to the output file. If it is not provided, the module will not do anything | `string` | `""` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [output\_file\_path](#output\_output\_file\_path) | The absolute path to the output file | + diff --git a/terraform/common/modules/template/main.tf b/terraform/common/modules/template/main.tf new file mode 100644 index 0000000..33228c9 --- /dev/null +++ b/terraform/common/modules/template/main.tf @@ -0,0 +1,35 @@ +locals { + enabled = alltrue([ + fileexists(var.input_file_path), + var.client_id != "", + var.client_secret != "", + var.base_url != "", + var.output_file_path != "", + ]) + + input_file_content = local.enabled ? file(var.input_file_path) : "" + + template_variables = { + CLIENT_ID = var.client_id + CLIENT_SECRET = var.client_secret + PROXY_BASE_URL = var.base_url + } + + output_file_content = join("\n", [ + for line in split("\n", local.input_file_content) : + format( + replace(line, "/<(${join("|", keys(local.template_variables))})>/", "%s"), + [ + for value in flatten(regexall("<(${join("|", keys(local.template_variables))})>", line)) : + local.template_variables[value] + ]... + ) + ]) + + output_file_path = nonsensitive(local_sensitive_file.this.filename != "/dev/null" ? local_sensitive_file.this.filename : "") +} + +resource "local_sensitive_file" "this" { + content = local.output_file_content + filename = local.enabled ? var.output_file_path : "/dev/null" +} diff --git a/terraform/common/modules/template/outputs.tf b/terraform/common/modules/template/outputs.tf new file mode 100644 index 0000000..a57d302 --- /dev/null +++ b/terraform/common/modules/template/outputs.tf @@ -0,0 +1,4 @@ +output "output_file_path" { + description = "The absolute path to the output file" + value = local.output_file_path +} diff --git a/terraform/common/modules/template/terraform.tf b/terraform/common/modules/template/terraform.tf new file mode 100644 index 0000000..1dfa3f8 --- /dev/null +++ b/terraform/common/modules/template/terraform.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + } +} diff --git a/terraform/common/modules/template/variables.tf b/terraform/common/modules/template/variables.tf new file mode 100644 index 0000000..daa27d0 --- /dev/null +++ b/terraform/common/modules/template/variables.tf @@ -0,0 +1,34 @@ +variable "input_file_path" { + description = "The absolute path to the input file. If it doesn't exist, the module will not do anything" + type = string + nullable = false + default = "" +} + +variable "client_id" { + description = "The OAuth client ID. If it is not provided, the module will not do anything" + type = string + nullable = false + default = "" +} + +variable "client_secret" { + description = "The OAuth client secret. If it is not provided, the module will not do anything" + type = string + nullable = false + default = "" +} + +variable "base_url" { + description = "The base URL. If it is not provided, the module will not do anything" + type = string + nullable = false + default = "" +} + +variable "output_file_path" { + description = "The absolute path to the output file. If it is not provided, the module will not do anything" + type = string + nullable = false + default = "" +} diff --git a/terraform/gcp/README.md b/terraform/gcp/README.md new file mode 100644 index 0000000..a132446 --- /dev/null +++ b/terraform/gcp/README.md @@ -0,0 +1,80 @@ +# Terraform configuration for deploying the C# reference implementation to Google Cloud + +This Terraform root deploys the C# Data IO reference implementation to Google Cloud Run. It builds the application image from `ExtensionAppDataIO/Containerfile`, pushes it to Artifact Registry, provisions a Cloud Run service, and renders hosted manifest files into `.terraform/` for later upload to the DocuSign Developer Console. + +## Specific cloud prerequisites + +Before deploying on Google Cloud, complete the following setup: + +1. Sign in to a Google Cloud project that can create Cloud Run and Artifact Registry resources. +1. Install and initialize the Google Cloud CLI. +1. Authenticate locally: + + ```sh + gcloud auth login + gcloud auth application-default login + gcloud config set project + ``` + +1. Install Terraform. +1. Install and start Docker so Terraform can build and push the application image. + +If you prefer Podman, expose a Docker-compatible API endpoint and pass that endpoint through the `docker_host` Terraform input. This configuration still uses the Docker provider and related Docker resources, so Podman is not a drop-in local runtime. + +## What this configuration deploys + +The GCP Terraform root provisions: + +- A Google Artifact Registry Docker repository for the application image +- A Google Cloud Run service for the C# web application +- Generated hosted manifest files written into `.terraform/` + +The containerized application listens on port `8080` inside the container. Cloud Run is configured with `ASPNETCORE_URLS=http://+:8080` so the deployed service binds to the expected port. + +## Application sources used by Terraform + +Terraform builds from the repository root and uses `ExtensionAppDataIO/Containerfile` as the container build file. + +The generated hosted manifests are based on these source manifest trees: + +- `manifests/authorizationCode/ReadOnlyManifest.json` +- `manifests/authorizationCode/ReadWriteManifest.json` +- `manifests/clientCredentials/ReadOnlyManifest.json` +- `manifests/clientCredentials/ReadWriteManifest.json` + +During `terraform apply`, the rendered hosted manifests are emitted into `.terraform/` with filenames that include the manifest source directory name. + +## Runtime configuration injected by Terraform + +Terraform injects the ASP.NET Core authentication settings that the C# app binds from configuration: + +- `AuthSettings__JwtSecretKey` +- `AuthSettings__OAuthClientId` +- `AuthSettings__OAuthClientSecret` +- `AuthSettings__AuthorizationCode` + +If you do not provide explicit values for these variables, Terraform generates them for the Cloud Run runtime configuration. + +The generated hosted manifest files are templated separately. They only substitute these hosted manifest placeholders: + +- `CLIENT_ID` +- `CLIENT_SECRET` +- `PROXY_BASE_URL` + +## Deploying + +From the C# repository root: + +```sh +cd terraform/gcp +terraform init -upgrade +terraform plan -out app.tfplan +terraform apply app.tfplan +``` + +Use `terraform output` after apply to retrieve: + +- `application_service_url` +- `output_manifest_files_paths` + +Upload the generated hosted manifest files from `.terraform/` to the DocuSign Developer Console. diff --git a/terraform/gcp/cloudrun.tf b/terraform/gcp/cloudrun.tf new file mode 100644 index 0000000..07fa1a4 --- /dev/null +++ b/terraform/gcp/cloudrun.tf @@ -0,0 +1,51 @@ +locals { + application_cloud_run_service_name = lower(coalesce(var.application_cloud_run_service_name, join("-", compact(["run", var.application_name])))) + + application_cloud_run_container_environment_variables = { + ASPNETCORE_FORWARDEDHEADERS_ENABLED = "true" + ASPNETCORE_URLS = "http://+:${var.application_port}" + AuthSettings__JwtSecretKey = local.application_jwt_secret_key + AuthSettings__OAuthClientId = local.application_oauth_client_id + AuthSettings__OAuthClientSecret = local.application_oauth_client_secret + AuthSettings__AuthorizationCode = local.application_authorization_code + } + + application_service_url = one(google_cloud_run_service.this.status[*].url) +} + +resource "google_cloud_run_service" "this" { + name = local.application_cloud_run_service_name + location = local.region + + template { + spec { + service_account_name = local.application_service_account_email + + containers { + image = terraform_data.push_docker_image.output.application_image_name + + dynamic "env" { + for_each = local.application_cloud_run_container_environment_variables + + content { + name = env.key + value = env.value + } + } + + ports { + container_port = var.application_port + } + } + } + } +} + +resource "google_cloud_run_service_iam_binding" "invokers" { + service = google_cloud_run_service.this.name + location = google_cloud_run_service.this.location + role = "roles/run.invoker" + members = [ + "allUsers", + ] +} diff --git a/terraform/gcp/gar.tf b/terraform/gcp/gar.tf new file mode 100644 index 0000000..95a1d84 --- /dev/null +++ b/terraform/gcp/gar.tf @@ -0,0 +1,34 @@ +locals { + application_repository_name = lower(coalesce(var.application_repository_name, join("-", compact(["repo", var.application_name])))) + + artifact_registry_location = google_artifact_registry_repository.this.location + artifact_registry_project = google_artifact_registry_repository.this.project + artifact_registry_repository = google_artifact_registry_repository.this.repository_id + artifact_registry_login_server = join("-", compact([local.artifact_registry_location, "docker.pkg.dev"])) + + artifact_registry_protocol = "https" + artifact_registry_url = join("://", compact([ + local.artifact_registry_protocol, + local.artifact_registry_login_server + ])) +} + +resource "google_artifact_registry_repository" "this" { + repository_id = local.application_repository_name + location = local.region + format = "DOCKER" + + dynamic "docker_config" { + for_each = var.are_image_tags_mutable != null ? [1] : [] + content { + immutable_tags = var.are_image_tags_mutable + } + } + + dynamic "vulnerability_scanning_config" { + for_each = var.do_scan_images ? [1] : [] + content { + enablement_config = "INHERITED" + } + } +} diff --git a/terraform/gcp/image.tf b/terraform/gcp/image.tf new file mode 100644 index 0000000..ff0f700 --- /dev/null +++ b/terraform/gcp/image.tf @@ -0,0 +1,116 @@ +locals { + application_build_context_requested = ( + startswith(var.application_build_context, "/") || + can(regex("^[A-Za-z]:[/\\\\]", var.application_build_context)) + ) ? var.application_build_context : abspath(join(local.file_path_separator, compact([path.cwd, var.application_build_context]))) + # On Windows, Docker provider can mis-handle '#' in paths. Prefer a safe junction under the detected workspace root when available. + application_build_context_safe = join(local.file_path_separator, [local.application_build_context_requested, "extappdataio-safe"]) + application_build_context = fileexists(join(local.file_path_separator, [local.application_build_context_safe, "ExtensionAppDataIO", "Containerfile"])) ? local.application_build_context_safe : local.application_build_context_requested + application_runtime_image = var.application_build_base_image_name != "" ? var.application_build_base_image_name : var.application_build_runtime_image_name + + application_image_name = join(local.docker_registry_separator, compact([ + local.artifact_registry_login_server, + local.artifact_registry_project, + local.artifact_registry_repository, + join(local.docker_image_tag_separator, compact([ + var.application_name, + var.application_build_image_tag + ])), + ])) + + application_image_name_without_registry = trimprefix( + module.image.app_image_name, + join("", compact([ + local.artifact_registry_login_server, + local.docker_registry_separator, + ])) + ) + + application_build_arguments = { + PORT = tostring(var.application_port) + SDK_IMAGE = var.application_build_sdk_image_name + RUNTIME_IMAGE = local.application_runtime_image + PROJECT_CONTEXT_DIR = "ExtensionAppDataIO" + PROJECT_FILE = "ExtensionAppDataIO.csproj" + PROJECT_NAME = "ExtensionAppDataIO" + } + + application_build_dockerfile = { + docker = "ExtensionAppDataIO/Containerfile" + podman = "ExtensionAppDataIO/Containerfile" + } +} + +resource "local_file" "docker_config" { + filename = "${path.module}/.docker/config.json" + content = jsonencode({ + auths = { + (local.artifact_registry_url) = { + auth = base64encode("_json_key_base64:${local.application_service_account_private_key}") + } + } + }) +} + +module "image" { + source = "../common/modules/docker" + + base_image_name = local.application_runtime_image + + app_image_name = local.application_image_name + app_image_build_context = local.application_build_context + app_image_build_dockerfile = lookup(local.application_build_dockerfile, var.container_tool, null) + app_image_build_target_stage = var.application_environment_mode + app_image_build_paths = var.application_build_paths + app_image_build_args = local.application_build_arguments + app_image_build_labels = var.application_build_labels + + do_push_app_image = false +} + +resource "terraform_data" "login_container_registry" { + input = { + container_registry_login_server = local.artifact_registry_login_server + + docker_config = dirname(local_file.docker_config.filename) + } + + triggers_replace = [ + local_file.docker_config.id, + google_artifact_registry_repository.this.id, + google_artifact_registry_repository_iam_binding.readers.etag, + google_artifact_registry_repository_iam_binding.writers.etag, + ] + +} + + +resource "terraform_data" "push_docker_image" { + input = { + application_image_name = join(local.docker_registry_separator, compact([ + terraform_data.login_container_registry.output.container_registry_login_server, + local.application_image_name_without_registry, + ])) + + container_tool = var.container_tool + docker_config = terraform_data.login_container_registry.output.docker_config + } + + triggers_replace = [ + terraform_data.login_container_registry.id, + module.image.app_image_repo_digest, + ] + + provisioner "local-exec" { + environment = { + DOCKER_CONFIG = self.input.docker_config + } + command = "${self.input.container_tool} push ${self.input.application_image_name}" + } + + provisioner "local-exec" { + when = destroy + on_failure = continue + command = "gcloud artifacts docker images delete ${self.input.application_image_name} --quiet" + } +} diff --git a/terraform/gcp/main.tf b/terraform/gcp/main.tf new file mode 100644 index 0000000..725fd69 --- /dev/null +++ b/terraform/gcp/main.tf @@ -0,0 +1,64 @@ +data "google_client_config" "this" {} + +locals { + region = coalesce(var.region, data.google_client_config.this.region) + + application_jwt_secret_key = var.application_jwt_secret_key != "" ? var.application_jwt_secret_key : one(module.generate_jwt_secret_key[*].random_bytes) + application_oauth_client_id = var.application_oauth_client_id != "" ? var.application_oauth_client_id : one(module.generate_oauth_client_id[*].random_bytes) + application_oauth_client_secret = var.application_oauth_client_secret != "" ? var.application_oauth_client_secret : one(module.generate_oauth_client_secret[*].random_bytes) + application_authorization_code = var.application_authorization_code != "" ? var.application_authorization_code : one(module.generate_authorization_code[*].random_bytes) + + file_path_separator = "/" + docker_registry_separator = "/" + docker_image_tag_separator = ":" + + output_manifest_files_directory = abspath(join(local.file_path_separator, compact([path.cwd, var.output_manifest_files_directory]))) + output_manifest_files_paths = module.manifest[*].output_file_path + + labels = merge( + { + application = var.application_name + }, + var.labels + ) +} + +module "generate_jwt_secret_key" { + count = var.application_jwt_secret_key == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_id" { + count = var.application_oauth_client_id == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_oauth_client_secret" { + count = var.application_oauth_client_secret == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "generate_authorization_code" { + count = var.application_authorization_code == "" ? 1 : 0 + + source = "../common/modules/generate" +} + +module "manifest" { + count = length(var.manifest_files_paths) + + source = "../common/modules/template" + + input_file_path = abspath(join(local.file_path_separator, [path.cwd, var.manifest_files_paths[count.index]])) + output_file_path = join(local.file_path_separator, [ + local.output_manifest_files_directory, + "${basename(dirname(var.manifest_files_paths[count.index]))}.${basename(var.manifest_files_paths[count.index])}" + ]) + + client_id = local.application_oauth_client_id + client_secret = local.application_oauth_client_secret + base_url = local.application_service_url +} diff --git a/terraform/gcp/outputs.tf b/terraform/gcp/outputs.tf new file mode 100644 index 0000000..f0f4c7f --- /dev/null +++ b/terraform/gcp/outputs.tf @@ -0,0 +1,9 @@ +output "application_service_url" { + description = "The base URL of the application service" + value = local.application_service_url +} + +output "output_manifest_files_paths" { + description = "The absolute paths to the output manifest files" + value = local.output_manifest_files_paths +} diff --git a/terraform/gcp/providers.tf b/terraform/gcp/providers.tf new file mode 100644 index 0000000..f7d17f2 --- /dev/null +++ b/terraform/gcp/providers.tf @@ -0,0 +1,13 @@ +provider "google" { + project = var.project + region = var.region + zone = var.zone + + credentials = var.credentials + + default_labels = local.labels +} + +provider "docker" { + host = var.docker_host +} diff --git a/terraform/gcp/sa.tf b/terraform/gcp/sa.tf new file mode 100644 index 0000000..80ffa0d --- /dev/null +++ b/terraform/gcp/sa.tf @@ -0,0 +1,45 @@ +locals { + application_service_account_name = substr(lower(coalesce(var.application_service_account_name, join("-", compact(["sa", var.application_name])))), 6, 23) + application_service_account_email = google_service_account.application.email + application_service_account_private_key = google_service_account_key.application.private_key +} + +resource "google_service_account" "application" { + account_id = local.application_service_account_name + display_name = "Application Service Account" + description = "Service Account for ${var.application_name} application" +} + +resource "time_rotating" "application_service_account_key" { + rotation_days = var.application_service_account_key_rotation_days +} + +resource "google_service_account_key" "application" { + service_account_id = google_service_account.application.name + + keepers = { + rotation_time = time_rotating.application_service_account_key.rotation_rfc3339 + } +} + +resource "google_artifact_registry_repository_iam_binding" "readers" { + location = local.artifact_registry_location + project = local.artifact_registry_project + repository = local.artifact_registry_repository + + role = "roles/artifactregistry.reader" + members = [ + google_service_account.application.member, + ] +} + +resource "google_artifact_registry_repository_iam_binding" "writers" { + location = local.artifact_registry_location + project = local.artifact_registry_project + repository = local.artifact_registry_repository + + role = "roles/artifactregistry.writer" + members = [ + google_service_account.application.member, + ] +} diff --git a/terraform/gcp/terraform.tf b/terraform/gcp/terraform.tf new file mode 100644 index 0000000..9a8afee --- /dev/null +++ b/terraform/gcp/terraform.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + google = { + source = "hashicorp/google" + version = "~> 6.16" + } + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/terraform/gcp/variables.tf b/terraform/gcp/variables.tf new file mode 100644 index 0000000..15d14ea --- /dev/null +++ b/terraform/gcp/variables.tf @@ -0,0 +1,244 @@ +variable "project" { + description = "The default project project to manage resources in. If another project is specified on a resource, it will take precedence. This can also be specified using the `GOOGLE_PROJECT` environment variable" + type = string + nullable = true + default = null +} + +variable "region" { + description = "The default region to manage resources in. If another region is specified on a regional resource, it will take precedence. Alternatively, this can be specified using the `GOOGLE_REGION` environment variable" + type = string + nullable = true + default = "us-central1" +} + +variable "zone" { + description = "The default zone to manage resources in. Generally, this zone should be within the default region you specified. If another zone is specified on a zonal resource, it will take precedence. Alternatively, this can be specified using the `GOOGLE_ZONE` environment variable" + type = string + nullable = true + default = "us-central1-a" +} + +variable "credentials" { + description = "The credentials to use to authenticate against Google Cloud Platform. This can be a path to a file which contains service account key file in JSON format, or the credentials themselves. You can alternatively use the `GOOGLE_CREDENTIALS` environment variable" + type = any + default = null +} + +variable "docker_host" { + description = "The Docker host (e.g. 'tcp://127.0.0.1:2376' or 'unix:///var/run/docker.sock') to connect to. If empty, the default Docker host will be used" + type = string + nullable = true + default = null +} + +variable "container_tool" { + description = "The container tool to use for building and pushing images" + type = string + nullable = false + default = "docker" + + validation { + condition = contains(["docker", "podman"], var.container_tool) + error_message = "The container tool must be one of 'docker' or 'podman'" + } +} + +variable "application_name" { + description = "The name of the application" + type = string + nullable = false + default = "extension-app-data-io-cs" + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.application_name)) && length(var.application_name) <= 32 + error_message = "The application name must contain only alphanumeric characters and hyphens and be at most 32 characters long" + } +} + +variable "application_port" { + description = "The port the application listens on" + type = number + nullable = false + default = 8080 +} + +variable "application_build_base_image_name" { + description = "Deprecated compatibility alias for the runtime base image. If non-empty, it overrides application_build_runtime_image_name." + type = string + nullable = false + default = "" +} + +variable "application_build_sdk_image_name" { + description = "The SDK image to use for the application build stages" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/sdk:10.0" +} + +variable "application_build_runtime_image_name" { + description = "The runtime image to use for the final application container stage" + type = string + nullable = false + default = "mcr.microsoft.com/dotnet/aspnet:10.0" +} + +variable "application_build_image_tag" { + description = "The tag to apply to the application build image. If empty the timestamp tag will be used." + type = string + nullable = false + default = "" +} + +variable "application_build_context" { + description = "The relative path to the build context for the application. The build context is the directory from which the Dockerfile is read. If it is empty the current working directory will be used." + type = string + nullable = false + default = "../.." +} + +variable "application_build_paths" { + description = "Paths of files relative to the build context, changes to which lead to a rebuild of the image. Supported pattern matches are the same as for the `fileset` Terraform function (https://developer.hashicorp.com/terraform/language/functions/fileset)." + type = list(string) + default = [ + ".dockerignore", + "ExtensionAppDataIO/**/*.cs", + "ExtensionAppDataIO/**/*.cshtml", + "ExtensionAppDataIO/**/*.json", + "ExtensionAppDataIO/**/*.http", + "ExtensionAppDataIO/Controllers/**", + "ExtensionAppDataIO/Data/**", + "ExtensionAppDataIO/DataModels/**", + "ExtensionAppDataIO/Models/**", + "ExtensionAppDataIO/Properties/**", + "ExtensionAppDataIO/Services/**", + "ExtensionAppDataIO/Views/**", + "ExtensionAppDataIO/wwwroot/**", + "ExtensionAppDataIO/ExtensionAppDataIO.csproj", + "ExtensionAppDataIO/Containerfile", + "manifests/**" + ] +} + +variable "application_environment_mode" { + description = "The environment mode for the application" + type = string + nullable = true + default = "production" + + validation { + condition = contains(["development", "production"], var.application_environment_mode) + error_message = "The environment mode must be one of 'development' or 'production'" + } +} + +variable "application_build_labels" { + description = "The labels to apply to the application build image" + type = map(string) + default = { + "org.opencontainers.image.title" = "Data IO Extension App Reference Implementation (C#)" + "org.opencontainers.image.description" = "C# reference implementation for data input and output extension app workflows." + "org.opencontainers.image.source" = "https://github.com/docusign/extension-app-data-io-reference-implementation-csharp" + "org.opencontainers.image.licenses" = "MIT" + "org.opencontainers.image.authors" = "DocuSign Inc." + "org.opencontainers.image.vendor" = "DocuSign Inc." + } +} + +variable "application_service_account_name" { + description = "The name of the application service account. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "application_service_account_key_rotation_days" { + description = "The number of days after which the application service account key should be rotated" + type = number + nullable = false + default = 30 +} + +variable "application_cloud_run_service_name" { + description = "The name of the Cloud Run service. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "application_jwt_secret_key" { + description = "The secret key to use for signing JWT tokens. If empty, a random key will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_id" { + description = "The OAuth client ID for the application. If empty, a random client ID will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_oauth_client_secret" { + description = "The OAuth client secret for the application. If empty, a random client secret will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "application_authorization_code" { + description = "The authorization code for the application. If empty, a random code will be generated." + type = string + sensitive = true + nullable = false + default = "" +} + +variable "manifest_files_paths" { + description = "The list of manifest files relative paths to generate" + type = list(string) + default = [ + "../../manifests/authorizationCode/ReadOnlyManifest.json", + "../../manifests/authorizationCode/ReadWriteManifest.json", + "../../manifests/clientCredentials/ReadOnlyManifest.json", + "../../manifests/clientCredentials/ReadWriteManifest.json", + ] +} + +variable "output_manifest_files_directory" { + description = "The directory to output the generated manifest files" + type = string + nullable = false + default = ".terraform" +} + +variable "application_repository_name" { + description = "The name of the Google Artifact Registry repository. If it is not defined, the prefixed application name will be used" + type = string + nullable = true + default = null +} + +variable "are_image_tags_mutable" { + description = "The image tag mutability setting for the Google Artifact Registry repository" + type = bool + nullable = true + default = false +} + +variable "do_scan_images" { + description = "Whether images are scanned after being pushed to the Google Artifact Registry repository" + type = bool + default = true +} + +variable "labels" { + description = "A set of key/value label pairs to assign to the resources" + type = map(string) + default = {} +}