plugsync-cli 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/PKG-INFO +3 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/README.md +2 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/client.py +76 -4
- plugsync_cli-0.5.0/plugsync_cli/commands/connector.py +253 -0
- plugsync_cli-0.5.0/plugsync_cli/commands/diff.py +439 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/plugin.py +94 -7
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/push.py +33 -4
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/context.py +10 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/main.py +2 -0
- plugsync_cli-0.5.0/plugsync_cli/schema_defaults.py +183 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/serializer.py +202 -7
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/SOURCES.txt +5 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/pyproject.toml +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_api_contract.py +5 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_commands.py +634 -1
- plugsync_cli-0.5.0/tests/test_connector_commands.py +285 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_e2e_draft_api.py +153 -2
- plugsync_cli-0.5.0/tests/test_hand_edited_yaml.py +341 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_plugin_commands.py +328 -0
- plugsync_cli-0.5.0/tests/test_schema_defaults.py +179 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +0 -122
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/preview.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/pull.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/findings.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/setup.cfg +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_serializer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
|
|
5
5
|
Author-email: Exelab <hello@plugsync.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -75,6 +75,8 @@ override the config file, handy for CI.
|
|
|
75
75
|
| `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
|
|
76
76
|
| `plugsync log <name>` | Show a connector's published version history |
|
|
77
77
|
| `plugsync rollback <name>` | Restore a published version into the draft |
|
|
78
|
+
| `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
|
|
79
|
+
| `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
|
|
78
80
|
| `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
|
|
79
81
|
|
|
80
82
|
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
@@ -44,6 +44,8 @@ override the config file, handy for CI.
|
|
|
44
44
|
| `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
|
|
45
45
|
| `plugsync log <name>` | Show a connector's published version history |
|
|
46
46
|
| `plugsync rollback <name>` | Restore a published version into the draft |
|
|
47
|
+
| `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
|
|
48
|
+
| `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
|
|
47
49
|
| `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
|
|
48
50
|
|
|
49
51
|
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.5.0"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""HTTP client wrapper for PluSync REST API."""
|
|
2
2
|
import json
|
|
3
|
+
from urllib.parse import quote
|
|
3
4
|
|
|
4
5
|
import httpx
|
|
5
6
|
|
|
@@ -120,6 +121,30 @@ class PlugSyncClient:
|
|
|
120
121
|
r.raise_for_status()
|
|
121
122
|
return r.json()
|
|
122
123
|
|
|
124
|
+
def retry_flow(self, connector_id: str, flow_name: str) -> dict:
|
|
125
|
+
"""Retry one flow whose circuit breaker is open, returning the
|
|
126
|
+
refreshed connector.
|
|
127
|
+
|
|
128
|
+
The API moves the breaker to `half_open`, which makes the next message
|
|
129
|
+
through that flow the recovery probe: it is not a close, and a failed
|
|
130
|
+
probe re-opens the breaker with a longer cooldown. Idempotent when the
|
|
131
|
+
breaker is already half-open; 404 when the flow has no open breaker at
|
|
132
|
+
all, which the command translates into "nothing to retry".
|
|
133
|
+
|
|
134
|
+
The flow name is a config value, not a URL-safe token, so it is quoted
|
|
135
|
+
with no safe characters: a flow called `orders/eu` has to reach the API
|
|
136
|
+
as one path segment rather than two.
|
|
137
|
+
"""
|
|
138
|
+
with self._client() as c:
|
|
139
|
+
r = c.post(
|
|
140
|
+
self._url(
|
|
141
|
+
f"/connectors/{connector_id}/flows/"
|
|
142
|
+
f"{quote(flow_name, safe='')}/retry"
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
r.raise_for_status()
|
|
146
|
+
return r.json()
|
|
147
|
+
|
|
123
148
|
def get_draft(self, connector_id: str) -> dict:
|
|
124
149
|
"""The connector's editable config (backend DraftResponse).
|
|
125
150
|
|
|
@@ -154,6 +179,19 @@ class PlugSyncClient:
|
|
|
154
179
|
r.raise_for_status()
|
|
155
180
|
return r.json()
|
|
156
181
|
|
|
182
|
+
def config_surface(self) -> dict:
|
|
183
|
+
"""The config catalogue: actions, transforms, when, sources, json_schema.
|
|
184
|
+
|
|
185
|
+
`json_schema` is `ConnectorConfigV3.model_json_schema()`, defaults
|
|
186
|
+
included: it is how `plugsync diff` knows which values the server wrote
|
|
187
|
+
from a schema default rather than the operator (#1259). Org-scoped
|
|
188
|
+
auth only -- the catalogue itself is the same for every org.
|
|
189
|
+
"""
|
|
190
|
+
with self._client() as c:
|
|
191
|
+
r = c.get(self._url("/config-surface"))
|
|
192
|
+
r.raise_for_status()
|
|
193
|
+
return r.json()
|
|
194
|
+
|
|
157
195
|
def get_version(self, connector_id: str, version: int) -> dict:
|
|
158
196
|
"""One published config version, config included (backend VersionResponse)."""
|
|
159
197
|
with self._client() as c:
|
|
@@ -175,11 +213,19 @@ class PlugSyncClient:
|
|
|
175
213
|
r.raise_for_status()
|
|
176
214
|
return r.json()
|
|
177
215
|
|
|
178
|
-
def publish(
|
|
216
|
+
def publish(
|
|
217
|
+
self, connector_id: str, change_summary: str | None = None, force: bool = False,
|
|
218
|
+
) -> dict:
|
|
219
|
+
"""`force` (#1367, DX-61): publish a new version even when the draft
|
|
220
|
+
is identical to the active one - the values a publish resolves (plugin
|
|
221
|
+
ARN/version, HubSpot schema) can drift without any draft edit. Unrelated
|
|
222
|
+
to `plugsync push --force`, which discards a concurrent DRAFT edit."""
|
|
179
223
|
with self._client() as c:
|
|
180
224
|
body = {}
|
|
181
225
|
if change_summary:
|
|
182
226
|
body["change_summary"] = change_summary
|
|
227
|
+
if force:
|
|
228
|
+
body["force"] = True
|
|
183
229
|
r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
|
|
184
230
|
r.raise_for_status()
|
|
185
231
|
return r.json()
|
|
@@ -255,12 +301,38 @@ class PlugSyncClient:
|
|
|
255
301
|
r.raise_for_status()
|
|
256
302
|
return r.json()
|
|
257
303
|
|
|
258
|
-
def plugin_invoke(
|
|
259
|
-
|
|
304
|
+
def plugin_invoke(
|
|
305
|
+
self,
|
|
306
|
+
plugin_id: str,
|
|
307
|
+
payload: dict,
|
|
308
|
+
config: dict | None = None,
|
|
309
|
+
auth_credential: str | None = None,
|
|
310
|
+
auth_key: str | None = None,
|
|
311
|
+
) -> dict:
|
|
312
|
+
"""Proxy an invocation to the plugin's Lambda function.
|
|
313
|
+
|
|
314
|
+
`payload` is the event BODY only -- it lands at `event.payload` on
|
|
315
|
+
the Lambda side, not the whole PluginPayload envelope (issue #1365).
|
|
316
|
+
`config` mirrors `event.config`.
|
|
317
|
+
|
|
318
|
+
`auth_credential` is a name, never a secret in the clear: the server
|
|
319
|
+
reads it as an event source name and resolves that source's outbound
|
|
320
|
+
auth exactly as a real flow does (its credential, auth_type and
|
|
321
|
+
auth_header), falling back to a stored credential name when the org
|
|
322
|
+
has no source called that. The event.auth KEY those headers land
|
|
323
|
+
under is `auth_key` when given, else `auth_credential` itself; that
|
|
324
|
+
key is also the `auth` capability grant the server requires on the
|
|
325
|
+
plugin, mirroring what the runtime hands a plugin.
|
|
326
|
+
"""
|
|
260
327
|
with self._client() as c:
|
|
261
328
|
r = c.post(
|
|
262
329
|
self._url(f"/plugins/{plugin_id}/invoke"),
|
|
263
|
-
json={
|
|
330
|
+
json={
|
|
331
|
+
"payload": payload,
|
|
332
|
+
"config": config or {},
|
|
333
|
+
"auth_credential": auth_credential,
|
|
334
|
+
"auth_key": auth_key,
|
|
335
|
+
},
|
|
264
336
|
)
|
|
265
337
|
r.raise_for_status()
|
|
266
338
|
return r.json()
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""plugsync connector -- read a connector's flow circuit breakers, retry one.
|
|
2
|
+
|
|
3
|
+
Self-service only, with the same org-scoped auth as every other command: the
|
|
4
|
+
owner of a connector lists the flows whose circuit breaker is open and retries
|
|
5
|
+
one of them, from a script or from CI, without opening the dashboard. The
|
|
6
|
+
platform-side force-close is a different operation with a different
|
|
7
|
+
authentication contract (it refuses an org API key) and deliberately has no
|
|
8
|
+
command here.
|
|
9
|
+
"""
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from plugsync_cli.client import PlugSyncClient
|
|
19
|
+
from plugsync_cli.context import build_client, fail
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_CONNECTOR_NOT_FOUND = (
|
|
24
|
+
"Connector '{label}' not found, or it does not belong to this organization."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _looks_like_a_connector_id(reference: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
uuid.UUID(reference)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_connector(client: PlugSyncClient, reference: str) -> tuple[str, str]:
|
|
37
|
+
"""A connector reference (name or id) as `(connector_id, label)`.
|
|
38
|
+
|
|
39
|
+
A UUID is taken as the id and used as-is: resolving it through the
|
|
40
|
+
connector listing would cost a round trip to learn a name the endpoints
|
|
41
|
+
already return in their own response.
|
|
42
|
+
"""
|
|
43
|
+
if _looks_like_a_connector_id(reference):
|
|
44
|
+
return reference, reference
|
|
45
|
+
connector = client.find_connector(reference)
|
|
46
|
+
if not connector:
|
|
47
|
+
fail(f"Connector '{reference}' not found")
|
|
48
|
+
return connector["id"], connector.get("name") or reference
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _detail(exc: httpx.HTTPStatusError) -> str:
|
|
52
|
+
"""The API's `detail` string, or "" when the body carries none."""
|
|
53
|
+
try:
|
|
54
|
+
payload = exc.response.json()
|
|
55
|
+
except ValueError:
|
|
56
|
+
return ""
|
|
57
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
58
|
+
return detail if isinstance(detail, str) else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _open_breakers(connector: dict) -> list[dict]:
|
|
62
|
+
"""The connector's non-closed flow breakers, one shape for two payloads.
|
|
63
|
+
|
|
64
|
+
`health.flows` is the richer form and the one to read. An API that predates
|
|
65
|
+
it answers only the legacy list, which carries the same flows under other
|
|
66
|
+
key names: normalizing it here keeps such a backend from reading as a
|
|
67
|
+
connector with nothing blocked.
|
|
68
|
+
"""
|
|
69
|
+
flows = (connector.get("health") or {}).get("flows")
|
|
70
|
+
if flows:
|
|
71
|
+
return flows
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
"name": flow.get("flow_name"),
|
|
75
|
+
"state": flow.get("state"),
|
|
76
|
+
"detail": flow.get("reason"),
|
|
77
|
+
"opened_at": flow.get("tripped_at"),
|
|
78
|
+
}
|
|
79
|
+
for flow in connector.get("tripped_flows") or []
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _breaker_for(connector: dict, flow_name: str) -> dict | None:
|
|
84
|
+
for breaker in _open_breakers(connector):
|
|
85
|
+
if breaker.get("name") == flow_name:
|
|
86
|
+
return breaker
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _reason(breaker: dict) -> str:
|
|
91
|
+
"""The human-readable diagnostic, falling back to the machine code."""
|
|
92
|
+
return str(breaker.get("detail") or breaker.get("reason") or "")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _relative(value, *, now: datetime | None = None) -> str:
|
|
96
|
+
"""An ISO timestamp as a short relative time, past or future.
|
|
97
|
+
|
|
98
|
+
Both directions matter in one table: a breaker opened in the past, and a
|
|
99
|
+
cooldown that expires in the future. An unparseable value is printed as it
|
|
100
|
+
arrived rather than dropped.
|
|
101
|
+
"""
|
|
102
|
+
if not value:
|
|
103
|
+
return ""
|
|
104
|
+
try:
|
|
105
|
+
moment = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return str(value)
|
|
108
|
+
if moment.tzinfo is None:
|
|
109
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
110
|
+
|
|
111
|
+
delta = (moment - (now or datetime.now(timezone.utc))).total_seconds()
|
|
112
|
+
seconds = int(abs(delta))
|
|
113
|
+
if seconds < 60:
|
|
114
|
+
return "now"
|
|
115
|
+
if seconds < 3600:
|
|
116
|
+
magnitude = f"{seconds // 60}m"
|
|
117
|
+
elif seconds < 86400:
|
|
118
|
+
magnitude = f"{seconds // 3600}h"
|
|
119
|
+
else:
|
|
120
|
+
magnitude = f"{seconds // 86400}d"
|
|
121
|
+
return f"in {magnitude}" if delta > 0 else f"{magnitude} ago"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@click.group()
|
|
125
|
+
def connector():
|
|
126
|
+
"""Inspect a connector's operational state."""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@connector.group()
|
|
131
|
+
def flow():
|
|
132
|
+
"""Act on one event flow of a connector."""
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@flow.command("retry")
|
|
137
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
138
|
+
@click.argument("flow_name", metavar="FLOW")
|
|
139
|
+
def flow_retry(connector_reference: str, flow_name: str):
|
|
140
|
+
"""Retry a flow whose circuit breaker is open.
|
|
141
|
+
|
|
142
|
+
CONNECTOR is a connector name or id, FLOW the event flow name. The breaker
|
|
143
|
+
moves to half-open: the next message through the flow is the recovery
|
|
144
|
+
probe. It is not a force-close, and a failed probe re-opens the breaker
|
|
145
|
+
with a longer cooldown.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
plugsync connector flow retry my-connector shop-orders
|
|
149
|
+
"""
|
|
150
|
+
client = build_client()
|
|
151
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
updated = client.retry_flow(connector_id, flow_name)
|
|
155
|
+
except httpx.HTTPStatusError as exc:
|
|
156
|
+
if exc.response.status_code != 404:
|
|
157
|
+
raise
|
|
158
|
+
if "connector not found" in _detail(exc).lower():
|
|
159
|
+
fail(_CONNECTOR_NOT_FOUND.format(label=label))
|
|
160
|
+
fail(
|
|
161
|
+
f"Flow '{flow_name}' has no open circuit breaker on '{label}': "
|
|
162
|
+
f"nothing to retry. Run 'plugsync connector breakers {label}' to "
|
|
163
|
+
"list the flows that are blocked."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
name = updated.get("name") or label
|
|
167
|
+
breaker = _breaker_for(updated, flow_name)
|
|
168
|
+
if breaker is None:
|
|
169
|
+
console.print(
|
|
170
|
+
f"Retry accepted for flow '{flow_name}' on connector '{name}', but "
|
|
171
|
+
"the flow no longer appears among its open circuit breakers: there "
|
|
172
|
+
"is no breaker state left to report.",
|
|
173
|
+
soft_wrap=True,
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
state = breaker.get("state") or "unknown"
|
|
178
|
+
console.print(
|
|
179
|
+
f"[green]Flow '{flow_name}' on connector '{name}': breaker is now "
|
|
180
|
+
f"{state}.[/green]",
|
|
181
|
+
soft_wrap=True,
|
|
182
|
+
)
|
|
183
|
+
if state == "half_open":
|
|
184
|
+
console.print(
|
|
185
|
+
"The next message through this flow is the recovery probe: if it "
|
|
186
|
+
"succeeds the breaker closes, if it fails the breaker re-opens "
|
|
187
|
+
"with a longer cooldown.",
|
|
188
|
+
soft_wrap=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@connector.command("breakers")
|
|
193
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
194
|
+
def connector_breakers(connector_reference: str):
|
|
195
|
+
"""List the flows of a connector whose circuit breaker is open.
|
|
196
|
+
|
|
197
|
+
CONNECTOR is a connector name or id. Read-only, and always exits 0 when it
|
|
198
|
+
could read the connector: a blocked flow is not a command failure.
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
plugsync connector breakers my-connector
|
|
202
|
+
"""
|
|
203
|
+
client = build_client()
|
|
204
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
data = client.get_connector(connector_id)
|
|
208
|
+
except httpx.HTTPStatusError as exc:
|
|
209
|
+
if exc.response.status_code != 404:
|
|
210
|
+
raise
|
|
211
|
+
fail(_CONNECTOR_NOT_FOUND.format(label=label))
|
|
212
|
+
|
|
213
|
+
name = data.get("name") or label
|
|
214
|
+
breakers = _open_breakers(data)
|
|
215
|
+
if not breakers:
|
|
216
|
+
console.print(f"[green]No open circuit breaker on '{name}'.[/green]")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
220
|
+
# `overflow="fold"` on the flow name: rich shrinks the widest column when
|
|
221
|
+
# the table outgrows the terminal, and the default is an ellipsis. The
|
|
222
|
+
# name is exactly what the operator has to retype into `flow retry`, so it
|
|
223
|
+
# wraps rather than being cut. `Reason` comes last for the same reason --
|
|
224
|
+
# it is the column the wrapping should land on.
|
|
225
|
+
table.add_column("Flow", overflow="fold")
|
|
226
|
+
table.add_column("State")
|
|
227
|
+
table.add_column("Opened")
|
|
228
|
+
table.add_column("Retry after")
|
|
229
|
+
table.add_column("Opens", justify="right")
|
|
230
|
+
table.add_column("Reason", overflow="fold")
|
|
231
|
+
|
|
232
|
+
for breaker in breakers:
|
|
233
|
+
opens = breaker.get("opens")
|
|
234
|
+
table.add_row(
|
|
235
|
+
str(breaker.get("name") or ""),
|
|
236
|
+
str(breaker.get("state") or ""),
|
|
237
|
+
_relative(breaker.get("opened_at")),
|
|
238
|
+
_relative(breaker.get("retry_after_at")),
|
|
239
|
+
"" if opens is None else str(opens),
|
|
240
|
+
_reason(breaker),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
console.print(f"\n[bold]Open circuit breakers: {name}[/bold]\n")
|
|
244
|
+
console.print(table)
|
|
245
|
+
console.print(
|
|
246
|
+
f"\nRetry one now with: plugsync connector flow retry {name} <flow>",
|
|
247
|
+
soft_wrap=True,
|
|
248
|
+
)
|
|
249
|
+
console.print(
|
|
250
|
+
"A flow whose retry window has already passed is probed automatically "
|
|
251
|
+
"by the next message it receives.\n",
|
|
252
|
+
soft_wrap=True,
|
|
253
|
+
)
|