tangier 0.2.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.
- tangier/__init__.py +15 -0
- tangier/__main__.py +5 -0
- tangier/changemap.py +310 -0
- tangier/cli.py +164 -0
- tangier/commands/__init__.py +1 -0
- tangier/commands/args.py +18 -0
- tangier/commands/changemap_cmds.py +243 -0
- tangier/commands/deploy_cmds.py +323 -0
- tangier/commands/gate_cmds.py +822 -0
- tangier/commands/image_cmds.py +155 -0
- tangier/commands/tailnet_cmds.py +106 -0
- tangier/config.py +972 -0
- tangier/deploy.py +194 -0
- tangier/gate.py +745 -0
- tangier/git.py +246 -0
- tangier/github.py +43 -0
- tangier/globs.py +79 -0
- tangier/image.py +167 -0
- tangier/jobs.py +459 -0
- tangier/ranon.py +43 -0
- tangier/runner.py +219 -0
- tangier/tailnet.py +67 -0
- tangier/tests/__init__.py +16 -0
- tangier/tests/fixtures/compose.tmpl.yml +31 -0
- tangier/tests/fixtures/synthetic.toml +100 -0
- tangier/tests/support.py +201 -0
- tangier/tests/test_changemap.py +453 -0
- tangier/tests/test_changemap_cmds.py +461 -0
- tangier/tests/test_config.py +745 -0
- tangier/tests/test_deploy.py +694 -0
- tangier/tests/test_gate.py +1933 -0
- tangier/tests/test_gate_jobs.py +543 -0
- tangier/tests/test_github.py +51 -0
- tangier/tests/test_globs.py +64 -0
- tangier/tests/test_image.py +420 -0
- tangier/tests/test_image_golden.py +112 -0
- tangier/tests/test_tailnet.py +193 -0
- tangier/tests/test_version.py +14 -0
- tangier-0.2.1.dist-info/METADATA +121 -0
- tangier-0.2.1.dist-info/RECORD +42 -0
- tangier-0.2.1.dist-info/WHEEL +4 -0
- tangier-0.2.1.dist-info/entry_points.txt +2 -0
tangier/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""tangier — a content-addressed CI/deploy pipeline toolkit.
|
|
2
|
+
|
|
3
|
+
Five concerns, one config (`pipeline.toml`):
|
|
4
|
+
|
|
5
|
+
changemap which parts of the repo does this diff touch?
|
|
6
|
+
image what is this bucket's content hash, and how do I build it?
|
|
7
|
+
deploy render and apply k8s manifests for those image tags.
|
|
8
|
+
tailnet can this machine reach the cluster, and as what identity?
|
|
9
|
+
gate has this content already passed its gate?
|
|
10
|
+
|
|
11
|
+
Pure stdlib. See `docs/specs/changemap.md` and `docs/specs/gate.md`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Kept equal to `pyproject.toml` by a test. Gate records carry it.
|
|
15
|
+
__version__ = "0.2.1"
|
tangier/__main__.py
ADDED
tangier/changemap.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""The answer set: which tags does this diff touch, and what are their SHAs?
|
|
2
|
+
|
|
3
|
+
The single computed view that every `changemap` subcommand formats. Nothing
|
|
4
|
+
here formats output or shells out to a cluster.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from tangier import git, globs
|
|
14
|
+
from tangier.config import Config
|
|
15
|
+
|
|
16
|
+
# `from tangier import git` — NOT `from tangier.git import changed_files`. Tests
|
|
17
|
+
# patch `git.changed_files`, and a direct-symbol import would bind a second
|
|
18
|
+
# reference that the patch never reaches.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AnswerSet:
|
|
23
|
+
matched: set[str]
|
|
24
|
+
expanded: set[str]
|
|
25
|
+
shas: dict[str, str]
|
|
26
|
+
items: dict[str, list[str]]
|
|
27
|
+
touched: dict[str, bool]
|
|
28
|
+
# File-set group name (e.g. `unittest-files`) -> directly-changed files
|
|
29
|
+
# selected by that group's globs. Drives each runner's `--files` flag.
|
|
30
|
+
file_sets: dict[str, list[str]]
|
|
31
|
+
ignored: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tag_matches(cfg: Config, tag: str, path: str) -> bool:
|
|
35
|
+
"""Whether `path` belongs to `tag`: matches its `paths`, minus its `exclude`.
|
|
36
|
+
|
|
37
|
+
The single definition of tag membership — every match site (diff resolution
|
|
38
|
+
and SHA bucket hashing) goes through here so the two cannot diverge.
|
|
39
|
+
"""
|
|
40
|
+
return globs.matches(path, cfg.paths.get(tag, [])) and not globs.matches(path, cfg.exclude.get(tag, []))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# SHA — content hash for a bucket
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha_of_paths(paths: list[str], head: str, keep: Callable[[str], bool] | None = None) -> str:
|
|
49
|
+
"""Hash the `git ls-tree -r` walk of `paths`, optionally filtered by `keep`.
|
|
50
|
+
|
|
51
|
+
`keep` receives each walked file path and decides whether it contributes —
|
|
52
|
+
the only way to express exclusion, since `ls-tree` has no negation.
|
|
53
|
+
`keep=None` skips the per-path call entirely; it is an optimisation, not a
|
|
54
|
+
parity mechanism, since a `keep` returning True for every line hashes
|
|
55
|
+
identically.
|
|
56
|
+
|
|
57
|
+
What byte-identical hashing actually rests on is here and in
|
|
58
|
+
`globs.globs_to_ls_tree_paths`: the exact `ls-tree` line text, joined with
|
|
59
|
+
`\\n` and hashed with no trailing newline, truncated to 10 hex chars. Change
|
|
60
|
+
any of that and every image tag in every consuming repo moves.
|
|
61
|
+
|
|
62
|
+
Policy-free by design — the caller composes `keep`, so `[sha] exclude` and
|
|
63
|
+
per-tag `exclude` meet in one place rather than two.
|
|
64
|
+
"""
|
|
65
|
+
return _sha_of_lines(tree_lines(paths, head, keep))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tree_lines(paths: list[str], head: str, keep: Callable[[str], bool] | None = None) -> list[str]:
|
|
69
|
+
"""The `git ls-tree -r` lines for `paths`, optionally filtered by `keep`."""
|
|
70
|
+
lines = git.ls_tree(head, paths)
|
|
71
|
+
return [line for line in lines if keep is None or keep(git.ls_tree_path(line))]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sha_of_lines(lines: list[str]) -> str:
|
|
75
|
+
return hashlib.sha1("\n".join(lines).encode()).hexdigest()[:10]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def transitive_deps(tag: str, depends: dict[str, list[str]]) -> set[str]:
|
|
79
|
+
"""Transitive closure of `tag`'s forward dependencies (what `tag` depends on)."""
|
|
80
|
+
seen: set[str] = set()
|
|
81
|
+
queue = [tag]
|
|
82
|
+
while queue:
|
|
83
|
+
cur = queue.pop()
|
|
84
|
+
for dep in depends.get(cur, ()):
|
|
85
|
+
if dep not in seen:
|
|
86
|
+
seen.add(dep)
|
|
87
|
+
queue.append(dep)
|
|
88
|
+
return seen
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def buckets(cfg: Config) -> dict[str, list[str]]:
|
|
92
|
+
"""bucket-name -> member tags (tags whose `sha` field maps here)."""
|
|
93
|
+
out: dict[str, list[str]] = {}
|
|
94
|
+
for tag, bucket in cfg.sha_bucket.items():
|
|
95
|
+
out.setdefault(bucket, []).append(tag)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sha_for_bucket(cfg: Config, bucket: str, head: str = "HEAD") -> str:
|
|
100
|
+
"""SHA of the union of a bucket's members' paths + their transitive deps' paths."""
|
|
101
|
+
return _sha_of_lines(scope_lines(cfg, buckets(cfg).get(bucket, []), head))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scope_lines(cfg: Config, tags: Iterable[str], head: str = "HEAD") -> list[str]:
|
|
105
|
+
"""The `ls-tree` lines for `tags`' paths plus their transitive deps' paths.
|
|
106
|
+
|
|
107
|
+
A SHA bucket and a gate scope both hash these lines, so a package means
|
|
108
|
+
the same thing to an image and to a gate.
|
|
109
|
+
|
|
110
|
+
Contributing tags' `exclude` globs are honoured so the lines match
|
|
111
|
+
the tags' match sets. `git ls-tree` cannot express negation, so the file list
|
|
112
|
+
it returns is filtered afterwards. Exclusion is a property of the individual
|
|
113
|
+
tag, not the union: a path one contributing tag excludes still counts if
|
|
114
|
+
another contributing tag claims it (that tag genuinely ships the file).
|
|
115
|
+
|
|
116
|
+
Two filters compose here, and they are deliberately different:
|
|
117
|
+
- `[sha] exclude` applies unconditionally (docs never rebuild an image).
|
|
118
|
+
- the per-tag `claimed_by` filter applies only when some contributing tag
|
|
119
|
+
actually declares `exclude`.
|
|
120
|
+
Conflating them would change the hash of every bucket and scope that has no exclusions.
|
|
121
|
+
"""
|
|
122
|
+
bucket_globs, keep = _scope_filter(cfg, tags)
|
|
123
|
+
return tree_lines(globs.globs_to_ls_tree_paths(bucket_globs), head, keep)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scope_touched(cfg: Config, tags: Iterable[str], files: Iterable[str]) -> bool:
|
|
127
|
+
"""Whether any of `files` is one of `scope_lines`' inputs for `tags`.
|
|
128
|
+
|
|
129
|
+
The same globs and the same `keep` filter as `scope_lines`, so a file
|
|
130
|
+
touches the scope exactly when it can move the scope's lines. Gate scopes
|
|
131
|
+
hold only literal paths and `dir/**` globs, for which a glob match equals
|
|
132
|
+
membership in the `ls-tree` walk. A deleted file counts: it left the walk.
|
|
133
|
+
"""
|
|
134
|
+
scope_globs, keep = _scope_filter(cfg, tags)
|
|
135
|
+
return any(globs.matches(f, scope_globs) and (keep is None or keep(f)) for f in files)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _scope_filter(cfg: Config, tags: Iterable[str]) -> tuple[list[str], Callable[[str], bool] | None]:
|
|
139
|
+
"""The globs `tags` contribute, with their transitive deps, and the `keep` filter for them.
|
|
140
|
+
|
|
141
|
+
`keep` is None when neither filter applies (see `scope_lines`).
|
|
142
|
+
"""
|
|
143
|
+
bucket_globs: list[str] = []
|
|
144
|
+
contributing: set[str] = set()
|
|
145
|
+
for m in tags:
|
|
146
|
+
# Set iteration, so the order of `bucket_globs` — and hence of the path
|
|
147
|
+
# arguments to `git ls-tree` — is not stable across runs. That is safe
|
|
148
|
+
# only because `git ls-tree -r` sorts and dedups its output regardless
|
|
149
|
+
# of argument order; the SHA depends on the output, not the arguments.
|
|
150
|
+
for tag in {m, *transitive_deps(m, cfg.depends)}:
|
|
151
|
+
if tag in contributing:
|
|
152
|
+
continue
|
|
153
|
+
contributing.add(tag)
|
|
154
|
+
bucket_globs.extend(cfg.paths.get(tag, []))
|
|
155
|
+
|
|
156
|
+
sha_exclude = cfg.sha.exclude
|
|
157
|
+
needs_claim_filter = any(cfg.exclude.get(tag) for tag in contributing)
|
|
158
|
+
|
|
159
|
+
def _keep(path: str) -> bool:
|
|
160
|
+
if sha_exclude and globs.matches(path, sha_exclude):
|
|
161
|
+
return False
|
|
162
|
+
if needs_claim_filter:
|
|
163
|
+
return any(tag_matches(cfg, tag, path) for tag in contributing)
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
# Neither filter applies -> walk everything unfiltered, the byte-stable fast path.
|
|
167
|
+
return bucket_globs, _keep if (sha_exclude or needs_claim_filter) else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Dependency expansion
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _invert_depends(depends: dict[str, list[str]]) -> dict[str, set[str]]:
|
|
176
|
+
"""Build dep -> {tags that directly depend on dep}."""
|
|
177
|
+
out: dict[str, set[str]] = {}
|
|
178
|
+
for tag, deps in depends.items():
|
|
179
|
+
for dep in deps:
|
|
180
|
+
out.setdefault(dep, set()).add(tag)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def expand_with_dependents(tags: Iterable[str], depends: dict[str, list[str]]) -> set[str]:
|
|
185
|
+
"""Expand a matched tag set via the reverse-transitive closure of depends.
|
|
186
|
+
|
|
187
|
+
For each input tag X, union in every tag whose dependency closure
|
|
188
|
+
transitively contains X.
|
|
189
|
+
"""
|
|
190
|
+
inverse = _invert_depends(depends)
|
|
191
|
+
result: set[str] = set(tags)
|
|
192
|
+
queue: list[str] = list(tags)
|
|
193
|
+
while queue:
|
|
194
|
+
cur = queue.pop()
|
|
195
|
+
for dependent in inverse.get(cur, ()):
|
|
196
|
+
if dependent not in result:
|
|
197
|
+
result.add(dependent)
|
|
198
|
+
queue.append(dependent)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def provenance(matched_raw: set[str], expanded: set[str], depends: dict[str, list[str]]) -> dict[str, set[str]]:
|
|
203
|
+
"""For each tag in `expanded - matched_raw`, the matched-raw tags whose
|
|
204
|
+
dependents transitively include it."""
|
|
205
|
+
added = expanded - matched_raw
|
|
206
|
+
out: dict[str, set[str]] = {}
|
|
207
|
+
for tag in added:
|
|
208
|
+
sources = {src for src in matched_raw if tag in expand_with_dependents({src}, depends)}
|
|
209
|
+
if sources:
|
|
210
|
+
out[tag] = sources
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Answer set
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def match_files_to_tags(cfg: Config, files: Iterable[str]) -> tuple[dict[str, list[str]], list[str]]:
|
|
220
|
+
"""Group files by the tag(s) they match. Returns (per_tag, ignored)."""
|
|
221
|
+
per_tag: dict[str, list[str]] = {}
|
|
222
|
+
ignored: list[str] = []
|
|
223
|
+
for f in files:
|
|
224
|
+
hit = False
|
|
225
|
+
for tag in cfg.paths:
|
|
226
|
+
if tag_matches(cfg, tag, f):
|
|
227
|
+
per_tag.setdefault(tag, []).append(f)
|
|
228
|
+
hit = True
|
|
229
|
+
if not hit:
|
|
230
|
+
ignored.append(f)
|
|
231
|
+
return per_tag, ignored
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def project_items(cfg: Config, name: str, expanded: set[str]) -> list[str]:
|
|
235
|
+
"""Stable, deduped projection of one items name through the expanded set."""
|
|
236
|
+
tag_map = cfg.items.get(name, {})
|
|
237
|
+
seen: set[str] = set()
|
|
238
|
+
collected: list[str] = []
|
|
239
|
+
for tag in sorted(expanded):
|
|
240
|
+
for path in tag_map.get(tag, []):
|
|
241
|
+
if path not in seen:
|
|
242
|
+
seen.add(path)
|
|
243
|
+
collected.append(path)
|
|
244
|
+
return collected
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def compute_answer_set(
|
|
248
|
+
cfg: Config, base: str, head: str, *, expand: bool = True, compute_shas: bool = True
|
|
249
|
+
) -> AnswerSet:
|
|
250
|
+
return answer_set_for_files(cfg, git.changed_files(base, head), head, expand=expand, compute_shas=compute_shas)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def answer_set_for_files(
|
|
254
|
+
cfg: Config, files: list[str], head: str, *, expand: bool = True, compute_shas: bool = True
|
|
255
|
+
) -> AnswerSet:
|
|
256
|
+
"""The answer set for a list of changed files. `head` is read only for the bucket SHAs."""
|
|
257
|
+
per_tag, ignored = match_files_to_tags(cfg, files)
|
|
258
|
+
matched = set(per_tag.keys())
|
|
259
|
+
expanded = expand_with_dependents(matched, cfg.depends) if expand else set(matched)
|
|
260
|
+
|
|
261
|
+
items = {name: project_items(cfg, name, expanded) for name in cfg.items}
|
|
262
|
+
|
|
263
|
+
shas: dict[str, str] = {}
|
|
264
|
+
if compute_shas:
|
|
265
|
+
for bucket in sorted(buckets(cfg).keys()):
|
|
266
|
+
shas[bucket] = sha_for_bucket(cfg, bucket, head)
|
|
267
|
+
|
|
268
|
+
touched = {tag: (tag in expanded) for tag in cfg.touched}
|
|
269
|
+
|
|
270
|
+
# File-set projection: each `files = true` table's globs select the
|
|
271
|
+
# directly-changed files for a runner's `--files` flag. Membership is decided
|
|
272
|
+
# purely by the table's globs over the raw diff — scoped to the code each
|
|
273
|
+
# runner can actually execute. Files outside every file-set's globs silently
|
|
274
|
+
# drop out, the same ignore-by-default contract as the rest of changemap.
|
|
275
|
+
file_sets = {
|
|
276
|
+
group: [f for f in files if globs.matches(f, group_globs)] for group, group_globs in cfg.file_sets.items()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return AnswerSet(
|
|
280
|
+
matched=matched,
|
|
281
|
+
expanded=expanded,
|
|
282
|
+
shas=shas,
|
|
283
|
+
items=items,
|
|
284
|
+
touched=touched,
|
|
285
|
+
file_sets=file_sets,
|
|
286
|
+
ignored=ignored,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def full_answer_set(cfg: Config, head: str, *, compute_shas: bool = True) -> AnswerSet:
|
|
291
|
+
"""The answer set as if every tag changed: `--full`. Reads no diff, so it needs no merge base.
|
|
292
|
+
|
|
293
|
+
Each file-set holds every tracked file at `head` that its globs match. See
|
|
294
|
+
`[run-full]` in `docs/specs/gate.md`.
|
|
295
|
+
"""
|
|
296
|
+
# `ls_tree` reads a bad ref as an empty tree, which would empty every file-set without a word.
|
|
297
|
+
_ = git.rev_parse_tree(head)
|
|
298
|
+
every_tag = set(cfg.paths)
|
|
299
|
+
tracked = [git.ls_tree_path(line) for line in git.ls_tree(head, [])]
|
|
300
|
+
return AnswerSet(
|
|
301
|
+
matched=every_tag,
|
|
302
|
+
expanded=every_tag,
|
|
303
|
+
shas={bucket: sha_for_bucket(cfg, bucket, head) for bucket in sorted(buckets(cfg))} if compute_shas else {},
|
|
304
|
+
items={name: project_items(cfg, name, every_tag) for name in cfg.items},
|
|
305
|
+
touched={tag: True for tag in cfg.touched},
|
|
306
|
+
file_sets={
|
|
307
|
+
group: [f for f in tracked if globs.matches(f, group_globs)] for group, group_globs in cfg.file_sets.items()
|
|
308
|
+
},
|
|
309
|
+
ignored=[],
|
|
310
|
+
)
|
tangier/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""tangier CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from tangier.commands import changemap_cmds, deploy_cmds, gate_cmds, image_cmds, tailnet_cmds
|
|
10
|
+
from tangier.commands.args import add_diff_args, add_full
|
|
11
|
+
from tangier.config import Config, ConfigError, read_config
|
|
12
|
+
from tangier.deploy import DeployError
|
|
13
|
+
from tangier.gate import GateError
|
|
14
|
+
from tangier.git import GitError
|
|
15
|
+
from tangier.image import ImageError
|
|
16
|
+
from tangier.runner import Runner
|
|
17
|
+
from tangier.tailnet import TailnetError
|
|
18
|
+
|
|
19
|
+
# The config lives in the repo tangier runs from — each project carries its own
|
|
20
|
+
# pipeline.toml at its root. TANGIER_CONFIG lets the parity harness point both
|
|
21
|
+
# implementations at the same file without copying or symlinking it.
|
|
22
|
+
DEFAULT_CONFIG = os.environ.get("TANGIER_CONFIG") or "pipeline.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cli_main() -> None:
|
|
26
|
+
"""Console-script entry point."""
|
|
27
|
+
raise SystemExit(main())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None, *, runner: Runner | None = None) -> int:
|
|
31
|
+
"""Run one command. `runner` replaces the subprocess seam, for tests."""
|
|
32
|
+
parser = _build_parser()
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
# The arguments as given, so a gate job can run the same command again.
|
|
35
|
+
args.argv = list(sys.argv[1:] if argv is None else argv)
|
|
36
|
+
if runner is not None:
|
|
37
|
+
args.runner = runner
|
|
38
|
+
if not getattr(args, "func", None):
|
|
39
|
+
parser.print_help()
|
|
40
|
+
return 1
|
|
41
|
+
try:
|
|
42
|
+
config = read_config(args.config)
|
|
43
|
+
except ConfigError as e:
|
|
44
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
45
|
+
return 2
|
|
46
|
+
except FileNotFoundError as e:
|
|
47
|
+
# A repo with no images and no deploy environments still wants the
|
|
48
|
+
# tailnet preflight — k8s-cluster is helm and kubectl, and a config
|
|
49
|
+
# file there would describe nothing. Every other command needs the
|
|
50
|
+
# config to mean anything, so only this one degrades.
|
|
51
|
+
if not getattr(args, "config_optional", False):
|
|
52
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
config = Config()
|
|
55
|
+
try:
|
|
56
|
+
return args.func(config, args) or 0
|
|
57
|
+
except ConfigError as e:
|
|
58
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
except (ImageError, DeployError, TailnetError, GateError, GitError) as e:
|
|
61
|
+
print(f"error: {e}", file=sys.stderr)
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _add_no_expand(p: argparse.ArgumentParser) -> None:
|
|
66
|
+
_ = p.add_argument(
|
|
67
|
+
"--no-expand",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="don't expand matched tags via depends (debug)",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
p = argparse.ArgumentParser(
|
|
75
|
+
prog="tangier",
|
|
76
|
+
description="smart CI for monorepos: change-scoped tests, local gate reuse, content-addressed builds",
|
|
77
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
78
|
+
)
|
|
79
|
+
_ = p.add_argument(
|
|
80
|
+
"--config",
|
|
81
|
+
default=DEFAULT_CONFIG,
|
|
82
|
+
help=f"path to TOML config (default: {DEFAULT_CONFIG})",
|
|
83
|
+
)
|
|
84
|
+
sub = p.add_subparsers(dest="group")
|
|
85
|
+
_add_changemap_parser(sub)
|
|
86
|
+
image_cmds.add_parsers(sub)
|
|
87
|
+
deploy_cmds.add_parsers(sub)
|
|
88
|
+
tailnet_cmds.add_parsers(sub)
|
|
89
|
+
gate_cmds.add_parsers(sub)
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_changemap_parser(sub: argparse._SubParsersAction) -> None:
|
|
94
|
+
cm = sub.add_parser("changemap", help="which parts of the repo does this diff touch?")
|
|
95
|
+
cmsub = cm.add_subparsers(dest="cmd", required=True)
|
|
96
|
+
|
|
97
|
+
lp = cmsub.add_parser("list", help="print all tags and their globs")
|
|
98
|
+
_ = lp.add_argument(
|
|
99
|
+
"--graph",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help="print each tag with its depends entries indented underneath",
|
|
102
|
+
)
|
|
103
|
+
lp.set_defaults(func=changemap_cmds.cmd_list)
|
|
104
|
+
|
|
105
|
+
sp = cmsub.add_parser("sha", help="git-tree content hash for a SHA bucket")
|
|
106
|
+
_ = sp.add_argument("bucket", nargs="?")
|
|
107
|
+
_ = sp.add_argument("--all", action="store_true")
|
|
108
|
+
_ = sp.add_argument("--github-notice", action="store_true")
|
|
109
|
+
_ = sp.add_argument("--head", default="HEAD", help="git ref to hash at (default: HEAD)")
|
|
110
|
+
sp.set_defaults(func=changemap_cmds.cmd_sha)
|
|
111
|
+
|
|
112
|
+
ip = cmsub.add_parser("items", help="project the expanded changed set into a named item list")
|
|
113
|
+
_ = ip.add_argument("name", help="items name (e.g. `unittest`, `e2e`)")
|
|
114
|
+
add_diff_args(ip)
|
|
115
|
+
_add_no_expand(ip)
|
|
116
|
+
add_full(ip)
|
|
117
|
+
ip.set_defaults(func=changemap_cmds.cmd_items)
|
|
118
|
+
|
|
119
|
+
li = cmsub.add_parser(
|
|
120
|
+
"list-ignored",
|
|
121
|
+
help="list changed files in the diff that matched no tag — sanity-check helper",
|
|
122
|
+
)
|
|
123
|
+
add_diff_args(li)
|
|
124
|
+
li.set_defaults(func=changemap_cmds.cmd_list_ignored)
|
|
125
|
+
|
|
126
|
+
ep = cmsub.add_parser("explain", help="show modified tags, dependents, and resulting CI invocations")
|
|
127
|
+
add_diff_args(ep)
|
|
128
|
+
_add_no_expand(ep)
|
|
129
|
+
add_full(ep)
|
|
130
|
+
_ = ep.add_argument(
|
|
131
|
+
"--files",
|
|
132
|
+
action="append",
|
|
133
|
+
metavar="GROUP=CSV",
|
|
134
|
+
help="csv of changed files for a file-set group, forwarded into that runner's --files arg (repeatable)",
|
|
135
|
+
)
|
|
136
|
+
ep.set_defaults(func=_explain_with_files)
|
|
137
|
+
|
|
138
|
+
bm = cmsub.add_parser(
|
|
139
|
+
"build-matrix",
|
|
140
|
+
help="emit the buildable buckets this diff touches, as a JSON array for a build matrix",
|
|
141
|
+
)
|
|
142
|
+
add_diff_args(bm)
|
|
143
|
+
_add_no_expand(bm)
|
|
144
|
+
add_full(bm)
|
|
145
|
+
bm.set_defaults(func=changemap_cmds.cmd_build_matrix)
|
|
146
|
+
|
|
147
|
+
gp = cmsub.add_parser(
|
|
148
|
+
"github-outputs",
|
|
149
|
+
help="emit the answer set (SHAs, items, touched, file-sets) as $GITHUB_OUTPUT lines",
|
|
150
|
+
)
|
|
151
|
+
add_diff_args(gp)
|
|
152
|
+
_add_no_expand(gp)
|
|
153
|
+
add_full(gp)
|
|
154
|
+
gp.set_defaults(func=changemap_cmds.cmd_github_outputs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _explain_with_files(config, args: argparse.Namespace) -> int:
|
|
158
|
+
"""Validate `--files GROUP=CSV` before running explain, so a malformed pair exits 2."""
|
|
159
|
+
try:
|
|
160
|
+
args.files_map = changemap_cmds.parse_files_args(args.files)
|
|
161
|
+
except ValueError as e:
|
|
162
|
+
print(f"error: {e}", file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
return changemap_cmds.cmd_explain(config, args)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI subcommand implementations — formatters over the computed answer set."""
|
tangier/commands/args.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Arguments that the `changemap` and `gate` parsers share, so each flag is defined once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_diff_args(p: argparse.ArgumentParser, *, head: bool = True) -> None:
|
|
9
|
+
"""`--base`, and `--head` unless the command keys the working tree."""
|
|
10
|
+
_ = p.add_argument("--base", default="origin/main")
|
|
11
|
+
if head:
|
|
12
|
+
_ = p.add_argument("--head", default="HEAD")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_full(
|
|
16
|
+
p: argparse.ArgumentParser, help: str = "answer as if every tag changed; reads no diff, so --base is ignored"
|
|
17
|
+
) -> None:
|
|
18
|
+
_ = p.add_argument("--full", action="store_true", help=help)
|