plugsync-cli 0.4.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.4.0 → plugsync_cli-0.5.0}/PKG-INFO +3 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/README.md +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/client.py +25 -0
- plugsync_cli-0.5.0/plugsync_cli/commands/connector.py +253 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/main.py +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/SOURCES.txt +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/pyproject.toml +1 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_api_contract.py +4 -0
- plugsync_cli-0.5.0/tests/test_connector_commands.py +285 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/diff.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/preview.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/pull.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/push.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/context.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/findings.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/schema_defaults.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/serializer.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/setup.cfg +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_commands.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_e2e_draft_api.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_hand_edited_yaml.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_plugin_commands.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_schema_defaults.py +0 -0
- {plugsync_cli-0.4.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
|
|
|
@@ -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
|
+
)
|
|
@@ -115,6 +115,7 @@ from plugsync_cli.commands.log import log_cmd
|
|
|
115
115
|
from plugsync_cli.commands.rollback import rollback
|
|
116
116
|
from plugsync_cli.commands.plugin import plugin
|
|
117
117
|
from plugsync_cli.commands.auth import auth
|
|
118
|
+
from plugsync_cli.commands.connector import connector
|
|
118
119
|
|
|
119
120
|
cli.add_command(pull)
|
|
120
121
|
cli.add_command(push)
|
|
@@ -125,3 +126,4 @@ cli.add_command(log_cmd)
|
|
|
125
126
|
cli.add_command(rollback)
|
|
126
127
|
cli.add_command(plugin)
|
|
127
128
|
cli.add_command(auth)
|
|
129
|
+
cli.add_command(connector)
|
|
@@ -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.
|
|
@@ -18,6 +18,7 @@ plugsync_cli.egg-info/requires.txt
|
|
|
18
18
|
plugsync_cli.egg-info/top_level.txt
|
|
19
19
|
plugsync_cli/commands/__init__.py
|
|
20
20
|
plugsync_cli/commands/auth.py
|
|
21
|
+
plugsync_cli/commands/connector.py
|
|
21
22
|
plugsync_cli/commands/diff.py
|
|
22
23
|
plugsync_cli/commands/log.py
|
|
23
24
|
plugsync_cli/commands/plugin.py
|
|
@@ -32,6 +33,7 @@ tests/test_client.py
|
|
|
32
33
|
tests/test_commands.py
|
|
33
34
|
tests/test_compat.py
|
|
34
35
|
tests/test_config.py
|
|
36
|
+
tests/test_connector_commands.py
|
|
35
37
|
tests/test_e2e_draft_api.py
|
|
36
38
|
tests/test_hand_edited_yaml.py
|
|
37
39
|
tests/test_plugin_commands.py
|
|
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
|
|
|
11
11
|
|
|
12
12
|
[project]
|
|
13
13
|
name = "plugsync-cli"
|
|
14
|
-
version = "0.
|
|
14
|
+
version = "0.5.0"
|
|
15
15
|
description = "Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required"
|
|
16
16
|
readme = "README.md"
|
|
17
17
|
license = { text = "Proprietary" }
|
|
@@ -64,6 +64,10 @@ DELEGATING_CALLS: dict[str, tuple] = {
|
|
|
64
64
|
CLIENT_CALLS: dict[str, tuple] = {
|
|
65
65
|
"list_connectors": (),
|
|
66
66
|
"get_connector": (CONNECTOR_ID,),
|
|
67
|
+
# A flow name with no character the path has to escape: the guard resolves
|
|
68
|
+
# a concrete path against the OpenAPI templates segment by segment, so a
|
|
69
|
+
# percent-encoded sentinel would only make the match harder to read.
|
|
70
|
+
"retry_flow": (CONNECTOR_ID, "shop-orders"),
|
|
67
71
|
"get_draft": (CONNECTOR_ID,),
|
|
68
72
|
"put_draft": (CONNECTOR_ID, {"version": "3.1", "entities": [], "flows": []}),
|
|
69
73
|
"config_surface": (),
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Tests for `plugsync connector` -- flow circuit breakers over the org key.
|
|
2
|
+
|
|
3
|
+
The client is mocked here, as in test_commands.py: these cover argument
|
|
4
|
+
handling, the rendering of a `ConnectorResponse` and the 404 branches. That
|
|
5
|
+
the mocked calls hit endpoints which actually exist is
|
|
6
|
+
`test_api_contract.py`'s job.
|
|
7
|
+
"""
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from click.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
from plugsync_cli.main import cli
|
|
14
|
+
|
|
15
|
+
CONNECTOR_ID = "11111111-1111-1111-1111-111111111111"
|
|
16
|
+
ORG_ID = "22222222-2222-2222-2222-222222222222"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _flat(output: str) -> str:
|
|
20
|
+
"""Output with wrapping collapsed: rich hard-wraps at the console width,
|
|
21
|
+
so a message is asserted on its words, not on where the line broke."""
|
|
22
|
+
return " ".join(output.split())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _breaker(name: str = "shop-orders", state: str = "open", **overrides) -> dict:
|
|
26
|
+
"""One `health.flows` entry (backend `FlowBreakerState`)."""
|
|
27
|
+
row = {
|
|
28
|
+
"name": name,
|
|
29
|
+
"state": state,
|
|
30
|
+
"reason": "fatal_streak",
|
|
31
|
+
"detail": "upsert refused",
|
|
32
|
+
"opened_at": "2026-09-10T08:00:00Z",
|
|
33
|
+
"retry_after_at": "2026-09-10T08:05:00Z",
|
|
34
|
+
"cooldown_seconds": 300,
|
|
35
|
+
"opens": 2,
|
|
36
|
+
}
|
|
37
|
+
row.update(overrides)
|
|
38
|
+
return row
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _connector(
|
|
42
|
+
name: str = "shop",
|
|
43
|
+
breakers: list[dict] | None = None,
|
|
44
|
+
tripped_flows: list[dict] | None = None,
|
|
45
|
+
health_state: str = "degraded",
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""What the connector endpoints answer (backend `ConnectorResponse`)."""
|
|
48
|
+
return {
|
|
49
|
+
"id": CONNECTOR_ID,
|
|
50
|
+
"name": name,
|
|
51
|
+
"status": "active",
|
|
52
|
+
"org_id": ORG_ID,
|
|
53
|
+
"direction": "inbound",
|
|
54
|
+
"settings": {},
|
|
55
|
+
"mode": "live",
|
|
56
|
+
"tripped_flows": tripped_flows or [],
|
|
57
|
+
"health": {"state": health_state, "flows": breakers or []},
|
|
58
|
+
"created_at": "2026-09-01T10:00:00Z",
|
|
59
|
+
"updated_at": "2026-09-10T08:00:00Z",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _client(**attrs) -> MagicMock:
|
|
64
|
+
client = MagicMock()
|
|
65
|
+
client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
|
|
66
|
+
client.get_connector.return_value = _connector()
|
|
67
|
+
client.retry_flow.return_value = _connector()
|
|
68
|
+
for key, value in attrs.items():
|
|
69
|
+
setattr(client, key, value)
|
|
70
|
+
return client
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _http_error(status: int, payload, method: str = "POST", path: str = "/api/x"):
|
|
74
|
+
"""The httpx exception a non-2xx raises inside the client."""
|
|
75
|
+
request = httpx.Request(method, f"https://api.test{path}")
|
|
76
|
+
response = httpx.Response(status, json=payload, request=request)
|
|
77
|
+
return httpx.HTTPStatusError("boom", request=request, response=response)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(client: MagicMock, args: list[str]):
|
|
81
|
+
runner = CliRunner()
|
|
82
|
+
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
83
|
+
return runner.invoke(cli, args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestFlowRetry:
|
|
87
|
+
def test_retry_reports_the_half_open_probe(self):
|
|
88
|
+
client = _client(
|
|
89
|
+
retry_flow=MagicMock(
|
|
90
|
+
return_value=_connector(breakers=[_breaker(state="half_open")])
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
|
|
95
|
+
|
|
96
|
+
assert result.exit_code == 0, result.output
|
|
97
|
+
client.retry_flow.assert_called_once_with(CONNECTOR_ID, "shop-orders")
|
|
98
|
+
output = _flat(result.output)
|
|
99
|
+
assert "shop-orders" in output
|
|
100
|
+
assert "half_open" in output
|
|
101
|
+
assert "recovery probe" in output
|
|
102
|
+
|
|
103
|
+
def test_retry_accepts_a_connector_id_without_a_name_lookup(self):
|
|
104
|
+
"""A UUID argument is the connector id: no listing round trip for it."""
|
|
105
|
+
client = _client(
|
|
106
|
+
retry_flow=MagicMock(
|
|
107
|
+
return_value=_connector(breakers=[_breaker(state="half_open")])
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
result = _run(
|
|
112
|
+
client, ["connector", "flow", "retry", CONNECTOR_ID, "shop-orders"]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert result.exit_code == 0, result.output
|
|
116
|
+
client.find_connector.assert_not_called()
|
|
117
|
+
client.retry_flow.assert_called_once_with(CONNECTOR_ID, "shop-orders")
|
|
118
|
+
|
|
119
|
+
def test_a_flow_with_no_open_breaker_is_reported_not_raised(self):
|
|
120
|
+
"""The API's 404 for "nothing to retry", said in the CLI's own words."""
|
|
121
|
+
client = _client(
|
|
122
|
+
retry_flow=MagicMock(
|
|
123
|
+
side_effect=_http_error(
|
|
124
|
+
404,
|
|
125
|
+
{"detail": "Flow 'shop-orders' has no open circuit breaker to retry"},
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
|
|
131
|
+
|
|
132
|
+
assert result.exit_code == 1
|
|
133
|
+
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
134
|
+
output = _flat(result.output)
|
|
135
|
+
assert "no open circuit breaker" in output
|
|
136
|
+
assert "nothing to retry" in output
|
|
137
|
+
assert "Traceback" not in result.output
|
|
138
|
+
|
|
139
|
+
def test_a_connector_of_another_org_is_reported(self):
|
|
140
|
+
client = _client(
|
|
141
|
+
retry_flow=MagicMock(
|
|
142
|
+
side_effect=_http_error(404, {"detail": "Connector not found"})
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
result = _run(
|
|
147
|
+
client, ["connector", "flow", "retry", CONNECTOR_ID, "shop-orders"]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assert result.exit_code == 1
|
|
151
|
+
output = _flat(result.output)
|
|
152
|
+
assert "not found" in output
|
|
153
|
+
assert "nothing to retry" not in output
|
|
154
|
+
|
|
155
|
+
def test_an_unknown_connector_name_is_reported(self):
|
|
156
|
+
client = _client(find_connector=MagicMock(return_value=None))
|
|
157
|
+
|
|
158
|
+
result = _run(client, ["connector", "flow", "retry", "nope", "shop-orders"])
|
|
159
|
+
|
|
160
|
+
assert result.exit_code == 1
|
|
161
|
+
assert "Connector 'nope' not found" in _flat(result.output)
|
|
162
|
+
client.retry_flow.assert_not_called()
|
|
163
|
+
|
|
164
|
+
def test_a_response_without_the_flow_does_not_invent_a_state(self):
|
|
165
|
+
"""The retry went through, but the answer carries no breaker for the
|
|
166
|
+
flow: report that rather than claiming a state nobody sent."""
|
|
167
|
+
client = _client(retry_flow=MagicMock(return_value=_connector(breakers=[])))
|
|
168
|
+
|
|
169
|
+
result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
|
|
170
|
+
|
|
171
|
+
assert result.exit_code == 0, result.output
|
|
172
|
+
output = _flat(result.output)
|
|
173
|
+
assert "half_open" not in output
|
|
174
|
+
assert "no longer appears" in output
|
|
175
|
+
|
|
176
|
+
def test_a_non_404_reaches_the_generic_handler(self):
|
|
177
|
+
"""Only the 404 is special-cased; the rest keeps the group's rendering
|
|
178
|
+
(message from the response body, no traceback)."""
|
|
179
|
+
client = _client(
|
|
180
|
+
retry_flow=MagicMock(
|
|
181
|
+
side_effect=_http_error(
|
|
182
|
+
503, {"detail": "Service Unavailable"}, path="/api/connectors/x"
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
|
|
188
|
+
|
|
189
|
+
assert result.exit_code == 1
|
|
190
|
+
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
191
|
+
output = _flat(result.output)
|
|
192
|
+
assert "503" in output
|
|
193
|
+
assert "Service Unavailable" in output
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestBreakers:
|
|
197
|
+
def test_the_open_breakers_are_listed(self):
|
|
198
|
+
client = _client(
|
|
199
|
+
get_connector=MagicMock(
|
|
200
|
+
return_value=_connector(
|
|
201
|
+
breakers=[
|
|
202
|
+
_breaker(name="shop-orders", state="open"),
|
|
203
|
+
_breaker(name="crm-sync", state="half_open", opens=5),
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
result = _run(client, ["connector", "breakers", "shop"])
|
|
210
|
+
|
|
211
|
+
assert result.exit_code == 0, result.output
|
|
212
|
+
client.get_connector.assert_called_once_with(CONNECTOR_ID)
|
|
213
|
+
output = _flat(result.output)
|
|
214
|
+
assert "shop-orders" in output
|
|
215
|
+
assert "crm-sync" in output
|
|
216
|
+
assert "open" in output
|
|
217
|
+
assert "half_open" in output
|
|
218
|
+
assert "upsert refused" in output
|
|
219
|
+
# The footer points at the command that acts on what was just listed.
|
|
220
|
+
assert "plugsync connector flow retry" in output
|
|
221
|
+
|
|
222
|
+
def test_no_open_breaker_is_said_plainly(self):
|
|
223
|
+
client = _client(get_connector=MagicMock(return_value=_connector()))
|
|
224
|
+
|
|
225
|
+
result = _run(client, ["connector", "breakers", "shop"])
|
|
226
|
+
|
|
227
|
+
assert result.exit_code == 0, result.output
|
|
228
|
+
assert "No open circuit breaker on 'shop'" in _flat(result.output)
|
|
229
|
+
|
|
230
|
+
def test_an_api_without_the_health_rollup_still_lists_the_flows(self):
|
|
231
|
+
"""An older API carries only the legacy list; render that instead of
|
|
232
|
+
reporting a healthy connector."""
|
|
233
|
+
client = _client(
|
|
234
|
+
get_connector=MagicMock(
|
|
235
|
+
return_value={
|
|
236
|
+
"id": CONNECTOR_ID,
|
|
237
|
+
"name": "shop",
|
|
238
|
+
"status": "active",
|
|
239
|
+
"org_id": ORG_ID,
|
|
240
|
+
"settings": {},
|
|
241
|
+
"mode": "live",
|
|
242
|
+
"tripped_flows": [
|
|
243
|
+
{
|
|
244
|
+
"flow_name": "shop-orders",
|
|
245
|
+
"reason": "upsert refused",
|
|
246
|
+
"state": "open",
|
|
247
|
+
"tripped_at": "2026-09-10T08:00:00Z",
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
"created_at": "2026-09-01T10:00:00Z",
|
|
251
|
+
"updated_at": "2026-09-10T08:00:00Z",
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
result = _run(client, ["connector", "breakers", "shop"])
|
|
257
|
+
|
|
258
|
+
assert result.exit_code == 0, result.output
|
|
259
|
+
output = _flat(result.output)
|
|
260
|
+
assert "shop-orders" in output
|
|
261
|
+
assert "upsert refused" in output
|
|
262
|
+
|
|
263
|
+
def test_an_unknown_connector_name_is_reported(self):
|
|
264
|
+
client = _client(find_connector=MagicMock(return_value=None))
|
|
265
|
+
|
|
266
|
+
result = _run(client, ["connector", "breakers", "nope"])
|
|
267
|
+
|
|
268
|
+
assert result.exit_code == 1
|
|
269
|
+
assert "Connector 'nope' not found" in _flat(result.output)
|
|
270
|
+
client.get_connector.assert_not_called()
|
|
271
|
+
|
|
272
|
+
def test_a_connector_of_another_org_is_reported(self):
|
|
273
|
+
client = _client(
|
|
274
|
+
get_connector=MagicMock(
|
|
275
|
+
side_effect=_http_error(
|
|
276
|
+
404, {"detail": "Connector not found"}, method="GET"
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
result = _run(client, ["connector", "breakers", CONNECTOR_ID])
|
|
282
|
+
|
|
283
|
+
assert result.exit_code == 1
|
|
284
|
+
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
285
|
+
assert "not found" in _flat(result.output)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|