flowstash-cli 0.11.0__tar.gz → 0.13.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.
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/PKG-INFO +2 -2
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/pyproject.toml +2 -2
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/apikey.py +50 -13
- flowstash_cli-0.13.0/src/flowstash/cli/commands/diagnostics.py +336 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/api_client.py +22 -1
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/config.py +19 -5
- flowstash_cli-0.13.0/src/flowstash/cli/core/diagnostics.py +193 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/main.py +6 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/auth.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/build.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/client.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/deploy.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/project.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/run.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/commands/webhook.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/auth_server.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/builder.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/docker_utils.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/core/patcher.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/AGENTS.md +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/README.md +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_.dockerignore +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_.flowstash +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_api_main.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/[env]/(backend-asyncio)/backend.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/[env]/(backend-dramatiq)/backend.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/[env]/(backend-managed)/backend.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/[env]/(observability-logfile)/observability.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/[env]/(observability-managed)/observability.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/shared/backend.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/shared/clients/demoClient.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_config/shared/clients.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_deployment/shared/api.Dockerfile +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_deployment/shared/worker.Dockerfile +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_pyproject.toml +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_api/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_api/_routes/webhooks.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_shared/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_shared/clients/client.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_shared/models/models.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_shared/tasks/sharedTasks.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_worker/__init__.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_worker_main.py +0 -0
- {flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/ui/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: flowstash-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: CLI for the flowstash Managed Platform
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: juraj.bezdek@gmail.com
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Requires-Dist: flowstash-runtime (>=0.
|
|
14
|
+
Requires-Dist: flowstash-runtime (>=0.13.0,<0.14.0)
|
|
15
15
|
Requires-Dist: httpx (>=0.27.0)
|
|
16
16
|
Requires-Dist: keyring (>=25.0.0)
|
|
17
17
|
Requires-Dist: libcst (>=1.1.0)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flowstash-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.0"
|
|
4
4
|
description = "CLI for the flowstash Managed Platform"
|
|
5
5
|
authors = [{name = "juraj.bezdek@gmail.com", email = "juraj.bezdek@gmail.com"}]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.11"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"flowstash-runtime>=0.
|
|
9
|
+
"flowstash-runtime>=0.13.0,<0.14.0",
|
|
10
10
|
"typer[all]>=0.12.0",
|
|
11
11
|
"httpx>=0.27.0",
|
|
12
12
|
"pyyaml>=6.0.1",
|
|
@@ -24,7 +24,7 @@ from rich.prompt import Prompt
|
|
|
24
24
|
from rich.table import Table
|
|
25
25
|
|
|
26
26
|
from ..core.api_client import APIClient
|
|
27
|
-
from ..core.config import get_access_token, resolve_credentials
|
|
27
|
+
from ..core.config import get_access_token, resolve_credentials, find_project_root, load_project_config
|
|
28
28
|
|
|
29
29
|
app = typer.Typer(
|
|
30
30
|
name="api-keys",
|
|
@@ -33,9 +33,10 @@ app = typer.Typer(
|
|
|
33
33
|
)
|
|
34
34
|
console = Console()
|
|
35
35
|
|
|
36
|
-
_VALID_SCOPES = {"observability:ingest", "admin"}
|
|
36
|
+
_VALID_SCOPES = {"observability:ingest", "observability:read", "admin"}
|
|
37
37
|
_SCOPE_DESCRIPTIONS = {
|
|
38
38
|
"observability:ingest": "Write-only ingestion (SDK / workers)",
|
|
39
|
+
"observability:read": "Read-only managed diagnostics (agents)",
|
|
39
40
|
"admin": "Full management access (CI / automation)",
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -49,7 +50,8 @@ def _format_ts(ts) -> str:
|
|
|
49
50
|
return str(ts)
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
def _find_project_root(start_path: Path =
|
|
53
|
+
def _find_project_root(start_path: Optional[Path] = None) -> Optional[Path]:
|
|
54
|
+
start_path = start_path or Path.cwd()
|
|
53
55
|
for parent in [start_path] + list(start_path.parents):
|
|
54
56
|
if (parent / ".flowstash").exists() or (parent / "pyproject.toml").exists():
|
|
55
57
|
return parent
|
|
@@ -104,6 +106,8 @@ def _create_key(
|
|
|
104
106
|
scope: Optional[str],
|
|
105
107
|
env: Optional[str],
|
|
106
108
|
user: Optional[str] = None,
|
|
109
|
+
project: Optional[str] = None,
|
|
110
|
+
write_env: Optional[str] = None,
|
|
107
111
|
):
|
|
108
112
|
"""Shared implementation for 'new' and 'create'."""
|
|
109
113
|
token = resolve_credentials(user=user)
|
|
@@ -119,7 +123,7 @@ def _create_key(
|
|
|
119
123
|
"Key scope:",
|
|
120
124
|
choices=[
|
|
121
125
|
questionary.Choice(f"{s} — {_SCOPE_DESCRIPTIONS[s]}", s)
|
|
122
|
-
for s in _VALID_SCOPES
|
|
126
|
+
for s in sorted(_VALID_SCOPES - {"observability:read"})
|
|
123
127
|
],
|
|
124
128
|
default="observability:ingest",
|
|
125
129
|
).ask()
|
|
@@ -132,6 +136,21 @@ def _create_key(
|
|
|
132
136
|
)
|
|
133
137
|
raise typer.Exit(code=1)
|
|
134
138
|
|
|
139
|
+
# Preserve legacy ingest/admin --env file setup without --project.
|
|
140
|
+
# Read keys require explicit scope and never write to a file implicitly.
|
|
141
|
+
restricted_env = env if project or scope == "observability:read" else None
|
|
142
|
+
if restricted_env and not project:
|
|
143
|
+
root = find_project_root()
|
|
144
|
+
try:
|
|
145
|
+
config = load_project_config(root, strict=True) if root else None
|
|
146
|
+
except ValueError:
|
|
147
|
+
console.print("[red]Malformed .flowstash configuration.[/red]")
|
|
148
|
+
raise typer.Exit(code=2)
|
|
149
|
+
project = config.project_id if config else None
|
|
150
|
+
if not project:
|
|
151
|
+
console.print("[red]An environment restriction requires --project or a linked project.[/red]")
|
|
152
|
+
raise typer.Exit(code=2)
|
|
153
|
+
|
|
135
154
|
if scope == "admin" and env:
|
|
136
155
|
console.print(
|
|
137
156
|
"[yellow]Warning: writing an 'admin' key to .env is not recommended. "
|
|
@@ -140,7 +159,12 @@ def _create_key(
|
|
|
140
159
|
|
|
141
160
|
async def _create():
|
|
142
161
|
api = APIClient(token=token)
|
|
143
|
-
|
|
162
|
+
body = {"label": label, "scopes": [scope]}
|
|
163
|
+
if project:
|
|
164
|
+
body["project_id"] = project
|
|
165
|
+
if restricted_env:
|
|
166
|
+
body["environment"] = restricted_env
|
|
167
|
+
return await api.post("/v1/api-keys", json=body)
|
|
144
168
|
|
|
145
169
|
try:
|
|
146
170
|
result = asyncio.run(_create())
|
|
@@ -155,14 +179,19 @@ def _create_key(
|
|
|
155
179
|
console.print(f" Key ID : [bold]{result['key_id']}[/bold]")
|
|
156
180
|
console.print(f" Label : {label}")
|
|
157
181
|
console.print(f" Scope : {scope}")
|
|
182
|
+
if project:
|
|
183
|
+
console.print(f" Project : {project}")
|
|
184
|
+
if restricted_env:
|
|
185
|
+
console.print(f" Env : {restricted_env}")
|
|
158
186
|
console.print()
|
|
159
187
|
console.print(
|
|
160
188
|
"[bold yellow]⚠ Save this key — it will NOT be shown again:[/bold yellow]"
|
|
161
189
|
)
|
|
162
190
|
console.print(f"\n [bold cyan]{raw_key}[/bold cyan]\n")
|
|
163
191
|
|
|
164
|
-
if
|
|
165
|
-
|
|
192
|
+
target_env = write_env or (env if scope != "observability:read" else None)
|
|
193
|
+
if target_env:
|
|
194
|
+
_write_key_to_env(target_env, raw_key)
|
|
166
195
|
|
|
167
196
|
|
|
168
197
|
@app.command("new")
|
|
@@ -171,17 +200,19 @@ def apikey_new(
|
|
|
171
200
|
None, "--label", "-l", help="Human-readable label for the key"
|
|
172
201
|
),
|
|
173
202
|
scope: Optional[str] = typer.Option(
|
|
174
|
-
None, "--scope", "-s", help="Scope: observability:ingest | admin"
|
|
203
|
+
None, "--scope", "-s", help="Scope: observability:ingest | observability:read | admin"
|
|
175
204
|
),
|
|
176
205
|
env: Optional[str] = typer.Option(
|
|
177
|
-
None, "--env", "-e", help="
|
|
206
|
+
None, "--env", "-e", help="Restrict read keys to an environment; legacy ingest/admin .env setup"
|
|
178
207
|
),
|
|
179
208
|
user: Optional[str] = typer.Option(
|
|
180
209
|
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
181
210
|
),
|
|
211
|
+
project: Optional[str] = typer.Option(None, "--project", help="Restrict key to a managed project"),
|
|
212
|
+
write_env: Optional[str] = typer.Option(None, "--write-env", help="Explicitly write key to an environment .env file"),
|
|
182
213
|
):
|
|
183
214
|
"""Create a new API key and optionally write it to an environment .env file."""
|
|
184
|
-
_create_key(label=label, scope=scope, env=env, user=user)
|
|
215
|
+
_create_key(label=label, scope=scope, env=env, user=user, project=project, write_env=write_env)
|
|
185
216
|
|
|
186
217
|
|
|
187
218
|
@app.command("create")
|
|
@@ -190,17 +221,19 @@ def apikey_create(
|
|
|
190
221
|
None, "--label", "-l", help="Human-readable label for the key"
|
|
191
222
|
),
|
|
192
223
|
scope: Optional[str] = typer.Option(
|
|
193
|
-
None, "--scope", "-s", help="Scope: observability:ingest | admin"
|
|
224
|
+
None, "--scope", "-s", help="Scope: observability:ingest | observability:read | admin"
|
|
194
225
|
),
|
|
195
226
|
env: Optional[str] = typer.Option(
|
|
196
|
-
None, "--env", "-e", help="
|
|
227
|
+
None, "--env", "-e", help="Restrict read keys to an environment; legacy ingest/admin .env setup"
|
|
197
228
|
),
|
|
198
229
|
user: Optional[str] = typer.Option(
|
|
199
230
|
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
200
231
|
),
|
|
232
|
+
project: Optional[str] = typer.Option(None, "--project", help="Restrict key to a managed project"),
|
|
233
|
+
write_env: Optional[str] = typer.Option(None, "--write-env", help="Explicitly write key to an environment .env file"),
|
|
201
234
|
):
|
|
202
235
|
"""Create a new API key (alias for 'new')."""
|
|
203
|
-
_create_key(label=label, scope=scope, env=env, user=user)
|
|
236
|
+
_create_key(label=label, scope=scope, env=env, user=user, project=project, write_env=write_env)
|
|
204
237
|
|
|
205
238
|
|
|
206
239
|
@app.command("list")
|
|
@@ -235,6 +268,8 @@ def apikey_list(
|
|
|
235
268
|
table.add_column("Label")
|
|
236
269
|
table.add_column("Prefix")
|
|
237
270
|
table.add_column("Scopes", style="cyan")
|
|
271
|
+
table.add_column("Project")
|
|
272
|
+
table.add_column("Environment")
|
|
238
273
|
table.add_column("Created")
|
|
239
274
|
table.add_column("Last Used")
|
|
240
275
|
|
|
@@ -244,6 +279,8 @@ def apikey_list(
|
|
|
244
279
|
k["label"],
|
|
245
280
|
k["key_prefix"],
|
|
246
281
|
", ".join(k.get("scopes", [])),
|
|
282
|
+
k.get("project_id") or "—",
|
|
283
|
+
k.get("environment") or "—",
|
|
247
284
|
_format_ts(k.get("created_at")),
|
|
248
285
|
_format_ts(k.get("last_used_at")),
|
|
249
286
|
)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Resource-specific read-only diagnostics. No payload hydration or page draining."""
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from ..core.diagnostics import DiagnosticCommand, DiagnosticGroup, execute, resolve_context, query_options
|
|
6
|
+
|
|
7
|
+
class Order(str, Enum):
|
|
8
|
+
asc = "asc"
|
|
9
|
+
desc = "desc"
|
|
10
|
+
|
|
11
|
+
class EventType(str, Enum):
|
|
12
|
+
STARTED = "STARTED"
|
|
13
|
+
PROGRESS = "PROGRESS"
|
|
14
|
+
ENDED = "ENDED"
|
|
15
|
+
SCHEDULED = "SCHEDULED"
|
|
16
|
+
|
|
17
|
+
class BuildSeverity(str, Enum):
|
|
18
|
+
DEFAULT = "DEFAULT"
|
|
19
|
+
DEBUG = "DEBUG"
|
|
20
|
+
INFO = "INFO"
|
|
21
|
+
NOTICE = "NOTICE"
|
|
22
|
+
WARNING = "WARNING"
|
|
23
|
+
ERROR = "ERROR"
|
|
24
|
+
CRITICAL = "CRITICAL"
|
|
25
|
+
ALERT = "ALERT"
|
|
26
|
+
EMERGENCY = "EMERGENCY"
|
|
27
|
+
|
|
28
|
+
obs_app = typer.Typer(help="Read managed execution evidence", cls=DiagnosticGroup, no_args_is_help=True)
|
|
29
|
+
builds_app = typer.Typer(help="Read managed builds and paginated build logs", cls=DiagnosticGroup, no_args_is_help=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def observation(resource, options):
|
|
33
|
+
options = dict(options)
|
|
34
|
+
json_mode = options.pop("json_mode")
|
|
35
|
+
project, env, user = (options.pop(k) for k in ("project", "env", "user"))
|
|
36
|
+
run_id = options.get("run_id")
|
|
37
|
+
async def work():
|
|
38
|
+
params, window = query_options(options, scoped=bool(run_id), logs=resource == "logs", default_window=not run_id)
|
|
39
|
+
context = await resolve_context(project, env, user)
|
|
40
|
+
result = await context.api.diagnostic_query(resource, {**context.location, **params}, run_id if resource != "runs" else None)
|
|
41
|
+
return result, window
|
|
42
|
+
execute(work, json_mode)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@obs_app.command("run", cls=DiagnosticCommand)
|
|
46
|
+
def run_summary(
|
|
47
|
+
run_id: str = typer.Argument(...),
|
|
48
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
49
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
50
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
51
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
52
|
+
):
|
|
53
|
+
"""Read one run summary in the selected workspace."""
|
|
54
|
+
async def work():
|
|
55
|
+
context = await resolve_context(project, env, user)
|
|
56
|
+
result = await context.api.diagnostic_run(run_id, context.location)
|
|
57
|
+
context.check_item(result)
|
|
58
|
+
return result, {}
|
|
59
|
+
execute(work, json_mode)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@obs_app.command("runs", cls=DiagnosticCommand)
|
|
63
|
+
def runs(
|
|
64
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
65
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
66
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
67
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
68
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
69
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
70
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
71
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
72
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
73
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
74
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
75
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
76
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
77
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
78
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
79
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
80
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
81
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
82
|
+
status: Optional[str] = typer.Option(None, "--status", help='status'),
|
|
83
|
+
):
|
|
84
|
+
"""Read one page of runs; fetch additional pages explicitly."""
|
|
85
|
+
observation("runs", locals())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@obs_app.command("logs", cls=DiagnosticCommand)
|
|
89
|
+
def logs(
|
|
90
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
91
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
92
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
93
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
94
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
95
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
96
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
97
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
98
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
99
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
100
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
101
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
102
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
103
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
104
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
105
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
106
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
107
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
108
|
+
severity: Optional[str] = typer.Option(None, "--severity", help='severity'),
|
|
109
|
+
search: Optional[str] = typer.Option(None, "--search", help='search'),
|
|
110
|
+
):
|
|
111
|
+
"""Read one page of logs; fetch additional pages explicitly."""
|
|
112
|
+
observation("logs", locals())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@obs_app.command("spans", cls=DiagnosticCommand)
|
|
116
|
+
def spans(
|
|
117
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
118
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
119
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
120
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
121
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
122
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
123
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
124
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
125
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
126
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
127
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
128
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
129
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
130
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
131
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
132
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
133
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
134
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
135
|
+
status: Optional[str] = typer.Option(None, "--status", help='status'),
|
|
136
|
+
search: Optional[str] = typer.Option(None, "--search", help='search'),
|
|
137
|
+
):
|
|
138
|
+
"""Read one page of spans; fetch additional pages explicitly."""
|
|
139
|
+
observation("spans", locals())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@obs_app.command("io", cls=DiagnosticCommand)
|
|
143
|
+
def io(
|
|
144
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
145
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
146
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
147
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
148
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
149
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
150
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
151
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
152
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
153
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
154
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
155
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
156
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
157
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
158
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
159
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
160
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
161
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
162
|
+
state: Optional[str] = typer.Option(None, "--state", help='state'),
|
|
163
|
+
remote_system: Optional[str] = typer.Option(None, "--remote-system", help='remote system'),
|
|
164
|
+
channel: Optional[str] = typer.Option(None, "--channel", help='channel'),
|
|
165
|
+
operation: Optional[str] = typer.Option(None, "--operation", help='operation'),
|
|
166
|
+
status_code: Optional[int] = typer.Option(None, "--status-code", help='status code'),
|
|
167
|
+
query: Optional[str] = typer.Option(None, "--query", help='query'),
|
|
168
|
+
):
|
|
169
|
+
"""Read one page of io; fetch additional pages explicitly."""
|
|
170
|
+
observation("data-exchanges", locals())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@obs_app.command("records", cls=DiagnosticCommand)
|
|
174
|
+
def records(
|
|
175
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
176
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
177
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
178
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
179
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
180
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
181
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
182
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
183
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
184
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
185
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
186
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
187
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
188
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
189
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
190
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
191
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
192
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
193
|
+
kind: Optional[str] = typer.Option(None, "--kind", help='kind'),
|
|
194
|
+
source: Optional[str] = typer.Option(None, "--source", help='source'),
|
|
195
|
+
):
|
|
196
|
+
"""Read one page of records; fetch additional pages explicitly."""
|
|
197
|
+
observation("records", locals())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@obs_app.command("events", cls=DiagnosticCommand)
|
|
201
|
+
def events(
|
|
202
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
203
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
204
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
205
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
206
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
207
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
208
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
209
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
210
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
211
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
212
|
+
integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
|
|
213
|
+
pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
|
|
214
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
|
|
215
|
+
span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
|
|
216
|
+
trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
|
|
217
|
+
parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
|
|
218
|
+
operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
|
|
219
|
+
record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
|
|
220
|
+
status: Optional[str] = typer.Option(None, "--status", help='status'),
|
|
221
|
+
event_type: Optional[EventType] = typer.Option(None, "--event-type", help='event type'),
|
|
222
|
+
):
|
|
223
|
+
"""Read one page of events; fetch additional pages explicitly."""
|
|
224
|
+
observation("run-events", locals())
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@obs_app.command("payload", cls=DiagnosticCommand)
|
|
228
|
+
def payload(
|
|
229
|
+
payload_ref: str = typer.Argument(...),
|
|
230
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
231
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
232
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
233
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
234
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help='Captured owning run'),
|
|
235
|
+
):
|
|
236
|
+
"""Fetch one stored payload; binary data remains base64 in JSON."""
|
|
237
|
+
async def work():
|
|
238
|
+
context = await resolve_context(project, env, user)
|
|
239
|
+
params = {**context.location}
|
|
240
|
+
if run_id is not None:
|
|
241
|
+
params["run_id"] = run_id
|
|
242
|
+
return await context.api.diagnostic_payload(payload_ref, params), {}
|
|
243
|
+
execute(work, json_mode)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@builds_app.command("get", cls=DiagnosticCommand)
|
|
247
|
+
def build_get(
|
|
248
|
+
build_id: str = typer.Argument(...),
|
|
249
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
250
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
251
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
252
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
253
|
+
):
|
|
254
|
+
"""Read status using a managed build ID; does not trigger a build."""
|
|
255
|
+
async def work():
|
|
256
|
+
context = await resolve_context(project, env, user)
|
|
257
|
+
result = await context.api.build_status(build_id)
|
|
258
|
+
context.check_item(result, environment=False)
|
|
259
|
+
return result, {}
|
|
260
|
+
execute(work, json_mode)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@builds_app.command("list", cls=DiagnosticCommand)
|
|
264
|
+
def build_list(
|
|
265
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
266
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
267
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
268
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
269
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
270
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
271
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
272
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
273
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
274
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
275
|
+
):
|
|
276
|
+
"""Read one page of environment-neutral builds (server time/order semantics)."""
|
|
277
|
+
options = locals().copy()
|
|
278
|
+
async def work():
|
|
279
|
+
filters = {k: options[k] for k in ("limit", "page_token", "order", "from_time", "to_time", "since")}
|
|
280
|
+
params, window = query_options(filters, scoped=True)
|
|
281
|
+
# The native build-list API has fixed ordering, no environment filter,
|
|
282
|
+
# and optional epoch bounds rather than observability's 24-hour default.
|
|
283
|
+
if order is not None and order != Order.desc:
|
|
284
|
+
from ..core.diagnostics import DiagnosticError
|
|
285
|
+
raise DiagnosticError("INVALID_ORDER", "Build list uses server descending order; use --order desc")
|
|
286
|
+
params.pop("order")
|
|
287
|
+
params["page_size"] = params.pop("limit")
|
|
288
|
+
from ..core.diagnostics import timestamp
|
|
289
|
+
for old, new in (("from_time", "start_ts"), ("to_time", "end_ts")):
|
|
290
|
+
if old in params:
|
|
291
|
+
params[new] = timestamp(params.pop(old)).timestamp()
|
|
292
|
+
context = await resolve_context(project, env, user)
|
|
293
|
+
params["project_id"] = context.info["project_id"]
|
|
294
|
+
return await context.api.get("/v1/builds", params=params), window
|
|
295
|
+
execute(work, json_mode)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@builds_app.command("logs", cls=DiagnosticCommand)
|
|
299
|
+
def build_logs(
|
|
300
|
+
build_id: str = typer.Argument(...),
|
|
301
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
302
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
303
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
304
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
305
|
+
limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
|
|
306
|
+
page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
|
|
307
|
+
order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
|
|
308
|
+
from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
|
|
309
|
+
to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
|
|
310
|
+
since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
|
|
311
|
+
severity: Optional[BuildSeverity] = typer.Option(None, "--severity", help='Cloud Logging severity'),
|
|
312
|
+
search: Optional[str] = typer.Option(None, "--search", help='Cloud Logging quoted text search'),
|
|
313
|
+
):
|
|
314
|
+
"""Read one page of all build log entries, including preamble."""
|
|
315
|
+
options = locals().copy()
|
|
316
|
+
async def work():
|
|
317
|
+
filters = {k: options[k] for k in ("limit", "page_token", "order", "from_time", "to_time", "since", "severity", "search")}
|
|
318
|
+
params, window = query_options(filters, scoped=True)
|
|
319
|
+
context = await resolve_context(project, env, user)
|
|
320
|
+
status = await context.api.build_status(build_id)
|
|
321
|
+
context.check_item(status, environment=False)
|
|
322
|
+
return await context.api.build_log_entries(build_id, params), window
|
|
323
|
+
execute(work, json_mode)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def context_command(
|
|
327
|
+
json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
|
|
328
|
+
project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
|
|
329
|
+
env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
|
|
330
|
+
user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
|
|
331
|
+
):
|
|
332
|
+
"""Resolve and validate the current managed project and authentication."""
|
|
333
|
+
async def work():
|
|
334
|
+
context = await resolve_context(project, env, user, require_managed=False)
|
|
335
|
+
return context.info, {}
|
|
336
|
+
execute(work, json_mode)
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import httpx
|
|
2
|
+
import os
|
|
3
|
+
from urllib.parse import quote
|
|
2
4
|
from typing import Optional, Dict, Any
|
|
3
5
|
from .config import load_global_config, get_access_token
|
|
4
6
|
|
|
@@ -6,7 +8,7 @@ from .config import load_global_config, get_access_token
|
|
|
6
8
|
class APIClient:
|
|
7
9
|
def __init__(self, token: Optional[str] = None):
|
|
8
10
|
self.config = load_global_config()
|
|
9
|
-
self.base_url = self.config.api_url.rstrip("/")
|
|
11
|
+
self.base_url = os.getenv("FLOWSTASH_API_URL", self.config.api_url).rstrip("/")
|
|
10
12
|
self._token = token # explicit token takes priority over resolved one
|
|
11
13
|
|
|
12
14
|
def _get_headers(self) -> Dict[str, str]:
|
|
@@ -24,6 +26,25 @@ class APIClient:
|
|
|
24
26
|
response.raise_for_status()
|
|
25
27
|
return response.json()
|
|
26
28
|
|
|
29
|
+
async def diagnostic_query(self, resource: str, params: Dict[str, Any], run_id: Optional[str] = None):
|
|
30
|
+
path = f"/observability/{resource}"
|
|
31
|
+
if run_id is not None:
|
|
32
|
+
suffix = "events" if resource == "run-events" else resource
|
|
33
|
+
path = f"/observability/runs/{quote(run_id, safe='')}/{suffix}"
|
|
34
|
+
return await self.get(path, params=params)
|
|
35
|
+
|
|
36
|
+
async def diagnostic_run(self, run_id: str, params: Dict[str, Any]):
|
|
37
|
+
return await self.get(f"/observability/runs/{quote(run_id, safe='')}", params=params)
|
|
38
|
+
|
|
39
|
+
async def diagnostic_payload(self, ref: str, params: Dict[str, Any]):
|
|
40
|
+
return await self.get("/observability/blobs", params={**params, "ref": ref})
|
|
41
|
+
|
|
42
|
+
async def build_status(self, build_id: str):
|
|
43
|
+
return await self.get(f"/v1/builds/{quote(build_id, safe='')}/status")
|
|
44
|
+
|
|
45
|
+
async def build_log_entries(self, build_id: str, params: Dict[str, Any]):
|
|
46
|
+
return await self.get(f"/v1/builds/{quote(build_id, safe='')}/log-entries", params=params)
|
|
47
|
+
|
|
27
48
|
async def post(
|
|
28
49
|
self, path: str, json: Optional[Dict[str, Any]] = None, timeout: float = 30.0
|
|
29
50
|
):
|
|
@@ -107,7 +107,7 @@ def register_user(
|
|
|
107
107
|
save_global_config(config)
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
def resolve_credentials(user: Optional[str] = None) -> Optional[str]:
|
|
110
|
+
def resolve_credentials(user: Optional[str] = None, project_root: Optional[Path] = None) -> Optional[str]:
|
|
111
111
|
"""
|
|
112
112
|
Resolve an access token using the priority chain:
|
|
113
113
|
1. Explicit user (--user flag)
|
|
@@ -121,7 +121,8 @@ def resolve_credentials(user: Optional[str] = None) -> Optional[str]:
|
|
|
121
121
|
if effective_user:
|
|
122
122
|
return get_token_for_user(effective_user)
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
root = project_root if project_root is not None else find_project_root()
|
|
125
|
+
project_config = load_project_config(root) if root is not None else None
|
|
125
126
|
if project_config and project_config.linked_user:
|
|
126
127
|
token = get_token_for_user(project_config.linked_user)
|
|
127
128
|
if token:
|
|
@@ -173,9 +174,20 @@ def delete_access_token(email: Optional[str] = None) -> None:
|
|
|
173
174
|
pass
|
|
174
175
|
|
|
175
176
|
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
if not
|
|
177
|
+
def find_project_root(start_path: Optional[Path] = None) -> Optional[Path]:
|
|
178
|
+
"""Find the nearest actual .flowstash file, evaluating cwd at invocation."""
|
|
179
|
+
start = (start_path if start_path is not None else Path.cwd()).resolve()
|
|
180
|
+
for parent in (start, *start.parents):
|
|
181
|
+
if (parent / PROJECT_CONFIG_FILE).is_file():
|
|
182
|
+
return parent
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_project_config(root: Optional[Path] = None, *, strict: bool = False) -> Optional[ProjectConfig]:
|
|
187
|
+
path = Path(root) if root is not None else Path(PROJECT_CONFIG_FILE)
|
|
188
|
+
if path.is_dir():
|
|
189
|
+
path = path / PROJECT_CONFIG_FILE
|
|
190
|
+
if not path.is_file():
|
|
179
191
|
return None
|
|
180
192
|
|
|
181
193
|
try:
|
|
@@ -183,6 +195,8 @@ def load_project_config() -> Optional[ProjectConfig]:
|
|
|
183
195
|
data = yaml.safe_load(f) or {}
|
|
184
196
|
return ProjectConfig(**data)
|
|
185
197
|
except Exception:
|
|
198
|
+
if strict:
|
|
199
|
+
raise ValueError(f"Malformed project configuration: {path}") from None
|
|
186
200
|
return None
|
|
187
201
|
|
|
188
202
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read-only managed context, fixed query windows, and machine-safe output."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from typer.core import TyperCommand, TyperGroup
|
|
15
|
+
|
|
16
|
+
from .api_client import APIClient
|
|
17
|
+
from .config import find_project_root, load_project_config, resolve_credentials
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiagnosticError(Exception):
|
|
21
|
+
def __init__(self, code, message, exit_code=2, http_status=None):
|
|
22
|
+
self.code, self.message = code, message
|
|
23
|
+
self.exit_code, self.http_status = exit_code, http_status
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fail(error, json_mode):
|
|
27
|
+
detail = {"code": error.code, "message": error.message}
|
|
28
|
+
if error.http_status is not None:
|
|
29
|
+
detail["http_status"] = error.http_status
|
|
30
|
+
typer.echo(json.dumps({"error": detail}) if json_mode else error.message, err=True)
|
|
31
|
+
raise typer.Exit(error.exit_code)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MachineParsing:
|
|
35
|
+
"""Keep Click's own parameter/unknown-option errors machine readable too."""
|
|
36
|
+
def parse_args(self, ctx, args):
|
|
37
|
+
json_mode = "--json" in args
|
|
38
|
+
try:
|
|
39
|
+
return super().parse_args(ctx, args)
|
|
40
|
+
except click.ClickException as exc:
|
|
41
|
+
fail(DiagnosticError("INVALID_PARAMETERS", exc.format_message()), json_mode)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DiagnosticCommand(MachineParsing, TyperCommand):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DiagnosticGroup(MachineParsing, TyperGroup):
|
|
49
|
+
def resolve_command(self, ctx, args):
|
|
50
|
+
try:
|
|
51
|
+
return super().resolve_command(ctx, args)
|
|
52
|
+
except click.ClickException as exc:
|
|
53
|
+
fail(DiagnosticError("INVALID_PARAMETERS", exc.format_message()), "--json" in args)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ManagedContext:
|
|
58
|
+
api: APIClient
|
|
59
|
+
info: dict
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def location(self):
|
|
63
|
+
return {"project_id": self.info["project_id"], "environment": self.info["environment"]}
|
|
64
|
+
|
|
65
|
+
def check_item(self, item, *, environment=True):
|
|
66
|
+
# Single-item backend routes may not accept workspace query filters.
|
|
67
|
+
if item.get("project_id") != self.info["project_id"] or (
|
|
68
|
+
environment and item.get("environment") != self.info["environment"]
|
|
69
|
+
):
|
|
70
|
+
raise DiagnosticError("NOT_FOUND", "Item is outside the selected project/environment", 4, 404)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def resolve_context(project=None, env=None, user=None, *, require_managed=True):
|
|
74
|
+
root = find_project_root()
|
|
75
|
+
try:
|
|
76
|
+
local = load_project_config(root, strict=True) if root else None
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
raise DiagnosticError("INVALID_CONTEXT", str(exc)) from None
|
|
79
|
+
project_id = project or (local.project_id if local else None)
|
|
80
|
+
if not project_id:
|
|
81
|
+
raise DiagnosticError("PROJECT_REQUIRED", "Link a .flowstash project or supply --project PROJECT_ID")
|
|
82
|
+
environment = env or "prod"
|
|
83
|
+
managed = True
|
|
84
|
+
if local and local.environments:
|
|
85
|
+
mode = next((e for e in local.environments if e.name == environment), None)
|
|
86
|
+
if mode is None:
|
|
87
|
+
raise DiagnosticError("UNKNOWN_ENVIRONMENT", f"Unknown environment '{environment}'; configured: {', '.join(e.name for e in local.environments)}")
|
|
88
|
+
managed = mode.managed
|
|
89
|
+
if require_managed and not managed:
|
|
90
|
+
raise DiagnosticError("LOCAL_ENVIRONMENT", f"Environment '{environment}' is local; diagnostics require a managed --env")
|
|
91
|
+
key = os.getenv("FLOWSTASH_API_KEY")
|
|
92
|
+
if key and user:
|
|
93
|
+
raise DiagnosticError("AMBIGUOUS_IDENTITY", "Do not combine --user with FLOWSTASH_API_KEY")
|
|
94
|
+
token = key or resolve_credentials(user=user, project_root=root)
|
|
95
|
+
if not token:
|
|
96
|
+
message = f"No token for requested user '{user}'" if user else "Run flowstash login or configure FLOWSTASH_API_KEY"
|
|
97
|
+
raise DiagnosticError("AUTH_REQUIRED", message, 3, 401)
|
|
98
|
+
api = APIClient(token=token)
|
|
99
|
+
identity = await api.get("/v1/auth/me")
|
|
100
|
+
requested_user = user or (os.getenv("FLOWSTASH_USER") if not key else None)
|
|
101
|
+
if requested_user and identity.get("email") != requested_user:
|
|
102
|
+
raise DiagnosticError("IDENTITY_MISMATCH", "Authenticated identity differs from the requested user; log in again for that user", 3, 401)
|
|
103
|
+
tenant = identity.get("tenant_id")
|
|
104
|
+
if local and local.tenant_id and local.tenant_id != tenant:
|
|
105
|
+
raise DiagnosticError("TENANT_MISMATCH", "Local tenant differs from authenticated tenant; supply the correct --user or FLOWSTASH_API_KEY")
|
|
106
|
+
if identity.get("project_id") and identity["project_id"] != project_id:
|
|
107
|
+
raise DiagnosticError("PROJECT_RESTRICTION", "API key is restricted to another project; select its --project", 3, 403)
|
|
108
|
+
if identity.get("environment") and identity["environment"] != environment:
|
|
109
|
+
raise DiagnosticError("ENVIRONMENT_RESTRICTION", f"API key requires --env {identity['environment']}", 3, 403)
|
|
110
|
+
remote = await api.get(f"/v1/projects/{quote(project_id, safe='')}")
|
|
111
|
+
if remote.get("tenant_id") != tenant or remote.get("project_id") != project_id:
|
|
112
|
+
raise DiagnosticError("TENANT_MISMATCH", "Remote project does not match authenticated tenant/project", 3, 403)
|
|
113
|
+
return ManagedContext(api, {
|
|
114
|
+
"project_root": str(root) if root else None,
|
|
115
|
+
"project_id": project_id, "project_name": remote.get("name"),
|
|
116
|
+
"tenant_id": tenant, "environment": environment, "managed": managed,
|
|
117
|
+
"api_url": api.base_url, "auth_method": "api_key" if key else "login",
|
|
118
|
+
"user_email": identity.get("email"),
|
|
119
|
+
"project_source": "explicit" if project else "local_config",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def timestamp(value):
|
|
124
|
+
if value is None:
|
|
125
|
+
return None
|
|
126
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})", value):
|
|
127
|
+
raise DiagnosticError("INVALID_TIME", "Timestamps must be timezone-aware RFC3339 values")
|
|
128
|
+
try:
|
|
129
|
+
return datetime.fromisoformat(value.upper().replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise DiagnosticError("INVALID_TIME", "Invalid RFC3339 timestamp") from None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def time_window(from_time=None, to_time=None, since=None, *, default_window=False, page_token=None):
|
|
135
|
+
start, end = timestamp(from_time), timestamp(to_time)
|
|
136
|
+
duration = None
|
|
137
|
+
if since is not None:
|
|
138
|
+
if from_time is not None:
|
|
139
|
+
raise DiagnosticError("INVALID_TIME", "--since cannot be combined with --from")
|
|
140
|
+
match = re.fullmatch(r"([1-9][0-9]*)([smhd])", since)
|
|
141
|
+
if not match:
|
|
142
|
+
raise DiagnosticError("INVALID_TIME", "--since requires a positive integer duration, e.g. 30m, 2h, 7d")
|
|
143
|
+
try:
|
|
144
|
+
duration = timedelta(seconds=int(match[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]])
|
|
145
|
+
except (OverflowError, ValueError):
|
|
146
|
+
raise DiagnosticError("INVALID_TIME", "--since duration is too large") from None
|
|
147
|
+
# Never manufacture different timestamps for a continuation. An opaque cursor
|
|
148
|
+
# cannot tell us the exact timestamps sent by an earlier CLI process.
|
|
149
|
+
if page_token and (since or (default_window and (start is None or end is None))):
|
|
150
|
+
raise DiagnosticError("FIXED_WINDOW_REQUIRED", "For pagination reuse the exact --from and --to from the first page; do not use --since")
|
|
151
|
+
try:
|
|
152
|
+
if default_window or duration:
|
|
153
|
+
end = end or datetime.now(timezone.utc)
|
|
154
|
+
start = start or end - (duration or timedelta(hours=24))
|
|
155
|
+
except OverflowError:
|
|
156
|
+
raise DiagnosticError("INVALID_TIME", "Time range is outside supported dates") from None
|
|
157
|
+
if start and end and start >= end:
|
|
158
|
+
raise DiagnosticError("INVALID_TIME", "--from must be before --to")
|
|
159
|
+
return {key: value.isoformat().replace("+00:00", "Z") for key, value in (("from_time", start), ("to_time", end)) if value is not None}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def query_options(options, *, scoped=False, logs=False, default_window=False):
|
|
163
|
+
options = dict(options)
|
|
164
|
+
window = time_window(options.pop("from_time", None), options.pop("to_time", None), options.pop("since", None), default_window=default_window, page_token=options.get("page_token"))
|
|
165
|
+
limit = options.get("limit")
|
|
166
|
+
options["limit"] = limit if limit is not None else (100 if logs and scoped else 50)
|
|
167
|
+
options["order"] = options.get("order") or ("asc" if scoped else "desc")
|
|
168
|
+
return {k: v for k, v in {**options, **window}.items() if v is not None}, window
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def execute(work, json_mode=False):
|
|
172
|
+
try:
|
|
173
|
+
result, window = asyncio.run(work())
|
|
174
|
+
except DiagnosticError as exc:
|
|
175
|
+
fail(exc, json_mode)
|
|
176
|
+
except httpx.HTTPStatusError as exc:
|
|
177
|
+
status = exc.response.status_code
|
|
178
|
+
codes = {400: ("INVALID_QUERY", 2), 401: ("AUTH_REQUIRED", 3), 403: ("FORBIDDEN", 3), 404: ("NOT_FOUND", 4), 422: ("INVALID_PARAMETERS", 2)}
|
|
179
|
+
code, exit_code = codes.get(status, ("PROVIDER_FAILURE", 5))
|
|
180
|
+
# Do not print request URLs/headers or arbitrary provider response bodies:
|
|
181
|
+
# they can contain credentials, cursor values, and untrusted content.
|
|
182
|
+
messages = {400: "Invalid filter or cursor; start a fresh search if filters changed", 401: "Run flowstash login or configure FLOWSTASH_API_KEY", 403: "Access denied; check observability:read scope and project/environment restrictions", 404: "Item missing or inaccessible; recent evidence may still be ingesting", 422: "Invalid request parameters", 502: "Provider or storage unavailable"}
|
|
183
|
+
fail(DiagnosticError(code, messages.get(status, "Managed API request failed"), exit_code, status), json_mode)
|
|
184
|
+
except httpx.RequestError:
|
|
185
|
+
fail(DiagnosticError("NETWORK_FAILURE", "Unable to reach the managed API", 5), json_mode)
|
|
186
|
+
except Exception:
|
|
187
|
+
fail(DiagnosticError("CLIENT_FAILURE", "Unable to read diagnostics; check configuration and credential storage", 5), json_mode)
|
|
188
|
+
if not json_mode:
|
|
189
|
+
if window:
|
|
190
|
+
typer.echo("Time range: " + " ".join(f"--{k.replace('_time', '')} {v}" for k, v in window.items()))
|
|
191
|
+
if isinstance(result, dict) and result.get("next_page_token"):
|
|
192
|
+
typer.echo(f"Next page: --page-token {result['next_page_token']} (reuse the exact time range and filters)")
|
|
193
|
+
typer.echo(json.dumps(result, ensure_ascii=False, indent=None if json_mode else 2))
|
|
@@ -9,6 +9,8 @@ from .commands import run as run_cmds
|
|
|
9
9
|
from .commands import webhook as webhook_cmds
|
|
10
10
|
from .commands import apikey as apikey_cmds
|
|
11
11
|
from .commands import client as client_cmds
|
|
12
|
+
from .commands import diagnostics as diagnostic_cmds
|
|
13
|
+
from .core.diagnostics import DiagnosticCommand
|
|
12
14
|
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
import importlib.metadata
|
|
@@ -34,6 +36,10 @@ Run commands below to initialize, build, and deploy your integrations.
|
|
|
34
36
|
rich_markup_mode="rich",
|
|
35
37
|
)
|
|
36
38
|
|
|
39
|
+
app.add_typer(diagnostic_cmds.obs_app, name="obs")
|
|
40
|
+
app.add_typer(diagnostic_cmds.builds_app, name="builds")
|
|
41
|
+
app.command("context", cls=DiagnosticCommand)(diagnostic_cmds.context_command)
|
|
42
|
+
|
|
37
43
|
env_app = typer.Typer(
|
|
38
44
|
name="env",
|
|
39
45
|
help="Manage environments",
|
|
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
|
{flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_shared/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.11.0 → flowstash_cli-0.13.0}/src/flowstash/cli/templates/_src/_worker/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|