diff --git a/README.md b/README.md index 0789bdd0..01809cee 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ The following dependencies must be available: A service account with the following roles must be used to provision the resources of this module: +- roles/cloudbuild.builds.editor - roles/cloudsql.admin - roles/compute.admin - roles/compute.networkAdmin @@ -75,6 +76,7 @@ the resources of this module: - roles/firebasehosting.admin - roles/iam.serviceAccountAdmin - roles/iam.serviceAccountUser +- roles/pubsub.editor - roles/resourcemanager.projectIamAdmin - roles/run.admin - roles/secretmanager.admin diff --git a/app/placeholder/Dockerfile b/app/placeholder/Dockerfile index 81870466..45b23c26 100644 --- a/app/placeholder/Dockerfile +++ b/app/placeholder/Dockerfile @@ -16,9 +16,10 @@ # Execute with "docker run --build-arg PROJECT_ID=$PROJECT_ID ..." ARG PROJECT_ID=YOURPROJECTID FROM gcr.io/$PROJECT_ID/firebase +WORKDIR /app -RUN apk add gettext +RUN apk add gettext curl RUN npm install -g json -COPY . ./ +COPY . /app/ -ENTRYPOINT ./placeholder-deploy.sh +ENTRYPOINT ["/app/placeholder-deploy.sh"] diff --git a/app/placeholder/placeholder-deploy.sh b/app/placeholder/placeholder-deploy.sh index c3ce9941..d55df618 100755 --- a/app/placeholder/placeholder-deploy.sh +++ b/app/placeholder/placeholder-deploy.sh @@ -17,6 +17,24 @@ # any errors? exit immediately. set -e +# Ensure we got to the right directory. Cloud Build may start us in /workspace +cd /app + +# escape if firebase_url not defined (mandatory, required later) +if [[ -z $FIREBASE_URL ]]; then + echo "FIREBASE_URL not defined. Cannot deploy. Exiting." + exit 1 +fi + +# Only run the placeholder script if the site has been deployed before. +# Check if the firebase url has "Site Not Found" (the pre-deployment state) +if curl "$FIREBASE_URL" | grep -q "Site Not Found"; then + echo "Firebase site $FIREBASE_URL hasn't been deployed before, so it needs a placeholder." +else + echo "Firebase site $FIREBASE_URL has been deployed before. Not going to deploy placeholder. Exiting." + exit 0 +fi + # if deploying with a suffix (from infra/jobs.tf), adjust the config to suit the custom site # https://firebase.google.com/docs/hosting/multisites#set_up_deploy_targets if [[ -n $SUFFIX ]]; then @@ -24,7 +42,7 @@ if [[ -n $SUFFIX ]]; then UPDATED=true # Use template file to generate configuration - envsubst < firebaserc.tmpl > .firebaserc + envsubst .firebaserc echo "Customised .firebaserc created to support site." cat .firebaserc fi @@ -35,6 +53,10 @@ if [[ -n $UPDATED ]]; then cat firebase.json fi +# Finally, deploy the application echo "Deploying placeholder to Firebase..." - firebase deploy --project "$PROJECT_ID" --only hosting + +# Setup for greater chances of success by explicitly purging cache +echo "Purging firebase cache" +curl -X PURGE "${FIREBASE_URL}/" diff --git a/infra/README.md b/infra/README.md index 977d1d65..cf4de0c1 100644 --- a/infra/README.md +++ b/infra/README.md @@ -48,7 +48,7 @@ Functional examples are included in the | database\_name | Cloud SQL database name | `string` | `"django"` | no | | database\_username | Cloud SQL database name | `string` | `"server"` | no | | enable\_apis | Whether or not to enable underlying apis in this solution. | `bool` | `true` | no | -| image\_version | Version of the Container Registry image to use | `string` | `"v1.8.2"` | no | +| image\_version | Version of the Container Registry image to use | `string` | `"v1.9.0"` | no | | init | Initialize database? | `bool` | `true` | no | | instance\_name | Cloud SQL Instance name | `string` | `"psql"` | no | | labels | A set of key/value label pairs to assign to the resources deployed by this blueprint. | `map(string)` | `{}` | no | @@ -89,6 +89,7 @@ The following dependencies must be available: A service account with the following roles must be used to provision the resources of this module: +- roles/cloudbuild.builds.editor - roles/cloudsql.admin - roles/compute.admin - roles/compute.networkAdmin @@ -96,6 +97,7 @@ the resources of this module: - roles/firebasehosting.admin - roles/iam.serviceAccountAdmin - roles/iam.serviceAccountUser +- roles/pubsub.editor - roles/resourcemanager.projectIamAdmin - roles/run.admin - roles/secretmanager.admin diff --git a/infra/apis.tf b/infra/apis.tf index 583e47c4..2916801f 100644 --- a/infra/apis.tf +++ b/infra/apis.tf @@ -50,6 +50,7 @@ module "project_services" { "compute.googleapis.com", "firebase.googleapis.com", "firebasehosting.googleapis.com", + "pubsub.googleapis.com", "iam.googleapis.com", "run.googleapis.com", "secretmanager.googleapis.com", diff --git a/infra/iam.tf b/infra/iam.tf index 2513e9eb..ce82d898 100644 --- a/infra/iam.tf +++ b/infra/iam.tf @@ -14,15 +14,31 @@ * limitations under the License. */ -# Service Accounts - locals { - # Helpers for the clunky formatting of these values - automation_SA = "serviceAccount:${google_service_account.automation.email}" - server_SA = "serviceAccount:${google_service_account.server.email}" - client_SA = "serviceAccount:${google_service_account.client.email}" + # Lists of required roles + server_iam_members = [ + "roles/cloudsql.client", + "roles/run.viewer", + "roles/cloudtrace.agent" + ] + client_iam_members = [ + "roles/run.viewer", + "roles/firebasehosting.admin", + ] + automation_iam_members = [ + "roles/cloudsql.client" + ] + init_iam_members = [ + "roles/logging.logWriter", + "roles/cloudbuild.builds.builder", + "roles/iam.serviceAccountUser", + "roles/run.developer", + "roles/firebasehosting.admin" + ] } +# Accounts + resource "google_service_account" "server" { account_id = var.random_suffix ? "api-backend-${random_id.suffix.hex}" : "api-backend" display_name = "API Backend service account" @@ -41,66 +57,58 @@ resource "google_service_account" "automation" { depends_on = [module.project_services] } -resource "google_service_account" "compute" { - account_id = var.random_suffix ? "compute-startup-${random_id.suffix.hex}" : "compute-startup" - display_name = "Head Start App Compute Instance SA" + +resource "google_service_account" "init" { + account_id = var.random_suffix ? "init-startup-${random_id.suffix.hex}" : "init-startup" + display_name = "Jump Start App Init SA" depends_on = [module.project_services] count = var.init ? 1 : 0 } -# The Cloud Run server can access the database +# Permissions + resource "google_project_iam_member" "server_permissions" { - project = var.project_id - role = "roles/cloudsql.client" - member = local.server_SA - depends_on = [google_service_account.server] -} + count = length(local.server_iam_members) -# Cloud Build can access the database -resource "google_project_iam_member" "build_permissions" { - project = var.project_id - role = "roles/cloudsql.client" - member = local.automation_SA - depends_on = [google_service_account.automation] + project = var.project_id + role = local.server_iam_members[count.index] + member = "serviceAccount:${google_service_account.server.email}" } -# Server needs introspection permissions -resource "google_project_iam_member" "server_introspection" { - project = var.project_id - role = "roles/run.viewer" - member = local.server_SA - depends_on = [google_service_account.server] -} +resource "google_project_iam_member" "client_permissions" { + count = length(local.client_iam_members) -# Client needs introspection permissions -resource "google_project_iam_member" "client_introspection" { - project = var.project_id - role = "roles/run.viewer" - member = local.client_SA - depends_on = [google_service_account.client] + project = var.project_id + role = local.client_iam_members[count.index] + member = "serviceAccount:${google_service_account.client.email}" } -# Client may need permission to deploy the front end -resource "google_project_iam_member" "client_permissions" { - project = var.project_id - role = "roles/firebasehosting.admin" - member = local.client_SA - depends_on = [google_service_account.client] +resource "google_project_iam_member" "automation_permissions" { + count = length(local.automation_iam_members) + + project = var.project_id + role = local.automation_iam_members[count.index] + member = "serviceAccount:${google_service_account.automation.email}" } -# GCE instance needs access to start Jobs -resource "google_project_iam_member" "computestartup_permissions" { - project = var.project_id - role = "roles/run.developer" - member = "serviceAccount:${google_service_account.compute[0].email}" - depends_on = [google_service_account.compute] - count = var.init ? 1 : 0 +resource "google_project_iam_member" "init_permissions" { + count = length(local.init_iam_members) + + project = var.project_id + role = local.init_iam_members[count.index] + member = "serviceAccount:${google_service_account.init[0].email}" } -# Server needs to write to Cloud Trace -resource "google_project_iam_member" "server_traceagent" { - project = var.project_id - role = "roles/cloudtrace.agent" - member = local.server_SA - depends_on = [google_service_account.server] +# Ensure google_service_account.init is not used before permissions are available. +# Introduced to allow for IAM policy propagation delay. Time selected to allow: +# propagation delay + ~2 minute firebase hosting deploy <= 5 minutes. +# Shortest delay preferred. +# Warning: Trying to meet IAM propagation delay on roles/logging.logWriter. +# Exceeded safe limit to avoid race conditions between placeholder and init process. +resource "time_sleep" "init_permissions_propagation" { + depends_on = [ + google_project_iam_member.init_permissions + ] + + create_duration = "60s" } diff --git a/infra/jobs.tf b/infra/jobs.tf index 74583cdc..4cf808fa 100644 --- a/infra/jobs.tf +++ b/infra/jobs.tf @@ -14,54 +14,7 @@ * limitations under the License. */ -resource "google_cloud_run_v2_job" "setup" { - name = var.random_suffix ? "setup-${random_id.suffix.hex}" : "setup" - location = var.region - - labels = var.labels - - template { - template { - service_account = google_service_account.automation.email - containers { - image = local.server_image - command = ["setup"] - env { - name = "DJANGO_ENV" - value_source { - secret_key_ref { - secret = google_secret_manager_secret.django_settings.secret_id - version = "latest" - } - } - } - env { - name = "DJANGO_SUPERUSER_PASSWORD" - value_source { - secret_key_ref { - secret = google_secret_manager_secret.django_admin_password.secret_id - version = "latest" - } - } - } - volume_mounts { - name = "cloudsql" - mount_path = "/cloudsql" - } - } - volumes { - name = "cloudsql" - cloud_sql_instance { - instances = [google_sql_database_instance.postgres.connection_name] - } - } - } - } - depends_on = [ - google_secret_manager_secret_version.django_settings - ] -} - +# Job to apply server database updates resource "google_cloud_run_v2_job" "migrate" { name = var.random_suffix ? "migrate-${random_id.suffix.hex}" : "migrate" location = var.region @@ -101,75 +54,4 @@ resource "google_cloud_run_v2_job" "migrate" { ] } - - -resource "google_cloud_run_v2_job" "client" { - - name = var.random_suffix ? "client-${random_id.suffix.hex}" : "client" - location = var.region - - labels = var.labels - - template { - template { - service_account = google_service_account.client.email - containers { - image = local.client_image - - # Variables used to customise Firebase configuration on deployment - # https://github.com/GoogleCloudPlatform/avocano/blob/main/client/docker-deploy.sh - env { - name = "SUFFIX" - value = var.random_suffix ? random_id.suffix.hex : "" - } - env { - name = "SERVICE_NAME" - value = google_cloud_run_v2_service.server.name - } - env { - name = "REGION" - value = var.region - } - env { - name = "PROJECT_ID" - value = var.project_id - } - - } - } - } - - depends_on = [ - module.project_services - ] -} - - -resource "google_cloud_run_v2_job" "placeholder" { - name = var.random_suffix ? "placeholder-${random_id.suffix.hex}" : "placeholder" - location = var.region - - labels = var.labels - - template { - template { - service_account = google_service_account.client.email - containers { - image = local.placeholder_image - env { - name = "PROJECT_ID" - value = var.project_id - } - - env { - name = "SUFFIX" - value = var.random_suffix ? random_id.suffix.hex : "" - } - } - } - } - - depends_on = [ - module.project_services - ] -} +# Other jobs defined in postdeployment.tf diff --git a/infra/metadata.yaml b/infra/metadata.yaml index 04c0cd4c..17bfe30d 100644 --- a/infra/metadata.yaml +++ b/infra/metadata.yaml @@ -69,7 +69,7 @@ spec: - name: image_version description: Version of the Container Registry image to use varType: string - defaultValue: v1.8.2 + defaultValue: v1.9.0 - name: init description: Initialize database? varType: bool @@ -125,6 +125,7 @@ spec: roles: - level: Project roles: + - roles/cloudbuild.builds.editor - roles/cloudsql.admin - roles/compute.admin - roles/compute.networkAdmin @@ -132,6 +133,7 @@ spec: - roles/firebasehosting.admin - roles/iam.serviceAccountAdmin - roles/iam.serviceAccountUser + - roles/pubsub.editor - roles/resourcemanager.projectIamAdmin - roles/run.admin - roles/secretmanager.admin @@ -158,6 +160,7 @@ spec: - firebase.googleapis.com - firebasehosting.googleapis.com - iam.googleapis.com + - pubsub.googleapis.com - run.googleapis.com - secretmanager.googleapis.com - sqladmin.googleapis.com diff --git a/infra/outputs.tf b/infra/outputs.tf index a6790c00..ab48df7f 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -71,5 +71,5 @@ output "server_service_name" { output "client_job_name" { description = "Name of the Cloud Run Job, deploying the front end" - value = google_cloud_run_v2_job.client.name + value = local.client_job_name } diff --git a/infra/postdeployment.tf b/infra/postdeployment.tf index 0ca6b1dd..f30c1382 100644 --- a/infra/postdeployment.tf +++ b/infra/postdeployment.tf @@ -14,126 +14,248 @@ * limitations under the License. */ -resource "google_compute_network" "gce_init" { - count = var.init ? 1 : 0 +locals { + random_suffix_value = var.random_suffix ? random_id.suffix.hex : "" # literal value (NNNN) + random_suffix_append = var.random_suffix ? "-${random_id.suffix.hex}" : "" # appended value (-NNNN) + + setup_job_name = "setup${local.random_suffix_append}" + client_job_name = "client${local.random_suffix_append}" - name = var.random_suffix ? "gce-init-network-${random_id.suffix.hex}" : "gce-init-network" - auto_create_subnetworks = false - routing_mode = "GLOBAL" - project = var.project_id - delete_default_routes_on_create = false - mtu = 0 + gcloud_step_container = "gcr.io/google.com/cloudsdktool/cloud-sdk:slim" +} + +# used to collect access token, for authenticated POST commands +data "google_client_config" "current" { +} - depends_on = [module.project_services] +# topic that is never used, except as a configuration type for Cloud Build Triggers +resource "google_pubsub_topic" "faux" { + name = "faux-topic${local.random_suffix_append}" } -resource "google_compute_subnetwork" "gce_init" { + +## Failing Build - help get Cloud Build ready for work +resource "google_cloudbuild_trigger" "activategcb" { count = var.init ? 1 : 0 - name = var.random_suffix ? "subnet-gce-init-${random_id.suffix.hex}" : "subnet-gce-init" - network = google_compute_network.gce_init[0].id - ip_cidr_range = "10.10.10.0/24" - region = var.region + name = "activategcb${local.random_suffix_append}" + location = var.region + + description = "Get Cloud Build ready for work. This build is expected to fail." + + pubsub_config { + topic = google_pubsub_topic.faux.id + } - depends_on = [module.project_services] + service_account = google_service_account.init[0].id + + build { + step { + id = "no-op" + name = "ubuntu" + script = "echo 'Hello Cloud Build'" + } + + options { + logging = "CLOUD_LOGGING_ONLY" + } + } + + depends_on = [ + # This is waiting for IAM to update but skipping the delay for IAM propagation. + google_project_iam_member.init_permissions + ] } -resource "google_compute_instance" "gce_init" { +# execute the trigger, once it and other dependencies exist. Intended side-effect. +# tflint-ignore: terraform_unused_declarations +data "http" "execute_activategcb_trigger" { count = var.init ? 1 : 0 + url = "https://cloudbuild.googleapis.com/v1/${google_cloudbuild_trigger.activategcb[0].id}:run" + method = "POST" + request_headers = { + Authorization = "Bearer ${data.google_client_config.current.access_token}" + } depends_on = [ - module.project_services, - google_sql_database_instance.postgres, - google_cloud_run_v2_job.setup, - google_cloud_run_v2_job.client, + google_cloudbuild_trigger.activategcb[0] ] +} - name = var.random_suffix ? "head-start-initialize-${random_id.suffix.hex}" : "head-start-initialize" - machine_type = "n1-standard-1" - zone = var.zone - desired_status = "RUNNING" # https://github.com/GoogleCloudPlatform/terraform-dynamic-python-webapp/pull/75#issuecomment-1547198414 - allow_stopping_for_update = true +## Placeholder - deploys a placeholder website - uses prebuilt image in /app/placeholder +resource "google_cloudbuild_trigger" "placeholder" { + count = var.init ? 1 : 0 - boot_disk { - initialize_params { - image = "debian-cloud/debian-11" - } + name = "placeholder${local.random_suffix_append}" + location = var.region + + description = "Deploy a placeholder Firebase website" + + pubsub_config { + topic = google_pubsub_topic.faux.id } - network_interface { - network = google_compute_network.gce_init[0].self_link - subnetwork = google_compute_subnetwork.gce_init[0].self_link + service_account = google_service_account.init[0].id - access_config { - // Ephemeral public IP + build { + step { + id = "deploy-placeholder" + name = local.placeholder_image + env = [ + "PROJECT_ID=${var.project_id}", + "SUFFIX=${local.random_suffix_value}", + "FIREBASE_URL=${local.firebase_url}", + ] } - } - service_account { - email = google_service_account.compute[0].email - scopes = ["cloud-platform"] # TODO: Restrict?? + options { + logging = "CLOUD_LOGGING_ONLY" + } } - metadata_startup_script = <Avocano") + assertResponseContains(t, assert, firebase_url, "Avocano") } }) example.Test() } -func assertResponseContains(assert *assert.Assertions, url string, text ...string) { - code, responseBody, err := httpGetRequest(url) +func assertResponseContains(t *testing.T, assert *assert.Assertions, url string, text ...string) { + t.Helper() + var code int + var responseBody string + var err error + + fn := func() (bool, error) { + t.Logf("HTTP Request - GET %s", url) + code, responseBody, err = httpGetRequest(url) + retry := err != nil || code < 200 || code > 299 + switch { + case retry && err == nil: + t.Logf("Failed HTTP Request: Status Code %d", code) + case retry && err != nil: + t.Logf("Failed HTTP Request: %v", err) + default: + // In Verbose mode with success, the asserts below are a "silent pass" during test output. + // Facilitates real-time evaluation during long test process. + t.Log("Successful HTTP Request") + } + return retry, nil + } + utils.Poll(t, fn, 36, 10*time.Second) + + // Assert expectations of the last checked response. assert.Nil(err) assert.GreaterOrEqual(code, 200) assert.LessOrEqual(code, 299) @@ -134,7 +151,9 @@ func assertResponseContains(assert *assert.Assertions, url string, text ...strin } } -func assertErrorResponseContains(assert *assert.Assertions, url string, wantCode int, text string) { +func assertErrorResponseContains(t *testing.T, assert *assert.Assertions, url string, wantCode int, text string) { + t.Helper() + code, responseBody, err := httpGetRequest(url) assert.Nil(err) assert.Equal(code, wantCode) @@ -151,3 +170,16 @@ func httpGetRequest(url string) (statusCode int, body string, err error) { buffer, err := io.ReadAll(res.Body) return res.StatusCode, string(buffer), err } + +// delayUntilServiceDeploy gives a 1 minute delay, then polls until Cloud Run service deploy. +// The application may still be starting on completion of this delay. +func delayUntilServiceDeploy(t *testing.T, projectID string, serviceName string) { + t.Helper() + + time.Sleep(time.Minute) + fn := func() (bool, error) { + percent := gcloud.Run(t, "run services list", gcloud.WithCommonArgs([]string{"--filter", "metadata.name=" + serviceName, "--project", projectID, "--format", "value(status.traffic.percent)"})).Int() + return percent != 100, nil + } + utils.Poll(t, fn, 24, 10*time.Second) +} diff --git a/infra/test/setup/iam.tf b/infra/test/setup/iam.tf index 8e4ff5c5..a7664cd6 100644 --- a/infra/test/setup/iam.tf +++ b/infra/test/setup/iam.tf @@ -16,6 +16,7 @@ locals { int_required_roles = [ + "roles/cloudbuild.builds.editor", "roles/cloudsql.admin", "roles/compute.admin", "roles/compute.networkAdmin", @@ -23,6 +24,7 @@ locals { "roles/firebasehosting.admin", "roles/iam.serviceAccountAdmin", "roles/iam.serviceAccountUser", + "roles/pubsub.editor", "roles/resourcemanager.projectIamAdmin", "roles/run.admin", "roles/secretmanager.admin", diff --git a/infra/test/setup/main.tf b/infra/test/setup/main.tf index 9b9750b7..df968f9d 100644 --- a/infra/test/setup/main.tf +++ b/infra/test/setup/main.tf @@ -49,6 +49,7 @@ module "project" { "firebase.googleapis.com", "firebasehosting.googleapis.com", "iam.googleapis.com", + "pubsub.googleapis.com", "run.googleapis.com", "secretmanager.googleapis.com", "sqladmin.googleapis.com", diff --git a/infra/variables.tf b/infra/variables.tf index b7845fc9..de655b9a 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -29,6 +29,7 @@ variable "region" { # HSA +# tflint-ignore: terraform_unused_declarations variable "zone" { type = string description = "GCP zone for provisioning zonal resources." @@ -61,7 +62,7 @@ variable "init" { variable "image_version" { type = string - default = "v1.8.2" + default = "v1.9.0" description = "Version of the Container Registry image to use" }