@@ -18,7 +18,7 @@ import textwrap
1818
1919from packaging import version
2020from urllib .parse import urlparse
21- from typing import Iterable , Optional
21+ from typing import Iterable , Optional , Dict , Tuple
2222
2323PARENT_FOLDER = os .path .realpath (os .path .join (os .path .dirname (__file__ ), ".." ))
2424if os .path .isdir (os .path .join (PARENT_FOLDER , ".venv" )):
@@ -75,6 +75,14 @@ terraform {
7575 }
7676}
7777"""
78+ TF_REMOTE_STATE_CONFIG = """
79+ data "terraform_remote_state" "<name>" {
80+ backend = "s3"
81+ <workspace-placeholder>
82+ config = {<configs>
83+ }
84+ }
85+ """
7886PROCESS = None
7987# some services have aliases which are mutually exclusive to each other
8088# see https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#available-endpoint-customizations
@@ -215,6 +223,9 @@ def create_provider_config_file(provider_file_path: str, provider_aliases=None)
215223 # create s3 backend config
216224 tf_config += generate_s3_backend_config ()
217225
226+ # create remote state config
227+ tf_config += generate_remote_state_config ()
228+
218229 # write temporary config file
219230 write_provider_config_file (provider_file_path , tf_config )
220231
@@ -261,31 +272,90 @@ def determine_provider_aliases() -> list:
261272
262273def generate_s3_backend_config () -> str :
263274 """Generate an S3 `backend {..}` block with local endpoints, if configured"""
264- is_tf_legacy = TF_VERSION < version .Version ("1.6" )
265- backend_config = None
275+ s3_backend_config = {}
266276 tf_files = parse_tf_files ()
267277 for filename , obj in tf_files .items ():
268278 if LS_PROVIDERS_FILE == filename :
269279 continue
270280 tf_configs = ensure_list (obj .get ("terraform" , []))
271281 for tf_config in tf_configs :
272- tmp_backend_config = ensure_list (tf_config .get ("backend" ))
273- if tmp_backend_config [0 ]:
274- backend_config = tmp_backend_config [0 ]
275- break
276- backend_config = backend_config and backend_config .get ("s3" )
277- if not backend_config :
282+ if tf_config .get ("backend" ):
283+ backend_config = ensure_list (tf_config .get ("backend" ))[0 ]
284+ if backend_config .get ("s3" ):
285+ s3_backend_config = backend_config ["s3" ]
286+ break
287+
288+ if not s3_backend_config :
278289 return ""
279290
291+ config_values , config_string = _generate_s3_backend_config (s3_backend_config )
292+ if not DRY_RUN :
293+ get_or_create_bucket (config_values ["bucket" ])
294+ if "dynamodb_table" in config_values :
295+ get_or_create_ddb_table (
296+ config_values ["dynamodb_table" ],
297+ region = config_values ["region" ],
298+ )
299+
300+ result = TF_S3_BACKEND_CONFIG .replace ("<configs>" , config_string )
301+ return result
302+
303+
304+ def generate_remote_state_config () -> str :
305+ """
306+ Generate configuration for terraform_remote_state data sources to use LocalStack endpoints.
307+ Similar to generate_s3_backend_config but for terraform_remote_state blocks.
308+ """
309+
310+ tf_files = parse_tf_files ()
311+ result = ""
312+ for filename , obj in tf_files .items ():
313+ if LS_PROVIDERS_FILE == filename :
314+ continue
315+ data_blocks = ensure_list (obj .get ("data" , []))
316+ for data_block in data_blocks :
317+ terraform_remote_state = data_block .get ("terraform_remote_state" )
318+ if not terraform_remote_state :
319+ continue
320+ for data_name , data_config in terraform_remote_state .items ():
321+ if data_config .get ("backend" ) != "s3" :
322+ continue
323+ # Create override for S3 remote state
324+ backend_config = data_config .get ("config" , {})
325+ if not backend_config :
326+ continue
327+ workspace = data_config .get ("workspace" , "" )
328+ if workspace :
329+ if workspace [0 ] == "$" :
330+ workspace = workspace .lstrip ('${' ).rstrip ('}' )
331+ else :
332+ workspace = f'"{ workspace } "'
333+ workspace = f"workspace = { workspace } "
334+
335+ _ , config_str = _generate_s3_backend_config (backend_config )
336+
337+ # Create the final config
338+ remote_state_config = TF_REMOTE_STATE_CONFIG .replace (
339+ "<name>" , data_name
340+ ) \
341+ .replace ("<configs>" , config_str ) \
342+ .replace ("<workspace-placeholder>" , workspace )
343+ result += remote_state_config
344+
345+ return result
346+
347+
348+ def _generate_s3_backend_config (backend_config : Dict ) -> Tuple [Dict , str ]:
349+ is_tf_legacy = TF_VERSION < version .Version ("1.6" )
280350 legacy_endpoint_mappings = {
281351 "endpoint" : "s3" ,
282352 "iam_endpoint" : "iam" ,
283353 "sts_endpoint" : "sts" ,
284354 "dynamodb_endpoint" : "dynamodb" ,
285355 }
286356
287- configs = {
288- # note: default values, updated by `backend_config` further below...
357+ # Set up default config
358+ default_config = {
289359 "bucket" : "tf-test-state" ,
290360 "key" : "terraform.tfstate" ,
291361 "region" : get_region (),
@@ -300,6 +370,7 @@ def generate_s3_backend_config() -> str:
300370 "dynamodb" : get_service_endpoint ("dynamodb" ),
301371 },
302372 }
373+
303374 # Merge in legacy endpoint configs if not existing already
304375 if is_tf_legacy and backend_config .get ("endpoints" ):
305376 print (
@@ -308,56 +379,53 @@ def generate_s3_backend_config() -> str:
308379 exit (1 )
309380 for legacy_endpoint , endpoint in legacy_endpoint_mappings .items ():
310381 if (
311- legacy_endpoint in backend_config
312- and backend_config .get ("endpoints" )
313- and endpoint in backend_config ["endpoints" ]
382+ legacy_endpoint in backend_config
383+ and backend_config .get ("endpoints" )
384+ and endpoint in backend_config ["endpoints" ]
314385 ):
315386 del backend_config [legacy_endpoint ]
316387 continue
317388 if legacy_endpoint in backend_config and (
318- not backend_config .get ("endpoints" )
319- or endpoint not in backend_config ["endpoints" ]
389+ not backend_config .get ("endpoints" )
390+ or endpoint not in backend_config ["endpoints" ]
320391 ):
321392 if not backend_config .get ("endpoints" ):
322393 backend_config ["endpoints" ] = {}
323394 backend_config ["endpoints" ].update (
324395 {endpoint : backend_config [legacy_endpoint ]}
325396 )
326397 del backend_config [legacy_endpoint ]
398+
327399 # Add any missing default endpoints
328400 if backend_config .get ("endpoints" ):
329401 backend_config ["endpoints" ] = {
330402 k : backend_config ["endpoints" ].get (k ) or v
331- for k , v in configs ["endpoints" ].items ()
403+ for k , v in default_config ["endpoints" ].items ()
332404 }
405+
333406 backend_config ["access_key" ] = (
334407 get_access_key (backend_config ) if CUSTOMIZE_ACCESS_KEY else DEFAULT_ACCESS_KEY
335408 )
336- configs .update (backend_config )
337- if not DRY_RUN :
338- get_or_create_bucket (configs ["bucket" ])
339- if "dynamodb_table" in configs :
340- get_or_create_ddb_table (configs ["dynamodb_table" ], region = configs ["region" ])
341- result = TF_S3_BACKEND_CONFIG
342- config_options = ""
343- for key , value in sorted (configs .items ()):
409+
410+ # Update with user-provided configs
411+ default_config .update (backend_config )
412+ # Generate config string
413+ config_string = ""
414+ for key , value in sorted (default_config .items ()):
344415 if isinstance (value , bool ):
345416 value = str (value ).lower ()
346417 elif isinstance (value , dict ):
347418 if key == "endpoints" and is_tf_legacy :
348419 for legacy_endpoint , endpoint in legacy_endpoint_mappings .items ():
349- config_options += (
350- f'\n { legacy_endpoint } = "{ configs [key ][endpoint ]} "'
351- )
420+ config_string += f'\n { legacy_endpoint } = "{ default_config [key ][endpoint ]} "'
352421 continue
353422 else :
423+ joined_values = "\n " .join ([f' { k } = "{ v } "' for k , v in value .items ()])
354424 value = textwrap .indent (
355- text = f"{ key } = {{\n "
356- + "\n " .join ([f' { k } = "{ v } "' for k , v in value .items ()])
357- + "\n }" ,
425+ text = f"{ key } = {{\n { joined_values } \n }}" ,
358426 prefix = " " * 4 ,
359427 )
360- config_options += f"\n { value } "
428+ config_string += f"\n { value } "
361429 continue
362430 elif isinstance (value , list ):
363431 # TODO this will break if it's a list of dicts or other complex object
@@ -366,13 +434,13 @@ def generate_s3_backend_config() -> str:
366434 value = f"[{ ', ' .join (as_string )} ]"
367435 else :
368436 value = f'"{ str (value )} "'
369- config_options += f"\n { key } = { value } "
370- result = result . replace ( "<configs>" , config_options )
371- return result
437+ config_string += f"\n { key } = { value } "
438+
439+ return default_config , config_string
372440
373441
374442def check_override_file (providers_file : str ) -> None :
375- """Checks override file existance """
443+ """Checks override file existence """
376444 if os .path .exists (providers_file ):
377445 msg = f"Providers override file { providers_file } already exists"
378446 err_msg = msg + " - please delete it first, exiting..."
0 commit comments