284 lines
9.4 KiB
Python
284 lines
9.4 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2022 Google LLC. All Rights Reserved.
|
|
#
|
|
# 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.
|
|
"""Validate that a terraform plan complies with policies."""
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import os.path
|
|
|
|
from googlecloudsdk.calliope import base
|
|
from googlecloudsdk.command_lib.util.anthos import binary_operations
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import metrics
|
|
from googlecloudsdk.core import properties
|
|
from googlecloudsdk.core.console import progress_tracker
|
|
from googlecloudsdk.core.credentials.store import GetFreshAccessToken
|
|
from googlecloudsdk.core.util import encoding
|
|
from googlecloudsdk.core.util import files
|
|
|
|
MISSING_BINARY = ('Could not locate terraform-tools executable [{binary}]. '
|
|
'Please ensure gcloud terraform-tools component is '
|
|
'properly installed. '
|
|
'See https://cloud.google.com/sdk/docs/components for '
|
|
'more details.')
|
|
|
|
|
|
class TerraformToolsTfplanToCaiOperation(
|
|
binary_operations.StreamingBinaryBackedOperation):
|
|
"""Streaming operation for Terraform Tools tfplan-to-cai command."""
|
|
custom_errors = {}
|
|
|
|
def __init__(self, **kwargs):
|
|
custom_errors = {
|
|
'MISSING_EXEC': MISSING_BINARY.format(binary='terraform-tools'),
|
|
}
|
|
super(TerraformToolsTfplanToCaiOperation, self).__init__(
|
|
binary='terraform-tools',
|
|
check_hidden=True,
|
|
install_if_missing=True,
|
|
custom_errors=custom_errors,
|
|
structured_output=True,
|
|
**kwargs)
|
|
|
|
def _ParseArgsForCommand(self, command, terraform_plan_json, project, region,
|
|
zone, verbosity, output_path, **kwargs):
|
|
args = [
|
|
command,
|
|
terraform_plan_json,
|
|
'--output-path',
|
|
output_path,
|
|
'--verbosity',
|
|
verbosity,
|
|
'--user-agent',
|
|
metrics.GetUserAgent(),
|
|
]
|
|
if project:
|
|
args += ['--project', project]
|
|
if region:
|
|
args += ['--region', region]
|
|
if zone:
|
|
args += ['--zone', zone]
|
|
return args
|
|
|
|
|
|
class TerraformToolsValidateOperation(binary_operations.BinaryBackedOperation):
|
|
"""operation for Terraform Tools validate-cai command."""
|
|
custom_errors = {}
|
|
|
|
def __init__(self, **kwargs):
|
|
custom_errors = {
|
|
'MISSING_EXEC': MISSING_BINARY.format(binary='terraform-tools'),
|
|
}
|
|
super(TerraformToolsValidateOperation, self).__init__(
|
|
binary='terraform-tools',
|
|
check_hidden=True,
|
|
# Install will be handled by the conversion operation
|
|
install_if_missing=False,
|
|
custom_errors=custom_errors,
|
|
**kwargs)
|
|
|
|
def _ParseArgsForCommand(self, command, input_file, policy_library, verbosity,
|
|
**kwargs):
|
|
args = [
|
|
command,
|
|
input_file,
|
|
'--verbosity',
|
|
verbosity,
|
|
'--policy-library',
|
|
os.path.expanduser(policy_library),
|
|
]
|
|
return args
|
|
|
|
|
|
@base.ReleaseTracks(base.ReleaseTrack.ALPHA, base.ReleaseTrack.BETA)
|
|
class Vet(base.Command):
|
|
"""Validate that a terraform plan complies with policies."""
|
|
|
|
detailed_help = {
|
|
'EXAMPLES':
|
|
"""
|
|
To validate that a terraform plan complies with a policy library
|
|
at `/my/policy/library`:
|
|
|
|
$ {command} tfplan.json --policy-library=/my/policy/library
|
|
""",
|
|
}
|
|
|
|
@staticmethod
|
|
def Args(parser):
|
|
parser.add_argument(
|
|
'terraform_plan_json',
|
|
help=(
|
|
'File which contains a JSON export of a terraform plan. This file '
|
|
'will be validated against the given policy library.'),
|
|
)
|
|
parser.add_argument(
|
|
'--policy-library',
|
|
required=True,
|
|
help='Directory which contains a policy library',
|
|
)
|
|
parser.add_argument(
|
|
'--zone',
|
|
required=False,
|
|
help='Default zone to use for resources that do not have one set',
|
|
)
|
|
parser.add_argument(
|
|
'--region',
|
|
required=False,
|
|
help='Default region to use for resources that do not have one set',
|
|
)
|
|
|
|
def Run(self, args):
|
|
tfplan_to_cai_operation = TerraformToolsTfplanToCaiOperation()
|
|
validate_cai_operation = TerraformToolsValidateOperation()
|
|
validate_tfplan_operation = TerraformToolsValidateOperation()
|
|
|
|
env_vars = {
|
|
'GOOGLE_OAUTH_ACCESS_TOKEN':
|
|
GetFreshAccessToken(account=properties.VALUES.core.account.Get()),
|
|
'USE_STRUCTURED_LOGGING':
|
|
'true',
|
|
}
|
|
|
|
proxy_env_names = [
|
|
'HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY',
|
|
'no_proxy'
|
|
]
|
|
|
|
# env names and orders are from
|
|
# https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference#full-reference
|
|
project_env_names = [
|
|
'GOOGLE_PROJECT',
|
|
'GOOGLE_CLOUD_PROJECT',
|
|
'GCLOUD_PROJECT',
|
|
]
|
|
|
|
zone_env_names = [
|
|
'GOOGLE_ZONE',
|
|
'GCLOUD_ZONE',
|
|
'CLOUDSDK_COMPUTE_ZONE',
|
|
]
|
|
|
|
region_env_names = [
|
|
'GOOGLE_REGION',
|
|
'GCLOUD_REGION',
|
|
'CLOUDSDK_COMPUTE_REGION',
|
|
]
|
|
|
|
for env_key, env_val in os.environ.items():
|
|
if env_key in proxy_env_names:
|
|
env_vars[env_key] = env_val
|
|
|
|
with files.TemporaryDirectory() as tempdir:
|
|
cai_assets = os.path.join(tempdir, 'cai_assets.json')
|
|
|
|
# project flag and CLOUDSDK_CORE_PROJECT env are linked with core property
|
|
project = properties.VALUES.core.project.Get()
|
|
if project:
|
|
log.debug('Setting project to {} from properties'.format(project))
|
|
else:
|
|
for env_key in project_env_names:
|
|
project = encoding.GetEncodedValue(os.environ, env_key)
|
|
if project:
|
|
log.debug('Setting project to {} from env {}'.format(
|
|
project, env_key))
|
|
break
|
|
|
|
region = ''
|
|
if args.region:
|
|
region = args.region
|
|
log.debug('Setting region to {} from args'.format(region))
|
|
else:
|
|
for env_key in region_env_names:
|
|
region = encoding.GetEncodedValue(os.environ, env_key)
|
|
if region:
|
|
log.debug('Setting region to {} from env {}'.format(
|
|
region, env_key))
|
|
break
|
|
|
|
zone = ''
|
|
if args.zone:
|
|
zone = args.zone
|
|
log.debug('Setting zone to {} from args'.format(zone))
|
|
else:
|
|
for env_key in zone_env_names:
|
|
zone = encoding.GetEncodedValue(os.environ, env_key)
|
|
if zone:
|
|
log.debug('Setting zone to {} from env {}'.format(zone, env_key))
|
|
break
|
|
|
|
response = tfplan_to_cai_operation(
|
|
command='tfplan-to-cai',
|
|
project=project,
|
|
region=region,
|
|
zone=zone,
|
|
terraform_plan_json=args.terraform_plan_json,
|
|
verbosity=args.verbosity,
|
|
output_path=cai_assets,
|
|
env=env_vars)
|
|
self.exit_code = response.exit_code
|
|
if self.exit_code > 0:
|
|
# The streaming binary backed operation handles its own writing to
|
|
# stdout and stderr, so there's nothing left to do here.
|
|
return None
|
|
|
|
with progress_tracker.ProgressTracker(
|
|
message='Validating resources',
|
|
aborted_message='Aborted validation.'):
|
|
cai_response = validate_cai_operation(
|
|
command='validate-cai',
|
|
policy_library=args.policy_library,
|
|
input_file=cai_assets,
|
|
verbosity=args.verbosity,
|
|
env=env_vars)
|
|
tfplan_response = validate_tfplan_operation(
|
|
command='validate-tfplan',
|
|
policy_library=args.policy_library,
|
|
input_file=args.terraform_plan_json,
|
|
verbosity=args.verbosity,
|
|
env=env_vars)
|
|
|
|
# exit code 2 from a validate_* command indicates violations; we need to
|
|
# pass that through to users so they can detect this case. However, if
|
|
# either command errors out (exit code 1) return that instead.
|
|
if cai_response.exit_code == 1 or tfplan_response.exit_code == 1:
|
|
self.exit_code = 1
|
|
elif cai_response.exit_code == 2 or tfplan_response.exit_code == 2:
|
|
self.exit_code = 2
|
|
|
|
# Output from validate commands uses "structured output", same as the
|
|
# streaming output from conversion. The final output should be a combined
|
|
# list of violations.
|
|
violations = []
|
|
|
|
for policy_type, response in (('CAI', cai_response), ('Terraform',
|
|
tfplan_response)):
|
|
if response.stdout:
|
|
try:
|
|
msg = binary_operations.ReadStructuredOutput(
|
|
response.stdout, as_json=True)
|
|
except binary_operations.StructuredOutputError:
|
|
log.warning('Could not parse {} policy validation output.'.format(
|
|
policy_type))
|
|
else:
|
|
violations += msg.resource_body
|
|
if response.stderr:
|
|
handler = binary_operations.DefaultStreamStructuredErrHandler(None)
|
|
for line in response.stderr.split('\n'):
|
|
handler(line)
|
|
|
|
return violations
|