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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- 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)
|