Skip to content

Commit

Permalink
INF-5144: validate planfile lineage (#31)
Browse files Browse the repository at this point in the history
This PR ensures that a provided backend plan matches the current terraform state. This is an important check to make sure that the plan file being consumed is appropriate for the terraform state.

In addition the following minor changes are also included:
* only load backend handlers when --backend-plans is provided
* remove extraneous DEBUG message
  • Loading branch information
rmaynardap authored Aug 25, 2023
1 parent 4c6888b commit 7de9725
Show file tree
Hide file tree
Showing 3 changed files with 51 additions and 11 deletions.
57 changes: 50 additions & 7 deletions tfworker/backends/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from contextlib import closing
from pathlib import Path
from uuid import uuid4
from zipfile import ZipFile

import boto3
import botocore
Expand Down Expand Up @@ -391,6 +392,7 @@ def _check_plan(self, planfile: Path, definition: str, **kwargs):
"""check_plan runs while the plan is being checked, it should fetch a file from the backend and store it in the local location"""
# ensure planfile does not exist or is zero bytes if it does
remotefile = f"{self._authenticator.prefix}/{definition}/{planfile.name}"
statefile = f"{self._authenticator.prefix}/{definition}/terraform.tfstate"
if planfile.exists():
if planfile.stat().st_size == 0:
planfile.unlink()
Expand All @@ -400,10 +402,20 @@ def _check_plan(self, planfile: Path, definition: str, **kwargs):
if self._s3_get_plan(planfile, remotefile):
if not planfile.exists():
raise HandlerError(f"planfile not found after download: {planfile}")
click.secho(
f"remote planfile downloaded: s3://{self._authenticator.bucket}/{remotefile} -> {planfile}",
fg="yellow",
)
# verify the lineage and serial from the planfile matches the statefile
if not self._verify_lineage(planfile, statefile):
click.secho(
f"planfile lineage does not match statefile, remote plan is unsuitable and will be removed",
fg="red",
)
self._s3_delete_plan(remotefile)
planfile.unlink()
else:
click.secho(
f"remote planfile downloaded: s3://{self._authenticator.bucket}/{remotefile} -> {planfile}",
fg="yellow",
)
return None

def _post_plan(
self, planfile: Path, definition: str, changes: bool = False, **kwargs
Expand Down Expand Up @@ -433,12 +445,12 @@ def _pre_apply(self, planfile: Path, definition: str, **kwargs):
logfile = planfile.with_suffix(".log")
remotefile = f"{self._authenticator.prefix}/{definition}/{planfile.name}"
remotelog = remotefile.replace(".tfplan", ".log")
if self._s3_delete_plan(remotefile, planfile):
if self._s3_delete_plan(remotefile):
click.secho(
f"remote planfile removed: s3://{self._authenticator.bucket}/{remotefile}",
fg="yellow",
)
if self._s3_delete_plan(remotelog, logfile):
if self._s3_delete_plan(remotelog):
click.secho(
f"remote logfile removed: s3://{self._authenticator.bucket}/{remotelog}",
fg="yellow",
Expand Down Expand Up @@ -478,7 +490,7 @@ def _s3_put_plan(self, planfile: Path, remotefile: str) -> bool:
raise HandlerError(f"Error uploading planfile: {e}")
return uploaded

def _s3_delete_plan(self, remotefile: str, planfile: str) -> bool:
def _s3_delete_plan(self, remotefile: str) -> bool:
"""_delete_plan removes a remote plan file"""
deleted = False
try:
Expand All @@ -489,3 +501,34 @@ def _s3_delete_plan(self, remotefile: str, planfile: str) -> bool:
except botocore.exceptions.ClientError as e:
raise HandlerError(f"Error deleting planfile: {e}")
return deleted

def _verify_lineage(self, planfile: Path, statefile: str) -> bool:
# load the statefile as a json object from the backend
state = None
try:
state = json.loads(
self._s3_client.get_object(
Bucket=self._authenticator.bucket, Key=statefile
)["Body"].read()
)
except botocore.exceptions.ClientError as e:
raise HandlerError(f"Error downloading statefile: {e}")

# load the planfile as a json object
plan = None
try:
with ZipFile(str(planfile), "r") as zip:
with zip.open("tfstate") as f:
plan = json.loads(f.read())
except Exception as e:
raise HandlerError(f"Error loading planfile: {e}")

# compare the lineage and serial from the planfile to the statefile
if not (state and plan):
return False
if state["serial"] != plan["serial"]:
return False
if state["lineage"] != plan["lineage"]:
return False

return True
2 changes: 1 addition & 1 deletion tfworker/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def __init__(self, rootc, deployment="undefined", limit=tuple(), **kwargs):
raise SystemExit(1)

# allow a backend to implement handlers as well since they already control the provider session
if self._backend.handlers:
if self._backend.handlers and self._backend_plans:
self._handlers.update(self._backend.handlers)

@property
Expand Down
3 changes: 0 additions & 3 deletions tfworker/commands/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,6 @@ def _check_apply_or_destroy(self, changes, definition) -> bool:
def _exec_apply_or_destroy(self, definition) -> None:
"""_exec_apply_or_destroy executes a terraform apply or destroy"""
# call handlers for pre apply
click.secho(
f"DEBUG: executing {self._plan_for} for {definition.tag}", fg="yellow"
)
try:
self._execute_handlers(
action=self._plan_for,
Expand Down

0 comments on commit 7de9725

Please sign in to comment.