runEnvironments<E extends Enum> function
The entry point of a project with one Stack per environment. The
environments are the members of an enum of the project's own — any
names, each carrying its values — so the Stack takes a typed env and
derives everything per environment from it, its backend included:
// bin/run_environments.dart
enum Env {
dev(stateBucket: null),
qa(stateBucket: 'acme-qa-state'),
prd(stateBucket: 'acme-prd-state');
const Env({required this.stateBucket});
/// The GCS bucket of the state; `null` keeps it in a local file.
final String? stateBucket;
}
final class OrdersStack extends Stack {
OrdersStack({required Env env})
: super(
providers: const [TimeProvider()],
backend: switch (env.stateBucket) {
final bucket? => GcsBackend(bucket: bucket, prefix: 'orders'),
null => LocalBackend(path: 'state/${env.name}.tfstate'),
},
);
}
Future<void> main(List<String> args) =>
runEnvironments(args, Env.values, (env) => OrdersStack(env: env));
dart run bin/infra.dart --env qa writes tf-out/qa/main.tf.json;
without --env every environment is written. A name that is not a
member is a usage error (exit code 64) that lists the members.
terradart apply --env qa runs the entry point the same way, then the
engine in the directory it wrote; the names terradart accepts are the
enum's.
defaultEnv is the environment terradart runs against when neither
--env nor the TERRADART_ENV environment variable names one; apply
and destroy ask before they run against it. Without --env, the entry
point still writes every environment, or only defaultEnv when they
share a directory.
// bin/default_env.dart
enum Stage { dev, prd }
final class WebStack extends Stack {
WebStack(Stage stage) : super(providers: const [TimeProvider()]);
}
Future<void> main(List<String> args) =>
runEnvironments(args, Stage.values, WebStack.new, defaultEnv: Stage.dev);
Environments that keep their state in one backend whose settings differ can share one directory instead of one each:
diris the directory an environment writes (defaulttf-out/<name>). Two environments may share one only whenworkspaceorbackendConfigtells their states apart.workspaceis the Terraform workspaceterradartselects (and creates on the firstplanorapply).backendConfigis the partial backend configurationterradartpasses toinitas-backend-config: files relative to the project, orkey=valuepairs.
// lib/partial_backend_config.dart
enum Region { eu, us }
final class ApiStack extends Stack {
ApiStack(Region region)
: super(
providers: const [TimeProvider()],
backend: const GcsBackend(),
);
}
Future<void> infra(List<String> args) => runEnvironments(
args,
Region.values,
ApiStack.new,
dir: (_) => 'tf-out',
backendConfig: (region) => ['backend/${region.name}.gcs.tfbackend'],
);
Implementation
Future<void> runEnvironments<E extends Enum>(
List<String> args,
List<E> environments,
Stack Function(E env) build, {
String Function(E env)? dir,
String? Function(E env)? workspace,
List<String> Function(E env)? backendConfig,
E? defaultEnv,
}) async {
if (environments.isEmpty) {
throw ArgumentError.value(environments, 'environments', 'is empty');
}
if (defaultEnv != null && !environments.contains(defaultEnv)) {
throw ArgumentError.value(
defaultEnv,
'defaultEnv',
'is not one of the environments',
);
}
String dirOf(E env) => dir?.call(env) ?? 'tf-out/${env.name}';
final shared = <String, List<E>>{};
for (final env in environments) {
(shared[_normalize(dirOf(env))] ??= []).add(env);
}
for (final MapEntry(key: path, value: envs) in shared.entries) {
if (envs.length < 2) continue;
final states = <String>{};
for (final env in envs) {
final ws = workspace?.call(env);
final config = backendConfig?.call(env) ?? const [];
if (ws == null && config.isEmpty) {
throw ArgumentError(
'Environments ${envs.map((e) => e.name).join(', ')} all write '
'$path, but ${env.name} has no workspace or backendConfig to keep '
'its state apart; give it one, or its own dir.',
);
}
if (!states.add('$ws\n${config.join('\n')}')) {
throw ArgumentError(
'Environments sharing $path need different workspaces or '
'backendConfig; ${env.name} repeats another one\'s.',
);
}
}
}
final name = _option(args, '--env');
final List<E> selected;
if (name == null) {
final clash = [
for (final MapEntry(key: path, value: envs) in shared.entries)
if (envs.length > 1) '$path (${envs.map((e) => e.name).join(', ')})',
];
if (clash.isEmpty) {
selected = environments;
} else if (defaultEnv != null) {
selected = [defaultEnv];
} else {
stderr.writeln(
'infra: environments share ${clash.join(', ')}; pass --env <name>, '
'one of ${environments.map((e) => e.name).join(', ')}.',
);
exitCode = 64;
return;
}
} else {
selected = [
for (final env in environments)
if (env.name == name) env,
];
if (selected.isEmpty) {
stderr.writeln(
'infra: unknown environment "$name"; known envs: '
'${environments.map((e) => e.name).join(', ')}.',
);
exitCode = 64;
return;
}
}
final roots = <Map<String, Object?>>[];
for (final env in selected) {
final stack = build(env);
final out = dirOf(env);
await stack.writeTo(out);
stdout.writeln('synthesized $out/main.tf.json (${env.name})');
roots.add(
_root(
env.name,
out,
workspace?.call(env),
backendConfig?.call(env) ?? const [],
stack,
),
);
}
await _writeManifest(
environments: [for (final e in environments) e.name],
selected: name,
defaultEnv: defaultEnv?.name,
roots: roots,
);
}