pycarta-service-agent 0.2.0.dev1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ """The carta-service-agent binary, plus the Python contract for talking to it.
2
+
3
+ This package delivers the platform-specific carta-service-agent executable and
4
+ everything describing the seam between it and the Python that drives it. Those
5
+ facts change when the Go code changes, so they ship in the same artifact as the
6
+ binary and cannot drift out of step with it.
7
+
8
+ Not here: the seam between Carta and service authors. The register decorator,
9
+ execution-event access, request routing, and the loop that drives user handlers
10
+ all belong to pycarta.services. That is what a service author imports.
11
+
12
+ find_binary is the only name exported at the top level. Everything else is in a
13
+ submodule, imported directly so using one does not drag in the others:
14
+
15
+ =============== =============================================================
16
+ Module Holds
17
+ =============== =============================================================
18
+ ``command`` argv builders, environment variable names, exit codes
19
+ ``config`` the carta.service.json shape
20
+ ``errors`` the exception hierarchy
21
+ ``ipc`` stdio JSON framing and the supervised subprocess
22
+ ``messages`` IPC message type names and PROTOCOL_VERSION
23
+ =============== =============================================================
24
+
25
+ pycarta is the only intended consumer, but it is a real one. Treat a change to
26
+ any name these modules export as a breaking change.
27
+
28
+ Example:
29
+ Running a fully enrolled service, one execution at a time::
30
+
31
+ from pycarta_service_agent import command, config, ipc
32
+
33
+ cfg = config.ServiceConfig.load(project_dir)
34
+ client = ipc.connect(
35
+ command.run_argv(cfg, once=True),
36
+ classify_exit=command.classify_exit,
37
+ )
38
+ """
39
+
40
+ from ._locate import find_binary
41
+
42
+ __all__ = ["find_binary"]
@@ -0,0 +1,81 @@
1
+ """Locating the carta-service-agent binary bundled in this wheel.
2
+
3
+ Also holds the CARTA_SERVICE_AGENT_BIN escape hatch, which points the package at
4
+ a binary elsewhere on disk. That is for developing this package: run a locally
5
+ built Go binary against the Python driving it without rebuilding a wheel first.
6
+ Not part of the contract with consumers, who should always get the binary their
7
+ wheel shipped.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import stat
15
+ from importlib import resources
16
+
17
+ _BIN_DIR = "_bin"
18
+ _BIN_NAME = (
19
+ "carta-service-agent.exe"
20
+ if platform.system() == "Windows"
21
+ else "carta-service-agent"
22
+ )
23
+ _PKG_NAME = "pycarta-service-agent"
24
+
25
+ # Development escape hatch, see the module docstring. Private on purpose. For
26
+ # developers of this package, not the consumers it exports to.
27
+ _ENV_OVERRIDE = "CARTA_SERVICE_AGENT_BIN"
28
+
29
+
30
+ def find_binary() -> str:
31
+ """Absolute path to the carta-service-agent binary bundled in this wheel.
32
+
33
+ Each wheel carries exactly one binary, built for that wheel's platform tag,
34
+ so pip installs the one matching the host. Marked executable on first call if
35
+ it is not already, since a wheel does not reliably preserve the mode bit.
36
+
37
+ CARTA_SERVICE_AGENT_BIN, if set to an absolute path, replaces the bundled
38
+ binary. Escape hatch for developing this package against a locally built
39
+ binary. Not something a consumer should rely on. Validated the same way the
40
+ bundled path is, so a stale or misspelled value fails here with a useful
41
+ message instead of an opaque spawn error later.
42
+
43
+ Returns:
44
+ str: Absolute path to the executable, safe to pass as argv[0].
45
+
46
+ Raises:
47
+ FileNotFoundError: CARTA_SERVICE_AGENT_BIN is set but names nothing, or
48
+ the package is installed with its bundled binary missing (broken or
49
+ hand-assembled install).
50
+ """
51
+ override = os.environ.get(_ENV_OVERRIDE, "").strip()
52
+ if override:
53
+ if not os.path.isfile(override):
54
+ raise FileNotFoundError(
55
+ f"{_ENV_OVERRIDE} is set to {override!r}, which is not a file."
56
+ )
57
+ _ensure_executable(override)
58
+ return override
59
+
60
+ ref = resources.files(__package__).joinpath(_BIN_DIR, _BIN_NAME)
61
+ with resources.as_file(ref) as p:
62
+ path = os.fspath(p)
63
+
64
+ if not os.path.isfile(path):
65
+ raise FileNotFoundError(
66
+ f"{_PKG_NAME} is installed but its bundled binary is missing at {path!r}."
67
+ )
68
+
69
+ _ensure_executable(path)
70
+ return path
71
+
72
+
73
+ def _ensure_executable(path: str) -> None:
74
+ if os.name != "nt":
75
+ mode = os.stat(path).st_mode
76
+ expected = mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
77
+ if mode != expected:
78
+ try:
79
+ os.chmod(path, expected)
80
+ except OSError:
81
+ pass
@@ -0,0 +1,459 @@
1
+ """Invoking the carta-service-agent binary: flags, env vars, exit codes.
2
+
3
+ The binary takes all of its config from flags and never reads
4
+ carta.service.json at runtime, so an adapter has to translate Python arguments
5
+ into an argv. The environment supplies credentials only, never config. Those two
6
+ contracts plus the exit-code contract are facts about the binary, so they live
7
+ here next to it rather than in the adapter.
8
+
9
+ The flag builders are pure. They return argv fragments and touch nothing.
10
+ run_argv and run_subcommand also resolve the binary's path, and run_subcommand
11
+ spawns it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+
19
+ from .config import ServiceConfig, SUPPORTED_INVOKER
20
+ from .errors import CommandError
21
+ from .ipc import ExitAction
22
+ from ._locate import find_binary
23
+
24
+ # -- environment -------------------------------------------------------------
25
+
26
+ ENV_SESSION_TOKEN: str = "CARTA_SESSION_TOKEN"
27
+ """Cognito ID token. Lets enroll and init skip the interactive browser login.
28
+
29
+ Read by enroll and init only. Required when --no-browser is set, since the PKCE
30
+ loopback flow needs a browser and there is not one.
31
+
32
+ Env var rather than a flag because command lines are visible in ps.
33
+
34
+ Same token the browser login would have produced. An adapter that already
35
+ authenticated the user can inject it here instead of prompting a second time.
36
+ """
37
+
38
+ ENV_API_TOKEN: str = "CARTA_SERVICE_API_TOKEN"
39
+ """Per-service API token, for a runner that did not enroll itself.
40
+
41
+ Read by run and export. Takes precedence over both the OS keychain and the
42
+ credentials fallback file. Set this with ENV_RUNNER_ID and run needs no local
43
+ credential storage at all, which is what makes a container or serverless
44
+ deployment work: no keychain available, and nothing should be writing credential
45
+ files.
46
+
47
+ "carta-service-agent export" prints the value to seed this.
48
+ """
49
+
50
+ ENV_RUNNER_ID: str = "CARTA_SERVICE_RUNNER_ID"
51
+ """Runner identity, for a runner that did not enroll itself.
52
+
53
+ Read by run and export, same precedence over the keychain and fallback file as
54
+ ENV_API_TOKEN. Normally enroll creates the runner ID and caches it in the
55
+ keychain. This is how a pre-provisioned deployment gets told which runner it is
56
+ instead.
57
+ """
58
+
59
+ ENV_SMCP_JWT: str = "CARTA_SERVICE_SMCP_JWT"
60
+ """Already-exchanged SMCP JWT, for a runner whose deployment shares one.
61
+
62
+ Read by run. Seeds the agent's session cache so no token exchange with the
63
+ agent gateway is needed while the token remains valid. For short-lived runners
64
+ (a Lambda woken per signal) that would otherwise each perform their own
65
+ exchange, the deployment exchanges once, stashes the JWT, and injects it here.
66
+
67
+ Supply a token whose remaining validity covers the whole run. The agent
68
+ validates expiry itself and falls back to a normal exchange (via ENV_API_TOKEN)
69
+ when the token is expired, expiring, or malformed.
70
+ """
71
+
72
+
73
+ # -- exit codes --------------------------------------------------------------
74
+
75
+ EXIT_STOP: int = 0
76
+ """run shut down cleanly. Stdin closed, or a signal arrived.
77
+
78
+ Also what run returns after finishing its work under --once or
79
+ --max-executions, and when --exit-when-idle finds an empty queue. Do not
80
+ restart.
81
+ """
82
+
83
+ EXIT_RESTART: int = 1
84
+ """run hit a transient failure. Restart with backoff.
85
+
86
+ Reserved. run swallows loop errors into its own backoff and emits only
87
+ EXIT_STOP or EXIT_FAIL.
88
+ """
89
+
90
+ EXIT_RESTART_NOW: int = 2
91
+ """run asked to be restarted immediately, no backoff delay.
92
+
93
+ Reserved for a future config-reload or file-watch mode. Not currently emitted.
94
+ """
95
+
96
+ EXIT_FAIL: int = 3
97
+ """run failed deterministically. Do not restart.
98
+
99
+ Every setup failure lands here: flag parsing, missing or invalid required flags,
100
+ config validation, logger setup, credential lookup, gateway client, invoker
101
+ startup (including a failed hello handshake or a version mismatch). A restart
102
+ fails the same way.
103
+ """
104
+
105
+
106
+ def classify_exit(returncode: int) -> ExitAction:
107
+ """Map a run exit code onto the action a supervisor should take.
108
+
109
+ Pass this to ipc.connect as classify_exit so the supervisor honors the
110
+ binary's contract instead of the generic "zero stops, anything else
111
+ restarts" default.
112
+
113
+ Args:
114
+ returncode: Exit code from "carta-service-agent run".
115
+
116
+ Returns:
117
+ ExitAction: STOP for EXIT_STOP, RESTART_NOW for EXIT_RESTART_NOW, FAIL
118
+ for EXIT_FAIL, RESTART for anything else. Codes the binary does not
119
+ define and signal-derived codes fall in that last bucket and are treated
120
+ as transient.
121
+ """
122
+ if returncode == EXIT_STOP:
123
+ return ExitAction.STOP
124
+ if returncode == EXIT_RESTART_NOW:
125
+ return ExitAction.RESTART_NOW
126
+ if returncode == EXIT_FAIL:
127
+ return ExitAction.FAIL
128
+ return ExitAction.RESTART
129
+
130
+
131
+ # -- flags -------------------------------------------------------------------
132
+
133
+
134
+ def url_flags(
135
+ carta_url: str = "",
136
+ control_plane_url: str = "",
137
+ gateway_url: str = "",
138
+ platform_env: str = "",
139
+ ) -> list[str]:
140
+ """Build the endpoint flags every subcommand accepts.
141
+
142
+ Empty values are omitted rather than passed as empty strings, which lets the
143
+ binary apply its own defaults. A blank URL gets derived from the platform
144
+ environment, and a blank platform environment means production.
145
+
146
+ Args:
147
+ carta_url: Override for the Carta API base URL.
148
+ control_plane_url: Override for the control plane base URL.
149
+ gateway_url: Override for the agent gateway base URL.
150
+ platform_env: Platform environment to derive unset URLs from, either
151
+ "development" or "production". Not validated here. The binary
152
+ rejects an unrecognized value.
153
+
154
+ Returns:
155
+ list[str]: Flag/value pairs for the non-empty arguments, stable order.
156
+ Empty list when every argument is empty.
157
+ """
158
+ flags: list[str] = []
159
+ for flag, value in (
160
+ ("--carta-url", carta_url),
161
+ ("--control-plane-url", control_plane_url),
162
+ ("--gateway-url", gateway_url),
163
+ ("--platform-env", platform_env),
164
+ ):
165
+ if value:
166
+ flags.extend([flag, value])
167
+ return flags
168
+
169
+
170
+ def enroll_flags(
171
+ carta_url: str = "",
172
+ control_plane_url: str = "",
173
+ gateway_url: str = "",
174
+ platform_env: str = "",
175
+ namespace: str = "",
176
+ service_name: str = "",
177
+ no_auth: bool = False,
178
+ no_browser: bool = False,
179
+ no_keychain: bool = False,
180
+ ) -> list[str]:
181
+ """Build the flags for "carta-service-agent enroll".
182
+
183
+ Enroll registers the service and this device with the platform. It resolves
184
+ or creates the runner, creates the service, links them, mints the API token,
185
+ and writes the resolved service_id back into carta.service.json.
186
+
187
+ --invoker is always included, since this package is the Python adapter.
188
+
189
+ namespace and service_name are required by the binary but optional here, so
190
+ a caller can build a partial argv. The binary names the missing flag.
191
+
192
+ Args:
193
+ carta_url: Override for the Carta API base URL.
194
+ control_plane_url: Override for the control plane base URL.
195
+ gateway_url: Override for the agent gateway base URL.
196
+ platform_env: "development" or "production".
197
+ namespace: Carta namespace to enroll the service into.
198
+ service_name: Name for the service within the namespace.
199
+ no_auth: Skip authentication. Only valid when the platform is also
200
+ running without auth. Enroll does not currently honor it.
201
+ no_browser: Authenticate from ENV_SESSION_TOKEN only, never open a
202
+ browser. Fails fast if that variable is unset, malformed, or expired.
203
+ no_keychain: Skip the OS keychain, use the credentials fallback file. For
204
+ a host with no keychain, where every keychain call fails and logs a
205
+ warning first.
206
+
207
+ Returns:
208
+ list[str]: The flags, without the binary path or the "enroll" subcommand
209
+ name. Pass to run_subcommand.
210
+ """
211
+ flags = ["--invoker", SUPPORTED_INVOKER]
212
+ flags += url_flags(carta_url, control_plane_url, gateway_url, platform_env)
213
+ if namespace:
214
+ flags += ["--namespace", namespace]
215
+ if service_name:
216
+ flags += ["--name", service_name]
217
+ if no_auth:
218
+ flags.append("--no-auth")
219
+ if no_browser:
220
+ flags.append("--no-browser")
221
+ if no_keychain:
222
+ flags.append("--no-keychain")
223
+ return flags
224
+
225
+
226
+ def init_flags(
227
+ carta_url: str = "",
228
+ control_plane_url: str = "",
229
+ gateway_url: str = "",
230
+ platform_env: str = "",
231
+ no_browser: bool = False,
232
+ no_keychain: bool = False,
233
+ ) -> list[str]:
234
+ """Build the flags for "carta-service-agent init".
235
+
236
+ Init is interactive one-time project setup. It authenticates the user, lets
237
+ them pick or create a namespace, and writes carta.service.json into the
238
+ current working directory. It does not capture a service name. That arrives
239
+ at enroll time.
240
+
241
+ Args:
242
+ carta_url: Override for the Carta API base URL.
243
+ control_plane_url: Override for the control plane base URL.
244
+ gateway_url: Override for the agent gateway base URL.
245
+ platform_env: "development" or "production".
246
+ no_browser: Authenticate from ENV_SESSION_TOKEN only, never open a
247
+ browser. Init is interactive in other respects, so this suppresses
248
+ the login prompt only.
249
+ no_keychain: Skip the OS keychain, use the credentials fallback file.
250
+
251
+ Returns:
252
+ list[str]: The flags, without the binary path or the "init" subcommand
253
+ name. Pass to run_subcommand.
254
+ """
255
+ flags = url_flags(carta_url, control_plane_url, gateway_url, platform_env)
256
+ if no_browser:
257
+ flags.append("--no-browser")
258
+ if no_keychain:
259
+ flags.append("--no-keychain")
260
+ return flags
261
+
262
+
263
+ def export_flags(
264
+ service_id: str,
265
+ namespace: str,
266
+ service_name: str,
267
+ carta_url: str = "",
268
+ control_plane_url: str = "",
269
+ gateway_url: str = "",
270
+ platform_env: str = "",
271
+ no_keychain: bool = False,
272
+ ) -> list[str]:
273
+ """Build the flags for "carta-service-agent export".
274
+
275
+ Export is read-only and contacts no platform service. It prints this
276
+ machine's credentials for one service as JSON on stdout, so a
277
+ pre-provisioned deployment can be seeded with ENV_API_TOKEN and
278
+ ENV_RUNNER_ID. It exists because an API token value cannot be recovered from
279
+ the platform after creation. The machine that enrolled the service holds the
280
+ only copy.
281
+
282
+ The output contains a live credential. Capture stdout rather than letting it
283
+ reach a terminal or a log.
284
+
285
+ Args:
286
+ service_id: Platform service ID to export credentials for. This is the
287
+ keychain account holding the API token, so it has to match the
288
+ service that was enrolled.
289
+ namespace: Carta namespace owning the service.
290
+ service_name: Service name within the namespace.
291
+ carta_url: Override for the Carta API base URL.
292
+ control_plane_url: Override for the control plane base URL.
293
+ gateway_url: Override for the agent gateway base URL.
294
+ platform_env: "development" or "production". Decides which URLs appear
295
+ in the exported JSON.
296
+ no_keychain: Skip the OS keychain, read the credentials fallback file.
297
+
298
+ Returns:
299
+ list[str]: The flags, without the binary path or the "export" subcommand
300
+ name.
301
+ """
302
+ flags = [
303
+ "--id",
304
+ service_id,
305
+ "--namespace",
306
+ namespace,
307
+ "--name",
308
+ service_name,
309
+ "--invoker",
310
+ SUPPORTED_INVOKER,
311
+ ]
312
+ flags += url_flags(carta_url, control_plane_url, gateway_url, platform_env)
313
+ if no_keychain:
314
+ flags.append("--no-keychain")
315
+ return flags
316
+
317
+
318
+ def run_argv(
319
+ cfg: ServiceConfig,
320
+ *,
321
+ exit_when_idle: bool = False,
322
+ log_format: str = "",
323
+ log_level: str = "",
324
+ max_executions: int = 0,
325
+ no_debug_logf: bool = False,
326
+ no_keychain: bool = False,
327
+ once: bool = False,
328
+ quiet: bool = False,
329
+ verbose: bool = False,
330
+ ) -> list[str]:
331
+ """Build the full argv for "carta-service-agent run", binary path included.
332
+
333
+ Unlike the other builders here, the result is ready to hand to ipc.connect.
334
+ --log-stderr is always appended: the binary's stdout is the IPC channel, so
335
+ log output there corrupts the message framing.
336
+
337
+ The defaults describe a long-lived agent holding a long poll, waiting for
338
+ work. once and exit_when_idle describe the opposite, a runner started per
339
+ wake-up that skips the long poll, drains what is queued, and exits.
340
+
341
+ Args:
342
+ cfg: Service configuration. service_id, namespace, and service_name have
343
+ to be non-blank. The URL fields and platform_env are forwarded when
344
+ set, omitted when empty.
345
+ exit_when_idle: Exit as soon as the queue is empty.
346
+ log_format: Console stream format, "text" or "json". Empty uses the
347
+ default of "text". The debug log file is always JSON.
348
+ log_level: Console stream level, one of "debug", "info", "warn", or
349
+ "error". Empty uses the default of "info". Does not affect the
350
+ debug log file, which is always at debug.
351
+ max_executions: Stop after claiming this many executions. 0 means no
352
+ limit. Counted at claim time, not on success, so a service that
353
+ fails every execution still terminates. Negative values are rejected
354
+ by the binary.
355
+ no_debug_logf: Do not write the debug log file.
356
+ no_keychain: Skip the OS keychain, read credentials from the fallback
357
+ file. Unnecessary when ENV_API_TOKEN and ENV_RUNNER_ID are set,
358
+ since those bypass both.
359
+ once: Handle at most one execution and exit. This is the shorthand for
360
+ "--exit-when-idle --max-executions 1". Overrides max_executions.
361
+ quiet: Log the console stream at error level. Overrides log_level.
362
+ Incompatible with verbose.
363
+ verbose: Log the console stream at debug level. Overrides log_level.
364
+ Incompatible with quiet.
365
+
366
+ Returns:
367
+ list[str]: Full argv, starting with the absolute path to the binary.
368
+
369
+ Raises:
370
+ ValueError: cfg.service_id, cfg.namespace, or cfg.service_name is empty
371
+ or whitespace.
372
+ FileNotFoundError: The binary could not be located.
373
+ """
374
+ required = {
375
+ "service_id": cfg.service_id,
376
+ "namespace": cfg.namespace,
377
+ "service_name": cfg.service_name,
378
+ }
379
+ for name, value in required.items():
380
+ if not value or not value.strip():
381
+ raise ValueError(f"{name} is required to launch the agent")
382
+ argv = [
383
+ find_binary(),
384
+ "run",
385
+ "--id",
386
+ cfg.service_id,
387
+ "--namespace",
388
+ cfg.namespace,
389
+ "--name",
390
+ cfg.service_name,
391
+ "--invoker",
392
+ SUPPORTED_INVOKER,
393
+ ]
394
+ argv += url_flags(
395
+ cfg.carta_url,
396
+ cfg.control_plane_url,
397
+ cfg.gateway_url,
398
+ cfg.platform_env,
399
+ )
400
+
401
+ if once:
402
+ argv.append("--once")
403
+ if exit_when_idle:
404
+ argv.append("--exit-when-idle")
405
+ if max_executions:
406
+ argv += ["--max-executions", str(max_executions)]
407
+ if no_keychain:
408
+ argv.append("--no-keychain")
409
+
410
+ if log_level:
411
+ argv += ["--log-level", log_level]
412
+ if log_format:
413
+ argv += ["--log-format", log_format]
414
+ if verbose:
415
+ argv.append("--verbose")
416
+ if quiet:
417
+ argv.append("--quiet")
418
+ if no_debug_logf:
419
+ argv.append("--no-debug-logf")
420
+
421
+ # log stderr required, the agent's stdout is the IPC channel
422
+ argv.append("--log-stderr")
423
+ return argv
424
+
425
+
426
+ # -- invocation --------------------------------------------------------------
427
+
428
+
429
+ def run_subcommand(
430
+ subcommand: str,
431
+ flags: list[str],
432
+ *,
433
+ env: dict[str, str] | None = None,
434
+ ) -> None:
435
+ """Run a subcommand to completion with the terminal's stdio.
436
+
437
+ The child inherits this process's stdin, stdout, and stderr, so its prompts
438
+ and diagnostics reach the user directly. Right for init and enroll, which are
439
+ interactive. Wrong for export, whose stdout is JSON worth capturing.
440
+
441
+ Do not use this for run. That subcommand's stdio is the IPC channel. Use
442
+ run_argv with ipc.connect instead.
443
+
444
+ Args:
445
+ subcommand: Subcommand name, e.g. "enroll" or "init".
446
+ flags: Flags for it, usually from enroll_flags or init_flags.
447
+ env: Variables to merge onto this process's environment for the child.
448
+ The parent environment is otherwise inherited unchanged, so this is
449
+ additive. Use it to inject ENV_SESSION_TOKEN.
450
+
451
+ Raises:
452
+ CommandError: The subcommand exited non-zero. The child has already
453
+ written its diagnostics to the inherited stderr.
454
+ FileNotFoundError: The binary could not be located.
455
+ """
456
+ proc_env = {**os.environ, **env} if env else None
457
+ result = subprocess.run([find_binary(), subcommand, *flags], env=proc_env)
458
+ if result.returncode != 0:
459
+ raise CommandError(f"{subcommand} failed (see output above)")