Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion demo/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
config.json

bin
bin

manifest
7 changes: 7 additions & 0 deletions demo/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,10 @@ standalone-compose:

standalone-integration: standalone-compose
mv ./configWithStandalone.json ../router-tests/testenv/testdata/configWithGRPC.json

.PHONY: build-manifest build-manifest-integration
build-manifest:
python3 build_manifest.py --router-config ./config.json --out ./manifest --cleanup

build-manifest-integration:
python3 build_manifest.py --router-config ./config.json --out ../router-tests/testenv/testdata/manifest --cleanup
182 changes: 182 additions & 0 deletions demo/build_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# TEMPORARY: This script is a stopgap for generating split manifest directories
# (mapper.json + latest.json + feature-flags/*.json) from a single router
# execution config. The same capability is being added to the `wgc` CLI; once
# `wgc` can produce local manifests this file should be deleted and any callers
# (Makefile targets, integration test setup) should be migrated to `wgc`.
import argparse
import json
import os.path

from pathlib import Path
import shutil
from typing import Any


class ManifestBuilder:
"""Splits a single router execution config into the manifest layout the
router expects when `WithManifestConfig` is used:

<output_dir>/
mapper.json # { "": <base version>, "<ff name>": <ff version>, ... }
latest.json # base graph execution config
feature-flags/<ff name>.json # one execution config per feature flag

The router watches mapper.json and re-reads latest.json / feature-flags/*
whenever the mapper's mtime changes.
"""

def __init__(self, router_config: str, output_dir: str, cleanup: bool):
# Resolve to absolute paths so error messages and downstream os.path
# operations don't depend on the caller's cwd.
self.router_config = os.path.abspath(router_config)
self.output_dir = os.path.abspath(output_dir)
self.cleanup = cleanup

# State populated by `destructure_router_config` and consumed by
# `build_manifest`. Pre-initialised so attribute access is safe even
# if destructuring fails partway through.
self.mapper: dict[str, str] = {} # feature-flag name -> version hash ("" is the base graph)
self.full_config: dict[str, Any] = {} # the original combined router config as loaded from disk
self.latest_config: dict[str, Any] = {} # the subset of full_config that becomes latest.json
self.feature_flags: dict[str, Any] = {} # per-feature-flag execution configs, keyed by flag name

# Validation may exit the process — do it before we try to read the
# router config so we fail fast with a clear message.
try:
self.validate()
except Exception as e:
print(f"Error: {e}")
exit(1)

self.destructure_router_config()

def validate(self):
"""Ensure the input file exists and the output directory is usable.

With `cleanup=True` the output directory is wiped first so stale
feature-flag files from a previous run don't linger.
"""
if not os.path.exists(self.router_config):
raise FileNotFoundError(
f"Router config file not found: {self.router_config}"
)

# When cleanup is requested, remove the directory wholesale so we
# don't have to enumerate and delete individual stale files (e.g.
# feature-flags that no longer exist in the new config).
if self.cleanup:
if os.path.exists(self.output_dir):
shutil.rmtree(self.output_dir)
if not os.path.exists(self.output_dir):
os.makedirs(self.output_dir)
if not os.path.isdir(self.output_dir):
raise NotADirectoryError(
f"Output directory is not a directory: {self.output_dir}"
)

def destructure_router_config(self):
"""Load the combined router config and split it into the in-memory
pieces that `build_manifest` will later write out.

The combined config carries the base graph alongside every feature
flag in a single `featureFlagConfigs.configByFeatureFlagName` map.
We pull each feature flag into its own dict and record its version
under its name in the mapper. The base graph is recorded under the
empty-string key — that's the convention the router expects.
"""
with open(self.router_config, "r") as f:
self.full_config = json.load(f)

# latest.json is the base graph only — strip the feature flag
# bundle out so it's not duplicated on disk.
self.latest_config = {
"version": self.full_config["version"],
"engineConfig": self.full_config["engineConfig"],
"subgraphs": self.full_config["subgraphs"],
"compatibilityVersion": self.full_config["compatibilityVersion"],
}

# Base graph entry in the mapper uses the empty-string key.
self.mapper[""] = self.latest_config["version"]

# Each feature flag becomes its own entry in the mapper and its
# own file under feature-flags/. The mapper value is the flag's
# version hash, which is also what the router reports back via
# the X-Router-Config-Version header when that flag is active.
for feature_flag_name, feature_flag_config in self.full_config[
"featureFlagConfigs"
]["configByFeatureFlagName"].items():
self.mapper[feature_flag_name] = feature_flag_config["version"]
self.feature_flags[feature_flag_name] = {
"version": feature_flag_config["version"],
"engineConfig": feature_flag_config["engineConfig"],
"subgraphs": feature_flag_config["subgraphs"],
}

def build_manifest(self):
"""Write the in-memory pieces produced by `destructure_router_config`
to disk in the layout the router watches.

Order is intentional: mapper.json is written last because it is the
file the router stats for mtime changes. Writing it after latest.json
and the feature-flag configs ensures the watcher never observes a
mapper that references files which are not yet on disk (or are stale).
Callers that want fully atomic swaps should write to a sibling
directory and rename — this script is for one-shot local generation
only.
"""
# latest.json — the base graph's execution config.
latest_path = os.path.join(self.output_dir, "latest.json")
self.write_content_to_file(self.latest_config, Path(latest_path))

# feature-flags/ must exist even when there are no feature flags;
# the router stats this directory during config assembly.
feature_flag_path = os.path.join(self.output_dir, "feature-flags")
if not os.path.exists(feature_flag_path):
os.makedirs(feature_flag_path)

# One file per feature flag, named after the flag itself so the
# router can resolve `<name>.json` directly from the mapper key.
for feature_flag_name, feature_flag_config in self.feature_flags.items():
feature_flag_config_path = os.path.join(feature_flag_path, f"{feature_flag_name}.json")
self.write_content_to_file(feature_flag_config, Path(feature_flag_config_path))

# mapper.json — the file the manifest watcher stats for mtime
# changes. Written last so its mtime bump only occurs after the
# configs it references are already on disk.
mapper_path = os.path.join(self.output_dir, "mapper.json")
self.write_content_to_file(self.mapper, Path(mapper_path))

def write_content_to_file(self, content: dict[str, Any], path: Path):
# Pretty-printed JSON so the generated files are diff-friendly in
# source control / when checked into testdata.
path.write_text(json.dumps(content, indent=2), encoding="utf-8")


def main():
"""CLI entry point. Parses arguments, builds the manifest, and exits
non-zero on any failure so callers (Makefile targets, CI) can detect it.
"""
parser = argparse.ArgumentParser(description="Build the manifest for the demo")
parser.add_argument(
"--router-config", type=str, required=True, help="The path to the router config"
)
parser.add_argument(
"--out", type=str, required=True, help="The path to the output directory"
)
parser.add_argument(
"--cleanup", action="store_true", help="Cleanup the output directory before building the manifest"
)

args = parser.parse_args()
builder = ManifestBuilder(args.router_config, args.out, args.cleanup)

try:
builder.build_manifest()
except Exception as e:
print(f"Error: {e}")
exit(1)


if __name__ == "__main__":
main()
Loading
Loading