briar-cli 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,110 @@
1
+ """`briar runbook` — multi-company knowledge extraction from YAML.
2
+
3
+ Subcommands:
4
+ extract one-shot run (optionally filtered to a `--task`)
5
+ sweep one-shot over every YAML in a directory
6
+ serve long-lived in-process scheduler — replaces cron
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ from briar.commands.base import Command
16
+ from briar.errors import CliError
17
+ from briar.formatting import render
18
+ from briar.iac.runbook import (
19
+ RunbookScheduler,
20
+ extract_runbook,
21
+ load_runbook_file,
22
+ )
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ class CommandRunbook(Command):
29
+ name = "runbook"
30
+ help = "Run extractors and/or serve the in-process scheduler."
31
+
32
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
33
+ sub = parser.add_subparsers(dest="op", required=True)
34
+
35
+ ex = sub.add_parser(
36
+ "extract",
37
+ help="walk a YAML's `extract:` or `schedules:` and write knowledge files",
38
+ )
39
+ ex.add_argument("file")
40
+ ex.add_argument(
41
+ "--task",
42
+ default="",
43
+ help="run only the schedule whose `task:` matches this name",
44
+ )
45
+
46
+ sw = sub.add_parser(
47
+ "sweep",
48
+ help="run extract for every *.yaml in a directory",
49
+ )
50
+ sw.add_argument("directory")
51
+
52
+ sv = sub.add_parser(
53
+ "serve",
54
+ help="long-running scheduler — registers every (company, task) " "and runs the schedule loop forever",
55
+ )
56
+ sv.add_argument("directory")
57
+ sv.add_argument(
58
+ "--tick",
59
+ type=float,
60
+ default=1.0,
61
+ help="seconds between schedule.run_pending() ticks (default: 1)",
62
+ )
63
+
64
+ def run(self, args: argparse.Namespace) -> int:
65
+ if args.op == "extract":
66
+ return self._extract(args)
67
+ if args.op == "sweep":
68
+ return self._sweep(args)
69
+ if args.op == "serve":
70
+ return self._serve(args)
71
+ raise CliError(f"unknown runbook op {args.op!r}")
72
+
73
+ def _extract(self, args: argparse.Namespace) -> int:
74
+ runbook = load_runbook_file(Path(args.file))
75
+ rows = extract_runbook(runbook, args.task)
76
+ items = [{"company": r.company, "task": r.task, "status": r.status, "output": r.output} for r in rows]
77
+ render(items, args.format, ["company", "task", "status", "output"])
78
+ return 0
79
+
80
+ def _sweep(self, args: argparse.Namespace) -> int:
81
+ root = Path(args.directory)
82
+ if not root.is_dir():
83
+ raise CliError(f"sweep: {root} is not a directory")
84
+ files = sorted(p for p in root.glob("*.yaml") if p.is_file())
85
+ if not files:
86
+ print(f"sweep: no *.yaml under {root}")
87
+ return 0
88
+ exit_code = 0
89
+ for path in files:
90
+ log.info("sweep: processing %s", path.name)
91
+ print(f"--- {path.name} ---")
92
+ try:
93
+ runbook = load_runbook_file(path)
94
+ rows = extract_runbook(runbook)
95
+ items = [{"company": r.company, "task": r.task, "status": r.status, "output": r.output} for r in rows]
96
+ render(items, args.format, ["company", "task", "status", "output"])
97
+ except Exception: # noqa: BLE001
98
+ log.exception("sweep: %s failed", path.name)
99
+ exit_code = 1
100
+ return exit_code
101
+
102
+ def _serve(self, args: argparse.Namespace) -> int:
103
+ directory = Path(args.directory)
104
+ if not directory.is_dir():
105
+ raise CliError(f"serve: {directory} is not a directory")
106
+ scheduler = RunbookScheduler(directory)
107
+ for entry in scheduler.register_all():
108
+ log.info("registered company=%s task=%s every=%r", entry.company, entry.task, entry.every)
109
+ scheduler.run_forever(args.tick)
110
+ return 0
@@ -0,0 +1,165 @@
1
+ """`briar secrets` — credential management surface.
2
+
3
+ Subcommands:
4
+ doctor walk the configured runbooks + report which (company, extractor,
5
+ env var) tuples are set vs missing — without ever printing values.
6
+
7
+ The doctor reads required env-var lists FROM THE PROVIDER CLASSES
8
+ (``RepositoryProvider.required_env_vars`` / etc.) rather than from a
9
+ hand-maintained ``(extractor × provider) → creds`` table. Adding a new
10
+ extractor or provider needs no edit here — the provider declares its
11
+ requirements and the doctor picks them up via
12
+ ``KnowledgeExtractor.provider_class_for(args)``."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ from pathlib import Path
18
+ from typing import ClassVar, Dict
19
+
20
+ from briar.commands.base import Command
21
+ from briar.credentials import CredentialStoreRegistry, make_credential_store
22
+
23
+
24
+ class CommandSecrets(Command):
25
+ name = "secrets"
26
+ help = "Inspect credential coverage (briar secrets doctor)."
27
+
28
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
29
+ sub = parser.add_subparsers(dest="secrets_action", required=True)
30
+
31
+ doctor = sub.add_parser("doctor", help="Audit (company, extractor) → env-var coverage")
32
+ doctor.add_argument(
33
+ "--examples",
34
+ type=Path,
35
+ default=Path("./examples"),
36
+ help="Runbook YAML directory (default: ./examples)",
37
+ )
38
+ doctor.add_argument(
39
+ "--store",
40
+ default="envfile",
41
+ choices=list(CredentialStoreRegistry.kinds()),
42
+ help="Credential store backend (default: envfile)",
43
+ )
44
+
45
+ from briar.credentials._bootstraps import CredentialBootstrapRegistry
46
+
47
+ bootstrap = sub.add_parser(
48
+ "bootstrap",
49
+ help="One-off invocation of a credential bootstrap (e.g. Infisical fetch). "
50
+ "Normally runs automatically at CLI startup; this subcommand is for testing.",
51
+ )
52
+ bootstrap.add_argument(
53
+ "--kind",
54
+ default="",
55
+ choices=list(CredentialBootstrapRegistry.kinds()),
56
+ help="Force one bootstrap backend. Default: auto-detect via is_available().",
57
+ )
58
+ bootstrap.add_argument(
59
+ "--dry-run",
60
+ action="store_true",
61
+ help="Run the remote fetch but DON'T write to os.environ. Prints the keys that "
62
+ "would be set, without revealing values.",
63
+ )
64
+
65
+ _ACTIONS: ClassVar[Dict[str, str]] = {
66
+ "doctor": "_doctor",
67
+ "bootstrap": "_bootstrap",
68
+ }
69
+
70
+ def run(self, args: argparse.Namespace) -> int:
71
+ handler_name = self._ACTIONS.get(args.secrets_action)
72
+ if handler_name is None:
73
+ known = ", ".join(sorted(self._ACTIONS.keys()))
74
+ print(f"unknown secrets action: {args.secrets_action} (known: {known})")
75
+ return 1
76
+ return getattr(self, handler_name)(args)
77
+
78
+ @staticmethod
79
+ def _bootstrap(args: argparse.Namespace) -> int:
80
+ from briar.credentials._bootstraps import auto_bootstrap, make_bootstrap
81
+
82
+ if args.kind:
83
+ bs = make_bootstrap(args.kind)
84
+ if not bs.is_available():
85
+ names = ", ".join(bs.required_env_vars())
86
+ print(f"bootstrap kind={bs.kind} not configured — required env: {names}")
87
+ return 1
88
+ result = bs.hydrate(dry_run=args.dry_run)
89
+ else:
90
+ result = auto_bootstrap(dry_run=args.dry_run)
91
+
92
+ if not result.ok:
93
+ print(f"bootstrap {result.backend} failed: {result.error}")
94
+ return 1
95
+ if result.backend == "(none)":
96
+ print("no credential-bootstrap backend configured (auto-detect found nothing)")
97
+ return 0
98
+ marker = "would write" if args.dry_run else "wrote"
99
+ print(f"bootstrap {result.backend}: {marker} {result.count} env vars (preserved {len(result.skipped)} already-set)")
100
+ if result.written:
101
+ print(" keys: " + ", ".join(sorted(result.written)))
102
+ return 0
103
+
104
+ def _doctor(self, args: argparse.Namespace) -> int:
105
+ from briar.extract import EXTRACTORS
106
+ from briar.iac.runbook import load_runbook_file
107
+ from briar.iac.runbook.executor import RunbookSchedules
108
+ from briar.messaging import WRITERS
109
+
110
+ store = make_credential_store(args.store)
111
+ examples_dir: Path = args.examples
112
+ if not examples_dir.exists():
113
+ print(f"no examples dir at {examples_dir}")
114
+ return 1
115
+
116
+ any_missing = False
117
+ for yaml_path in sorted(examples_dir.glob("*.yaml")):
118
+ try:
119
+ rb = load_runbook_file(yaml_path)
120
+ except Exception as exc: # noqa: BLE001
121
+ print(f" {yaml_path.name}: load failed — {exc}")
122
+ continue
123
+ for company_name, company in rb.companies.items():
124
+ print(f"\n=== {company_name} ({yaml_path.name}) ===")
125
+ for schedule in RunbookSchedules.for_company(company):
126
+ for entry in schedule.extract:
127
+ extractor = EXTRACTORS.get(entry.name)
128
+ if extractor is None:
129
+ print(f" ? {entry.name} — unknown extractor, skipping")
130
+ continue
131
+ # Synthesize the same Namespace the runbook
132
+ # executor would build for this entry.
133
+ ns = argparse.Namespace(company=company_name, **entry.args)
134
+ provider_cls = extractor.provider_class_for(ns)
135
+ if provider_cls is None:
136
+ # Local-only / not provider-backed (no
137
+ # credential dependency). Report as ok.
138
+ print(f" ok {entry.name} (no provider deps)")
139
+ continue
140
+ provider_kind = getattr(provider_cls, "kind", "?")
141
+ required = provider_cls.required_env_vars(company=company_name)
142
+ missing = [env_name for env_name in required if not store.read(env_name)]
143
+ if missing:
144
+ any_missing = True
145
+ print(f" X {entry.name} (provider={provider_kind}) — MISSING: {', '.join(missing)}")
146
+ else:
147
+ print(f" ok {entry.name} (provider={provider_kind})")
148
+
149
+ # Audit the `messages:` block too — each writer has its
150
+ # own required_env_vars classmethod, same shape as the
151
+ # provider audit above.
152
+ for handle, binding in (getattr(company, "messages", {}) or {}).items():
153
+ kind = getattr(binding, "kind", "") or (binding.get("kind", "") if isinstance(binding, dict) else "")
154
+ writer_cls = WRITERS.get(kind)
155
+ if writer_cls is None:
156
+ print(f" ? messages.{handle} (kind={kind}) — unknown writer, skipping")
157
+ continue
158
+ required = writer_cls.required_env_vars(company=company_name)
159
+ missing = [env_name for env_name in required if not store.read(env_name)]
160
+ if missing:
161
+ any_missing = True
162
+ print(f" X messages.{handle} (kind={kind}) — MISSING: {', '.join(missing)}")
163
+ else:
164
+ print(f" ok messages.{handle} (kind={kind})")
165
+ return 1 if any_missing else 0
@@ -0,0 +1,17 @@
1
+ """`briar version` — local client version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from briar import __version__
8
+ from briar.commands.base import Command
9
+
10
+
11
+ class CommandVersion(Command):
12
+ name = "version"
13
+ help = "Print client version."
14
+
15
+ def run(self, args: argparse.Namespace) -> int:
16
+ print(f"briar-cli {__version__}")
17
+ return 0
@@ -0,0 +1,48 @@
1
+ """Credential store registry. The `EnvFileStore` is the only fully
2
+ working backend today; the rest are stubs that fail loudly so callers
3
+ know to wire them up before depending on them."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Dict, Tuple, Type
8
+
9
+ from briar._registry import build_registry
10
+ from briar.credentials._store import CredentialStore
11
+ from briar.credentials.aws_secrets import AwsSecretsManagerStore
12
+ from briar.credentials.envfile import EnvFileStore
13
+ from briar.credentials.infisical import InfisicalStore
14
+ from briar.credentials.ssm import SsmParameterStore
15
+ from briar.credentials.vault import VaultStore
16
+ from briar.errors import CliError
17
+
18
+
19
+ STORES: Dict[str, Type[CredentialStore]] = build_registry(
20
+ (EnvFileStore, AwsSecretsManagerStore, SsmParameterStore, VaultStore, InfisicalStore),
21
+ kind="credential store",
22
+ name_attr="kind",
23
+ )
24
+
25
+
26
+ class CredentialStoreRegistry:
27
+ @classmethod
28
+ def kinds(cls) -> Tuple[str, ...]:
29
+ return tuple(STORES.keys())
30
+
31
+ @classmethod
32
+ def make(cls, kind: str) -> CredentialStore:
33
+ store_cls = STORES.get(kind)
34
+ if store_cls is None:
35
+ known = ", ".join(sorted(STORES.keys()))
36
+ raise CliError(f"unknown credential store {kind!r}; known: {known}")
37
+ return store_cls()
38
+
39
+
40
+ make_credential_store = CredentialStoreRegistry.make
41
+
42
+
43
+ __all__ = [
44
+ "STORES",
45
+ "CredentialStore",
46
+ "CredentialStoreRegistry",
47
+ "make_credential_store",
48
+ ]
@@ -0,0 +1,93 @@
1
+ """`CredentialBootstrap` — bulk-write-at-startup credential hydration.
2
+
3
+ Distinct from `CredentialStore` (read-on-demand at the call site).
4
+ Where `CredentialStore.read(name)` is invoked by the doctor + by
5
+ adapters that need one specific value, `CredentialBootstrap.hydrate()`
6
+ runs ONCE at process startup, fetches every secret from a remote
7
+ store, and writes them to `os.environ` so the rest of briar (the
8
+ `CredEnv` enum, providers, writers, …) reads them through the
9
+ standard env-var path.
10
+
11
+ Two operating modes coexist:
12
+
13
+ /etc/briar/secrets.env → systemd EnvironmentFile=… → os.environ
14
+
15
+ briar reads here
16
+
17
+ INFISICAL_CLIENT_ID + creds → InfisicalBootstrap.hydrate() → os.environ
18
+
19
+ briar reads here
20
+
21
+ The local file path is the simpler default. Bootstrap mode is for
22
+ deployments where secrets live in a remote vault and the operator
23
+ only wants to bake the machine-identity credentials onto the host —
24
+ everything else is fetched at start.
25
+
26
+ Strategy + Registry behind `_bootstraps/`; constructed by
27
+ `make_bootstrap(kind)` or auto-selected by `auto_bootstrap()` (called
28
+ from `briar.cli.main` before any command logic runs)."""
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from abc import ABC, abstractmethod
34
+ from dataclasses import dataclass, field
35
+ from typing import ClassVar, Dict, List
36
+
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class HydrateResult:
43
+ """Outcome of one bootstrap. `written` lists the env-var names
44
+ that were `os.environ.setdefault`-ed; `skipped` lists the names
45
+ that were already set in `os.environ` and therefore preserved
46
+ (caller intent wins over the remote vault)."""
47
+
48
+ backend: str
49
+ written: List[str] = field(default_factory=list)
50
+ skipped: List[str] = field(default_factory=list)
51
+ error: str = ""
52
+
53
+ @property
54
+ def ok(self) -> bool:
55
+ return not self.error
56
+
57
+ @property
58
+ def count(self) -> int:
59
+ return len(self.written)
60
+
61
+
62
+ class CredentialBootstrap(ABC):
63
+ """Strategy contract. Concrete adapters wrap one remote vault."""
64
+
65
+ kind: ClassVar[str] = ""
66
+
67
+ @abstractmethod
68
+ def is_available(self) -> bool:
69
+ """True iff the bootstrap has the machine-identity credentials
70
+ it needs to authenticate against the remote vault. Without
71
+ this signal, `auto_bootstrap()` skips the backend (so a host
72
+ without Infisical creds is a no-op, not an error)."""
73
+
74
+ @abstractmethod
75
+ def hydrate(self, *, dry_run: bool = False) -> HydrateResult:
76
+ """Fetch every secret + write to `os.environ`. Uses
77
+ `setdefault` so an already-set env var (e.g. one populated by
78
+ systemd's EnvironmentFile=) takes precedence over the remote
79
+ value.
80
+
81
+ `dry_run=True` performs the remote fetch but does NOT write —
82
+ useful for `briar secrets bootstrap --dry-run` to see what
83
+ WOULD be set without leaking values into the process env.
84
+ Returns the same HydrateResult shape; `written` lists what
85
+ the non-dry-run would have written."""
86
+
87
+ @classmethod
88
+ def required_env_vars(cls) -> List[str]:
89
+ """The machine-identity env vars this bootstrap needs to
90
+ authenticate (NOT the secrets it fetches — those are
91
+ per-deployment). Used by `briar secrets doctor` to check
92
+ whether the bootstrap itself is configured."""
93
+ return []
@@ -0,0 +1,80 @@
1
+ """Credential-bootstrap registry — Strategy + Factory.
2
+
3
+ Each bootstrap fetches every secret from a remote vault at process
4
+ startup and writes them to `os.environ`. Distinct lifecycle from
5
+ `CredentialStore` (which is on-demand reads); see `_bootstrap.py`
6
+ for the contract.
7
+
8
+ Adding a new vault (Doppler, 1Password CLI, AWS Parameter Store
9
+ bulk-fetch, …) = one module here + one entry in the tuple. The
10
+ ``auto_bootstrap()`` helper picks the first available one — typical
11
+ use is one bootstrap configured per host, not several."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Dict, Tuple, Type
17
+
18
+ from briar._registry import build_registry
19
+ from briar.credentials._bootstrap import CredentialBootstrap, HydrateResult
20
+ from briar.credentials._bootstraps.infisical import InfisicalBootstrap
21
+ from briar.errors import CliError
22
+
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ BOOTSTRAPS: Dict[str, Type[CredentialBootstrap]] = build_registry(
28
+ (InfisicalBootstrap,),
29
+ kind="credential bootstrap",
30
+ name_attr="kind",
31
+ )
32
+
33
+
34
+ class CredentialBootstrapRegistry:
35
+ """Factory + introspection. Static."""
36
+
37
+ @classmethod
38
+ def kinds(cls) -> Tuple[str, ...]:
39
+ return tuple(BOOTSTRAPS.keys())
40
+
41
+ @classmethod
42
+ def make(cls, kind: str) -> CredentialBootstrap:
43
+ bs_cls = BOOTSTRAPS.get(kind)
44
+ if bs_cls is None:
45
+ known = ", ".join(sorted(BOOTSTRAPS.keys()))
46
+ raise CliError(f"unknown credential bootstrap {kind!r}; known: {known}")
47
+ return bs_cls()
48
+
49
+
50
+ make_bootstrap = CredentialBootstrapRegistry.make
51
+
52
+
53
+ def auto_bootstrap(*, dry_run: bool = False) -> HydrateResult:
54
+ """Iterate every registered bootstrap, run the first one whose
55
+ `is_available()` returns True. Called once from `briar.cli.main`
56
+ before any command logic runs.
57
+
58
+ Returns a `HydrateResult` even when no backend ran — callers
59
+ branch on `.ok` and `.count`. Logged at INFO level so a fresh
60
+ install with no bootstrap configured is self-explanatory in the
61
+ journalctl tail."""
62
+ for bs_cls in BOOTSTRAPS.values():
63
+ bs = bs_cls()
64
+ if not bs.is_available():
65
+ log.debug("credential-bootstrap: %s not configured — skip", bs.kind)
66
+ continue
67
+ log.info("credential-bootstrap: running %s%s", bs.kind, " (dry-run)" if dry_run else "")
68
+ return bs.hydrate(dry_run=dry_run)
69
+ log.debug("credential-bootstrap: no backend configured — using os.environ as-is")
70
+ return HydrateResult(backend="(none)")
71
+
72
+
73
+ __all__ = [
74
+ "BOOTSTRAPS",
75
+ "CredentialBootstrap",
76
+ "CredentialBootstrapRegistry",
77
+ "HydrateResult",
78
+ "auto_bootstrap",
79
+ "make_bootstrap",
80
+ ]
@@ -0,0 +1,115 @@
1
+ """Infisical `CredentialBootstrap`.
2
+
3
+ Lazy-imports `infisicalsdk` so it's an opt-in extra:
4
+ ``pip install briar-cli[infisical]``.
5
+
6
+ Auth: universal-auth machine identity. Three env vars
7
+ (`INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`, `INFISICAL_PROJECT_ID`)
8
+ plus optional `INFISICAL_ENV` (defaults to ``prod``) and
9
+ `INFISICAL_HOST` (defaults to ``https://app.infisical.com``).
10
+
11
+ `hydrate()` calls `client.secrets.list_secrets()` for the configured
12
+ project + environment, then `os.environ.setdefault(k, v)` for each
13
+ secret. Already-set env vars (e.g. populated by systemd before briar
14
+ starts) take precedence — matches the operator-intent-wins
15
+ semantics in the design doc."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib
20
+ import logging
21
+ import os
22
+ from typing import Any, List, Optional
23
+
24
+ from briar.credentials._bootstrap import CredentialBootstrap, HydrateResult
25
+
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ def _import_infisical_sdk() -> Optional[Any]:
31
+ try:
32
+ return importlib.import_module("infisical_sdk")
33
+ except ImportError:
34
+ return None
35
+
36
+
37
+ class InfisicalBootstrap(CredentialBootstrap):
38
+ kind = "infisical"
39
+ DEFAULT_HOST = "https://app.infisical.com"
40
+ DEFAULT_ENV = "prod"
41
+
42
+ REQUIRED_VARS: tuple = (
43
+ "INFISICAL_CLIENT_ID",
44
+ "INFISICAL_CLIENT_SECRET",
45
+ "INFISICAL_PROJECT_ID",
46
+ )
47
+
48
+ def __init__(self) -> None:
49
+ self._client_id = os.environ.get("INFISICAL_CLIENT_ID", "")
50
+ self._client_secret = os.environ.get("INFISICAL_CLIENT_SECRET", "")
51
+ self._project_id = os.environ.get("INFISICAL_PROJECT_ID", "")
52
+ self._env = os.environ.get("INFISICAL_ENV", self.DEFAULT_ENV)
53
+ self._host = os.environ.get("INFISICAL_HOST", self.DEFAULT_HOST)
54
+ self._client = None
55
+
56
+ def is_available(self) -> bool:
57
+ return all((self._client_id, self._client_secret, self._project_id))
58
+
59
+ def hydrate(self, *, dry_run: bool = False) -> HydrateResult:
60
+ if not self.is_available():
61
+ return HydrateResult(backend=self.kind, error="missing INFISICAL_{CLIENT_ID,CLIENT_SECRET,PROJECT_ID}")
62
+ try:
63
+ secrets = self._fetch_secrets()
64
+ except Exception as exc: # noqa: BLE001 — surface as result, don't crash startup
65
+ log.exception("infisical fetch failed")
66
+ return HydrateResult(backend=self.kind, error=f"fetch failed: {exc}")
67
+
68
+ written: List[str] = []
69
+ skipped: List[str] = []
70
+ for key, value in secrets:
71
+ if not key:
72
+ continue
73
+ if key in os.environ:
74
+ skipped.append(key)
75
+ continue
76
+ if not dry_run:
77
+ # setdefault — but we already checked `key not in os.environ`
78
+ # above so this is effectively just `os.environ[key] = value`.
79
+ # Kept as setdefault for the race-safe semantics under
80
+ # concurrent imports.
81
+ os.environ.setdefault(key, value)
82
+ written.append(key)
83
+
84
+ log.info(
85
+ "infisical-bootstrap: backend=%s wrote=%d preserved=%d host=%s env=%s%s",
86
+ self.kind,
87
+ len(written),
88
+ len(skipped),
89
+ self._host,
90
+ self._env,
91
+ " (DRY RUN — nothing written)" if dry_run else "",
92
+ )
93
+ return HydrateResult(backend=self.kind, written=written, skipped=skipped)
94
+
95
+ def _fetch_secrets(self) -> List[tuple]:
96
+ sdk = _import_infisical_sdk()
97
+ if sdk is None:
98
+ raise RuntimeError("infisical_sdk not installed — run `pip install briar-cli[infisical]`")
99
+ if self._client is None:
100
+ self._client = sdk.InfisicalSDKClient(host=self._host)
101
+ self._client.auth.universal_auth.login(
102
+ client_id=self._client_id,
103
+ client_secret=self._client_secret,
104
+ )
105
+ result = self._client.secrets.list_secrets(
106
+ project_id=self._project_id,
107
+ environment_slug=self._env,
108
+ secret_path="/",
109
+ )
110
+ # Tolerate both attribute and dict shapes since SDK versions vary.
111
+ return [(getattr(s, "secretKey", None) or s.get("secretKey", ""), getattr(s, "secretValue", None) or s.get("secretValue", "")) for s in (result.secrets or [])]
112
+
113
+ @classmethod
114
+ def required_env_vars(cls) -> List[str]:
115
+ return list(cls.REQUIRED_VARS)