diff --git a/.gitignore b/.gitignore index 150023ac..8875b023 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ backend-config.hcl credentials.json key.json terraform-ls.tf +bundle.zip diff --git a/cloud-operations/README.md b/cloud-operations/README.md new file mode 100644 index 00000000..4b243637 --- /dev/null +++ b/cloud-operations/README.md @@ -0,0 +1,15 @@ +# Operations examples + +The examples in this folder show how to wire together different Google Cloud services to simplify operations, and are meant for testing, or as minimal but sufficiently complete starting points for actual use. + +## Resource tracking and remediation via Cloud Asset feeds + + This [example](./asset-inventory-feed-remediation) shows how to leverage [Cloud Asset Inventory feeds](https://cloud.google.com/asset-inventory/docs/monitoring-asset-changes) to stream resource changes in real time, and how to programmatically use the feed change notifications for alerting or remediation, via a Cloud Function wired to the feed PubSub queue. + +The example's feed tracks changes to Google Compute instances, and the Cloud Function enforces policy compliance on each change so that tags match a set of simple rules. The obious use case is when instance tags are used to scope firewall rules, bu the example can easily be adapted to suit different use cases. + +
+ +## Granular Cloud DNS IAM via Service Directory + +TODO(ludoo): publish the working example diff --git a/cloud-operations/asset-inventory-feed-remediation/README.md b/cloud-operations/asset-inventory-feed-remediation/README.md new file mode 100644 index 00000000..bc59129b --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/README.md @@ -0,0 +1,75 @@ +# Cloud Asset Inventory feeds for resource change tracking and remediation + +This example shows how to leverage [Cloud Asset Inventory feeds](https://cloud.google.com/asset-inventory/docs/monitoring-asset-changes) to stream resource changes in real time, and how to programmatically react to changes by wiring a Cloud Function to the feed outputs. + +The Cloud Function can then be used for different purposes: + +- updating remote data (eg a CMDB) to reflect the changed resources +- triggering alerts to surface critical changes +- adapting the configuration of separate related resources +- implementing remediation steps that enforce policy compliance by tweaking or reverting the changes. + +This example shows a simple remediation use case: how to enforce policies on instance tags and revert non-compliant changes in near-real time, thus adding an additional measure of control when using tags for firewall rule scoping. Changing the [monitored asset](https://cloud.google.com/asset-inventory/docs/supported-asset-types) and the function logic allows simple adaptation to other common use cases: + +- enforcing a centrally defined Cloud Armor policy in backend services +- creating custom DNS records for instances or forwarding rules + +The example uses a single project for ease of testing, in actual use a few changes are needed to operate at the resource hierarchy level: + +- the feed should be set at the folder or organization level +- the custom role used to assign tag changing permissions should be defined at the organization level +- the role binding that grants the custom role to the Cloud Function service account should be set at the same level as the feed (folder or organization) + +The resources created in this example are shown in the high level diagram below: + + + + +## Running the example + +Clone this repository or [open it in cloud shell](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2Fterraform-google-modules%2Fcloud-foundation-fabric&cloudshell_print=cloud-shell-readme.txt&cloudshell_working_dir=cloud-operations%2Fasset-inventory-feed-remediation), then go through the following steps to create resources: + +- `terraform init` +- `terraform apply -var project_id=my-project-id` +- copy and paste the `feed_create` output in the console then run it to create the feed + +Once done testing, you can clean up resources by running `terraform destroy`. To persist state, check out the `backend.tf.sample` file. + +## Testing the example + +The terraform outputs generate preset `gcloud` commands that you can copy and run in the console, to complete configuration and test the example: + +- `feed_create` is run once to create the feed, as there's currently no Terraform resource available for Cloud Asset feeds +- `subscription_pull` shows messages in the PubSub queue, to check feed message format if the Cloud Function is disabled +- `cf_logs` shows Cloud Function logs to check that remediation works +- `tag_add` adds a non-compliant tag to the test instance, and triggers the Cloud Function remediation process +- `tag_show` displays the tags currently set on the test instance + +Run the `subscription_pull` command until it returns nothing, then run the following commands in order to test remediation: + +- the `tag_add` command +- the `cf_logs` command until the logs show that the change has been picked up, verified, and the compliant tags have been force-set on the instance +- the `tag_show` command to verify that the function output matches the resource state + + + +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| project_id | Project id that references existing project. | string | ✓ | | +| *bundle_path* | Path used to write the intermediate Cloud Function code bundle. | string | | ./bundle.zip | +| *name* | Arbitrary string used to name created resources. | string | | asset-feed | +| *region* | Compute region used in the example. | string | | europe-west1 | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| cf_logs | Cloud Function logs read command. | | +| feed_create | Feed gcloud command. | | +| subscription_pull | Subscription pull command. | | +| tag_add | Instance add tag command. | | +| tag_show | Instance add tag command. | | + + diff --git a/cloud-operations/asset-inventory-feed-remediation/backend.tf.sample b/cloud-operations/asset-inventory-feed-remediation/backend.tf.sample new file mode 100644 index 00000000..c042acd2 --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/backend.tf.sample @@ -0,0 +1,23 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# set a valid bucket below and rename this file to backend.tf + +terraform { + backend "gcs" { + bucket = "" + prefix = "fabric/operations/feeds" + } +} + diff --git a/cloud-operations/asset-inventory-feed-remediation/cf/main.py b/cloud-operations/asset-inventory-feed-remediation/cf/main.py new file mode 100755 index 00000000..87857b78 --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/cf/main.py @@ -0,0 +1,159 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +'''Cloud Function module to do simple instance tag enforcement. + +This module is designed to be plugged in a Cloud Function, attached to a PubSub +trigger that receives Cloud Inventory Asset updates on the instance type. Its +purpose is to do live checking, validation and remediation of instance tags. + +Tags are validated using two simple rules: global allowed tags must match the +fixed prefixes in the `_TAG_SHARED_PREFIXES` constant, while project local +tags must be prefixed with the project id. + +Quickstart to create the feed and deploy the function, assuming +all other prerequisites are in place: + +gcloud pubsub topics create asset-feed-instance \ + --project $PROJECT +gcloud asset feeds create instance-resource \ + --pubsub-topic projects/$PROJECT/topics/asset-feed-instance \ + --asset-types compute.googleapis.com/Instance \ + --content-type resource --project $PROJECT +gcloud functions deploy test-feed + --region europe-west1 --allow-unauthenticated + --entry-point main --runtime python38 + --trigger-topic asset-feed-instance --project $PROJECT +''' + +import base64 +import binascii +import json +import logging +import re +import sys +import time + +from googleapiclient import discovery +from googleapiclient.errors import HttpError + + +_SELF_LINK_RE = re.compile( + r'/projects/([^/]+)/zones/([^/]+)/instances/([^/]+)') +_TAG_SHARED_PREFIXES = ['shared-', 'gke-cluster-'] + + +class Error(Exception): + pass + + +def _set_tags(project, zone, name, fingerprint, tags): + 'Set specific tags on instance.' + body = {'fingerprint': fingerprint, 'items': tags} + compute = discovery.build('compute', 'v1', cache_discovery=False) + try: + result = compute.instances().setTags(project=project, zone=zone, + instance=name, body=body).execute() + while True: + if 'error' in result: + raise SystemExit(result['error']) + if result['status'] == 'DONE': + break + time.sleep(1) + result = compute.zoneOperations().get( + project=project, + zone=zone, + operation=result['name']).execute() + except HttpError as e: + raise Error('Error setting tags: %s' % e) + + +def _parse_asset(data): + 'Extract instance attributes from asset feed data.' + try: + asset_type = data['asset']['assetType'] + if asset_type != 'compute.googleapis.com/Instance': + raise Error('ignoring sset type %s' % asset_type) + instance = data['asset']['resource']['data'] + except KeyError: + raise Error('missing asset data') + # ensure we have at least status and selfLink + for k in ('status', 'selfLink'): + if k not in instance: + raise Error('no %s attribute in instance data' % k) + return instance + + +def _parse_event(event): + 'Check PubSub event and return asset feed data.' + if not event or 'data' not in event: + raise Error('no event received, or no data in event') + logging.info('parsing event data') + try: + data = base64.b64decode(event['data']) + return json.loads(data) + except binascii.Error as e: + logging.info('received event: %s' % event) + raise Error('cannot decode event data: %s' % e) + except json.JSONDecodeError as e: + logging.info('received data: %s', data) + raise Error('event data not in JSON format: %s' % e) + + +def _parse_self_link(self_link): + 'Parse instance self link and return project, zone, and instance name.' + m = _SELF_LINK_RE.search(self_link) + if not m: + raise Error('invalid self link %s' % self_link) + return m.groups() + + +def _validate_tags(project, tags): + 'Validate a set of tags and return valid tags in the set.' + _tags = [] + for tag in tags: + shared_valid = any(tag.startswith(p) for p in _TAG_SHARED_PREFIXES) + if shared_valid or tag.startswith(project): + _tags.append(tag) + return _tags + + +def main(event=None, context=None): + 'Cloud Function entry point.' + logging.basicConfig(level=logging.INFO) + try: + data = _parse_event(event) + instance = _parse_asset(data) + project, zone, name = _parse_self_link(instance['selfLink']) + except Error as e: + logging.critical(e.args[0]) + return + logging.info('checking %s', instance['selfLink']) + if instance['status'] not in ('RUNNING', 'TERMINATED'): + logging.info('ignoring status %s', instance['status']) + return + tags = instance.get('tags', {}) + logging.info('tags %s', tags.get('items')) + if not tags or not tags.get('items'): + return + valid_tags = _validate_tags(project, tags['items']) + if tags['items'] == valid_tags: + logging.info('all tags are valid') + return + logging.info('modify tags %s %s %s %s %s', project, + zone, name, tags['fingerprint'], valid_tags) + try: + _set_tags(project, zone, name, tags.get('fingerprint'), valid_tags) + except Error as e: + logging.critical(e.args[0]) diff --git a/cloud-operations/asset-inventory-feed-remediation/cf/requirements.txt b/cloud-operations/asset-inventory-feed-remediation/cf/requirements.txt new file mode 100644 index 00000000..704bcd50 --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/cf/requirements.txt @@ -0,0 +1 @@ +google-api-python-client \ No newline at end of file diff --git a/cloud-operations/asset-inventory-feed-remediation/cloud-shell-readme.txt b/cloud-operations/asset-inventory-feed-remediation/cloud-shell-readme.txt new file mode 100644 index 00000000..7c688dc3 --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/cloud-shell-readme.txt @@ -0,0 +1,10 @@ + + +################################# Quickstart ################################# + +- terraform init +- terraform apply -var project_id=$GOOGLE_CLOUD_PROJECT +- run the command in the command_feed_create output + +Refer to the README.md file for more info and testing flow. + diff --git a/cloud-operations/asset-inventory-feed-remediation/diagram.png b/cloud-operations/asset-inventory-feed-remediation/diagram.png new file mode 100644 index 00000000..cc0c25dc Binary files /dev/null and b/cloud-operations/asset-inventory-feed-remediation/diagram.png differ diff --git a/cloud-operations/asset-inventory-feed-remediation/main.tf b/cloud-operations/asset-inventory-feed-remediation/main.tf new file mode 100644 index 00000000..e2e47bef --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/main.tf @@ -0,0 +1,110 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + role_id = "projects/${module.project.project_id}/roles/${local.role_name}" + role_name = "feeds_cf" +} + +module "project" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/project?ref=v2.3.0" + name = var.project_id + project_create = false + services = [ + "cloudasset.googleapis.com", + "compute.googleapis.com", + "cloudfunctions.googleapis.com" + ] + service_config = { + disable_on_destroy = false, disable_dependent_services = false + } + custom_roles = { + (local.role_name) = [ + "compute.instances.list", + "compute.instances.setTags", + "compute.zones.list", + "compute.zoneOperations.get", + "compute.zoneOperations.list" + ] + } +} + +module "vpc" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/net-vpc?ref=v2.3.0" + project_id = module.project.project_id + name = var.name + subnets = [{ + ip_cidr_range = "192.168.0.0/24" + name = "${var.name}-default" + region = var.region + secondary_ip_range = {} + }] +} + +module "pubsub" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/pubsub?ref=v2.3.0" + project_id = module.project.project_id + name = var.name + subscriptions = { "${var.name}-default" = null } +} + +module "service-account" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/iam-service-accounts?ref=v2.3.0" + project_id = module.project.project_id + names = ["${var.name}-cf"] + iam_project_roles = { (module.project.project_id) = [local.role_id] } +} + +module "cf" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/cloud-function?ref=v2.3.0" + project_id = module.project.project_id + name = var.name + bucket_name = "${var.name}-${random_pet.random.id}" + bucket_config = { + location = var.region + lifecycle_delete_age = null + } + bundle_config = { + source_dir = "cf" + output_path = var.bundle_path + } + service_account = module.service-account.email + trigger_config = { + event = "google.pubsub.topic.publish" + resource = module.pubsub.topic.id + retry = null + } +} + +module "simple-vm-example" { + source = "github.com/terraform-google-modules/cloud-foundation-fabric//modules/compute-vm?ref=v2.3.0" + project_id = module.project.project_id + region = var.region + zone = "${var.region}-b" + name = var.name + network_interfaces = [{ + network = module.vpc.self_link, + subnetwork = module.vpc.subnet_self_links["${var.region}/${var.name}-default"], + nat = false, + addresses = null + }] + tags = ["${var.project_id}-test-feed", "shared-test-feed"] + instance_count = 1 +} + +resource "random_pet" "random" { + length = 1 +} diff --git a/cloud-operations/asset-inventory-feed-remediation/outputs.tf b/cloud-operations/asset-inventory-feed-remediation/outputs.tf new file mode 100644 index 00000000..2308de3d --- /dev/null +++ b/cloud-operations/asset-inventory-feed-remediation/outputs.tf @@ -0,0 +1,68 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "cf_logs" { + description = "Cloud Function logs read command." + value = <