veris-cli 2.27.1__tar.gz → 2.28.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.
Files changed (34) hide show
  1. {veris_cli-2.27.1 → veris_cli-2.28.0}/PKG-INFO +1 -1
  2. {veris_cli-2.27.1 → veris_cli-2.28.0}/pyproject.toml +1 -1
  3. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/api.py +15 -1
  4. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/env.py +192 -10
  5. {veris_cli-2.27.1 → veris_cli-2.28.0}/.gitignore +0 -0
  6. {veris_cli-2.27.1 → veris_cli-2.28.0}/README.md +0 -0
  7. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/__init__.py +0 -0
  8. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/build_context.py +0 -0
  9. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/cli.py +0 -0
  10. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/__init__.py +0 -0
  11. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/_helpers.py +0 -0
  12. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/auth.py +0 -0
  13. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/evaluations.py +0 -0
  14. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/profile.py +0 -0
  15. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/reports.py +0 -0
  16. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/run.py +0 -0
  17. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/scenarios.py +0 -0
  18. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/commands/simulations.py +0 -0
  19. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/config.py +0 -0
  20. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/output.py +0 -0
  21. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/prompts.py +0 -0
  22. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/run_output.py +0 -0
  23. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/scripts/__init__.py +0 -0
  24. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/scripts/docker_build.sh +0 -0
  25. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/scripts/docker_push.sh +0 -0
  26. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/searchable_checkbox.py +0 -0
  27. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/__init__.py +0 -0
  28. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/nemoclaw/__init__.py +0 -0
  29. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/nemoclaw/fetch.py +0 -0
  30. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/nemoclaw/repo_shape.py +0 -0
  31. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/nemoclaw/scripts/snapshot.sh +0 -0
  32. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/stacks/nemoclaw/snapshot.py +0 -0
  33. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/templates.py +0 -0
  34. {veris_cli-2.27.1 → veris_cli-2.28.0}/src/veris_cli/veris_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veris-cli
3
- Version: 2.27.1
3
+ Version: 2.28.0
4
4
  Summary: CLI to connect local agents to the Veris backend
5
5
  Project-URL: Homepage, https://github.com/veris-ai/veris-cli
6
6
  Project-URL: Bug Tracker, https://github.com/veris-ai/veris-cli/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "veris-cli"
3
- version = "2.27.1"
3
+ version = "2.28.0"
4
4
  description = "CLI to connect local agents to the Veris backend"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -252,7 +252,7 @@ class VerisAPI:
252
252
  return response.json()
253
253
 
254
254
  def create_image_tag(self, environment_id: str, tag: str) -> dict[str, Any]:
255
- """Register an image tag. Returns push config including pull_credentials if external registry."""
255
+ """Register an image tag. Returns push config including build_credentials if external registry."""
256
256
  with httpx.Client(
257
257
  base_url=self.base_url, headers=self._headers(), timeout=self.DEFAULT_TIMEOUT
258
258
  ) as client:
@@ -263,6 +263,20 @@ class VerisAPI:
263
263
  _raise_for_status(response)
264
264
  return response.json()
265
265
 
266
+ def download_service_wheels(self, environment_id: str) -> bytes:
267
+ """Download the exact entitled service wheels for a local (BYOC) build.
268
+
269
+ Returns the raw bytes of a zip containing services.lock.json plus
270
+ wheels/*.whl. The backend fetches the wheels with its own registry
271
+ credential, so none is exposed to the client.
272
+ """
273
+ with httpx.Client(
274
+ base_url=self.base_url, headers=self._headers(), timeout=self.DEFAULT_TIMEOUT
275
+ ) as client:
276
+ response = client.get(f"/v1/environments/{environment_id}/service-wheels")
277
+ _raise_for_status(response)
278
+ return response.content
279
+
266
280
  # Builds (Cloud Build)
267
281
  def create_build(self, environment_id: str, tag: str = "latest") -> dict[str, Any]:
268
282
  """Create a build and get a signed upload URL."""
@@ -82,6 +82,89 @@ def _check_sync_guard(
82
82
  return action
83
83
 
84
84
 
85
+ def _install_services_wrap(env_id: str, image_uri: str, api: VerisAPI):
86
+ """Install the org's entitled packaged services into a just-built image.
87
+
88
+ Local counterpart of the Cloud Build install-wrap (see veris-sandbox
89
+ backend/app/services/cloud_build.py), but credential-free: the backend
90
+ serves exactly the entitled wheels from GET /service-wheels (fetched with
91
+ Veris's own registry credential), so the customer never touches the wheel
92
+ repo. We extract the served wheels into the build context, bind-mount them
93
+ plus the sealed services.lock.json, and run /usr/local/bin/install_services.sh
94
+ (baked into the Veris base image, so it's present in the FROM-derived image),
95
+ which installs offline with --find-links and asserts the entitled set.
96
+ """
97
+ import io
98
+ import os
99
+ import shutil
100
+ import subprocess
101
+ import tempfile
102
+ import zipfile
103
+
104
+ output.print_info("Fetching entitled service packages...")
105
+ try:
106
+ zip_bytes = api.download_service_wheels(env_id)
107
+ except APIError as e:
108
+ output.print_error(f"Failed to fetch service packages: {e.detail}")
109
+ sys.exit(1)
110
+
111
+ # The wrap bind-mounts the served wheels dir + the sealed lockfile (both
112
+ # shipped in the served zip — services.lock.json is authoritative from the
113
+ # backend). install_services.sh installs from --find-links with --no-index.
114
+ run_line = (
115
+ "RUN --mount=type=bind,source=service-wheels,target=/tmp/service-wheels "
116
+ "--mount=type=bind,source=services.lock.json,target=/tmp/services.lock.json "
117
+ "/usr/local/bin/install_services.sh"
118
+ )
119
+
120
+ ctx_dir = tempfile.mkdtemp(prefix="veris-wrap-")
121
+ try:
122
+ # Unpack the served zip into the build context: services.lock.json at the
123
+ # root and wheels/*.whl -> service-wheels/. ZipFile.extract sanitizes
124
+ # member paths (strips leading slashes / .. ) so a malformed entry can't
125
+ # escape ctx_dir.
126
+ wheels_dir = os.path.join(ctx_dir, "service-wheels")
127
+ os.makedirs(wheels_dir, exist_ok=True)
128
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
129
+ for member in zf.namelist():
130
+ if member.endswith("/"):
131
+ continue
132
+ name = os.path.basename(member)
133
+ if member == "services.lock.json":
134
+ with open(os.path.join(ctx_dir, "services.lock.json"), "wb") as f:
135
+ f.write(zf.read(member))
136
+ elif member.startswith("wheels/") and name.endswith(".whl"):
137
+ with open(os.path.join(wheels_dir, name), "wb") as f:
138
+ f.write(zf.read(member))
139
+
140
+ output.print_info("Installing entitled service packages...")
141
+ wrap_dockerfile = os.path.join(ctx_dir, ".services-install.Dockerfile")
142
+ with open(wrap_dockerfile, "w") as f:
143
+ f.write(f"FROM {image_uri}\n{run_line}\n")
144
+
145
+ proc = subprocess.run(
146
+ [
147
+ "docker",
148
+ "build",
149
+ "--platform",
150
+ "linux/amd64",
151
+ "-t",
152
+ image_uri,
153
+ "-f",
154
+ wrap_dockerfile,
155
+ ctx_dir,
156
+ ],
157
+ env={**os.environ, "DOCKER_BUILDKIT": "1"},
158
+ text=True,
159
+ )
160
+ if proc.returncode != 0:
161
+ output.print_error("Service package install failed")
162
+ sys.exit(1)
163
+ output.print_success("Service packages installed")
164
+ finally:
165
+ shutil.rmtree(ctx_dir, ignore_errors=True)
166
+
167
+
85
168
  def _env_push_local(
86
169
  env_id: str,
87
170
  tag: str,
@@ -92,10 +175,12 @@ def _env_push_local(
92
175
  """Build locally and push to the customer's external registry."""
93
176
  import subprocess
94
177
 
95
- pull_creds = tag_result["pull_credentials"]
178
+ build_creds = tag_result.get("build_credentials") or {}
179
+ pull_creds = build_creds["pull_credentials"]
96
180
  image_uri = tag_result["image_uri"]
97
181
  base_image = tag_result["base_image"]
98
182
  registry_host = pull_creds["registry_url"]
183
+ api = VerisAPI(profile=profile)
99
184
 
100
185
  try:
101
186
  # Step 1: Login to Veris AR to pull base image
@@ -137,6 +222,15 @@ def _env_push_local(
137
222
  sys.exit(1)
138
223
  output.print_success("Image built")
139
224
 
225
+ # Step 2.5: install the org's entitled packaged services. The base image
226
+ # ships zero packaged services, so this wrap is what actually puts them
227
+ # in the image — identical contents to the Cloud Build path. Skipped
228
+ # when the sealed manifest has no pip-installable services. The wheels
229
+ # are fetched from the backend (credential-free), not a registry token.
230
+ services_lock = build_creds.get("services_lock") or []
231
+ if any(e.get("source") == "package" for e in services_lock):
232
+ _install_services_wrap(env_id, image_uri, api)
233
+
140
234
  # Step 3: Push to customer's registry
141
235
  # Customer must already be authenticated to their own registry (e.g. aws ecr get-login-password)
142
236
  output.print_info(f"Pushing to {image_uri}...")
@@ -388,6 +482,59 @@ def _try_load_veris_yaml(path: Path) -> VerisYaml | None:
388
482
  return _maybe_migrate_shape_a(path, parsed)
389
483
 
390
484
 
485
+ def _offer_existing_unconfigured_target(
486
+ profile: str | None,
487
+ ) -> tuple[str, str | None, str | None]:
488
+ """Interactive offer to bind a backend env to an existing veris.yaml target.
489
+
490
+ Restores the v2.26.0 entry point that the managed-only onboarding rewrite
491
+ (#161) dropped: if .veris/veris.yaml defines target(s) that aren't yet bound
492
+ to a backend env on this profile, let the user pick one rather than forcing
493
+ net-new onboarding.
494
+
495
+ Returns a ``(action, target_name, agent_name)`` tuple:
496
+ - ``("pick", <target>, <agent name or None>)`` — user chose an existing target.
497
+ - ``("cancel", None, None)`` — user aborted the prompt; caller should return.
498
+ - ``("new", None, None)`` — no offer applies, or user chose "create new";
499
+ caller should fall through to net-new onboarding.
500
+
501
+ Assumes the caller already gated on an interactive TTY.
502
+ """
503
+ veris_yaml_path = Path.cwd() / ".veris" / "veris.yaml"
504
+ if not veris_yaml_path.exists():
505
+ return ("new", None, None)
506
+ parsed = _try_load_veris_yaml(veris_yaml_path)
507
+ if not (parsed and parsed.targets):
508
+ return ("new", None, None)
509
+
510
+ unconfigured = [
511
+ t
512
+ for t in parsed.target_names
513
+ if not ProjectConfig(profile=profile, target=t).get_environment_id()
514
+ ]
515
+ if not unconfigured:
516
+ return ("new", None, None)
517
+
518
+ import questionary
519
+
520
+ _NEW = "__new__"
521
+ choices = [questionary.Choice(title=t, value=t) for t in unconfigured] + [
522
+ questionary.Choice(title="Create a new environment instead", value=_NEW)
523
+ ]
524
+ selected = questionary.select(
525
+ "Existing targets found in .veris/veris.yaml. Create an environment for:",
526
+ choices=choices,
527
+ ).ask()
528
+ if selected is None:
529
+ return ("cancel", None, None)
530
+ if selected == _NEW:
531
+ return ("new", None, None)
532
+
533
+ target_obj = parsed.get_target(selected)
534
+ agent_name = target_obj.agent.name if (target_obj.agent and target_obj.agent.name) else None
535
+ return ("pick", selected, agent_name)
536
+
537
+
391
538
  def _print_recipe_upgrade_advisory(api: VerisAPI, veris_dir: Path) -> None:
392
539
  """Compare pinned recipe version in .veris/config.yaml vs /recipes/<stack>/latest.
393
540
 
@@ -690,6 +837,26 @@ def env_create(
690
837
  output.print_error("--self-serve and --stack are mutually exclusive.")
691
838
  sys.exit(1)
692
839
 
840
+ # Restore the v2.26.0 behavior (regressed by the managed-only onboarding
841
+ # rewrite in #161): when the repo already has a .veris/veris.yaml whose
842
+ # target(s) aren't yet bound to a backend env on this profile, offer to
843
+ # create an env for one of those instead of treating this as net-new
844
+ # onboarding. Picking an existing target binds a backend env to the user's
845
+ # hand-authored config (a self-serve operation), pre-filling name + agent
846
+ # name from the target block. Only fires interactively with no explicit
847
+ # --name / --stack / --self-serve, so scripted and stack flows are unchanged.
848
+ from_existing_target = False
849
+ if not name and not stack and not self_serve and sys.stdin.isatty():
850
+ action, picked_name, picked_agent_name = _offer_existing_unconfigured_target(profile)
851
+ if action == "cancel":
852
+ return
853
+ if action == "pick":
854
+ name = picked_name
855
+ self_serve = True
856
+ from_existing_target = True
857
+ if not agent_name:
858
+ agent_name = picked_agent_name
859
+
693
860
  # Interactive picker when neither --stack nor --self-serve was passed.
694
861
  # Three branches: managed (default), self-serve, or one of the
695
862
  # registered stacks. Net-new envs without an explicit choice land
@@ -743,7 +910,7 @@ def env_create(
743
910
  sys.exit(1)
744
911
 
745
912
  if self_serve:
746
- _create_env_self_serve(profile, name, agent_name)
913
+ _create_env_self_serve(profile, name, agent_name, from_existing_target=from_existing_target)
747
914
  return
748
915
 
749
916
  # Every reachable path here leaves ``stack`` set. Stack flows own
@@ -763,6 +930,7 @@ def _create_env_self_serve(
763
930
  profile: str | None,
764
931
  name: str | None,
765
932
  agent_name: str | None,
933
+ from_existing_target: bool = False,
766
934
  ) -> None:
767
935
  """`veris env create --self-serve` — scaffold .veris/ locally, create
768
936
  a backend env in released state.
@@ -858,8 +1026,13 @@ def _create_env_self_serve(
858
1026
  # the backend today). Refuse and tell the customer how to recover.
859
1027
  # Detected BEFORE _scaffold_veris_dir so the function's
860
1028
  # no-op-on-existing-block path doesn't mask this.
1029
+ #
1030
+ # `from_existing_target` flips this off: when the caller deliberately
1031
+ # picked an already-present target block to bind a backend env to (the
1032
+ # restored v2.26.0 flow), the existing block is expected, not a failed
1033
+ # retry — binding it is the whole point.
861
1034
  veris_yaml_path = veris_dir / "veris.yaml"
862
- if veris_yaml_path.exists():
1035
+ if veris_yaml_path.exists() and not from_existing_target:
863
1036
  existing = _try_load_veris_yaml(veris_yaml_path)
864
1037
  if existing and env_name in existing.targets:
865
1038
  output.print_error(
@@ -1966,14 +2139,23 @@ def env_push(
1966
2139
  _auto_snapshot_for_stack(pinned_stack, Path.cwd())
1967
2140
 
1968
2141
  # On-prem clusters route through the customer's external registry instead of Cloud Build.
1969
- try:
1970
- if env_info.get("image_registry_url"):
2142
+ if env_info.get("image_registry_url"):
2143
+ tag_result = None
2144
+ try:
1971
2145
  tag_result = api.create_image_tag(env_id, tag)
1972
- if tag_result.get("pull_credentials"):
1973
- _env_push_local(env_id, tag, tag_result, profile, dockerfile=dockerfile_rel)
1974
- return
1975
- except Exception:
1976
- pass # Fall through to Cloud Build
2146
+ except APIError as e:
2147
+ # Entitlement / unknown-service errors are sealed server-side. Surface
2148
+ # them directly — a Cloud Build fallback can't reach the customer's
2149
+ # registry anyway, so masking them would only confuse.
2150
+ if e.status_code in (400, 403):
2151
+ output.print_error(str(e.detail))
2152
+ sys.exit(1)
2153
+ raise
2154
+ except Exception:
2155
+ tag_result = None # transient — fall through to Cloud Build below
2156
+ if tag_result and tag_result.get("build_credentials"):
2157
+ _env_push_local(env_id, tag, tag_result, profile, dockerfile=dockerfile_rel)
2158
+ return
1977
2159
 
1978
2160
  _env_push_remote(env_id, tag, profile, dockerfile=dockerfile_rel)
1979
2161
 
File without changes
File without changes