From 76cfd0e31d5e1633db7ccd86ad9023ef7a2461b2 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 1 Oct 2026 21:47:40 +0000 Subject: [PATCH 01/18] test(python): synchronize interactive exec TTY readiness (#4076) * test(python): synchronize interactive exec TTY readiness Wait for the complete readiness marker before streaming stdin so PTY echo cannot split the separately written TTY flags. Preserve pipe stream separation and verify consumed stdin and both output sentinels in TTY mode. Fixes #4075 Signed-off-by: Matthew Grossman * test(python): reuse interactive exec readiness marker Signed-off-by: Matthew Grossman --------- Signed-off-by: Matthew Grossman --- e2e/python/test_sandbox_api.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 5885f376b2..5544981e3f 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -136,6 +136,7 @@ def test_sandbox_interactive_exec_honors_tty( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: + ready_marker = b"tty-ready\n" stdin_sentinel = b"streamed-stdin-sentinel" stdout_sentinel = b"stdout-sentinel" stderr_sentinel = b"stderr-sentinel" @@ -150,7 +151,8 @@ def exec_interactive(sandbox_name: str, *, tty: bool) -> tuple[bytes, bytes]: "-c", "[ -t 0 ] && printf T || printf N; " "[ -t 1 ] && printf T || printf N; " - "[ -t 2 ] && printf T || printf N; printf '\\n'; " + "[ -t 2 ] && printf T || printf N; " + f"printf '\\n%s' '{ready_marker.decode()}'; " "IFS= read -r stdin_value; " "printf 'stdin:%s\\n' \"$stdin_value\"; " "printf 'stdout-sentinel\\n'; " @@ -161,35 +163,45 @@ def exec_interactive(sandbox_name: str, *, tty: bool) -> tuple[bytes, bytes]: ) ) + ready = threading.Event() done = threading.Event() def requests(): yield request + # PTY input echo can split the separate TTY flag writes. Wait for + # the complete marker, including its newline, before sending input. + if not ready.wait(timeout=20) or done.is_set(): + return yield openshell_pb2.ExecSandboxInput(stdin=stdin_sentinel + b"\n") done.wait(timeout=30) - stdout: list[bytes] = [] - stderr: list[bytes] = [] + stdout = bytearray() + stderr = bytearray() exit_code: int | None = None try: events = sandbox_client._stub.ExecSandboxInteractive(requests(), timeout=30) for event in events: payload = event.WhichOneof("payload") if payload == "stdout": - stdout.append(bytes(event.stdout.data)) + stdout.extend(event.stdout.data) + if ready_marker in stdout.replace(b"\r\n", b"\n"): + ready.set() elif payload == "stderr": - stderr.append(bytes(event.stderr.data)) + stderr.extend(event.stderr.data) elif payload == "exit": exit_code = int(event.exit.exit_code) finally: done.set() + # Unblock a request iterator waiting for readiness on early exit + # or RPC failure without sending input after the call has ended. + ready.set() assert exit_code == 0 - return b"".join(stdout), b"".join(stderr) + return bytes(stdout), bytes(stderr) with sandbox(delete_on_exit=True) as sb: stdout, stderr = exec_interactive(sb.sandbox.name, tty=False) - assert b"NNN" in stdout + assert b"NNN" in stdout.splitlines() assert b"stdin:" + stdin_sentinel in stdout assert stdout_sentinel in stdout assert stdout_sentinel not in stderr @@ -197,7 +209,11 @@ def requests(): assert stderr_sentinel not in stdout stdout, stderr = exec_interactive(sb.sandbox.name, tty=True) - assert b"TTT" in stdout + stderr + terminal_output = stdout + stderr + assert b"TTT" in terminal_output.splitlines() + assert b"stdin:" + stdin_sentinel in terminal_output + assert stdout_sentinel in terminal_output + assert stderr_sentinel in terminal_output def test_interactive_exec_drains_output_after_request_eof( From d20a711ac8dfbab79dfa4fb82b3db5a92738f878 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux[bot]" <126015336+red-hat-konflux[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:25:43 +0000 Subject: [PATCH 02/18] chore(deps): refresh rpm lockfiles [SECURITY] Signed-off-by: red-hat-konflux <126015336+red-hat-konflux[bot]@users.noreply.github.com> --- deploy/konflux/e2e-odh/rpms.lock.yaml | 122 +++++++++++++------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/deploy/konflux/e2e-odh/rpms.lock.yaml b/deploy/konflux/e2e-odh/rpms.lock.yaml index f604e6202d..cae27e5345 100644 --- a/deploy/konflux/e2e-odh/rpms.lock.yaml +++ b/deploy/konflux/e2e-odh/rpms.lock.yaml @@ -1,5 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 --- lockfileVersion: 1 lockfileVendor: redhat @@ -69,13 +67,13 @@ arches: name: glibc-devel evr: 2.34-275.el9_8 sourcerpm: glibc-2.34-275.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/k/kernel-headers-5.14.0-687.49.1.el9_8.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/k/kernel-headers-5.14.0-687.53.1.el9_8.aarch64.rpm repoid: ubi-9-appstream-rpms - size: 2899913 - checksum: sha256:929179b11dddaa905f5261d8df9977ce70f311fb482d01ef13ff2da6d8c1e825 + size: 2925601 + checksum: sha256:7e82c856a00206976aede9e7b3865d0a2363b1262c0f9e1949ba29c7b9c47281 name: kernel-headers - evr: 5.14.0-687.49.1.el9_8 - sourcerpm: kernel-5.14.0-687.49.1.el9_8.src.rpm + evr: 5.14.0-687.53.1.el9_8 + sourcerpm: kernel-5.14.0-687.53.1.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/l/libasan-11.5.0-14.el9.aarch64.rpm repoid: ubi-9-appstream-rpms size: 409047 @@ -314,13 +312,13 @@ arches: name: elfutils-libs evr: 0.194-1.el9 sourcerpm: elfutils-0.194-1.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.3.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.5.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 122400 - checksum: sha256:df004398da989f75bae9267cfb0ad402ada6e6db46ebef0932c869894c013c08 + size: 122957 + checksum: sha256:d13bd77f429835b303d388e24811fb935060be4788c8fe4cf87703640f2337e0 name: expat - evr: 2.5.0-6.el9_8.3 - sourcerpm: expat-2.5.0-6.el9_8.3.src.rpm + evr: 2.5.0-6.el9_8.5 + sourcerpm: expat-2.5.0-6.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/f/filesystem-3.16-5.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 5003914 @@ -335,13 +333,13 @@ arches: name: findutils evr: 1:4.8.0-7.el9 sourcerpm: findutils-4.8.0-7.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gawk-5.1.0-6.el9.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gawk-5.1.0-6.el9_8.1.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 1024204 - checksum: sha256:a4b7202ac90653a7d3e072c2444bde6a9270d6a818eb6f2ffcfcaa50774f1fad + size: 1026105 + checksum: sha256:54365d2a6150079c2dc5003eeb94ada32fc15801b5db05422361f02ed58e6772 name: gawk - evr: 5.1.0-6.el9 - sourcerpm: gawk-5.1.0-6.el9.src.rpm + evr: 5.1.0-6.el9_8.1 + sourcerpm: gawk-5.1.0-6.el9_8.1.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gdbm-libs-1.23-1.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 60311 @@ -720,13 +718,13 @@ arches: name: libxcrypt evr: 4.4.18-3.el9 sourcerpm: libxcrypt-4.4.18-3.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.4.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.5.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 754646 - checksum: sha256:10417dde519f111c6248fae0eb6fb9889efd3e68c575caced652823d7ef4f169 + size: 754850 + checksum: sha256:39e0ffab5e42aa3688a39a9f27cecb77ee8dbf03682ca5b38c3e15ebc5493863 name: libxml2 - evr: 2.9.13-14.el9_8.4 - sourcerpm: libxml2-2.9.13-14.el9_8.4.src.rpm + evr: 2.9.13-14.el9_8.5 + sourcerpm: libxml2-2.9.13-14.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libzstd-1.5.5-1.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 283159 @@ -776,20 +774,20 @@ arches: name: openldap evr: 2.6.8-4.el9 sourcerpm: openldap-2.6.8-4.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-9.9p1-11.el9_8.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-9.9p1-12.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 432812 - checksum: sha256:d8bcf6348f5ee9d840e7f74eaaa53801cb9c48c1184c7254dd06aa73f597c690 + size: 432970 + checksum: sha256:e2cc40546db9b067d9d969187aa1fea68ce399ee5f53e44ed91df6f2fc23f49c name: openssh - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-clients-9.9p1-11.el9_8.aarch64.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-clients-9.9p1-12.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 770368 - checksum: sha256:b5f49e9e5d66075859596aa71f62bc8cc951d50129dd43543b6aacd22386b1c1 + size: 770215 + checksum: sha256:8601b26e577d6f8f0726061d96d941a4d8c2d5f0700a2bea5a957bbc17da6c22 name: openssh-clients - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssl-3.5.8-1.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms size: 1546056 @@ -1095,13 +1093,13 @@ arches: name: glibc-headers evr: 2.34-275.el9_8 sourcerpm: glibc-2.34-275.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/k/kernel-headers-5.14.0-687.49.1.el9_8.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/k/kernel-headers-5.14.0-687.53.1.el9_8.x86_64.rpm repoid: ubi-9-appstream-rpms - size: 2939221 - checksum: sha256:ac9fe2b15be1ea2445c28abcaa21b62a118e56ef5984aea1c9d204885786f99d + size: 2965145 + checksum: sha256:2473df2cf5b65c762af7941fe87324e98dc0e8b42d1e5745051a0405e200b7f5 name: kernel-headers - evr: 5.14.0-687.49.1.el9_8 - sourcerpm: kernel-5.14.0-687.49.1.el9_8.src.rpm + evr: 5.14.0-687.53.1.el9_8 + sourcerpm: kernel-5.14.0-687.53.1.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/l/libmpc-1.2.1-4.el9.x86_64.rpm repoid: ubi-9-appstream-rpms size: 66075 @@ -1326,13 +1324,13 @@ arches: name: elfutils-libs evr: 0.194-1.el9 sourcerpm: elfutils-0.194-1.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.3.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.5.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 128577 - checksum: sha256:3c9c96529f94f84fc19c8039d8852269e1085d16b5af2933f44ff286dec66a35 + size: 129088 + checksum: sha256:e6e7edd632fdd1dc4ad94b19b7ab88646f3ef8c1c7956bac58985c3e7118a362 name: expat - evr: 2.5.0-6.el9_8.3 - sourcerpm: expat-2.5.0-6.el9_8.3.src.rpm + evr: 2.5.0-6.el9_8.5 + sourcerpm: expat-2.5.0-6.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/f/filesystem-3.16-5.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 5003807 @@ -1347,13 +1345,13 @@ arches: name: findutils evr: 1:4.8.0-7.el9 sourcerpm: findutils-4.8.0-7.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gawk-5.1.0-6.el9.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gawk-5.1.0-6.el9_8.1.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 1045534 - checksum: sha256:99fda6725a2c668bae29fbab74d1b347e074f4e8c8ed18d656cb928fb6fc92b7 + size: 1046649 + checksum: sha256:fcc5e724c32597cf781626728f0faff2f0e5ed6455ab95af4883eefca30a074e name: gawk - evr: 5.1.0-6.el9 - sourcerpm: gawk-5.1.0-6.el9.src.rpm + evr: 5.1.0-6.el9_8.1 + sourcerpm: gawk-5.1.0-6.el9_8.1.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gdbm-libs-1.23-1.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 60152 @@ -1725,13 +1723,13 @@ arches: name: libxcrypt evr: 4.4.18-3.el9 sourcerpm: libxcrypt-4.4.18-3.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.4.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.5.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 772646 - checksum: sha256:3b2b7f705584d2aa9dc1a110ef1b00dbefb3305285877a24a24605fcb9567eb0 + size: 773693 + checksum: sha256:d55744c4fe83a63a71906ca41607c5c0deff227a69b957708cc6a37537be4a29 name: libxml2 - evr: 2.9.13-14.el9_8.4 - sourcerpm: libxml2-2.9.13-14.el9_8.4.src.rpm + evr: 2.9.13-14.el9_8.5 + sourcerpm: libxml2-2.9.13-14.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libzstd-1.5.5-1.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 304135 @@ -1781,20 +1779,20 @@ arches: name: openldap evr: 2.6.8-4.el9 sourcerpm: openldap-2.6.8-4.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-9.9p1-11.el9_8.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-9.9p1-12.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 443050 - checksum: sha256:9fad2dc75044e577f03442e524b58223239eba14b12adbf8d14eeb82d22b596e + size: 443141 + checksum: sha256:12db3094d78ed82bd7edc6a7cdd2cbf5198da695e293b47b5d00c31ac142aeb7 name: openssh - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-clients-9.9p1-11.el9_8.x86_64.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-clients-9.9p1-12.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 799504 - checksum: sha256:445f8ef1a60229d575547695da0bf2a05fb51408bce01d06548a5273123c8877 + size: 800429 + checksum: sha256:a4307b914ec45f69fea0e17b593ae61597db3e2796ce01a8809c55173f30c121 name: openssh-clients - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssl-3.5.8-1.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms size: 1569041 From 8719fc9f37a93dd96435cf6753ae53c8ee8809e6 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 2 Oct 2026 06:35:15 +0000 Subject: [PATCH 03/18] fix(sandbox): restrict provider file mode (#4093) * fix(sandbox): restrict provider file mode (fixes #4091) Signed-off-by: Drew Newberry * refactor(sandbox): set provider file mode with safe API Signed-off-by: Drew Newberry --------- Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/provider_files.rs | 8 +++++++- e2e/rust/tests/provider_files.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/provider_files.rs b/crates/openshell-sandbox/src/provider_files.rs index 5efd4270f2..ab719cd355 100644 --- a/crates/openshell-sandbox/src/provider_files.rs +++ b/crates/openshell-sandbox/src/provider_files.rs @@ -7,9 +7,10 @@ use std::collections::HashMap; use std::ffi::CString; -use std::fs::File; +use std::fs::{File, Permissions}; use std::io::{self, Seek as _, SeekFrom, Write as _}; use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::unix::fs::PermissionsExt as _; use std::sync::{Arc, RwLock}; use openshell_isolation_interface::linux::seccomp_notify::{Notification, NotificationListener}; @@ -211,6 +212,9 @@ fn sealed_memfd(content: &[u8]) -> io::Result { return Err(io::Error::last_os_error()); } let mut file = unsafe { File::from_raw_fd(fd) }; + // memfd_create defaults to 0777. Keep the metadata private as well as the + // returned descriptor read-only, since workloads may inspect it with fstat. + file.set_permissions(Permissions::from_mode(0o600))?; file.write_all(content)?; file.seek(SeekFrom::Start(0))?; let seals = libc::F_SEAL_SEAL | libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK; @@ -228,6 +232,7 @@ mod tests { use std::collections::HashMap; use std::io::Read as _; use std::os::fd::AsRawFd as _; + use std::os::unix::fs::PermissionsExt as _; #[test] fn paths_cannot_escape_the_managed_tree() { @@ -249,6 +254,7 @@ mod tests { #[test] fn memfd_is_read_only_and_positioned_at_start() { let mut file = sealed_memfd(b"version = 1\n").unwrap(); + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; assert_eq!(flags & libc::O_ACCMODE, libc::O_RDONLY); let mut read = String::new(); diff --git a/e2e/rust/tests/provider_files.rs b/e2e/rust/tests/provider_files.rs index c2779fe097..18db5db9b7 100644 --- a/e2e/rust/tests/provider_files.rs +++ b/e2e/rust/tests/provider_files.rs @@ -107,6 +107,23 @@ async fn provider_file_open_update_and_detach() -> Result<(), String> { if !before.contains("project = \"production\"") { return Err(format!("initial provider file content missing:\n{before}")); } + let permissions = sandbox + .exec(&[ + "sh", + "-c", + &format!( + "set -eu; exec 3<{path}; test \"$(stat -Lc %a /proc/self/fd/3)\" = 600; \ + test \"$(stat -Lc %F /proc/self/fd/3)\" = 'regular file'; \ + if (printf x >&3) 2>/dev/null; then exit 1; fi; \ + echo provider-file-permissions-ok" + ), + ]) + .await?; + if !permissions.contains("provider-file-permissions-ok") { + return Err(format!( + "provider file permission assertion did not complete:\n{permissions}" + )); + } cli_ok(&[ "provider", From 6048bed3687049704298c920b80e5c8501f640f0 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 2 Oct 2026 11:27:20 +0000 Subject: [PATCH 04/18] fix(ci): retry Nix shell and app dependency preparation (#4066) * fix(ci): prepare Nix development shells in setup-nix Signed-off-by: Evan Lezar * fix(ci): retry Nix builds before executing apps once Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar Co-authored-by: Evan Lezar --- .agents/skills/watch-github-actions/SKILL.md | 7 ++++++ .../check-protobuf-compatibility/action.yml | 3 +++ .github/actions/setup-nix/action.yml | 24 ++++++++++++++++++- .github/actions/setup-rust/action.yml | 6 +---- .github/workflows/branch-checks.yml | 19 +++++++++------ .github/workflows/branch-e2e.yml | 2 ++ .github/workflows/build-binaries.yml | 1 + .github/workflows/build-vm-driver.yml | 5 +++- .github/workflows/integration-runner.yml | 5 +++- .../workflows/prepare-integration-inputs.yml | 15 +++++++++--- .github/workflows/trivy-changes.yml | 2 ++ .github/workflows/trivy-scan.yml | 1 + .github/workflows/workflow-security.yml | 2 ++ CI.md | 21 ++++++++++++++++ flake.nix | 5 ++++ 15 files changed, 100 insertions(+), 18 deletions(-) diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index 5437a1a882..8b5c491901 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -125,6 +125,13 @@ gh run list --json databaseId,status,headBranch,url --jq '.[] | {id: .databaseId ## View Job Logs +`setup-nix` retries development-shell preparation once when `prepare-shell` +is enabled. Inspect both attempts in the job log; `setup-rust` assumes the +shell has already been prepared. Cargo, lint, and test commands are not retried. +Direct Nix builds and app dependency preparation also retry once; apps run +once after preparation succeeds. Skipped dependent E2E suites indicate blocked +coverage. + For `Trivy Changes`, inspect the `Resolve PR baseline` step for the base and head SHAs. PR runs compare the tested merge commit with its first parent; change detection and scans must use the same pair. On reruns, do diff --git a/.github/actions/check-protobuf-compatibility/action.yml b/.github/actions/check-protobuf-compatibility/action.yml index f11af91a5e..79c27d128d 100644 --- a/.github/actions/check-protobuf-compatibility/action.yml +++ b/.github/actions/check-protobuf-compatibility/action.yml @@ -19,4 +19,7 @@ runs: env: CHECK_REF: ${{ inputs.ref }} run: | + # Retry dependency preparation, then run the compatibility check once. + nix build --no-link --no-write-lock-file .#check-protobuf-compatibility || + nix build --no-link --no-write-lock-file .#check-protobuf-compatibility nix run .#check-protobuf-compatibility --no-write-lock-file -- "$CHECK_REF" diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml index 3cd6940c05..e808ee257d 100644 --- a/.github/actions/setup-nix/action.yml +++ b/.github/actions/setup-nix/action.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 name: Setup Nix -description: Install Nix and configure the OpenShell Cachix cache +description: Install Nix, configure Cachix, and optionally prepare the development shell inputs: cachix-auth-token: @@ -10,6 +10,15 @@ inputs: required: false default: "" + prepare-shell: + description: Prepare the development shell, retrying once on failure + required: false + default: "false" + shell-installable: + description: Development shell to prepare + required: false + default: "." + runs: using: composite steps: @@ -22,3 +31,16 @@ runs: name: openshell authToken: ${{ inputs.cachix-auth-token }} skipPush: ${{ inputs.cachix-auth-token == '' }} + + - name: Prepare Nix development shell + if: inputs.prepare-shell == 'true' + shell: bash + env: + NIX_SHELL_INSTALLABLE: ${{ inputs.shell-installable }} + run: | + # HTTP 416 is not retried by Nix; a fresh invocation restarts downloads. + if nix develop "$NIX_SHELL_INSTALLABLE" -c true; then + exit 0 + fi + echo "::warning::Nix shell preparation failed; retrying once." + nix develop "$NIX_SHELL_INSTALLABLE" -c true diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index f6b579d26a..92320c7f68 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 name: Setup Rust -description: Configure the Nix development shell and Rust caches +description: Configure Rust caches after setup-nix has prepared the development shell inputs: cache-key: @@ -23,10 +23,6 @@ runs: shell_drv=$(nix eval --raw --impure .#devShells --apply 'shells: shells.${builtins.currentSystem}.default.drvPath') echo "hash=$(nix hash file --type sha256 --base16 "$shell_drv")" >> "$GITHUB_OUTPUT" - - name: Realize Nix development shell - shell: bash - run: nix develop -c true - - name: Cache Rust target and registry uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 5cb545a571..780d6ddbd1 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -114,14 +114,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + - uses: ./.github/actions/setup-nix with: - name: openshell - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + prepare-shell: "true" + shell-installable: .#devShells.x86_64-linux.default - name: Check dependencies run: cargo deny check licenses bans sources @@ -137,6 +134,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Format @@ -158,6 +156,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Verify Cargo lockfiles @@ -185,6 +184,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust @@ -221,6 +221,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust @@ -257,6 +258,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust @@ -306,6 +308,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust @@ -330,6 +333,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust @@ -356,6 +360,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index eb903a55fc..85097eea56 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -144,6 +144,7 @@ jobs: ref: ${{ github.sha }} - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: @@ -184,6 +185,7 @@ jobs: ref: ${{ github.sha }} - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ad34ee7926..82ef374939 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -51,6 +51,7 @@ jobs: ref: ${{ inputs.checkout-ref || github.sha }} - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml index c20706cdb2..0893f57f75 100644 --- a/.github/workflows/build-vm-driver.yml +++ b/.github/workflows/build-vm-driver.yml @@ -52,6 +52,7 @@ jobs: - uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Download openshell-sandbox @@ -81,7 +82,9 @@ jobs: path: vm-init - name: Build VM runtime - run: nix build .#vm-runtime + run: | + # HTTP 416 is not retried by Nix; restart the build once on failure. + nix build .#vm-runtime || nix build .#vm-runtime - name: Assemble compressed VM runtime run: | diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 089e73a242..89cc0ca920 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -85,4 +85,7 @@ jobs: ENVIRONMENT: ${{ matrix.environment }} INSTALLER: ${{ matrix.installer }} TESTSUITE: ${{ matrix.testsuite }} - run: nix run .#tmachine -- test "${ENVIRONMENT}" "${INSTALLER}" "${TESTSUITE}" + run: | + # Retry dependency preparation, then execute the test suite once. + nix build --no-link .#tmachine || nix build --no-link .#tmachine + nix run .#tmachine -- test "${ENVIRONMENT}" "${INSTALLER}" "${TESTSUITE}" diff --git a/.github/workflows/prepare-integration-inputs.yml b/.github/workflows/prepare-integration-inputs.yml index d9b2c2a9f3..7b2bf62ab1 100644 --- a/.github/workflows/prepare-integration-inputs.yml +++ b/.github/workflows/prepare-integration-inputs.yml @@ -152,13 +152,22 @@ jobs: docker save --output artifacts/images/openshell-supervisor-tmachine.tar openshell/supervisor:tmachine - name: Build test archives - run: nix run .#build-artifacts-test-archives + run: | + # Retry dependency preparation, then generate artifacts once. + nix build --no-link .#build-artifacts-test-archives || nix build --no-link .#build-artifacts-test-archives + nix run .#build-artifacts-test-archives - name: Build test workload images - run: nix run .#build-artifacts-test-images + run: | + # Retry dependency preparation, then generate artifacts once. + nix build --no-link .#build-artifacts-test-images || nix build --no-link .#build-artifacts-test-images + nix run .#build-artifacts-test-images - name: Package Helm chart - run: nix run .#build-artifacts-helm + run: | + # Retry dependency preparation, then generate artifacts once. + nix build --no-link .#build-artifacts-helm || nix build --no-link .#build-artifacts-helm + nix run .#build-artifacts-helm - name: Upload integration inputs id: upload-integration-inputs diff --git a/.github/workflows/trivy-changes.yml b/.github/workflows/trivy-changes.yml index 15e9b7af33..000a3ec8b6 100644 --- a/.github/workflows/trivy-changes.yml +++ b/.github/workflows/trivy-changes.yml @@ -110,6 +110,8 @@ jobs: - name: Set up Nix uses: ./.github/actions/setup-nix + with: + prepare-shell: "true" - name: Test report comparison run: tasks/scripts/trivy-scan-test.sh diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index cad7281321..b934decfc7 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -105,6 +105,7 @@ jobs: - name: Set up Nix uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Test scan reporting diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index a098b2c4ab..11a08dc43d 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -54,6 +54,7 @@ jobs: - name: Set up Nix uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Run Actionlint @@ -141,6 +142,7 @@ jobs: - name: Set up Nix uses: ./.github/actions/setup-nix with: + prepare-shell: "true" cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Run Zizmor diff --git a/CI.md b/CI.md index c5a19e1f08..6a44cd82bb 100644 --- a/CI.md +++ b/CI.md @@ -511,3 +511,24 @@ merge. Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL jobs to the required status list while they remain in observation mode. + +## Nix download recovery + +Jobs that enter the development shell enable `prepare-shell: "true"` on +`setup-nix`. After configuring Cachix, the action prepares the shell with +`nix develop -c true` and retries once on failure. Use `shell-installable` +to select a different development shell. Rust setup assumes this preparation +has completed. Jobs that only use Nix apps leave shell preparation disabled. + +Nix can report a transport error after receiving a complete cache download, +then resume at EOF and receive HTTP 416. A fresh invocation restarts the +operation. Both attempts appear in the job log; a second failure fails the +step. Any preparation failure is retried once, including deterministic errors. +Cargo, lint, and test commands are not retried. + +Direct `nix build` commands retry once. Before each `nix run`, CI builds the +app's package with `nix build --no-link`, retrying preparation once, then runs +the app once. The artifact and protobuf-check apps expose matching package +outputs for this preparation. Runtime failures from tests, artifact generation, +and compatibility checks are not retried. Downloads initiated inside an app +are outside this preparation retry. diff --git a/flake.nix b/flake.nix index 747a5f3c80..4928d2f151 100644 --- a/flake.nix +++ b/flake.nix @@ -182,6 +182,11 @@ }; packages = { + # Expose app derivations so CI can prepare them before executing once. + check-protobuf-compatibility = checkProtobufCompatibility; + build-artifacts-test-archives = artifacts.testArchives; + build-artifacts-test-images = artifacts.testImages; + build-artifacts-helm = artifacts.helm; vm-runtime = vmRuntime; tmachine = testMachines.package; tmachine-config = testMachines.config; From 6e865df349a11da818a8e6b1e79afa42342411c7 Mon Sep 17 00:00:00 2001 From: Oliver Calder Date: Fri, 2 Oct 2026 11:51:56 +0000 Subject: [PATCH 05/18] feat(snap): ship the standalone prover binary in the snap (#3717) Signed-off-by: Oliver Calder Signed-off-by: Evan Lezar --- .agents/skills/test-release-canary/SKILL.md | 3 ++- .github/workflows/release-canary.yml | 23 +++++++++++++++++ .github/workflows/snap-package.yml | 8 ++++++ CI.md | 2 +- docs/about/installation.mdx | 7 +++++- docs/about/support-matrix.mdx | 5 ++++ docs/how-it-works/policies/prover.mdx | 20 ++++++++++----- nix/test-guest/scripts/snap-gateway-repro.sh | 19 ++++++++++++++ snapcraft.yaml | 13 ++++++++-- tasks/scripts/test-packaging-assets.sh | 26 ++++++++++++++++++-- 10 files changed, 113 insertions(+), 13 deletions(-) diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 15430d8610..1f6965873c 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -16,7 +16,7 @@ The Release Canary (`.github/workflows/release-canary.yml`) smoke-tests the arti | `macos` | `macos-latest-xlarge` | Installs the dev Homebrew artifacts, reaches the VM gateway, and creates, executes in, and deletes a sandbox. | | `ubuntu-deb` | `ubuntu-latest` | Installs the dev Debian package, reaches the Docker gateway, and creates, executes in, and deletes a sandbox. | | `fedora` | `fedora:latest` container | Installs the dev RPM packages, reaches the Podman gateway, and creates, executes in, and deletes a sandbox. | -| `ubuntu-snap-system-docker` | `ubuntu-latest` | Uses `install.sh` to install the snap from `latest/edge`, reuses system Docker, reaches the Docker gateway, and creates, executes in, and deletes a sandbox, and verifies that the Docker snap is not installed. | +| `ubuntu-snap-system-docker` | `ubuntu-latest` | Uses `install.sh` to install the snap from `latest/edge`, reuses system Docker, verifies the packaged prover version and a local policy boundary check, reaches the Docker gateway, creates, executes in, and deletes a sandbox, and verifies that the Docker snap is not installed. | | `ubuntu-snap-docker-preflight` | `ubuntu-latest` | Verifies that `install.sh` rejects the OpenShell Snap path when Docker is absent or supplied by the Docker snap, without installing OpenShell. | | `kubernetes` | `ubuntu-latest` + kind | Installs the dev Helm chart, reaches the in-cluster gateway, and creates, executes in, and deletes a sandbox using the published runtime images. | @@ -144,6 +144,7 @@ Loopback registration auto-derives the gateway name to `openshell` if `--name` i | Sandbox create or exec fails | Published sandbox and supervisor artifacts are missing, incompatible, or cannot establish the protected runtime channel. | Gateway logs plus Docker, Podman, VM, Snap, or Kubernetes runtime diagnostics for the job. | | `macos`/`ubuntu-deb`/`fedora` job fails on `openshell status` | Local gateway service did not start (systemd/brew/podman). Often a driver issue. | Service logs in the job log; `OPENSHELL_COMPUTE_DRIVER` env in the "Ensure …" step. | | `ubuntu-snap-system-docker` fails during `install.sh` | System Docker was unavailable, the edge revision or automatic interfaces were unavailable, or the gateway did not become reachable. | Failure diagnostics dump system Docker, snap service/connection/change state, gateway and snapd journals, snap logs, and port 17670 listeners. | +| `ubuntu-snap-system-docker` fails during the prover checks | The prover artifact is missing or packaged for the wrong architecture, `openshell.prover` is not exposed or confined to read the test policies, or its solver linkage is not runnable. | The `Verify Snap installation` and `Check a policy boundary with the Snap prover` steps, plus `snap info openshell` and `snap connections openshell`. | | `ubuntu-snap-docker-preflight` unexpectedly succeeds | The installer no longer fails before installing the OpenShell snap when Docker is absent or supplied by the Docker snap. | Inspect `install.log`, `docker-snap.log`, `snap list`, and snapd changes. | | `kubernetes` job fails on `helm install --wait` | Chart did not deploy in 5 min — usually image pull failure or readiness probe failing. | "Diagnostics on failure" step dumps `helm status`, manifest, pod describe, pod logs. | | `kubernetes` job fails on `kubectl wait` | Gateway pod stuck `CrashLoopBackOff` or `ImagePullBackOff`. | Diagnostics dump; check `:dev` image existence at `ghcr.io/nvidia/openshell/gateway`. | diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 69fd3b3e55..286d685672 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -233,12 +233,35 @@ jobs: docker info sudo snap connections openshell | grep -E '^docker +openshell:docker +:docker +' openshell --version + openshell.prover --version sudo snap services openshell sudo journalctl -b -u snap.openshell.gateway.service --no-pager | grep -F "mTLS user authentication enabled" openshell gateway list | grep -F "https://127.0.0.1:17670" openshell status + - name: Check a policy boundary with the Snap prover + run: | + set -euo pipefail + prover_dir=$(mktemp -d "$HOME/openshell-prover-canary.XXXXXX") + trap 'rm -rf "$prover_dir"' EXIT + cat >"$prover_dir/boundary.yaml" <<'EOF' + version: 1 + filesystem_policy: + read_only: + - /usr + - /etc + EOF + cat >"$prover_dir/candidate.yaml" <<'EOF' + version: 1 + filesystem_policy: + read_only: + - /usr + EOF + result=$(openshell.prover check "$prover_dir/candidate.yaml" \ + --boundary "$prover_dir/boundary.yaml") + grep -q '^result: within_boundary$' <<<"$result" + - name: Create and exercise a sandbox run: | set -euo pipefail diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index 7eb78d3a27..f5eaa61e9d 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -89,6 +89,12 @@ jobs: name: openshell-${{ matrix.rust_arch }}-unknown-linux-musl path: prebuilt/cli + - name: Download prebuilt prover binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-prover-${{ matrix.rust_arch }}-unknown-linux-musl + path: prebuilt/prover + - name: Download prebuilt gateway binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -105,6 +111,7 @@ jobs: run: | set -euo pipefail chmod +x prebuilt/cli/openshell + chmod +x prebuilt/prover/openshell-prover chmod +x prebuilt/gateway/openshell-gateway chmod +x prebuilt/sandbox/openshell-sandbox ls -laR prebuilt/ @@ -115,6 +122,7 @@ jobs: mkdir -p snap/prebuilt cp prebuilt/cli/openshell snap/prebuilt/openshell + cp prebuilt/prover/openshell-prover snap/prebuilt/openshell-prover cp prebuilt/gateway/openshell-gateway snap/prebuilt/openshell-gateway cp prebuilt/sandbox/openshell-sandbox snap/prebuilt/openshell-sandbox diff --git a/CI.md b/CI.md index 6a44cd82bb..cb7121b2cf 100644 --- a/CI.md +++ b/CI.md @@ -491,7 +491,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | | `.github/workflows/release-tag.yml` | Publishes tagged stable releases and manually dispatched pre-releases. Its automatic tag trigger excludes `-pre.*`. Protobuf, security, and integration failures do not block pre-release artifact publication. Stable publication requires the currently implemented qualification profile to pass; the summary identifies the remaining RFC 0014 coverage. | -| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The Snap lanes verify a compatible system Docker lifecycle and `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | +| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The Snap lanes verify a compatible system Docker lifecycle and `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. The positive Snap lane also runs a local policy containment check with the packaged prover. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | ## Required status contexts diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index a279bb3510..957c4e2316 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -106,11 +106,16 @@ sudo loginctl enable-linger $USER The snap requires Docker Engine installed from your distribution or Docker's package repository. The Docker snap is not compatible. +The snap does not migrate existing Debian, RPM, or Homebrew installs. Remove any existing installation first, then either rerun the `install.sh` script with `OPENSHELL_INSTALL_METHOD=snap OPENSHELL_ACK_BREAKING_UPGRADE=1`, or install the snap directly: + ```shell sudo snap install openshell ``` -The snap does not migrate existing Debian, RPM, or Homebrew installs. Remove any existing installation first, then rerun the script with `OPENSHELL_INSTALL_METHOD=snap OPENSHELL_ACK_BREAKING_UPGRADE=1`. +The snap installs the standalone policy prover as `openshell.prover`. The +`openshell-prover` alias requires Snap Store approval and may not be available. +The prover reads local policy files through the `home` interface and does not +connect to the gateway. The gateway runs as a system service at `https://127.0.0.1:17670` and reads `/var/snap/openshell/common/gateway.toml`. It requires a client certificate. The install script copies that certificate to the installing user's Snap state and registers the gateway automatically. If you installed with `sudo snap install openshell`, give each trusted user the certificate and register the gateway from that user's account: diff --git a/docs/about/support-matrix.mdx b/docs/about/support-matrix.mdx index 9cace1a708..4115854e8e 100644 --- a/docs/about/support-matrix.mdx +++ b/docs/about/support-matrix.mdx @@ -106,6 +106,11 @@ OpenShell publishes standalone `openshell-prover` release assets for manual down These artifacts are attached to GitHub releases. The Linux binaries are static and do not require glibc. All prover archives include the required solver linkage. +The Debian, RPM, Homebrew, and Snap packages also include the prover. Debian, +RPM, and Homebrew installations expose it as `openshell-prover`; use +`openshell.prover` with the Snap. The `openshell-prover` Snap alias requires +Store approval and may not be available. + ## Runtimes The gateway can manage sandboxes through several runtimes. diff --git a/docs/how-it-works/policies/prover.mdx b/docs/how-it-works/policies/prover.mdx index 95b48dcf15..acde16f577 100644 --- a/docs/how-it-works/policies/prover.mdx +++ b/docs/how-it-works/policies/prover.mdx @@ -36,6 +36,16 @@ contain access that the proposal risk check would flag. This page covers the boundary check. To learn about the proposal risk check, refer to [Policy Advisor](/how-it-works/policies/advisor). +For Snap installations, replace `openshell-prover` in these examples with +`openshell.prover`. The `openshell-prover` Snap alias requires Store approval +and may not be available. + +The prover remains independent of the gateway at runtime. If you only need the +standalone binary, use the artifacts listed in the +[Support Matrix](/about/support-matrix#standalone-policy-prover). These +artifacts and `openshell-prover-checksums-sha256.txt` are attached to +[OpenShell releases](https://github.com/NVIDIA/OpenShell/releases). + ## Run a Boundary Check A boundary check compares the policy you are testing, called the candidate, with @@ -50,12 +60,10 @@ or MCP rules, the prover reports that it cannot check the policy instead of ignoring those rules. [What the Boundary Check Covers](#what-the-boundary-check-covers) describes each part and its limits. -The Homebrew, Debian, and RPM packages install the `openshell-prover` CLI. The -snap package does not include it, so on a snap installation, download the -`openshell-prover` archive for your platform from the [OpenShell -releases](https://github.com/NVIDIA/OpenShell/releases). The CLI reads policy -files on your machine, does not need a gateway, and does not apply or approve -policies. +The Homebrew, Debian, RPM, and Snap packages install the prover. Homebrew, +Debian, and RPM expose it as `openshell-prover`; use `openshell.prover` with the +Snap. The CLI reads policy files on your machine, does not need a gateway, and +does not apply or approve policies. Create `boundary.yaml`, a boundary that allows reading `/usr` and `/etc`: diff --git a/nix/test-guest/scripts/snap-gateway-repro.sh b/nix/test-guest/scripts/snap-gateway-repro.sh index 43b7f3949c..8dd09fc206 100755 --- a/nix/test-guest/scripts/snap-gateway-repro.sh +++ b/nix/test-guest/scripts/snap-gateway-repro.sh @@ -127,20 +127,39 @@ for attempt in $(seq 1 "${attempts}"); do fi sandbox="snap-${attempt}-$$" + prover_dir=$(mktemp -d "$HOME/openshell-prover-repro.XXXXXX") + cat >"${prover_dir}/boundary.yaml" <<'EOF' +version: 1 +filesystem_policy: + read_only: + - /usr + - /etc +EOF + cat >"${prover_dir}/candidate.yaml" <<'EOF' +version: 1 +filesystem_policy: + read_only: + - /usr +EOF if ! OPENSHELL_INSTALL_METHOD=snap OPENSHELL_VERSION=dev sh "${install_script}" || ! sudo snap list openshell >/dev/null || ! snap info openshell | grep -Eq '^tracking: +latest/edge$' || ! docker_is_ready || ! sudo snap connections openshell | grep -Eq '^docker +openshell:docker +:docker +' || ! /snap/bin/openshell status || + ! /snap/bin/openshell.prover --version || + ! /snap/bin/openshell.prover check "${prover_dir}/candidate.yaml" \ + --boundary "${prover_dir}/boundary.yaml" | grep -q '^result: within_boundary$' || ! /snap/bin/openshell sandbox create --name "${sandbox}" --detach || ! /snap/bin/openshell sandbox exec --name "${sandbox}" --no-tty -- true || ! /snap/bin/openshell sandbox delete "${sandbox}"; then echo "install.sh Snap reproduction failed" >&2 diagnostics "${attempt}" failures=$((failures + 1)) + rm -rf "${prover_dir}" continue fi + rm -rf "${prover_dir}" if sudo snap list docker >/dev/null 2>&1; then echo "install.sh unexpectedly installed the Docker snap" >&2 diff --git a/snapcraft.yaml b/snapcraft.yaml index c05011e1d9..7a7340d857 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -12,7 +12,8 @@ description: | profile-backed model-provider access. The OpenShell snap ships a CLI (`openshell`), a terminal UI - (`openshell.term`), and a managed gateway daemon (`openshell.gateway`). + (`openshell.term`), a standalone policy prover (`openshell.prover`), and a + managed gateway daemon (`openshell.gateway`). **Setup instructions** @@ -85,6 +86,12 @@ apps: - home - network - system-observe + prover: + command: bin/openshell-prover + aliases: + - openshell-prover + plugs: + - home gateway: command: bin/openshell-gateway-wrapper daemon: simple @@ -120,7 +127,7 @@ parts: set -euo pipefail MISSING=() - for bin in openshell openshell-gateway openshell-sandbox openshell-gateway-wrapper; do + for bin in openshell openshell-prover openshell-gateway openshell-sandbox openshell-gateway-wrapper; do if [ ! -f "$CRAFT_PART_SRC/$bin" ]; then MISSING+=("$bin") fi @@ -138,6 +145,8 @@ parts: install -D -m 0755 "$CRAFT_PART_SRC/openshell" \ "$CRAFT_PART_INSTALL/bin/openshell" + install -D -m 0755 "$CRAFT_PART_SRC/openshell-prover" \ + "$CRAFT_PART_INSTALL/bin/openshell-prover" install -D -m 0755 "$CRAFT_PART_SRC/openshell-gateway" \ "$CRAFT_PART_INSTALL/bin/openshell-gateway" install -D -m 0755 "$CRAFT_PART_SRC/openshell-sandbox" \ diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index b9ade5eb05..1d5a487a76 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -80,6 +80,7 @@ assert_not_contains "$spec" '%%S/openshell/tls' # Schema-v2 package startup wiring. snap_wrapper="${ROOT}/tasks/scripts/snap-gateway-wrapper.sh" snapcraft="${ROOT}/snapcraft.yaml" +snap_workflow="${ROOT}/.github/workflows/snap-package.yml" snap_install_docs="${ROOT}/docs/about/installation.mdx" snap_canary="${ROOT}/.github/workflows/release-canary.yml" snap_repro="${ROOT}/nix/test-guest/scripts/snap-gateway-repro.sh" @@ -87,6 +88,7 @@ snap_post_refresh_hook="${ROOT}/snap/hooks/post-refresh" package_deb="${ROOT}/tasks/scripts/package-deb.sh" assert_file_exists "$snap_wrapper" assert_file_exists "$snapcraft" +assert_file_exists "$snap_workflow" assert_file_exists "$snap_install_docs" assert_file_exists "$snap_canary" assert_file_exists "$snap_repro" @@ -131,18 +133,38 @@ if [[ ! -x "$snap_post_refresh_hook" ]]; then fi assert_not_contains "$ROOT/tasks/scripts/snap-gateway-wrapper.sh" 'OPENSHELL_DISABLE_TLS' bash "$ROOT/tasks/scripts/test-snap-post-refresh-hook.sh" "$snap_post_refresh_hook" +assert_contains "$snap_workflow" 'name: openshell-prover-${{ matrix.rust_arch }}-unknown-linux-musl' +assert_contains "$snap_workflow" 'chmod +x prebuilt/prover/openshell-prover' +assert_contains "$snap_workflow" 'cp prebuilt/prover/openshell-prover snap/prebuilt/openshell-prover' +assert_contains "$snapcraft" 'for bin in openshell openshell-prover openshell-gateway openshell-sandbox openshell-gateway-wrapper; do' +assert_contains "$snapcraft" '"$CRAFT_PART_INSTALL/bin/openshell-prover"' +if ! awk ' + /^ prover:$/ { in_prover = 1; next } + in_prover && /^ [[:alnum:]_-]+:$/ { finished = 1; exit } + in_prover && /command: bin\/openshell-prover/ { command = 1 } + in_prover && /- openshell-prover/ { alias = 1 } + in_prover && /^ plugs:$/ { in_plugs = 1; next } + in_prover && in_plugs && /^ - / { + plug_count++ + if ($0 == " - home") home = 1 + } + END { exit !(in_prover && finished && command && alias && home && plug_count == 1) } +' "$snapcraft"; then + echo "FAIL: Snap prover app must expose the openshell-prover alias with only home access" >&2 + exit 1 +fi assert_not_contains "$snap_install_docs" "snap connect openshell:home" assert_not_contains "$snap_install_docs" "snap connect openshell:network" assert_not_contains "$snap_install_docs" "snap connect openshell:network-bind" assert_contains "$snap_install_docs" "snap connect openshell:docker :docker" assert_contains "$snap_install_docs" "systemctl reset-failed snap.openshell.gateway.service" assert_contains "$snap_install_docs" "snap restart openshell.gateway" -assert_contains "$snap_install_docs" "Snap refreshes keep the running gateway process active" -assert_contains "$snap_install_docs" "install script refreshes and restarts the gateway automatically" assert_contains "$snap_canary" "install.sh | sh" assert_contains "$snap_canary" "ubuntu-snap-system-docker:" assert_contains "$snap_canary" "ubuntu-snap-docker-preflight:" +assert_contains "$snap_canary" "openshell.prover check" assert_contains "$snap_repro" 'OPENSHELL_INSTALL_METHOD=snap OPENSHELL_VERSION=dev sh "${install_script}"' +assert_contains "$snap_repro" "/snap/bin/openshell.prover check" assert_contains "$snap_repro" "system-docker" assert_contains "$snap_repro" "missing-docker" assert_contains "$snap_repro" "docker-snap" From 5d6b3b8120be176ca8082fee647f9e06591f109c Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 2 Oct 2026 12:25:46 +0000 Subject: [PATCH 06/18] fix(kubernetes): serialize lifecycle cleanup with sandbox restart (#4078) Signed-off-by: Matthew Grossman --- crates/openshell-driver-kubernetes/README.md | 7 + .../openshell-driver-kubernetes/src/driver.rs | 63 +++- crates/openshell-driver-kubernetes/src/lib.rs | 1 + .../src/lifecycle.rs | 26 ++ .../src/lifecycle_tests.rs | 305 ++++++++++++++++++ skills/debug-openshell-cluster/SKILL.md | 6 + 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 crates/openshell-driver-kubernetes/src/lifecycle.rs create mode 100644 crates/openshell-driver-kubernetes/src/lifecycle_tests.rs diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 6cb277fbbb..6dd2f764b8 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -182,6 +182,13 @@ The workload Pod does not share host network, PID, IPC, or process namespaces. The driver uses a scheduling gate to inspect the admitted Pod and bind its UID into the bootstrap claims before kubelet starts it. +Lifecycle RPCs and runtime reconciliation share a per-sandbox mutation gate +across clones of the driver. Reconciliation skips busy sandboxes and refreshes +the Sandbox CR under that gate before cleanup, so a stopped or stopping LIST +snapshot cannot delete a supervisor created by a concurrent restart in the same +driver instance. The gate preserves concurrency across sandboxes; it does not +provide distributed exclusion between separate gateway or driver processes. + ## GPU Support When a sandbox requests GPU support, the driver checks node allocatable capacity diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 64ffb0ca97..093a16de80 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -11,6 +11,7 @@ use crate::config::{ use crate::isolation::{ BOUNDARY_PAIR_LABEL, BOUNDARY_ROLE_LABEL, KubernetesSandboxRuntimeBoundarySpec, }; +use crate::lifecycle::LifecycleGates; use crate::sandbox_runtime::{ BOUNDARY_CERTIFICATE_PATH, BOUNDARY_CONFIG_PATH, BOUNDARY_PRIVATE_KEY_PATH, ClientTlsMaterial, SUPERVISOR_TERMINATION_GRACE_PERIOD_SECONDS, SandboxRuntimeNames, SupervisorClientTls, @@ -686,6 +687,7 @@ pub struct KubernetesComputeDriver { client: Client, watch_client: Client, sandbox_api_version: Arc>, + lifecycle_gates: Arc, config: KubernetesComputeConfig, operator_allowlist: Option, } @@ -713,6 +715,7 @@ impl KubernetesComputeDriver { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config, operator_allowlist: None, } @@ -794,6 +797,7 @@ impl KubernetesComputeDriver { client, watch_client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config, operator_allowlist, }; @@ -1764,6 +1768,11 @@ impl KubernetesComputeDriver { )] pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result { let span_status = openshell_otel::ErrorStatusGuard::current(); + let _guard = self + .lifecycle_gates + .gate_for(&sandbox.id) + .lock_owned() + .await; let result = Box::pin(self.create_sandbox_inner(sandbox)).await; span_status.finish(result) } @@ -2880,6 +2889,7 @@ impl KubernetesComputeDriver { )] pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { let span_status = openshell_otel::ErrorStatusGuard::current(); + let _guard = self.lifecycle_gates.gate_for(sandbox_id).lock_owned().await; let result = Box::pin(self.stop_sandbox_inner(sandbox_id)).await; span_status.finish(result) } @@ -2979,6 +2989,7 @@ impl KubernetesComputeDriver { expected_runtime_identity: &str, ) -> Result { let span_status = openshell_otel::ErrorStatusGuard::current(); + let _guard = self.lifecycle_gates.gate_for(sandbox_id).lock_owned().await; let result = Box::pin(self.start_sandbox_runtime_generation( sandbox_id, generation_id, @@ -3457,6 +3468,7 @@ impl KubernetesComputeDriver { )] pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { let span_status = openshell_otel::ErrorStatusGuard::current(); + let _guard = self.lifecycle_gates.gate_for(sandbox_id).lock_owned().await; let result = self.delete_sandbox_inner(sandbox_id).await; span_status.finish(result) } @@ -3657,6 +3669,44 @@ impl KubernetesComputeDriver { let Ok(sandbox_id) = sandbox_id_from_object(&object) else { continue; }; + // Lifecycle RPCs can replace the stable supervisor Pod name while + // this LIST snapshot still describes the previous stopped state. + // Skip in-flight mutations, then refresh under the shared gate so + // a snapshot taken before a completed restart cannot delete it. + let Ok(_guard) = self.lifecycle_gates.gate_for(&sandbox_id).try_lock_owned() else { + continue; + }; + let Some(name) = object.metadata.name.as_deref() else { + continue; + }; + let namespace = object + .metadata + .namespace + .as_deref() + .unwrap_or(&self.config.namespace); + let api = Self::agent_sandbox_api( + self.client.clone(), + &lookup_api.resource.version, + namespace, + ); + let refreshed = match tokio::time::timeout(KUBE_API_TIMEOUT, api.api.get(name)).await { + Ok(Ok(refreshed)) => refreshed, + Ok(Err(KubeError::Api(error))) if error.code == 404 => continue, + Ok(Err(error)) => { + debug!(%sandbox_id, %error, "could not refresh Sandbox for runtime reconciliation"); + continue; + } + Err(_) => { + warn!(%sandbox_id, "timed out refreshing Sandbox for runtime reconciliation"); + continue; + } + }; + if refreshed.metadata.uid != object.metadata.uid + || sandbox_id_from_object(&refreshed).as_deref() != Ok(sandbox_id.as_str()) + { + continue; + } + let object = refreshed; if let Err(error) = self.admit_stored_resources(&object).await { warn!(%sandbox_id, reason = %error.message(), "Sandbox resource admission revalidation failed"); if error.code() == tonic::Code::FailedPrecondition { @@ -7449,10 +7499,15 @@ mod tests { serde_json::json!({ "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "SandboxList", - "items": [sandbox] + "items": [sandbox.clone()] }), ), ), + ( + http::Method::GET, + "/apis/agents.x-k8s.io/v1beta1/namespaces/openshell/sandboxes/sandbox-cr", + kube_test_response(http::StatusCode::OK, sandbox), + ), ( http::Method::GET, "/api/v1/namespaces/openshell/persistentvolumeclaims/team-data", @@ -7488,6 +7543,7 @@ mod tests { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config: KubernetesComputeConfig::default(), operator_allowlist: None, }; @@ -8462,6 +8518,7 @@ mod tests { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config: KubernetesComputeConfig::default(), operator_allowlist: None, }; @@ -8552,6 +8609,7 @@ mod tests { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config: KubernetesComputeConfig::default(), operator_allowlist: None, }; @@ -11013,6 +11071,7 @@ mod tests { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), config, operator_allowlist: None, }; @@ -11748,4 +11807,6 @@ mod tests { alpha.data = serde_json::json!({"spec": {"replicas": 1}}); assert!(sandbox_runtime_should_run(&alpha)); } + + include!("lifecycle_tests.rs"); } diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index 607f95d382..aa980db44f 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod driver; pub mod grpc; pub mod isolation; +mod lifecycle; pub mod otel_tracing; mod resource_admission; mod sandbox_runtime; diff --git a/crates/openshell-driver-kubernetes/src/lifecycle.rs b/crates/openshell-driver-kubernetes/src/lifecycle.rs new file mode 100644 index 0000000000..74d2be3841 --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/lifecycle.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-sandbox serialization shared by lifecycle RPCs and driver reconciliation. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; +use tokio::sync::Mutex as AsyncMutex; + +#[derive(Debug, Default)] +pub struct LifecycleGates { + gates: Mutex>>>, +} + +impl LifecycleGates { + pub fn gate_for(&self, sandbox_id: &str) -> Arc> { + let mut gates = self.gates.lock().expect("lifecycle gate registry poisoned"); + gates.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = gates.get(sandbox_id).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(AsyncMutex::new(())); + gates.insert(sandbox_id.to_string(), Arc::downgrade(&gate)); + gate + } +} diff --git a/crates/openshell-driver-kubernetes/src/lifecycle_tests.rs b/crates/openshell-driver-kubernetes/src/lifecycle_tests.rs new file mode 100644 index 0000000000..9d70b4340e --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/lifecycle_tests.rs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod lifecycle_reconciliation { + use super::*; + + // Availability reads run concurrently. Match pending responses by path + // rather than assuming tokio::join! polls its branches in a fixed order. + fn read_only_driver(steps: Vec) -> ScriptedDriver { + let steps = Arc::new(std::sync::Mutex::new(VecDeque::from(steps))); + let pending = steps.clone(); + let service = tower::service_fn(move |request: http::Request| { + let pending = pending.clone(); + async move { + assert_eq!( + request.method(), + http::Method::GET, + "reconciliation must preserve the replacement" + ); + let mut pending = pending.lock().unwrap(); + let index = pending + .iter() + .position(|(method, path, _)| { + request.method() == method && request.uri().path() == *path + }) + .unwrap_or_else(|| panic!("unexpected read {}", request.uri().path())); + let (_, _, response) = pending.remove(index).unwrap(); + Ok::<_, std::convert::Infallible>(response) + } + }); + let client = Client::new(service, "openshell"); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + lifecycle_gates: Arc::default(), + config: KubernetesComputeConfig::default(), + operator_allowlist: None, + }; + (driver, steps, Arc::default()) + } + + fn snapshot(version: &str, phase: Option) -> serde_json::Value { + let mut object = serde_json::json!({ + "apiVersion": format!("{SANDBOX_GROUP}/{version}"), "kind": "Sandbox", + "metadata": { + "name": "sandbox-cr", "namespace": "openshell", "uid": "cr-uid", + "resourceVersion": "42", + "labels": {LABEL_SANDBOX_ID: "sandbox-1", LABEL_SANDBOX_WORKSPACE: "team-a"}, + "annotations": { + crate::resource_admission::CONFIG_USED: "false", + crate::resource_admission::IDENTITIES: "{}", + ANNOTATION_SANDBOX_RUNTIME_READINESS: "unavailable" + } + }, + "spec": {"podTemplate": {"spec": { + "automountServiceAccountToken": false, + "volumes": [{"name": SANDBOX_BOOTSTRAP_VOLUME_NAME, + "secret": {"secretName": "os-sandbox-sandbox-1-gen2"}}] + }}} + }); + object["spec"][if version == SANDBOX_VERSION_V1ALPHA1 { + "replicas" + } else { + "operatingMode" + }] = if version == SANDBOX_VERSION_V1ALPHA1 { + serde_json::json!(0) + } else { + serde_json::json!("Suspended") + }; + if let Some(phase) = phase { + object["metadata"]["annotations"][ANNOTATION_SANDBOX_RUNTIME_BOOTSTRAPPING] = + serde_json::json!("true"); + object["metadata"]["annotations"][ANNOTATION_SANDBOX_RUNTIME_BOOTSTRAP_OPERATION] = + serde_json::json!("stop"); + object["metadata"]["annotations"][ANNOTATION_SANDBOX_RUNTIME_BOOTSTRAP_PHASE] = + serde_json::json!(phase.as_str()); + object["metadata"]["annotations"][ANNOTATION_SANDBOX_RUNTIME_SUPERVISOR_UID] = + serde_json::json!("old-supervisor-uid"); + } + object + } + + fn restarted_snapshot(version: &str) -> serde_json::Value { + let mut object = snapshot(version, Some(SandboxRuntimeBootstrapPhase::Preparing)); + object["metadata"]["resourceVersion"] = serde_json::json!("43"); + let annotations = &mut object["metadata"]["annotations"]; + annotations[ANNOTATION_SANDBOX_RUNTIME_BOOTSTRAP_OPERATION] = serde_json::json!("restart"); + annotations[ANNOTATION_SANDBOX_RUNTIME_BOOTSTRAP_STARTED_AT] = + serde_json::json!(openshell_core::time::now_ms().to_string()); + annotations[ANNOTATION_SANDBOX_RUNTIME_SUPERVISOR_UID] = + serde_json::json!("new-supervisor-uid"); + annotations[ANNOTATION_SANDBOX_RUNTIME_NETWORK_POLICY_UID] = + serde_json::json!("sandbox-workload-fence-uid"); + annotations[ANNOTATION_SANDBOX_RUNTIME_NETWORK_POLICY_GENERATION] = serde_json::json!("1"); + object["spec"][if version == SANDBOX_VERSION_V1ALPHA1 { + "replicas" + } else { + "operatingMode" + }] = if version == SANDBOX_VERSION_V1ALPHA1 { + serde_json::json!(1) + } else { + serde_json::json!("Running") + }; + object + } + + fn listed(version: &str, object: serde_json::Value) -> KubeTestStep { + ( + http::Method::GET, + if version == SANDBOX_VERSION_V1ALPHA1 { + "/apis/agents.x-k8s.io/v1alpha1/namespaces/openshell/sandboxes" + } else { + "/apis/agents.x-k8s.io/v1beta1/namespaces/openshell/sandboxes" + }, + kube_test_response( + http::StatusCode::OK, + serde_json::json!({ + "apiVersion": format!("{SANDBOX_GROUP}/{version}"), + "kind": "SandboxList", "items": [object] + }), + ), + ) + } + + fn refreshed(version: &str, object: serde_json::Value) -> KubeTestStep { + ( + http::Method::GET, + if version == SANDBOX_VERSION_V1ALPHA1 { + "/apis/agents.x-k8s.io/v1alpha1/namespaces/openshell/sandboxes/sandbox-cr" + } else { + "/apis/agents.x-k8s.io/v1beta1/namespaces/openshell/sandboxes/sandbox-cr" + }, + kube_test_response(http::StatusCode::OK, object), + ) + } + + fn preparing_dependencies() -> Vec { + let mut fence = workload_fence("openshell", &SandboxRuntimeNames::new("sandbox-1"), 5500); + for (policy, component) in [ + (&mut fence.workload_policy, "sandbox-workload-fence"), + (&mut fence.supervisor_policy, "sandbox-supervisor-egress"), + ] { + policy.metadata.labels.get_or_insert_default().extend([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ("openshell.ai/component".to_string(), component.to_string()), + ]); + policy.metadata.uid = Some(format!("{component}-uid")); + policy.metadata.generation = Some(1); + } + let workload = || { + ( + http::Method::GET, + "/apis/networking.k8s.io/v1/namespaces/openshell/networkpolicies/openshell-sandbox-workloads", + kube_test_response( + http::StatusCode::OK, + serde_json::to_value(&fence.workload_policy).unwrap(), + ), + ) + }; + let supervisor = || { + ( + http::Method::GET, + "/apis/networking.k8s.io/v1/namespaces/openshell/networkpolicies/openshell-sandbox-supervisors", + kube_test_response( + http::StatusCode::OK, + serde_json::to_value(&fence.supervisor_policy).unwrap(), + ), + ) + }; + vec![ + workload(), + supervisor(), + workload(), + ( + http::Method::GET, + "/api/v1/namespaces/openshell/pods/os-supervisor-sandbox-1", + kube_test_response( + http::StatusCode::OK, + serde_json::json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "os-supervisor-sandbox-1", "uid": "new-supervisor-uid"}, + "spec": {"containers": []}, "status": {"phase": "Pending"} + }), + ), + ), + ( + http::Method::GET, + "/api/v1/namespaces/openshell/services/os-boundary-sandbox-1", + kube_test_response( + http::StatusCode::OK, + serde_json::json!({ + "apiVersion": "v1", "kind": "Service", "metadata": {"name": "os-boundary-sandbox-1"} + }), + ), + ), + workload(), + supervisor(), + ] + } + + #[tokio::test] + async fn stale_stop_snapshots_cannot_delete_a_restarted_supervisor() { + for version in [SANDBOX_VERSION_V1ALPHA1, SANDBOX_VERSION_V1BETA1] { + for phase in [ + None, + Some(SandboxRuntimeBootstrapPhase::Releasing), + Some(SandboxRuntimeBootstrapPhase::Suspending), + ] { + // Restart completed its critical section after LIST. The old + // implementation either deleted the replacement or attempted + // cleanup using the old stop transition. Every allowed request + // here is a read; unexpected DELETE/PATCH/Secret access fails. + let mut steps = vec![ + listed(version, snapshot(version, phase)), + refreshed(version, restarted_snapshot(version)), + ]; + steps.extend(preparing_dependencies()); + let (driver, steps, _) = read_only_driver(steps); + driver.sandbox_api_version.set(version).unwrap(); + driver.reconcile_sandbox_runtime_resources().await; + assert!(steps.lock().unwrap().is_empty()); + } + } + } + + #[tokio::test] + async fn reconciliation_skips_a_busy_restart_and_retries_after_release() { + let version = SANDBOX_VERSION_V1BETA1; + let mut steps = vec![ + listed(version, snapshot(version, None)), + listed(version, snapshot(version, None)), + refreshed(version, restarted_snapshot(version)), + ]; + steps.extend(preparing_dependencies()); + let (driver, steps, _) = read_only_driver(steps); + driver.sandbox_api_version.set(version).unwrap(); + let clone = driver.clone(); + let guard = clone + .lifecycle_gates + .gate_for("sandbox-1") + .lock_owned() + .await; + // Restart may already have created its Pod while the CR is still + // stopped. Reconciliation must skip even reading that companion. + driver.reconcile_sandbox_runtime_resources().await; + drop(guard); + driver.reconcile_sandbox_runtime_resources().await; + assert!(steps.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn refresh_cannot_adopt_a_replaced_sandbox_cr() { + let version = SANDBOX_VERSION_V1BETA1; + let mut replacement = restarted_snapshot(version); + replacement["metadata"]["uid"] = serde_json::json!("another-cr-uid"); + let (driver, steps, _) = read_only_driver(vec![ + listed(version, snapshot(version, None)), + refreshed(version, replacement), + ]); + driver.sandbox_api_version.set(version).unwrap(); + driver.reconcile_sandbox_runtime_resources().await; + assert!(steps.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn lifecycle_requests_share_gates_across_clones_without_blocking_other_sandboxes() { + let driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); + let clone = driver.clone(); + let guard = clone + .lifecycle_gates + .gate_for("sandbox-1") + .lock_owned() + .await; + let sandbox = Sandbox { + id: "sandbox-1".to_string(), + ..Default::default() + }; + let mut create = Box::pin(driver.create_sandbox(&sandbox)); + let mut stop = Box::pin(driver.stop_sandbox("sandbox-1")); + let mut delete = Box::pin(driver.delete_sandbox("sandbox-1")); + let mut start = Box::pin(driver.start_sandbox("sandbox-1", "", &[], "")); + assert!(futures::poll!(&mut create).is_pending()); + assert!(futures::poll!(&mut stop).is_pending()); + assert!(futures::poll!(&mut delete).is_pending()); + assert!(futures::poll!(&mut start).is_pending()); + driver + .start_sandbox("sandbox-2", "", &[], "") + .await + .unwrap_err(); + // Cancel queued mutations, then let start obtain the gate. Its normal + // generation validation proves release did not leave it deadlocked. + drop(create); + drop(stop); + drop(delete); + drop(guard); + assert!(matches!( + start.await, + Err(KubernetesDriverError::InvalidArgument(_)) + )); + } +} diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 46d5abc254..ab774e3a88 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -747,6 +747,12 @@ remain unchanged. A generation-bound session-token rejection usually means the supervisor is presenting credentials from a runtime that was replaced; inspect the persisted generation before retrying bootstrap. +The Kubernetes driver serializes lifecycle mutations and runtime reconciliation +per sandbox within one driver instance. A busy sandbox is checked again on the +next reconciliation pass. If restart still loses its supervisor, compare the +Sandbox and Pod UIDs and identify which gateway or external driver process +performed cleanup; the local mutation gate does not coordinate separate processes. + ```bash helm -n openshell get values openshell | grep -A3 sandboxServiceAccount kubectl -n get serviceaccount openshell-sandbox From 4ceb678090bb0839c5b8c72ea9b2607f61f9ba22 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux[bot]" <126015336+red-hat-konflux[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:59:57 -0400 Subject: [PATCH 07/18] chore(deps): refresh rpm lockfiles (#71) Signed-off-by: red-hat-konflux <126015336+red-hat-konflux[bot]@users.noreply.github.com> Co-authored-by: red-hat-konflux[bot] <126015336+red-hat-konflux[bot]@users.noreply.github.com> --- deploy/konflux/e2e-odh/rpms.lock.yaml | 122 +++++++++++++------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/deploy/konflux/e2e-odh/rpms.lock.yaml b/deploy/konflux/e2e-odh/rpms.lock.yaml index f604e6202d..cae27e5345 100644 --- a/deploy/konflux/e2e-odh/rpms.lock.yaml +++ b/deploy/konflux/e2e-odh/rpms.lock.yaml @@ -1,5 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 --- lockfileVersion: 1 lockfileVendor: redhat @@ -69,13 +67,13 @@ arches: name: glibc-devel evr: 2.34-275.el9_8 sourcerpm: glibc-2.34-275.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/k/kernel-headers-5.14.0-687.49.1.el9_8.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/k/kernel-headers-5.14.0-687.53.1.el9_8.aarch64.rpm repoid: ubi-9-appstream-rpms - size: 2899913 - checksum: sha256:929179b11dddaa905f5261d8df9977ce70f311fb482d01ef13ff2da6d8c1e825 + size: 2925601 + checksum: sha256:7e82c856a00206976aede9e7b3865d0a2363b1262c0f9e1949ba29c7b9c47281 name: kernel-headers - evr: 5.14.0-687.49.1.el9_8 - sourcerpm: kernel-5.14.0-687.49.1.el9_8.src.rpm + evr: 5.14.0-687.53.1.el9_8 + sourcerpm: kernel-5.14.0-687.53.1.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/appstream/os/Packages/l/libasan-11.5.0-14.el9.aarch64.rpm repoid: ubi-9-appstream-rpms size: 409047 @@ -314,13 +312,13 @@ arches: name: elfutils-libs evr: 0.194-1.el9 sourcerpm: elfutils-0.194-1.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.3.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.5.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 122400 - checksum: sha256:df004398da989f75bae9267cfb0ad402ada6e6db46ebef0932c869894c013c08 + size: 122957 + checksum: sha256:d13bd77f429835b303d388e24811fb935060be4788c8fe4cf87703640f2337e0 name: expat - evr: 2.5.0-6.el9_8.3 - sourcerpm: expat-2.5.0-6.el9_8.3.src.rpm + evr: 2.5.0-6.el9_8.5 + sourcerpm: expat-2.5.0-6.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/f/filesystem-3.16-5.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 5003914 @@ -335,13 +333,13 @@ arches: name: findutils evr: 1:4.8.0-7.el9 sourcerpm: findutils-4.8.0-7.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gawk-5.1.0-6.el9.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gawk-5.1.0-6.el9_8.1.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 1024204 - checksum: sha256:a4b7202ac90653a7d3e072c2444bde6a9270d6a818eb6f2ffcfcaa50774f1fad + size: 1026105 + checksum: sha256:54365d2a6150079c2dc5003eeb94ada32fc15801b5db05422361f02ed58e6772 name: gawk - evr: 5.1.0-6.el9 - sourcerpm: gawk-5.1.0-6.el9.src.rpm + evr: 5.1.0-6.el9_8.1 + sourcerpm: gawk-5.1.0-6.el9_8.1.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/g/gdbm-libs-1.23-1.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 60311 @@ -720,13 +718,13 @@ arches: name: libxcrypt evr: 4.4.18-3.el9 sourcerpm: libxcrypt-4.4.18-3.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.4.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.5.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 754646 - checksum: sha256:10417dde519f111c6248fae0eb6fb9889efd3e68c575caced652823d7ef4f169 + size: 754850 + checksum: sha256:39e0ffab5e42aa3688a39a9f27cecb77ee8dbf03682ca5b38c3e15ebc5493863 name: libxml2 - evr: 2.9.13-14.el9_8.4 - sourcerpm: libxml2-2.9.13-14.el9_8.4.src.rpm + evr: 2.9.13-14.el9_8.5 + sourcerpm: libxml2-2.9.13-14.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/l/libzstd-1.5.5-1.el9.aarch64.rpm repoid: ubi-9-baseos-rpms size: 283159 @@ -776,20 +774,20 @@ arches: name: openldap evr: 2.6.8-4.el9 sourcerpm: openldap-2.6.8-4.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-9.9p1-11.el9_8.aarch64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-9.9p1-12.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 432812 - checksum: sha256:d8bcf6348f5ee9d840e7f74eaaa53801cb9c48c1184c7254dd06aa73f597c690 + size: 432970 + checksum: sha256:e2cc40546db9b067d9d969187aa1fea68ce399ee5f53e44ed91df6f2fc23f49c name: openssh - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-clients-9.9p1-11.el9_8.aarch64.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssh-clients-9.9p1-12.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms - size: 770368 - checksum: sha256:b5f49e9e5d66075859596aa71f62bc8cc951d50129dd43543b6aacd22386b1c1 + size: 770215 + checksum: sha256:8601b26e577d6f8f0726061d96d941a4d8c2d5f0700a2bea5a957bbc17da6c22 name: openssh-clients - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/aarch64/baseos/os/Packages/o/openssl-3.5.8-1.el9_8.aarch64.rpm repoid: ubi-9-baseos-rpms size: 1546056 @@ -1095,13 +1093,13 @@ arches: name: glibc-headers evr: 2.34-275.el9_8 sourcerpm: glibc-2.34-275.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/k/kernel-headers-5.14.0-687.49.1.el9_8.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/k/kernel-headers-5.14.0-687.53.1.el9_8.x86_64.rpm repoid: ubi-9-appstream-rpms - size: 2939221 - checksum: sha256:ac9fe2b15be1ea2445c28abcaa21b62a118e56ef5984aea1c9d204885786f99d + size: 2965145 + checksum: sha256:2473df2cf5b65c762af7941fe87324e98dc0e8b42d1e5745051a0405e200b7f5 name: kernel-headers - evr: 5.14.0-687.49.1.el9_8 - sourcerpm: kernel-5.14.0-687.49.1.el9_8.src.rpm + evr: 5.14.0-687.53.1.el9_8 + sourcerpm: kernel-5.14.0-687.53.1.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/appstream/os/Packages/l/libmpc-1.2.1-4.el9.x86_64.rpm repoid: ubi-9-appstream-rpms size: 66075 @@ -1326,13 +1324,13 @@ arches: name: elfutils-libs evr: 0.194-1.el9 sourcerpm: elfutils-0.194-1.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.3.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/e/expat-2.5.0-6.el9_8.5.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 128577 - checksum: sha256:3c9c96529f94f84fc19c8039d8852269e1085d16b5af2933f44ff286dec66a35 + size: 129088 + checksum: sha256:e6e7edd632fdd1dc4ad94b19b7ab88646f3ef8c1c7956bac58985c3e7118a362 name: expat - evr: 2.5.0-6.el9_8.3 - sourcerpm: expat-2.5.0-6.el9_8.3.src.rpm + evr: 2.5.0-6.el9_8.5 + sourcerpm: expat-2.5.0-6.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/f/filesystem-3.16-5.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 5003807 @@ -1347,13 +1345,13 @@ arches: name: findutils evr: 1:4.8.0-7.el9 sourcerpm: findutils-4.8.0-7.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gawk-5.1.0-6.el9.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gawk-5.1.0-6.el9_8.1.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 1045534 - checksum: sha256:99fda6725a2c668bae29fbab74d1b347e074f4e8c8ed18d656cb928fb6fc92b7 + size: 1046649 + checksum: sha256:fcc5e724c32597cf781626728f0faff2f0e5ed6455ab95af4883eefca30a074e name: gawk - evr: 5.1.0-6.el9 - sourcerpm: gawk-5.1.0-6.el9.src.rpm + evr: 5.1.0-6.el9_8.1 + sourcerpm: gawk-5.1.0-6.el9_8.1.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/g/gdbm-libs-1.23-1.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 60152 @@ -1725,13 +1723,13 @@ arches: name: libxcrypt evr: 4.4.18-3.el9 sourcerpm: libxcrypt-4.4.18-3.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.4.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libxml2-2.9.13-14.el9_8.5.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 772646 - checksum: sha256:3b2b7f705584d2aa9dc1a110ef1b00dbefb3305285877a24a24605fcb9567eb0 + size: 773693 + checksum: sha256:d55744c4fe83a63a71906ca41607c5c0deff227a69b957708cc6a37537be4a29 name: libxml2 - evr: 2.9.13-14.el9_8.4 - sourcerpm: libxml2-2.9.13-14.el9_8.4.src.rpm + evr: 2.9.13-14.el9_8.5 + sourcerpm: libxml2-2.9.13-14.el9_8.5.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/l/libzstd-1.5.5-1.el9.x86_64.rpm repoid: ubi-9-baseos-rpms size: 304135 @@ -1781,20 +1779,20 @@ arches: name: openldap evr: 2.6.8-4.el9 sourcerpm: openldap-2.6.8-4.el9.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-9.9p1-11.el9_8.x86_64.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-9.9p1-12.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 443050 - checksum: sha256:9fad2dc75044e577f03442e524b58223239eba14b12adbf8d14eeb82d22b596e + size: 443141 + checksum: sha256:12db3094d78ed82bd7edc6a7cdd2cbf5198da695e293b47b5d00c31ac142aeb7 name: openssh - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm - - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-clients-9.9p1-11.el9_8.x86_64.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm + - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssh-clients-9.9p1-12.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms - size: 799504 - checksum: sha256:445f8ef1a60229d575547695da0bf2a05fb51408bce01d06548a5273123c8877 + size: 800429 + checksum: sha256:a4307b914ec45f69fea0e17b593ae61597db3e2796ce01a8809c55173f30c121 name: openssh-clients - evr: 9.9p1-11.el9_8 - sourcerpm: openssh-9.9p1-11.el9_8.src.rpm + evr: 9.9p1-12.el9_8 + sourcerpm: openssh-9.9p1-12.el9_8.src.rpm - url: https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/9/x86_64/baseos/os/Packages/o/openssl-3.5.8-1.el9_8.x86_64.rpm repoid: ubi-9-baseos-rpms size: 1569041 From 513e3d9e64e69289bbe67e8a62a041f5856eb211 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux[bot]" <126015336+red-hat-konflux[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:00:50 -0400 Subject: [PATCH 08/18] chore(deps): update registry.access.redhat.com/ubi9/nodejs-24-minimal docker tag to v9.8-1790838157 (#70) Signed-off-by: red-hat-konflux <126015336+red-hat-konflux[bot]@users.noreply.github.com> Co-authored-by: red-hat-konflux[bot] <126015336+red-hat-konflux[bot]@users.noreply.github.com> --- deploy/docker/Dockerfile.konflux.openclaw | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile.konflux.openclaw b/deploy/docker/Dockerfile.konflux.openclaw index 72d1007c41..591a60adfb 100644 --- a/deploy/docker/Dockerfile.konflux.openclaw +++ b/deploy/docker/Dockerfile.konflux.openclaw @@ -25,7 +25,7 @@ # `&& \` continuations only. # Both stages use the same base and OpenClaw version; bump them only here. -ARG NODEJS_IMAGE=registry.access.redhat.com/ubi9/nodejs-24-minimal:9.8-1790647840@sha256:9785f7415bff723e0dfcb1b928d81a06bf004b6a1e8a941bd08983e61b577b46 +ARG NODEJS_IMAGE=registry.access.redhat.com/ubi9/nodejs-24-minimal:9.8-1790838157@sha256:d61174601fc7035a21a43b74e3af25cfe97f1c37de21ea11badee2de6ffeb926 ARG OPENCLAW_VERSION=2026.9.5 # --------------------------------------------------------------------------- From 769e15ac3ec9f68a1b054b074a0588cfdeb8025e Mon Sep 17 00:00:00 2001 From: Emilien Macchi Date: Fri, 2 Oct 2026 09:15:24 -0400 Subject: [PATCH 09/18] CARRY: fix(konflux): build gateway with vendored-z3 feature Upstream renamed the bundled-z3 Cargo feature to vendored-z3 in NVIDIA/OpenShell#3275, and the last upstream sync brought that into main. Dockerfile.konflux.gateway still requested bundled-z3, so cargo rejected the feature set and every gateway build failed. The new feature maps to the same z3-sys vendored build, so the resulting binary is unchanged. Signed-off-by: Emilien Macchi --- deploy/docker/Dockerfile.konflux.gateway | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile.konflux.gateway b/deploy/docker/Dockerfile.konflux.gateway index bd8229e895..d462f16af8 100644 --- a/deploy/docker/Dockerfile.konflux.gateway +++ b/deploy/docker/Dockerfile.konflux.gateway @@ -74,7 +74,7 @@ RUN mkdir -p /tmp/z3-src \ cargo auditable build --release \ --package openshell-gateway \ --no-default-features \ - --features defaults-without-telemetry,bundled-z3 && \ + --features defaults-without-telemetry,vendored-z3 && \ if ! readelf -S /build/target/release/openshell-gateway | grep -q '\.dep-v0'; then \ echo "openshell-gateway is missing cargo-auditable .dep-v0 metadata" >&2; exit 1; \ fi From 88afd36de57be98be76ab7dd08569483e4793b33 Mon Sep 17 00:00:00 2001 From: alangou Date: Fri, 2 Oct 2026 14:12:25 +0000 Subject: [PATCH 10/18] fix(deps): upgrade russh to address Dependabot alert 40 (#4116) Signed-off-by: Adrien Langou --- Cargo.lock | 37 +++++++++---------- crates/openshell-server/Cargo.toml | 2 +- crates/openshell-server/src/grpc/sandbox.rs | 2 +- .../openshell-supervisor-process/Cargo.toml | 2 +- .../openshell-supervisor-process/src/ssh.rs | 2 +- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6173fee09..fc0b173702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,7 +1561,7 @@ dependencies = [ "asn1-rs", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "rusticata-macros", ] @@ -2919,18 +2919,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "internal-russh-num-bigint" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" -dependencies = [ - "num-integer", - "num-traits", - "rand 0.10.2", - "rand_core 0.10.1", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -3773,6 +3761,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -5964,7 +5964,7 @@ dependencies = [ "globset", "lazy_static", "msvc_spectre_libs", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "rand 0.9.4", "serde", @@ -6143,9 +6143,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.5" +version = "0.63.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" +checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce" dependencies = [ "aes", "aws-lc-rs", @@ -6174,13 +6174,12 @@ dependencies = [ "hex-literal", "hmac 0.13.0", "inout", - "internal-russh-num-bigint", "keccak", "log", "md5", "ml-kem", "module-lattice", - "num-bigint", + "num-bigint 0.5.1", "p256 0.14.0", "p384 0.14.0", "p521", @@ -6875,7 +6874,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "thiserror 2.0.20", "time", diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 1c206b70ec..74ff40d1ee 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -104,7 +104,7 @@ async-trait = "0.1" url = { workspace = true } glob = { workspace = true } hex = "0.4" -russh = "0.62" +russh = "0.63.1" rand = { workspace = true } petname = "2" ipnet = "2" diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 074b2590e0..594593a698 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3745,7 +3745,7 @@ impl russh::client::Handler for SandboxSshClientHandler { async fn check_server_key( &mut self, - _server_public_key: &russh::keys::PublicKey, + _server_public_key: &russh::keys::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 39d48a96e5..a7cd29ba8b 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -23,7 +23,7 @@ hex = "0.4" miette = { workspace = true } nix = { workspace = true } rand = "0.10" -russh = "0.62" +russh = "0.63.1" serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 9bacf55ddf..928571fd50 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1324,7 +1324,7 @@ mod tests { async fn check_server_key( &mut self, - _server_public_key: &russh::keys::PublicKey, + _server_public_key: &russh::keys::PublicKeyOrCertificate, ) -> Result { Ok(true) } From 36819f476d14e59f29fa6e50ef6c829976ac41d0 Mon Sep 17 00:00:00 2001 From: alangou Date: Fri, 2 Oct 2026 14:24:32 +0000 Subject: [PATCH 11/18] fix(cli): stop uploads when Git filtering fails or selects no files (#3957) Signed-off-by: Adrien Langou --- crates/openshell-cli/src/main.rs | 11 +- crates/openshell-cli/src/run.rs | 254 ++++++++++++++---- .../sandbox_create_lifecycle_integration.rs | 92 +++++++ .../tests/sandbox_upload_integration.rs | 218 ++++++++++++++- .../src/scenarios/file_transfer.rs | 72 +++-- docs/how-it-works/sandboxes/overview.mdx | 22 +- docs/upgrade/0-1-0.mdx | 2 + skills/openshell-cli/SKILL.md | 4 +- 8 files changed, 591 insertions(+), 84 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ef40f79936..14984388dd 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1459,8 +1459,10 @@ enum SandboxCommands { /// Format: `[:]`. /// When `SANDBOX_PATH` is omitted, files are uploaded to the container's /// working directory. - /// `.gitignore` rules are applied by default; use `--no-git-ignore` to - /// upload everything. + /// Inside a Git work tree, `.gitignore` rules are applied by default. + /// Outside a Git work tree, uploads proceed unfiltered with a warning. + /// Filtering errors or empty selections stop the upload; use + /// `--no-git-ignore` to intentionally upload everything. #[arg( long, value_hint = ValueHint::AnyPath, @@ -1764,6 +1766,11 @@ enum SandboxCommands { }, /// Upload local files to a sandbox. + /// + /// Inside a Git work tree, `.gitignore` rules are applied by default. + /// Outside a Git work tree, uploads proceed unfiltered with a warning. + /// Filtering errors or empty selections stop the upload; use + /// `--no-git-ignore` to intentionally upload everything. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Upload { /// Sandbox name. diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 3aa775685f..028b10dfdd 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -107,7 +107,6 @@ enum SandboxUploadPlan { files: Vec, }, Regular, - GitFilteredEmpty, } enum ProgressOutput { @@ -1059,7 +1058,12 @@ pub async fn sandbox_create( ); } let local = Path::new(local_path); - match sandbox_upload_plan(local, *git_ignore)? { + let upload_plan = sandbox_upload_plan(local, *git_ignore).wrap_err_with(|| { + format!( + "Sandbox '{sandbox_name}' was created and still exists.\nRetry the upload with 'openshell sandbox upload', or remove the sandbox with 'openshell sandbox delete'", + ) + })?; + match upload_plan { SandboxUploadPlan::GitAware { base_dir, files } => { sandbox_sync_up_files( &effective_server, @@ -1073,22 +1077,6 @@ pub async fn sandbox_create( ) .await?; } - SandboxUploadPlan::GitFilteredEmpty => { - eprintln!( - " {} .gitignore filtering excluded all files in {}; uploading unfiltered", - "⚠".yellow().bold(), - local.display(), - ); - sandbox_sync_up( - &effective_server, - &sandbox_name, - local, - dest, - &effective_tls, - workspace, - ) - .await?; - } SandboxUploadPlan::Regular => { sandbox_sync_up( &effective_server, @@ -4738,6 +4726,13 @@ fn workspace_to_json(workspace: &openshell_core::proto::Workspace) -> serde_json } pub fn git_repo_root(local_path: &Path) -> Result { + discover_git_repo_root(local_path)? + .ok_or_else(|| miette::miette!("path is outside a Git work tree: {}", local_path.display())) +} + +/// Only return `None` when Git reports no repository and no ancestor has a +/// `.git` entry. A corrupt repository can produce the same Git diagnostic. +fn discover_git_repo_root(local_path: &Path) -> Result> { let git_dir = if local_path.is_dir() { local_path } else { @@ -4748,6 +4743,9 @@ pub fn git_repo_root(local_path: &Path) -> Result { let mut command = Command::new("git"); scrub_git_env(&mut command); let output = command + .env("LC_ALL", "C") + .env_remove("GIT_CEILING_DIRECTORIES") + .env("GIT_DISCOVERY_ACROSS_FILESYSTEM", "1") .args(["rev-parse", "--show-toplevel"]) .current_dir(git_dir) .output() @@ -4755,6 +4753,32 @@ pub fn git_repo_root(local_path: &Path) -> Result { .wrap_err("failed to run git rev-parse")?; if !output.status.success() { + if output.status.code() == Some(128) + && String::from_utf8_lossy(&output.stderr).trim_end() + == "fatal: not a git repository (or any of the parent directories): .git" + { + for ancestor in git_dir.ancestors() { + let marker = ancestor.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(_) => { + return Err(miette::miette!( + "Git repository discovery failed despite an existing .git entry: {}", + marker.display() + )); + } + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => { + return Err(err).into_diagnostic().wrap_err_with(|| { + format!( + "failed to inspect Git repository marker: {}", + marker.display() + ) + }); + } + } + } + return Ok(None); + } return Err(miette::miette!( "git rev-parse --show-toplevel failed with status {}", output.status @@ -4768,24 +4792,21 @@ pub fn git_repo_root(local_path: &Path) -> Result { )); } - Ok(PathBuf::from(root)) + Ok(Some(PathBuf::from(root))) } pub fn git_sync_files(local_path: &Path) -> Result<(PathBuf, Vec)> { - let repo_root = std::fs::canonicalize(git_repo_root(local_path)?) - .into_diagnostic() - .wrap_err("failed to canonicalize git repository root")?; - let local_path = if local_path.is_absolute() { - local_path.to_path_buf() - } else { - std::env::current_dir() - .into_diagnostic() - .wrap_err("failed to resolve current directory")? - .join(local_path) - }; let local_path = std::fs::canonicalize(local_path) .into_diagnostic() .wrap_err("failed to canonicalize local upload path")?; + let repo_root = git_repo_root(&local_path)?; + git_sync_files_in_repo(&local_path, &repo_root) +} + +fn git_sync_files_in_repo(local_path: &Path, repo_root: &Path) -> Result<(PathBuf, Vec)> { + let repo_root = std::fs::canonicalize(repo_root) + .into_diagnostic() + .wrap_err("failed to canonicalize git repository root")?; let relative_path = local_path .strip_prefix(&repo_root) .into_diagnostic() @@ -4804,7 +4825,7 @@ pub fn git_sync_files(local_path: &Path) -> Result<(PathBuf, Vec)> { .map(Path::to_path_buf) .ok_or_else(|| miette::miette!("path has no parent: {}", local_path.display()))? } else { - local_path.clone() + local_path.to_path_buf() }; let pathspec = if relative_path.as_os_str().is_empty() { None @@ -4871,17 +4892,40 @@ fn sandbox_upload_plan(local_path: &Path, git_ignore: bool) -> Result Result { + let canonical_path = std::fs::canonicalize(local_path) + .into_diagnostic() + .wrap_err("failed to canonicalize local upload path")?; + let Some(repo_root) = discover_git_repo_root(&canonical_path)? else { + eprintln!( + "Warning: {} is outside a Git work tree; uploading without Git filtering (.gitignore rules are not applied).", + local_path.display() + ); + return Ok(SandboxUploadPlan::Regular); + }; + let (base_dir, files) = git_sync_files_in_repo(&canonical_path, &repo_root)?; + Ok(SandboxUploadPlan::GitAware { base_dir, files }) } /// Upload a local path to a sandbox. @@ -4919,14 +4963,6 @@ pub async fn sandbox_upload( ) .await?; } - SandboxUploadPlan::GitFilteredEmpty => { - eprintln!( - "{} .gitignore filtering excluded all files in {}; uploading unfiltered", - "⚠".yellow().bold(), - local_path.display(), - ); - sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?; - } SandboxUploadPlan::Regular => { sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?; } @@ -7777,7 +7813,7 @@ mod tests { } #[test] - fn sandbox_upload_plan_falls_back_when_all_files_gitignored() { + fn sandbox_upload_plan_rejects_empty_filtered_selections() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); let repo = tmpdir.path().join("repo"); fs::create_dir_all(repo.join("runs")).expect("create repo"); @@ -7785,14 +7821,126 @@ mod tests { fs::write(repo.join(".gitignore"), "runs/\n").expect("write .gitignore"); fs::write(repo.join("runs/test.json"), r#"{"key":"value"}"#).expect("write test.json"); - let plan = - sandbox_upload_plan(&repo.join("runs"), true).expect("upload plan should succeed"); + fs::create_dir(repo.join("empty")).expect("create empty directory"); + + for path in [ + repo.join("runs"), + repo.join("runs/test.json"), + repo.join("empty"), + ] { + let err = + sandbox_upload_plan(&path, true).expect_err("empty selection must stop upload"); + let message = err.to_string(); + assert!(message.contains("filtering selected no files"), "{message}"); + assert!( + message.contains("Git returned 0 uploadable paths"), + "{message}" + ); + assert!(message.contains("--no-git-ignore"), "{message}"); + assert_eq!( + sandbox_upload_plan(&path, false).expect("explicit unfiltered upload"), + super::SandboxUploadPlan::Regular, + ); + } + } + + #[test] + fn sandbox_upload_plan_allows_paths_outside_git_repository() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + fs::write(tmpdir.path().join("file.txt"), "hello").expect("write file"); + + for path in [tmpdir.path().to_path_buf(), tmpdir.path().join("file.txt")] { + assert_eq!( + sandbox_upload_plan(&path, true).expect("upload outside a repository"), + super::SandboxUploadPlan::Regular, + ); + } + } + + #[test] + fn sandbox_upload_plan_selects_only_unignored_files() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let repo = tmpdir.path(); + init_git_repo(repo); + fs::write(repo.join(".gitignore"), "*.log\n").expect("write .gitignore"); + fs::create_dir(repo.join("files")).expect("create files directory"); + fs::write(repo.join("files/keep.txt"), "keep").expect("write included file"); + fs::write(repo.join("files/skip.log"), "skip").expect("write ignored file"); assert_eq!( - plan, - super::SandboxUploadPlan::GitFilteredEmpty, - "gitignored directory should fall back with GitFilteredEmpty" + sandbox_upload_plan(&repo.join("files"), true).expect("filtered upload"), + super::SandboxUploadPlan::GitAware { + base_dir: fs::canonicalize(repo.join("files")).expect("canonical path"), + files: vec!["keep.txt".to_string()], + }, + ); + } + + #[test] + fn sandbox_upload_plan_filters_linked_worktrees_and_separate_git_dirs() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let repo = tmpdir.path().join("repo"); + fs::create_dir(&repo).expect("create repo"); + init_git_repo(&repo); + let mut commit = Command::new("git"); + super::scrub_git_env(&mut commit); + assert!( + commit + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]) + .current_dir(&repo) + .status() + .expect("initial commit") + .success() + ); + + let worktree = tmpdir.path().join("worktree"); + let mut add = Command::new("git"); + super::scrub_git_env(&mut add); + assert!( + add.args(["worktree", "add", "--detach"]) + .arg(&worktree) + .current_dir(&repo) + .status() + .expect("add worktree") + .success() ); + + let separate = tmpdir.path().join("separate"); + let mut init = Command::new("git"); + super::scrub_git_env(&mut init); + assert!( + init.args(["init", "--separate-git-dir"]) + .arg(tmpdir.path().join("metadata")) + .arg(&separate) + .status() + .expect("initialize separate git directory") + .success() + ); + + for source in [worktree, separate] { + assert!(source.join(".git").is_file()); + fs::write(source.join(".gitignore"), ".env\n").expect("write ignore rule"); + fs::write(source.join(".env"), "dummy").expect("write ignored file"); + fs::write(source.join("keep.txt"), "keep").expect("write included file"); + let super::SandboxUploadPlan::GitAware { mut files, .. } = + sandbox_upload_plan(&source, true).expect("filter gitfile worktree") + else { + panic!("a gitfile worktree must be filtered"); + }; + files.sort(); + assert_eq!(files, vec![".gitignore", "keep.txt"]); + } } #[test] diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 964559a8ad..e21f747112 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -3095,6 +3095,98 @@ async fn run_cli_sandbox_create( run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } +#[tokio::test] +async fn sandbox_create_upload_stops_before_ssh_when_git_filtering_fails_or_is_empty() { + let server = run_server().await; + let source = tempfile::tempdir().unwrap(); + fs::create_dir(source.path().join("runs")).unwrap(); + fs::write(source.path().join("runs/marker.txt"), "dummy content").unwrap(); + fs::write(source.path().join(".gitignore"), "runs/\n").unwrap(); + + // A broken repository must not be mistaken for a non-repository source. + fs::create_dir(source.path().join(".git")).unwrap(); + let path = source.path().join("runs"); + let args = ["--detach", "--upload", path.to_str().unwrap()]; + let result = run_cli_sandbox_create(&server, "upload-no-repository", &args).await; + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(!result.status.success(), "{stderr}"); + assert!(stderr.contains("Git filtering failed"), "{stderr}"); + assert!(stderr.contains("--no-git-ignore"), "{stderr}"); + assert!( + stderr.contains("Sandbox 'upload-no-repository' was created and still exists"), + "{stderr}", + ); + assert!(stderr.contains("openshell sandbox upload"), "{stderr}"); + assert!(stderr.contains("openshell sandbox delete"), "{stderr}"); + + fs::remove_dir(source.path().join(".git")).unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(source.path()) + .status() + .unwrap() + .success() + ); + let result = run_cli_sandbox_create(&server, "upload-empty-selection", &args).await; + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(!result.status.success(), "{stderr}"); + assert!(stderr.contains("filtering selected no files"), "{stderr}"); + assert!( + stderr.contains("Git returned 0 uploadable paths"), + "{stderr}" + ); + assert!(stderr.contains("--no-git-ignore"), "{stderr}"); + assert!( + stderr.contains("Sandbox 'upload-empty-selection' was created and still exists"), + "{stderr}", + ); + // Upload rejection intentionally leaves the provisioned sandbox available + // for an explicit retry; it does not roll back sandbox creation. + assert_eq!(create_requests(&server).await.len(), 2); + assert_eq!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst), + 0, + "a rejected creation-time upload must not open an SSH session", + ); +} + +#[tokio::test] +async fn sandbox_create_upload_warns_and_reaches_ssh_outside_git_repository() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); + let source = tempfile::tempdir().unwrap(); + fs::write(source.path().join("marker.txt"), "dummy content").unwrap(); + let args = ["--detach", "--upload", source.path().to_str().unwrap()]; + let result = run_cli_sandbox_create(&server, "upload-non-repository", &args).await; + let stderr = String::from_utf8_lossy(&result.stderr); + // The fake SSH transport fails; preflight must still let it try. + assert!(!result.status.success(), "{stderr}"); + assert!(stderr.contains("outside a Git work tree"), "{stderr}"); + assert!( + stderr.contains(".gitignore rules are not applied"), + "{stderr}" + ); + assert!(!stderr.contains("Git filtering failed"), "{stderr}"); + assert_eq!(create_requests(&server).await.len(), 1); + assert!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst) + > 0, + "an upload outside a repository must reach the SSH transport", + ); +} + async fn run_cli_sandbox_template_create( server: &TestServer, name: &str, diff --git a/crates/openshell-cli/tests/sandbox_upload_integration.rs b/crates/openshell-cli/tests/sandbox_upload_integration.rs index 16c9ec3364..67c2ce6c53 100644 --- a/crates/openshell-cli/tests/sandbox_upload_integration.rs +++ b/crates/openshell-cli/tests/sandbox_upload_integration.rs @@ -14,6 +14,7 @@ fn run_upload( config_dir: &Path, path: Option<&OsStr>, git_marker: Option<&Path>, + no_git_ignore: bool, ) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); command @@ -28,9 +29,14 @@ fn run_upload( ]) .arg(local_path) .arg("/sandbox/uploaded") + .current_dir(config_dir) .env("XDG_CONFIG_HOME", config_dir) .env("NO_COLOR", "1"); + if no_git_ignore { + command.arg("--no-git-ignore"); + } + if let Some(path) = path { command.env("PATH", path); } @@ -41,13 +47,38 @@ fn run_upload( command.output().expect("run openshell sandbox upload") } +#[test] +fn sandbox_upload_command_filters_bare_relative_filenames() { + let repo = tempfile::tempdir().expect("create repository"); + assert!( + Command::new("git") + .args(["init", "-q"]) + .current_dir(repo.path()) + .status() + .expect("initialize repository") + .success() + ); + fs::write(repo.path().join("keep.txt"), "keep").expect("write included file"); + fs::write(repo.path().join("skip.log"), "skip").expect("write ignored file"); + fs::write(repo.path().join(".gitignore"), "*.log\n").expect("write .gitignore"); + + for path in ["keep.txt", "./keep.txt"] { + let output = run_upload(Path::new(path), repo.path(), None, None, false); + assert_reached_transport(&output); + } + for path in ["skip.log", "./skip.log"] { + let output = run_upload(Path::new(path), repo.path(), None, None, false); + assert_filtering_stopped(&output, "filtering selected no files"); + } +} + #[test] fn sandbox_upload_command_accepts_dangling_symlink_preflight() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); let link = tmpdir.path().join("dangling-link"); symlink("missing-target", &link).expect("create dangling symlink"); - let output = run_upload(&link, tmpdir.path(), None, None); + let output = run_upload(&link, tmpdir.path(), None, None, false); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!output.status.success(), "the test gateway is unreachable"); @@ -100,7 +131,7 @@ fn sandbox_upload_command_skips_git_filtering_for_symlink_source() { } let path = std::env::join_paths(path_entries).expect("build test PATH"); - let output = run_upload(&link, tmpdir.path(), Some(&path), Some(&marker)); + let output = run_upload(&link, tmpdir.path(), Some(&path), Some(&marker), false); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!output.status.success(), "the test gateway is unreachable"); @@ -113,3 +144,186 @@ fn sandbox_upload_command_skips_git_filtering_for_symlink_source() { "standalone sandbox upload invoked Git-aware filtering for a symlink source" ); } + +#[test] +fn sandbox_upload_command_stops_when_git_is_unavailable() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let source = tmpdir.path().join("source"); + fs::create_dir(&source).expect("create source"); + fs::write(source.join("file.txt"), "hello").expect("write file"); + let empty_bin = tmpdir.path().join("bin"); + fs::create_dir(&empty_bin).expect("create empty PATH directory"); + + let output = run_upload( + &source, + tmpdir.path(), + Some(empty_bin.as_os_str()), + None, + false, + ); + assert_filtering_stopped(&output, "failed to run git rev-parse"); + + let output = run_upload( + &source, + tmpdir.path(), + Some(empty_bin.as_os_str()), + None, + true, + ); + assert_reached_transport(&output); +} + +#[test] +fn sandbox_upload_command_warns_outside_git_repository() { + let source = tempfile::tempdir().expect("create source"); + fs::create_dir(source.path().join("nested")).expect("create nested directory"); + fs::write(source.path().join(".gitignore"), ".env\n").expect("write ignore rules"); + fs::write(source.path().join("nested/.env"), "dummy").expect("write ignored name"); + + for path in [".", "nested", "nested/.env"] { + let output = run_upload(Path::new(path), source.path(), None, None, false); + assert_reached_transport(&output); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("outside a Git work tree"), "{stderr}"); + assert!( + stderr.contains(".gitignore rules are not applied"), + "{stderr}" + ); + } + let output = run_upload(source.path(), source.path(), None, None, true); + assert_reached_transport(&output); + assert!(!String::from_utf8_lossy(&output.stderr).contains("Warning:")); +} + +#[test] +fn sandbox_upload_command_rejects_broken_repository_markers_in_ancestors() { + let source = tempfile::tempdir().expect("create source"); + let nested = source.path().join("nested"); + fs::create_dir(&nested).expect("create nested directory"); + fs::write(nested.join(".env"), "dummy").expect("write file"); + let marker = source.path().join(".git"); + + // Git reports the same "not a git repository" error for an empty .git + // directory and a source that has never been a repository. + fs::create_dir(&marker).expect("create corrupt repository"); + let output = run_upload(&nested, source.path(), None, None, false); + assert_filtering_stopped(&output, "existing .git entry"); + fs::remove_dir(&marker).expect("remove corrupt repository"); + + for contents in [ + "invalid gitfile\n", + "gitdir: /nonexistent/openshell-test-gitdir\n", + ] { + fs::write(&marker, contents).expect("write invalid gitfile"); + let output = run_upload(&nested, source.path(), None, None, false); + assert_filtering_stopped(&output, "Git filtering failed"); + } + fs::remove_file(&marker).expect("remove gitfile"); + symlink("missing-git-dir", &marker).expect("create dangling repository marker"); + let output = run_upload(&nested, source.path(), None, None, false); + assert_filtering_stopped(&output, "existing .git entry"); + + let output = run_upload(&nested, source.path(), None, None, true); + assert_reached_transport(&output); +} + +#[test] +fn sandbox_upload_command_rejects_unreadable_repository() { + let source = tempfile::tempdir().expect("create source"); + let marker = source.path().join(".git"); + fs::create_dir(&marker).expect("create repository marker"); + fs::set_permissions(&marker, fs::Permissions::from_mode(0o000)).expect("deny access"); + let output = run_upload(source.path(), source.path(), None, None, false); + fs::set_permissions(&marker, fs::Permissions::from_mode(0o700)).expect("restore access"); + assert_filtering_stopped(&output, "Git filtering failed"); +} + +#[test] +fn sandbox_upload_command_rejects_bare_repository() { + let source = tempfile::tempdir().expect("create source"); + assert!( + Command::new("git") + .args(["init", "--bare", "-q"]) + .current_dir(source.path()) + .status() + .expect("create bare repository") + .success() + ); + let output = run_upload(source.path(), source.path(), None, None, false); + assert_filtering_stopped(&output, "Git filtering failed"); +} + +#[test] +fn sandbox_upload_command_stops_on_git_failures_and_empty_selection() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let source = tmpdir.path().join("source"); + fs::create_dir(&source).expect("create source"); + fs::write(source.join("file.txt"), "hello").expect("write file"); + let bin = tmpdir.path().join("bin"); + fs::create_dir(&bin).expect("create fake PATH directory"); + let fake_git = bin.join("git"); + let marker = tmpdir.path().join("git-invoked"); + + for (script, expected_error) in [ + ("exit 17\n", "git rev-parse --show-toplevel failed"), + ("exit 128\n", "git rev-parse --show-toplevel failed"), + ( + "echo 'fatal: detected dubious ownership in repository' >&2; exit 128\n", + "git rev-parse --show-toplevel failed", + ), + ( + "if [ \"$1\" = rev-parse ]; then pwd; else exit 18; fi\n", + "git ls-files failed", + ), + ( + "if [ \"$1\" = rev-parse ]; then pwd; fi\n", + "filtering selected no files", + ), + ] { + fs::write( + &fake_git, + format!("#!/bin/sh\n: > \"$OPENSHELL_TEST_GIT_MARKER\"\n{script}"), + ) + .expect("write fake git"); + fs::set_permissions(&fake_git, fs::Permissions::from_mode(0o755)) + .expect("make fake git executable"); + let output = run_upload( + &source, + tmpdir.path(), + Some(bin.as_os_str()), + Some(&marker), + false, + ); + assert_filtering_stopped(&output, expected_error); + assert!(marker.exists(), "Git filtering should have been attempted"); + fs::remove_file(&marker).expect("remove invocation marker"); + + let output = run_upload( + &source, + tmpdir.path(), + Some(bin.as_os_str()), + Some(&marker), + true, + ); + assert_reached_transport(&output); + assert!(!marker.exists(), "explicit override must not invoke Git"); + } +} + +fn assert_filtering_stopped(output: &Output, expected_error: &str) { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "filtering must stop the upload"); + assert!(stderr.contains(expected_error), "{stderr}"); + assert!(stderr.contains("--no-git-ignore"), "{stderr}"); + assert!( + !stderr.contains("Uploading "), + "transport started: {stderr}" + ); +} + +fn assert_reached_transport(output: &Output) { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "the test gateway is unreachable"); + assert!(stderr.contains("Uploading "), "{stderr}"); + assert!(!stderr.contains("upload stopped"), "{stderr}"); +} diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs index b1cd38dc6f..d774504661 100644 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -31,10 +31,10 @@ pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { run: run_round_trip, }; -/// Certify Git-aware upload filtering and fallback behavior. +/// Certify Git-aware upload filtering and explicit unfiltered uploads. pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { name: "file-transfer/git-filtering", - description: "Verify Git-aware upload selection and unfiltered fallback behavior.", + description: "Verify Git-aware upload selection and explicit unfiltered uploads.", run: run_git_filtering, }; @@ -68,7 +68,8 @@ fn run_git_filtering(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; - gitignored_directory_fallback(runner, &sandbox_name, &remote_root, local.path()).await?; + gitignored_directory_requires_override(runner, &sandbox_name, &remote_root, local.path()) + .await?; delete_sandbox(runner, &sandbox_name).await }) } @@ -151,7 +152,16 @@ async fn round_trip( fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; let remote = format!("{remote_root}/roundtrip"); - upload(runner, sandbox, "roundtrip/upload", &source, &remote, true).await?; + let result = + upload_result(runner, sandbox, "roundtrip/upload", &source, &remote, false).await?; + result.require_success()?; + if !result.stderr().contains("outside a Git work tree") + || !result.stderr().contains(".gitignore rules are not applied") + { + return Err( + result.failure_diagnostic("upload outside Git warns that filtering is disabled") + ); + } let destination = local_root.join("roundtrip-download"); fs::create_dir(&destination).map_err(fs_error("create round-trip destination"))?; @@ -193,7 +203,7 @@ async fn round_trip( "single/upload", &single, &remote_single, - true, + false, ) .await?; let single_destination = local_root.join("single-download"); @@ -462,7 +472,7 @@ async fn download_dash_leading_name( ) } -async fn gitignored_directory_fallback( +async fn gitignored_directory_requires_override( runner: &OpenShellRunner, sandbox: &str, remote_root: &str, @@ -472,22 +482,22 @@ async fn gitignored_directory_fallback( exec( runner, sandbox, - "gitignored-fallback/seed", + "gitignored-override/seed", &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), ) .await?; - let repository = local_root.join("fallback-repo"); - fs::create_dir(&repository).map_err(fs_error("create fallback repository"))?; + let repository = local_root.join("override-repo"); + fs::create_dir(&repository).map_err(fs_error("create override repository"))?; git_init(&repository).await?; fs::write(repository.join(".gitignore"), "runs/\n") - .map_err(fs_error("write fallback .gitignore"))?; + .map_err(fs_error("write override .gitignore"))?; let runs = repository.join("runs"); fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; download( runner, sandbox, - "gitignored-fallback/download-seed", + "gitignored-override/download-seed", &remote_seed, &runs, ) @@ -495,29 +505,47 @@ async fn gitignored_directory_fallback( require_exists(&runs.join("test.json"), "downloaded ignored file")?; let remote = format!("{remote_root}/reuploaded"); - let upload_result = upload_result( + let rejected = upload_result( runner, sandbox, - "gitignored-fallback/upload", + "gitignored-override/reject-upload", &runs, &remote, false, ) .await?; - upload_result.require_success()?; - let output = format!("{}\n{}", upload_result.stdout(), upload_result.stderr()); - if !output.contains(".gitignore filtering excluded all files") { - return Err(upload_result.failure_diagnostic( - "upload warns that Git filtering excluded every file and falls back to an unfiltered transfer", + let output = format!("{}\n{}", rejected.stdout(), rejected.stderr()); + if rejected.success() + || !output.contains("filtering selected no files") + || !output.contains("--no-git-ignore") + { + return Err(rejected.failure_diagnostic( + "upload rejects an empty Git selection and explains the explicit override", )); } + exec( + runner, + sandbox, + "gitignored-override/no-transfer", + &format!("test ! -e '{remote}'"), + ) + .await?; + upload( + runner, + sandbox, + "gitignored-override/upload", + &runs, + &remote, + true, + ) + .await?; - let destination = local_root.join("fallback-download"); - fs::create_dir(&destination).map_err(fs_error("create fallback destination"))?; + let destination = local_root.join("override-download"); + fs::create_dir(&destination).map_err(fs_error("create override destination"))?; download( runner, sandbox, - "gitignored-fallback/download", + "gitignored-override/download", &remote, &destination, ) @@ -558,7 +586,7 @@ async fn upload_result( } runner .step(step) - .description(format!("upload {source:?} to {destination:?} succeeds")) + .description(format!("upload {source:?} to {destination:?}")) .with_timeout(TRANSFER_TIMEOUT) .run(&args) .await diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index a9a1866d53..c39c71a3a4 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -857,10 +857,24 @@ openshell sandbox create --from registry.example.com/your-org/claude-agent:lates By default, uploads inside a Git repository respect `.gitignore` rules so that build artifacts, dependency caches, and other ignored files are not transferred. -If `.gitignore` filtering excludes every file in the upload path, the CLI falls -back to an unfiltered upload and prints a warning. Pass `--no-git-ignore` to -opt into unfiltered uploads explicitly, upload a path outside the Git work -tree, or force-add the intended files if they should remain Git-aware. +The CLI stops the affected upload if Git filtering fails or selects no files. +When the CLI confirms that the source is outside a Git work tree, it uploads +without filtering and warns that `.gitignore` rules are not applied. Git must +be installed to determine whether filtering applies. A broken or inaccessible +repository stops the upload. Pass `--no-git-ignore` to intentionally upload +without filtering. These rules apply to both `sandbox upload` and +`sandbox create --upload`. + +`sandbox create --upload` provisions the sandbox before checking Git filtering. +If filtering rejects an upload, the command exits with an error, but the sandbox +remains running. Earlier uploads in the same command may already have completed. +Use `openshell sandbox upload` to retry against the existing sandbox, or +`openshell sandbox delete` to remove it. Add `--no-git-ignore` to the retry only +if you intend to upload without filtering. + +Uploads preserve symlinks, including dangling links, instead of dereferencing +their targets. A symlink source bypasses Git filtering so the link itself is +archived. ## Stop and Start Sandboxes diff --git a/docs/upgrade/0-1-0.mdx b/docs/upgrade/0-1-0.mdx index 7801b48179..2e7ea7a37a 100644 --- a/docs/upgrade/0-1-0.mdx +++ b/docs/upgrade/0-1-0.mdx @@ -82,6 +82,8 @@ If you run OpenShell for a team, start here. If you use OpenShell through the CLI, policies, APIs, or SDKs, review these changes. +- **Git filtering failures stop uploads.** `sandbox upload` and `sandbox create --upload` now exit with an error when Git filtering fails or returns no files, instead of uploading the source unfiltered. This includes unavailable Git and broken or inaccessible repositories. Sources confirmed to be outside a Git work tree still upload without filtering, with a warning that `.gitignore` rules are not applied. Update scripts and CI to install Git and fix filtering errors, or pass `--no-git-ignore` when an unfiltered transfer is intended. A rejected creation-time upload leaves the sandbox running; retry with `sandbox upload` or remove it with `sandbox delete` ([PR #3957](https://github.com/NVIDIA/OpenShell/pull/3957)). + - **Build local images before sandbox creation.** `openshell sandbox create --from` no longer builds a Dockerfile or directory. Build and tag with the gateway's container engine, then pass the image reference. Remote gateways need an image they can pull from a registry ([PR #3214](https://github.com/NVIDIA/OpenShell/pull/3214)). ```shell diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 3977018a01..10856eefe5 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -398,7 +398,9 @@ openshell sandbox upload my-sandbox ./src openshell sandbox download my-sandbox output ./local-output ``` -Uploads honor `.gitignore` by default. Add `--no-git-ignore` only when ignored files are intentionally in scope. +Uploads inside a Git work tree honor `.gitignore` by default and stop if Git filtering fails or selects no files. Sources confirmed to be outside a Git work tree upload without filtering, with a warning that `.gitignore` rules are not applied. Git must be available to determine whether filtering applies; broken or inaccessible repositories stop the upload. Add `--no-git-ignore` for an intentional unfiltered upload. This also applies to `sandbox create --upload`. + +If `sandbox create --upload` rejects an upload, the sandbox remains running and earlier uploads may have completed. Retry with `sandbox upload` against that sandbox, or remove it with `sandbox delete`. Uploads preserve symlinks, including dangling symlinks, instead of dereferencing their targets. A symlink source bypasses Git-aware filtering so the link itself is archived. From f7273e48f6168a2579242fb167d9a1eb8b1f59fd Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 2 Oct 2026 15:33:37 +0000 Subject: [PATCH 12/18] fix(providers): restore supervisor-backed GCP metadata discovery (#3973) * fix(providers): restore supervisor-backed GCP metadata discovery Relay the reserved metadata endpoint to the supervisor and restore project, account, and placeholder token responses from live provider state. Cover Google SDK discovery and repeated refresh with provider E2E tests. Closes #3860 Signed-off-by: Philippe Martin * fix(providers): preserve default metadata account without an email Use the default account identifier when the optional service account email is missing or empty. Cover repeated SDK refresh for missing, empty, and configured email values with distinct providers for parallel E2E execution. Signed-off-by: Philippe Martin * test(sandbox): fix metadata relay lint and timeout Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- crates/openshell-core/src/google_cloud.rs | 22 +- .../src/provider_credentials.rs | 20 + .../openshell-sandbox/src/network_broker.rs | 75 +- .../src/google_cloud_metadata.rs | 790 ++++++++++++++++++ .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 250 +++++- docs/how-it-works/providers/google.mdx | 30 +- e2e/python/Dockerfile.workload | 2 +- e2e/python/test_sandbox_providers.py | 115 +++ skills/generate-sandbox-policy/SKILL.md | 4 +- 10 files changed, 1298 insertions(+), 11 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/google_cloud_metadata.rs diff --git a/crates/openshell-core/src/google_cloud.rs b/crates/openshell-core/src/google_cloud.rs index fcab45ae08..efc15a6527 100644 --- a/crates/openshell-core/src/google_cloud.rs +++ b/crates/openshell-core/src/google_cloud.rs @@ -14,10 +14,15 @@ /// Hostname served by the GCE metadata emulator via proxy interception. pub const METADATA_HOST: &str = "gcp.metadata.openshell.internal"; -/// Loopback address for the GCE metadata server inside sandbox namespaces. +/// Reserved loopback destination relayed to the supervisor metadata emulator. /// Go's metadata client dials this directly (bypasses `HTTP_PROXY`). pub const METADATA_LOOPBACK_ADDR: &str = "127.0.0.1:8174"; +/// Match only the reserved metadata service, never a host cloud metadata IP. +pub fn is_metadata_destination(destination: std::net::SocketAddr) -> bool { + destination == std::net::SocketAddr::from(([127, 0, 0, 1], 8174)) +} + // ── Env var alias arrays ──────────────────────────────────────────────────── /// Env vars that carry the GCP project ID inside sandboxes. @@ -85,6 +90,21 @@ mod tests { use super::*; use std::collections::HashSet; + #[test] + fn metadata_destination_matches_only_reserved_loopback_endpoint() { + assert!(is_metadata_destination( + METADATA_LOOPBACK_ADDR.parse().unwrap() + )); + for address in [ + "127.0.0.1:8175", + "127.0.0.2:8174", + "169.254.169.254:80", + "[::1]:8174", + ] { + assert!(!is_metadata_destination(address.parse().unwrap())); + } + } + #[test] fn static_config_keys_matches_alias_arrays_and_vertex_vars() { let expected: HashSet<&str> = PROJECT_ID_ENV_VARS diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 6988d1f4be..8e3a5fe850 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -573,6 +573,26 @@ impl ProviderCredentialState { Ok(revision) } + /// Read current provider configuration only when explicitly classified non-secret. + /// + /// Local metadata adapters must not unwrap credential values just because their + /// environment names match a conventional configuration key. + pub fn current_non_secret_environment_value(&self, key: &str) -> Option { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + if !inner.non_secret_environment_keys.contains(key) { + return None; + } + let placeholder = inner.current.child_env.get(key)?; + inner + .current_resolver + .as_ref()? + .resolve_placeholder(placeholder) + .map(str::to_string) + } + /// Return the GCP token placeholder and its remaining lifetime in seconds. /// /// Searches `google_cloud::TOKEN_ENV_KEYS` in priority order (SA before diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 56c857ce9c..f2f196c925 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -837,7 +837,11 @@ fn connect_socket( entry.release_preconnect(); return listener.respond_value(notification.id, 0); } - if destination.ip().is_loopback() { + // The metadata service lives in the supervisor, even though SDKs address + // it through loopback. Relay it before the ordinary local socket path. + if destination.ip().is_loopback() + && !openshell_core::google_cloud::is_metadata_destination(destination) + { let mut registry = lock(®istry); let entry = registry.resolve_mut(notification.tid, fd)?; connect_exact(entry.retained_preconnect()?.as_raw_fd(), destination)?; @@ -2090,6 +2094,75 @@ mod tests { ); } + #[test] + fn metadata_reservation_preserves_other_loopback_and_rejects_udp() { + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let _broker = NetworkBroker::start_for_test(listener).unwrap(); + let local_server = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = local_server.local_addr().unwrap(); + let connection = launcher + .execute(move || TcpStream::connect(address)) + .unwrap() + .unwrap(); + assert_eq!(connection.peer_addr().unwrap(), address); + let error = launcher + .execute(|| { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.connect(openshell_core::google_cloud::METADATA_LOOPBACK_ADDR) + }) + .unwrap() + .unwrap_err(); + assert_eq!(error.raw_os_error(), Some(libc::EACCES)); + } + + #[test] + fn metadata_loopback_connect_is_relayed_to_supervisor() { + use std::io::{Read as _, Write as _}; + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let broker = NetworkBroker::start_for_test(listener).unwrap(); + let client = std::thread::spawn(move || { + launcher + .execute(|| { + let mut stream = + TcpStream::connect(openshell_core::google_cloud::METADATA_LOOPBACK_ADDR)?; + stream.write_all(b"metadata-probe")?; + let mut reply = [0; 2]; + stream.read_exact(&mut reply)?; + Ok::<_, io::Error>(reply) + }) + .unwrap() + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let pending = tokio::time::timeout(Duration::from_secs(30), broker.accept()) + .await + .unwrap() + .unwrap(); + assert!(openshell_core::google_cloud::is_metadata_destination( + pending.destination + )); + let stream = pending + .complete(TcpOpenDecision::RelayReady) + .await + .unwrap() + .unwrap(); + stream.set_nonblocking(true).unwrap(); + let mut stream = tokio::net::TcpStream::from_std(stream).unwrap(); + let mut probe = [0; 14]; + stream.read_exact(&mut probe).await.unwrap(); + assert_eq!(&probe, b"metadata-probe"); + stream.write_all(b"ok").await.unwrap(); + }); + assert_eq!(&client.join().unwrap().unwrap(), b"ok"); + } + #[test] fn external_connect_times_out_when_supervisor_retains_the_decision() { let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() diff --git a/crates/openshell-supervisor-network/src/google_cloud_metadata.rs b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs new file mode 100644 index 0000000000..f4e29ece88 --- /dev/null +++ b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs @@ -0,0 +1,790 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! GCE metadata server emulator for sandbox credential injection. +//! +//! Implements a subset of the GCE instance metadata API so that GCP client +//! libraries (Go, Python, Node.js) can obtain `OAuth2` tokens natively inside +//! sandboxes. Tokens are served from the existing `ProviderCredentialState` +//! store — no separate refresh mechanism is needed. +//! +//! The sandbox broker relays the reserved loopback metadata destination to +//! this supervisor-owned handler. SDKs discover it through `GCE_METADATA_HOST`; +//! real credentials remain outside the workload boundary. + +use http::StatusCode; +use miette::{IntoDiagnostic, Result}; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_ocsf::{ + ActivityId, HttpActivityBuilder, HttpRequest, SeverityId, StatusId, ocsf_emit, +}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; + +type MetadataResponse = (u16, &'static str, String); + +const PATH_SERVICE_ACCOUNTS: &str = "/computeMetadata/v1/instance/service-accounts"; +const PATH_SERVICE_ACCOUNT_DEFAULT: &str = "/computeMetadata/v1/instance/service-accounts/default"; +const PATH_TOKEN: &str = "/computeMetadata/v1/instance/service-accounts/default/token"; +const PATH_EMAIL: &str = "/computeMetadata/v1/instance/service-accounts/default/email"; +const PATH_SCOPES: &str = "/computeMetadata/v1/instance/service-accounts/default/scopes"; +const PATH_ALIASES: &str = "/computeMetadata/v1/instance/service-accounts/default/aliases"; +const PATH_PROJECT_ID: &str = "/computeMetadata/v1/project/project-id"; + +const ENV_GCP_PROJECT_ID: &str = openshell_core::google_cloud::PROJECT_ID_ENV_VARS[0]; +const ENV_GCP_SERVICE_ACCOUNT_EMAIL: &str = + openshell_core::google_cloud::SERVICE_ACCOUNT_EMAIL_ENV_VARS[0]; + +const METADATA_FLAVOR_HEADER: &str = "metadata-flavor"; +const METADATA_FLAVOR_VALUE: &str = "Google"; +const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for"; + +#[derive(Debug, Clone)] +pub struct MetadataContext { + credentials: ProviderCredentialState, +} + +impl MetadataContext { + pub fn new(credentials: ProviderCredentialState) -> Self { + Self { credentials } + } +} + +pub async fn handle_forward_request( + ctx: &MetadataContext, + method: &str, + path: &str, + initial_request: &[u8], + client: &mut S, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let headers = parse_request_headers(initial_request); + let (status, content_type, body) = route_request(ctx, method, path, &headers); + write_metadata_response(client, status, content_type, &body).await +} + +fn route_request( + ctx: &MetadataContext, + method: &str, + path: &str, + headers: &[(String, String)], +) -> MetadataResponse { + if method != "GET" { + let status = StatusCode::METHOD_NOT_ALLOWED.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: unsupported method {method}"), + ); + return (status, "text/html", "Method Not Allowed".to_string()); + } + + if let Err(resp) = validate_metadata_headers(headers) { + emit_metadata_event( + method, + resp.0, + SeverityId::Medium, + StatusId::Failure, + &format!( + "metadata: header validation failed for {}", + path.split('?').next().unwrap_or(path) + ), + ); + return resp; + } + + let (route, query) = path.split_once('?').map_or((path, ""), |(r, q)| (r, q)); + let route = route.strip_suffix('/').unwrap_or(route); + let recursive = query.split('&').any(|p| p == "recursive=true"); + let account_route = ctx + .credentials + .current_non_secret_environment_value(ENV_GCP_SERVICE_ACCOUNT_EMAIL) + .filter(|email| !email.is_empty() && !email.contains('/')) + .and_then(|email| { + let suffix = route.strip_prefix(&format!("{PATH_SERVICE_ACCOUNTS}/{email}"))?; + (suffix.is_empty() || suffix.starts_with('/')) + .then(|| format!("{PATH_SERVICE_ACCOUNT_DEFAULT}{suffix}")) + }); + let route = account_route.as_deref().unwrap_or(route); + + match route { + PATH_TOKEN => handle_token(ctx, method), + PATH_EMAIL => handle_env(ctx, method, ENV_GCP_SERVICE_ACCOUNT_EMAIL), + PATH_PROJECT_ID => handle_env(ctx, method, ENV_GCP_PROJECT_ID), + PATH_ALIASES => (200, "text/plain", "default\n".to_string()), + PATH_SCOPES => ( + 200, + "text/plain", + "https://www.googleapis.com/auth/cloud-platform".to_string(), + ), + PATH_SERVICE_ACCOUNT_DEFAULT => { + if recursive { + handle_service_account_recursive(ctx) + } else { + ( + 200, + "text/plain", + "aliases\nemail\nscopes\ntoken\n".to_string(), + ) + } + } + PATH_SERVICE_ACCOUNTS => (200, "text/plain", "default/\n".to_string()), + "" | "/" | "/computeMetadata" | "/computeMetadata/v1" => { + (200, "text/plain", "computeMetadata/\n".to_string()) + } + "/computeMetadata/v1/instance" => (200, "text/plain", "service-accounts/\n".to_string()), + _ => { + let status = StatusCode::NOT_FOUND.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: unknown path {route}"), + ); + ( + status, + "application/json", + serde_json::json!({"error": "not_found"}).to_string(), + ) + } + } +} + +fn handle_token(ctx: &MetadataContext, method: &str) -> MetadataResponse { + let Some((placeholder, expires_in)) = ctx.credentials.gcp_token_response() else { + let status = StatusCode::SERVICE_UNAVAILABLE.as_u16(); + let has_resolver = ctx.credentials.resolver().is_some(); + let (msg, error_key) = if has_resolver { + ( + "metadata: no GCP access token available or expired", + "token_unavailable", + ) + } else { + ( + "metadata: token request but no credentials configured", + "credentials_unavailable", + ) + }; + emit_metadata_event(method, status, SeverityId::Medium, StatusId::Failure, msg); + return ( + status, + "application/json", + serde_json::json!({"error": error_key}).to_string(), + ); + }; + + let status = StatusCode::OK.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Informational, + StatusId::Success, + "metadata: token placeholder served", + ); + + let body = serde_json::json!({ + "access_token": placeholder, + "expires_in": expires_in, + "token_type": "Bearer" + }); + (status, "application/json", body.to_string()) +} + +fn handle_service_account_recursive(ctx: &MetadataContext) -> MetadataResponse { + let email = ctx + .credentials + .current_non_secret_environment_value(ENV_GCP_SERVICE_ACCOUNT_EMAIL) + .filter(|email| !email.is_empty()) + .unwrap_or_else(|| "default".to_string()); + + let scopes = "https://www.googleapis.com/auth/cloud-platform"; + + let body = serde_json::json!({ + "aliases": ["default"], + "email": email, + "scopes": [scopes], + }); + (200, "application/json", body.to_string()) +} + +/// Serve a non-secret config value (project ID, SA email) as plain text. +/// +/// Unlike `handle_token` which serves placeholders, this resolves to the real +/// value. This matches real GCE metadata server behavior and is safe because +/// these values are non-secret configuration (project IDs, email addresses). +fn handle_env(ctx: &MetadataContext, method: &str, env_key: &str) -> MetadataResponse { + ctx.credentials + .current_non_secret_environment_value(env_key) + .map_or_else( + || { + let status = StatusCode::NOT_FOUND.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: {env_key} not configured as non-secret"), + ); + ( + status, + "application/json", + serde_json::json!({"error": "not_found"}).to_string(), + ) + }, + |value| (200, "text/plain", value), + ) +} + +fn validate_metadata_headers(headers: &[(String, String)]) -> Result<(), MetadataResponse> { + if headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(X_FORWARDED_FOR_HEADER)) + { + return Err((403, "text/html", "Forbidden".to_string())); + } + + let has_flavor = headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(METADATA_FLAVOR_HEADER) + && value.trim().eq_ignore_ascii_case(METADATA_FLAVOR_VALUE) + }); + if !has_flavor { + return Err((403, "text/html", "Forbidden".to_string())); + } + + Ok(()) +} + +fn parse_request_headers(raw: &[u8]) -> Vec<(String, String)> { + let request = String::from_utf8_lossy(raw); + let mut headers = Vec::new(); + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + headers.push((name.trim().to_string(), value.trim().to_string())); + } + } + headers +} + +fn status_text(status: u16) -> &'static str { + match status { + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 503 => "Service Unavailable", + _ => "OK", + } +} + +async fn write_metadata_response( + client: &mut S, + status: u16, + content_type: &str, + body: &str, +) -> Result<()> +where + S: AsyncWrite + Unpin, +{ + let response = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: {content_type}\r\nMetadata-Flavor: Google\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + status_text(status), + body.len(), + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + Ok(()) +} + +fn emit_metadata_event( + method: &str, + response_code: u16, + severity: SeverityId, + status: StatusId, + message: &str, +) { + ocsf_emit!(build_metadata_event( + method, + response_code, + severity, + status, + message + )); +} + +fn build_metadata_event( + method: &str, + response_code: u16, + severity: SeverityId, + status: StatusId, + message: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::for_http_method(method)) + .http_request(HttpRequest { + http_method: method.parse().expect("HTTP method parsing is infallible"), + url: None, + }) + .http_response(openshell_ocsf::HttpResponse { + code: response_code, + }) + .severity(severity) + .status(status) + .message(message.to_string()) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn token_binding() -> openshell_core::proto::StaticCredentialBinding { + openshell_core::proto::StaticCredentialBinding { + endpoints: vec![openshell_core::proto::StaticCredentialEndpointBinding { + host: "storage.googleapis.com".into(), + port: 443, + path: "/**".into(), + }], + credential_identity: "test-google:token".into(), + workload_credential_handle: String::new(), + } + } + + fn make_context(env: HashMap) -> MetadataContext { + let config_keys = [ENV_GCP_PROJECT_ID, ENV_GCP_SERVICE_ACCOUNT_EMAIL] + .into_iter() + .filter(|key| env.contains_key(*key)) + .map(str::to_string) + .collect(); + let bindings = openshell_core::google_cloud::TOKEN_ENV_KEYS + .iter() + .filter(|key| env.contains_key(**key)) + .map(|key| ((*key).to_string(), token_binding())) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 0, + env, + HashMap::new(), + HashMap::new(), + bindings, + config_keys, + ) + .unwrap(); + MetadataContext::new(state) + } + + fn make_context_with_expiry( + env: HashMap, + expires: HashMap, + ) -> MetadataContext { + let state = ProviderCredentialState::from_environment(0, env, expires, HashMap::new()); + MetadataContext::new(state) + } + + fn flavor_headers() -> Vec<(String, String)> { + vec![("Metadata-Flavor".to_string(), "Google".to_string())] + } + + #[test] + fn metadata_events_include_response_for_ocsf18() { + use openshell_ocsf::tracing_layers::OcsfJsonlLayer; + use openshell_ocsf::validation::{ + load_class_schema, validate_enum_value, validate_required_fields, + }; + use tracing_subscriber::prelude::*; + + let schema = load_class_schema("http_activity"); + for (method, path, headers, expected_code, expected_activity_id) in [ + ("GET", PATH_TOKEN, flavor_headers(), 200, 3), + ("GET", "/?token=secret-query", Vec::new(), 403, 3), + ("GET", "/unknown", flavor_headers(), 404, 3), + ("POST", PATH_TOKEN, flavor_headers(), 405, 6), + ("GET", PATH_TOKEN, flavor_headers(), 503, 3), + ("GET", PATH_EMAIL, flavor_headers(), 404, 3), + ] { + let env = if expected_code == 503 { + HashMap::new() + } else { + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "test-token".to_string())]) + }; + let ctx = make_context(env); + let log = tempfile::NamedTempFile::new().unwrap(); + let subscriber = + tracing_subscriber::registry().with(OcsfJsonlLayer::new(log.reopen().unwrap())); + let response = tracing::subscriber::with_default(subscriber, || { + route_request(&ctx, method, path, &headers) + }); + assert_eq!(response.0, expected_code); + let output = std::fs::read_to_string(log.path()).unwrap(); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["http_response"]["code"], response.0); + assert!(!output.contains("secret-query"), "{output}"); + assert_eq!( + json["activity_id"], expected_activity_id, + "method: {method}" + ); + assert_eq!(json["http_request"]["http_method"], method); + assert!(json["http_request"].get("url").is_none()); + validate_required_fields(&json, &schema); + validate_enum_value(&json, "activity_id", &schema); + } + } + + #[test] + fn metadata_tracks_current_credentials_and_provider_removal() { + let ctx = make_context(HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "old-secret".into()), + ("GCP_PROJECT_ID".into(), "old-project".into()), + ("GCP_SERVICE_ACCOUNT_EMAIL".into(), "old@example.com".into()), + ])); + let (_, _, old_body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + ctx.credentials + .install_bound_environment( + 2, + HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "new-secret".into()), + ("GCP_PROJECT_ID".into(), "new-project".into()), + ("GCP_SERVICE_ACCOUNT_EMAIL".into(), "new@example.com".into()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), token_binding())]), + vec![ + ENV_GCP_PROJECT_ID.into(), + ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), + ], + ) + .unwrap(); + let (_, _, new_body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_ne!(old_body, new_body); + assert!(!new_body.contains("new-secret")); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).2, + "new-project" + ); + let (_, _, recursive) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}/?recursive=true"), + &flavor_headers(), + ); + let recursive: serde_json::Value = serde_json::from_str(&recursive).unwrap(); + assert_eq!(recursive["email"], "new@example.com"); + assert_eq!( + recursive["scopes"][0], + "https://www.googleapis.com/auth/cloud-platform" + ); + assert!(recursive.get("token").is_none()); + ctx.credentials + .install_environment(3, HashMap::new(), HashMap::new(), HashMap::new()); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 503 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).0, + 404 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()).0, + 404 + ); + let (_, _, recursive) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}?recursive=true"), + &flavor_headers(), + ); + assert!(!recursive.contains("new@example.com")); + } + + #[test] + fn metadata_does_not_unwrap_config_names_classified_as_credentials() { + let ctx = MetadataContext::new(ProviderCredentialState::from_environment( + 0, + HashMap::from([ + (ENV_GCP_PROJECT_ID.into(), "secret-project".into()), + (ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), "secret-email".into()), + ]), + HashMap::new(), + HashMap::new(), + )); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).0, + 404 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()).0, + 404 + ); + let (_, _, body) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}?recursive=true"), + &flavor_headers(), + ); + assert!(!body.contains("secret-email")); + } + + #[test] + fn configured_email_alias_supports_repeated_sdk_refresh() { + let ctx = make_context(HashMap::from([ + ( + ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), + "sdk@project.iam.gserviceaccount.com".into(), + ), + ("GCP_ADC_ACCESS_TOKEN".into(), "real-secret".into()), + ])); + for suffix in ["?recursive=true", "/token", "/email", "/scopes"] { + let alias = + format!("{PATH_SERVICE_ACCOUNTS}/sdk@project.iam.gserviceaccount.com{suffix}"); + let default = format!("{PATH_SERVICE_ACCOUNT_DEFAULT}{suffix}"); + assert_eq!( + route_request(&ctx, "GET", &alias, &flavor_headers()), + route_request(&ctx, "GET", &default, &flavor_headers()) + ); + } + let other = format!("{PATH_SERVICE_ACCOUNTS}/other@project.iam.gserviceaccount.com/token"); + assert_eq!(route_request(&ctx, "GET", &other, &flavor_headers()).0, 404); + } + + #[test] + fn missing_or_empty_email_keeps_a_usable_account_for_repeated_sdk_refresh() { + for email in [None, Some("")] { + let mut env = HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), "real-secret".into())]); + if let Some(email) = email { + env.insert(ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), email.into()); + } + let ctx = make_context(env); + let mut account = "default".to_string(); + for _ in 0..2 { + let path = format!("{PATH_SERVICE_ACCOUNTS}/{account}?recursive=true"); + let (status, _, body) = route_request(&ctx, "GET", &path, &flavor_headers()); + assert_eq!(status, 200); + let info: serde_json::Value = serde_json::from_str(&body).unwrap(); + account = info["email"].as_str().unwrap().to_string(); + assert_eq!(account, "default"); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 200 + ); + } + } + } + + #[test] + fn expired_token_is_unavailable() { + let ctx = make_context_with_expiry( + HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), "expired-secret".into())]), + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".into(), + openshell_core::time::now_ms() - 1000, + )]), + ); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 503 + ); + } + + #[test] + fn token_returns_placeholder_not_real_value() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.test-token".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "application/json"); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let token = json["access_token"].as_str().unwrap(); + assert!( + token.starts_with("openshell:resolve:env:"), + "token should be a placeholder, got: {token}" + ); + assert!(!token.contains("ya29"), "real token must not be served"); + assert_eq!(json["token_type"], "Bearer"); + assert!(json["expires_in"].is_number()); + } + + #[test] + fn token_expires_in_computed_from_credential_expiry() { + let now_ms = openshell_core::time::now_ms(); + let expires_at = now_ms + 1_800_000; // 30 minutes from now + let ctx = make_context_with_expiry( + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "ya29.tok".to_string())]), + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), expires_at)]), + ); + let (status, _, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let expires_in = json["expires_in"].as_i64().unwrap(); + assert!( + expires_in > 1700 && expires_in <= 1800, + "expires_in={expires_in}" + ); + } + + #[test] + fn token_no_expiry_defaults_to_3600() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let (_, _, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["expires_in"], 3600); + } + + #[test] + fn missing_metadata_flavor_header_403() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &[]); + assert_eq!(status, 403); + } + + #[test] + fn x_forwarded_for_header_403() { + let ctx = make_context(HashMap::new()); + let headers = vec![ + ("Metadata-Flavor".to_string(), "Google".to_string()), + ("X-Forwarded-For".to_string(), "10.0.0.1".to_string()), + ]; + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &headers); + assert_eq!(status, 403); + } + + #[test] + fn unknown_path_404() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request( + &ctx, + "GET", + "/computeMetadata/v1/unknown", + &flavor_headers(), + ); + assert_eq!(status, 404); + } + + #[test] + fn no_credentials_503() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 503); + } + + #[test] + fn post_method_405() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "POST", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 405); + } + + #[test] + fn project_id_served_as_plain_text() { + let ctx = make_context(HashMap::from([( + "GCP_PROJECT_ID".to_string(), + "my-project-123".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "text/plain"); + assert_eq!(body, "my-project-123"); + } + + #[test] + fn email_served_as_plain_text() { + let ctx = make_context(HashMap::from([( + "GCP_SERVICE_ACCOUNT_EMAIL".to_string(), + "sa@project.iam.gserviceaccount.com".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "text/plain"); + assert_eq!(body, "sa@project.iam.gserviceaccount.com"); + } + + #[test] + fn scopes_returns_cloud_platform() { + let ctx = make_context(HashMap::new()); + let (status, _, body) = route_request(&ctx, "GET", PATH_SCOPES, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(body, "https://www.googleapis.com/auth/cloud-platform"); + } + + #[test] + fn query_parameters_ignored_for_routing() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let path = format!("{PATH_TOKEN}?scopes=cloud-platform"); + let (status, _, _) = route_request(&ctx, "GET", &path, &flavor_headers()); + assert_eq!(status, 200); + } + + #[test] + fn metadata_flavor_case_insensitive() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let headers = vec![("metadata-FLAVOR".to_string(), "google".to_string())]; + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &headers); + assert_eq!(status, 200); + } + + #[test] + fn missing_env_var_returns_404() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + // project-id not set + let (status, _, _) = route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()); + assert_eq!(status, 404); + } + + #[test] + fn trailing_slash_handled_for_service_account_default() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let with_slash = route_request( + &ctx, + "GET", + "/computeMetadata/v1/instance/service-accounts/default/", + &flavor_headers(), + ); + let without_slash = route_request( + &ctx, + "GET", + "/computeMetadata/v1/instance/service-accounts/default", + &flavor_headers(), + ); + assert_eq!(with_slash.0, 200); + assert_eq!(without_slash.0, 200); + assert_eq!(with_slash.2, without_slash.2); + } + + #[test] + fn parse_request_headers_extracts_correctly() { + let raw = b"GET /path HTTP/1.1\r\nHost: example.com\r\nMetadata-Flavor: Google\r\n\r\n"; + let headers = parse_request_headers(raw); + assert_eq!(headers.len(), 2); + assert_eq!(headers[0].0, "Host"); + assert_eq!(headers[0].1, "example.com"); + assert_eq!(headers[1].0, "Metadata-Flavor"); + assert_eq!(headers[1].1, "Google"); + } +} diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 458e236f01..8115e33e09 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,7 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +mod google_cloud_metadata; #[cfg(target_os = "windows")] pub mod host; pub mod identity; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ec567bc47b..7064cda008 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -610,6 +610,35 @@ async fn preauthorize_transparent_open( timing, operation: "tcp", }; + if openshell_core::google_cloud::is_metadata_destination(destination) { + let identity_check = binary_identity + .as_ref() + .map_err(|_| TcpOpenDenial::IdentityUnavailable) + .and_then(|identity| { + identity_cache + .verify_or_cache_supplied_identity(identity) + .map_err(|error| match error { + SuppliedIdentityError::Unavailable(_) => TcpOpenDenial::IdentityUnavailable, + SuppliedIdentityError::CapacityExhausted => { + TcpOpenDenial::ResourceExhausted + } + }) + }); + if let Err(denial) = identity_check { + let _ = completion.send(TcpOpenDecision::Denied(denial)); + return None; + } + completion.send(TcpOpenDecision::RelayReady).ok()?; + return Some(( + stream, + Some(binary_identity), + None, + Some(TransparentOpen { + destination, + authorization: None, + }), + )); + } if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) { if destination.port() != 80 || !has_policy_local { emit_staged_transparent_denial( @@ -2416,11 +2445,15 @@ async fn handle_mediated_connection( .as_ref() .and_then(EndpointObservationSender::capture); let mut policy_local_transparent = false; + let mut metadata_transparent = false; let (mut preauthorized_decision, prevalidated_connector) = if let Some(transparent) = transparent_open { let destination = transparent.destination; - if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) + if openshell_core::google_cloud::is_metadata_destination(destination) { + metadata_transparent = true; + (None, None) + } else if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) && destination.port() == 80 { policy_local_transparent = true; @@ -2442,6 +2475,8 @@ async fn handle_mediated_connection( } else { (None, None) }; + let metadata_deadline = metadata_transparent + .then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(5)); let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -2458,7 +2493,16 @@ async fn handle_mediated_connection( // A mediated open's first workload bytes follow the synthesized CONNECT header. // Take only the header out of the reader so the bytes behind it stay buffered for // the relay; overlap three bytes so a terminator split across fills is found. - let available = client.fill_buf().await.into_diagnostic()?; + let available = if let Some(deadline) = metadata_deadline { + if let Ok(result) = tokio::time::timeout_at(deadline, client.fill_buf()).await { + result.into_diagnostic()? + } else { + respond(&mut client, b"HTTP/1.1 408 Request Timeout\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await?; + return Ok(()); + } + } else { + client.fill_buf().await.into_diagnostic()? + }; if available.is_empty() { return Ok(()); } @@ -2498,6 +2542,25 @@ async fn handle_mediated_connection( let method = parts.next().unwrap_or(""); let target = parts.next().unwrap_or(""); + if metadata_transparent { + let credentials = provider_credentials.unwrap_or_else(|| { + ProviderCredentialState::from_environment( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ) + }); + return crate::google_cloud_metadata::handle_forward_request( + &crate::google_cloud_metadata::MetadataContext::new(credentials), + method, + target, + &buf[..used], + &mut client, + ) + .await; + } + if policy_local_transparent { if !valid_policy_local_request(method, target, request) { respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; @@ -5224,6 +5287,28 @@ async fn handle_forward_proxy( let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); + if scheme == "http" + && ((host_lc == openshell_core::google_cloud::METADATA_HOST && port == 80) + || (host_lc == "127.0.0.1" && port == 8174)) + { + let credentials = provider_credentials.unwrap_or_else(|| { + ProviderCredentialState::from_environment( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ) + }); + return crate::google_cloud_metadata::handle_forward_request( + &crate::google_cloud_metadata::MetadataContext::new(credentials), + method, + &path, + &buf[..used], + client, + ) + .await; + } + if host_lc == POLICY_LOCAL_HOST { if scheme != "http" || port != 80 { respond( @@ -7374,6 +7459,167 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ) } + async fn drive_metadata_request(raw: &[u8], transparent: bool) -> Vec { + let engine = Arc::new( + OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + "network_policies: {}", + ) + .unwrap(), + ); + let (server, mut workload) = tokio::io::duplex(32768); + let credentials = ProviderCredentialState::from_environment( + 1, + std::collections::HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "real-secret-token".into()), + ("GCP_PROJECT_ID".into(), "test-project".into()), + ( + "GCP_SERVICE_ACCOUNT_EMAIL".into(), + "sa@test-project.iam.gserviceaccount.com".into(), + ), + ]), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ); + workload.write_all(raw).await.unwrap(); + let response = async move { + let mut bytes = Vec::new(); + workload.read_to_end(&mut bytes).await.unwrap(); + bytes + }; + let handler = async move { + Box::pin(handle_mediated_connection( + tokio::io::BufReader::new(Box::new(server)), + None, + None, + transparent.then(|| TransparentOpen { + destination: openshell_core::google_cloud::METADATA_LOOPBACK_ADDR + .parse() + .unwrap(), + authorization: None, + }), + None, + engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(1)), + None, + None, + AgentProposals::default(), + Arc::new(None), + Arc::new(None), + Arc::new(None), + Some(credentials), + None, + None, + None, + None, + None, + )) + .await + .unwrap(); + }; + let ((), response) = tokio::join!(handler, response); + response + } + + #[tokio::test] + async fn metadata_transparent_and_forward_requests_terminate_locally() { + for (target, transparent) in [ + ( + "/computeMetadata/v1/instance/service-accounts/default/token", + true, + ), + ( + "http://127.0.0.1:8174/computeMetadata/v1/instance/service-accounts/default/token", + false, + ), + ( + "http://gcp.metadata.openshell.internal/computeMetadata/v1/instance/service-accounts/default/token", + false, + ), + ] { + let raw = format!( + "GET {target} HTTP/1.1\r\nHost: unrelated.example\r\nMetadata-Flavor: Google\r\n\r\n" + ); + let response = drive_metadata_request(raw.as_bytes(), transparent).await; + let response = String::from_utf8(response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + assert!(response.contains("Metadata-Flavor: Google")); + assert!(response.contains("openshell:resolve:env:")); + assert!(!response.contains("real-secret-token")); + } + } + + #[tokio::test] + async fn metadata_ingress_rejects_malformed_and_oversized_headers() { + for (raw, status) in [ + (b"GET / HTTP/1.1\r\nHost: bad\0host\r\n\r\n".to_vec(), "400"), + ( + format!( + "GET / HTTP/1.1\r\nHost: local\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ) + .into_bytes(), + "431", + ), + ] { + let response = drive_metadata_request(&raw, true).await; + assert!( + String::from_utf8(response) + .unwrap() + .starts_with(&format!("HTTP/1.1 {status}")) + ); + } + } + + #[tokio::test(start_paused = true)] + async fn metadata_ingress_times_out_incomplete_headers() { + let response = drive_metadata_request(b"GET / HTTP/1.1\r\n", true).await; + assert!(response.starts_with(b"HTTP/1.1 408 Request Timeout")); + } + + #[tokio::test] + async fn metadata_staging_requires_verified_identity_without_egress_rules() { + let engine = OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + "network_policies: {}", + ) + .unwrap(); + for valid_identity in [true, false] { + let (mut open, completion) = staged_curl_open( + openshell_core::google_cloud::METADATA_LOOPBACK_ADDR + .parse() + .unwrap(), + engine.current_generation(), + ); + if !valid_identity { + open.binary_identity = Err(ResolveError::Failed("unavailable".into())); + } + let accepted = preauthorize_transparent_open( + open, + None, + &engine, + &BinaryIdentityCache::new(), + None, + None, + false, + None, + ) + .await; + if valid_identity { + let (_, _, _, transparent) = accepted.expect("metadata is local"); + assert!(transparent.unwrap().authorization.is_none()); + assert_eq!(completion.await.unwrap(), TcpOpenDecision::RelayReady); + } else { + assert!(accepted.is_none()); + assert_eq!( + completion.await.unwrap(), + TcpOpenDecision::Denied(TcpOpenDenial::IdentityUnavailable) + ); + } + } + } + #[tokio::test] async fn staged_transparent_open_dials_only_pinned_policy_dns_addresses() { let engine = OpaEngine::from_strings( diff --git a/docs/how-it-works/providers/google.mdx b/docs/how-it-works/providers/google.mdx index 2c7091476c..6d5b0c1256 100644 --- a/docs/how-it-works/providers/google.mdx +++ b/docs/how-it-works/providers/google.mdx @@ -117,7 +117,10 @@ Set these with `--config key=value` during provider creation: |-----|-------------|---------| | `project_id` | GCP project ID | `my-project-123` | | `region` | GCP region | `us-central1` | -| `service_account_email` | SA email for metadata endpoint | `sa@proj.iam.gserviceaccount.com` | +| `service_account_email` | Optional SA email for metadata endpoint | `sa@proj.iam.gserviceaccount.com` | + +When `service_account_email` is omitted or empty, recursive metadata account +discovery returns the `default` identifier so SDKs can refresh repeatedly. ## How It Works @@ -125,9 +128,10 @@ When a sandbox starts with the `google-cloud` provider attached: 1. The gateway mints a fresh GCP access token and stores it in the sandbox proxy's credential resolver. -2. A loopback HTTP server on `127.0.0.1:8174` emulates the GCE instance - metadata API, serving **credential placeholders** (not real tokens) to - GCP SDKs. The sandbox process never holds a real GCP credential. +2. The sandbox relays HTTP requests to the reserved `127.0.0.1:8174` + metadata endpoint over its authenticated supervisor connection. The + supervisor emulates the GCE instance metadata API and serves credential + placeholders to GCP SDKs. Real access tokens remain in the supervisor. 3. When the SDK makes an API call, it sends the placeholder in the `Authorization` header. The sandbox proxy TLS-terminates the outbound connection, resolves the placeholder to the real token, @@ -142,6 +146,18 @@ environment variables and are served by the metadata endpoint. These are non-secret identifiers, not credentials. Access tokens are never exposed; only placeholders reach the sandbox process. +The emulator supports Linux sandboxes using the Docker, Podman, Kubernetes, +and VM runtimes. Windows/MXC does not provide this metadata endpoint. SDKs must +honor the injected metadata discovery variables below. Requests require the +`Metadata-Flavor: Google` header; requests with `X-Forwarded-For` are rejected. +The endpoint serves project ID, service-account email, scopes, and token +placeholders, including recursive service-account discovery. + +OpenShell reserves `127.0.0.1:8174` for this service. Connections to this +address reach the emulator even if a workload binds its own listener there. +Requests to the host's cloud metadata service remain blocked; the emulator does not forward requests to +`metadata.google.internal` or `169.254.169.254`. + ### Injected Environment Variables The provider automatically injects these into the sandbox. Non-secret @@ -150,7 +166,7 @@ as placeholders for proxy-time resolution. | Variable | Value | Purpose | |----------|-------|---------| -| `GCE_METADATA_HOST` | `127.0.0.1:8174` | GCP SDK metadata discovery (loopback server) | +| `GCE_METADATA_HOST` | `127.0.0.1:8174` | GCP SDK metadata discovery (supervisor emulator) | | `GCE_METADATA_IP` | `127.0.0.1:8174` | Python google-auth ping detection | | `METADATA_SERVER_DETECTION` | `assume-present` | Node.js gcp-metadata skip detection | | `GCP_PROJECT_ID` | from `project_id` config | GCP SDK project | @@ -167,6 +183,10 @@ permissions for. Add the target API hosts to your sandbox network policy: Attach `my-gcp` to the sandbox first. Because the `google-cloud` profile has no endpoints, each API endpoint must bind to that provider instance. Without the binding, OpenShell withholds its access token from the sandbox. +The metadata token endpoint returns HTTP `503` when no bound access token is +available or the token has expired. Attach the provider, add a credential +binding, and check `openshell provider refresh status my-gcp` before testing +token discovery. ```yaml network_policies: diff --git a/e2e/python/Dockerfile.workload b/e2e/python/Dockerfile.workload index 0da6a88f6d..f25c05d7cf 100644 --- a/e2e/python/Dockerfile.workload +++ b/e2e/python/Dockerfile.workload @@ -19,7 +19,7 @@ RUN apt-get update \ && useradd --uid 1000 --gid sandbox --create-home --shell /bin/bash sandbox \ && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv python install "${PYTHON_VERSION}" \ && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv venv --python "${PYTHON_VERSION}" --seed /sandbox/.venv \ - && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 \ + && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 google-auth==2.40.3 requests==2.32.5 \ && chown -R sandbox:sandbox /sandbox \ && uv cache clean diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 43badec291..4c39bae95f 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -76,6 +76,7 @@ def provider( provider_type: str, credentials: dict[str, str], profile_workspace: str = "", + config: dict[str, str] | None = None, ) -> Iterator[str]: """Create a provider for the duration of the block, then delete it.""" _delete_provider(stub, name) @@ -86,6 +87,7 @@ def provider( metadata=datamodel_pb2.ObjectMeta(name=name), type=provider_type, credentials=credentials, + config=config or {}, profile_workspace=profile_workspace, ), ) @@ -365,6 +367,26 @@ def read_gcp_token() -> str: assert result.exit_code == 0, result.stderr assert result.stdout.strip() == "NOT_SET" + def read_metadata_token_status() -> int: + import os + import urllib.error + import urllib.request + + request = urllib.request.Request( + f"http://{os.environ['GCE_METADATA_HOST']}/computeMetadata/v1/instance/service-accounts/default/token", + headers={"Metadata-Flavor": "Google"}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + + result = sb.exec_python(read_metadata_token_status) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "503" + def test_endpointless_profile_credentials_use_explicit_policy_binding( sandbox: Callable[..., Sandbox], @@ -412,6 +434,99 @@ def read_gcp_token() -> str: ) +@pytest.mark.parametrize( + ("service_account_email", "provider_suffix"), + [ + (None, "no-email"), + ("", "empty-email"), + ("sdk@metadata-test-project.iam.gserviceaccount.com", "configured-email"), + ], + ids=["no-email", "empty-email", "configured-email"], +) +def test_google_metadata_sdk_discovery( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, + service_account_email: str | None, + provider_suffix: str, +) -> None: + """Google's SDK discovers project/account metadata and refreshes a placeholder.""" + config = {"project_id": "metadata-test-project"} + if service_account_email is not None: + config["service_account_email"] = service_account_email + with provider( + sandbox_client._stub, + name=f"e2e-google-metadata-sdk-{provider_suffix}", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-metadata-real-secret"}, + config=config, + ) as provider_name: + policy = _default_policy() + policy.network_policies["gcp_api"].CopyFrom( + sandbox_pb2.NetworkPolicyRule( + name="gcp_api", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="storage.googleapis.com", + port=443, + protocol="rest", + access=sandbox_pb2.NETWORK_ACCESS_PRESET_FULL, + credential_binding=sandbox_pb2.NetworkCredentialBinding( + provider=provider_name + ), + ) + ], + ) + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, providers=[provider_name]) + + def discover_metadata() -> str: + import json + import os + + import google.auth + import requests + from google.auth.compute_engine import _metadata + from google.auth.transport.requests import Request + + # Force ADC to use SDK metadata detection rather than a local key file. + for key in ( + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + ): + os.environ.pop(key, None) + session = requests.Session() + # Exercise the direct TCP path used by metadata clients. + session.trust_env = False + request = Request(session=session) + if not _metadata.ping(request): + raise RuntimeError("Google SDK could not detect the metadata endpoint") + credentials, project = google.auth.default(request=request) + credentials.refresh(request) + credentials.refresh(request) + return json.dumps( + { + "project": project, + "account": credentials.service_account_email, + "token": credentials.token, + "expiry_present": credentials.expiry is not None, + "metadata_host": os.environ["GCE_METADATA_HOST"], + "metadata_ip": os.environ["GCE_METADATA_IP"], + } + ) + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(discover_metadata) + assert result.exit_code == 0, result.stderr + data = json.loads(result.stdout) + assert data["project"] == "metadata-test-project" + assert data["account"] == (service_account_email or "default") + assert data["metadata_host"] == data["metadata_ip"] == "127.0.0.1:8174" + assert data["expiry_present"] + assert _is_placeholder_for_env_key(data["token"], "GCP_ADC_ACCESS_TOKEN") + assert "gcp-metadata-real-secret" not in result.stdout + + def test_nvidia_provider_injects_nvidia_api_key_env_var( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index b5a0f6f4b4..ac03c8576b 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -342,7 +342,9 @@ Use `allowed_ips` to pin the addresses an endpoint may reach. When it is set, ev - **Host + allowlist**: `host` + `allowed_ips` — domain must resolve to an IP in the allowlist - **Hostless allowlist**: `allowed_ips` only (no `host`) — any domain on the port is allowed if it resolves to an IP in the allowlist -Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), unspecified, and cloud metadata addresses are **always blocked** regardless of `allowed_ips`. +Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), unspecified, and cloud metadata addresses are **always blocked** as upstream destinations regardless of `allowed_ips`. + +The Google Cloud metadata emulator reserves `127.0.0.1:8174` in Linux sandboxes. OpenShell handles SDK discovery locally through the supervisor; do not add an `allowed_ips` exception or grant access to the host cloud metadata service. See the [Google provider documentation](https://docs.nvidia.com/openshell/latest/how-it-works/providers/google.md). ```yaml # Example: Pin an internal service to a known private IP range From a48920ac042554ae7cb17f56146e5c1e7881ccae Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Fri, 2 Oct 2026 15:36:29 +0000 Subject: [PATCH 13/18] feat(helm): add sandbox UID and GID values (#3947) * feat(helm): add sandbox UID and GID values Closes #2697 Signed-off-by: Eric Curtin * fix(helm): reject boolean sandbox UID and GID Signed-off-by: Eric Curtin --------- Signed-off-by: Eric Curtin --- deploy/helm/openshell/README.md | 2 + deploy/helm/openshell/templates/_helpers.tpl | 17 ++++ .../openshell/templates/gateway-config.yaml | 6 ++ .../tests/gateway_sandbox_identity_test.yaml | 93 +++++++++++++++++++ deploy/helm/openshell/values.yaml | 7 ++ docs/how-it-works/gateways/configuration.mdx | 1 + docs/how-it-works/sandboxes/runtimes.mdx | 1 + 7 files changed, 127 insertions(+) create mode 100644 deploy/helm/openshell/tests/gateway_sandbox_identity_test.yaml diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index df3ea173ad..aec9837368 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -450,6 +450,7 @@ discovery endpoint or its TLS CA. | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | | server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | | server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | +| server.sandboxGid | string | `""` | GID for sandbox pods (`sandbox_gid`). Empty (default) = same as sandboxUid. Must be an integer between 1 and 4294967294. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | server.sandboxJwt.gatewayId | string | `""` | Stable gateway identity embedded in iss/aud of every minted token. Defaults to the release name so HA replicas share identity. | | server.sandboxJwt.k8sSaTokenTtlSecs | int | `3600` | Lifetime (seconds) of the projected ServiceAccount token kubelet writes into each sandbox pod for the IssueSandboxToken bootstrap exchange. Kubelet enforces a minimum of 600s; the driver clamps values outside [600, 86400]. Default 3600 — generous, since the supervisor consumes the token within seconds of pod start. | @@ -457,6 +458,7 @@ discovery endpoint or its TLS CA. | server.sandboxJwt.signingSecretName | string | `""` | Name of the Opaque Secret holding the signing key material. Empty falls back to the chart fullname with "-jwt-keys" appended. | | server.sandboxJwt.ttlSecs | int | `3600` | Token TTL in seconds. Defaults to 3600 (1h). | | server.sandboxNamespace | string | `""` | Namespace where sandbox pods are created. Defaults to the Helm release namespace (.Release.Namespace) when left empty. | +| server.sandboxUid | string | `""` | UID for sandbox pods (`sandbox_uid`). Empty (default) = use the OpenShift SCC namespace annotation if present, otherwise the driver default. Must be an integer between 1 and 4294967294. | | server.telemetryEnabled | bool | `true` | Enable anonymous OpenShell telemetry from the gateway and the sandbox supervisors it launches. | | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | | server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Only used when enableMtls is true. Set to "" to disable client certificate verification for HTTPS-only mode. | diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index ab42458759..85cccaeba4 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -379,6 +379,23 @@ never {{- end -}} {{- end }} +{{/* +Render a sandbox UID/GID chart value as an integer, or nothing when unset. +Takes a dict with `name` (the values key, for errors) and `value`. The bounds +match openshell_policy::MIN_SANDBOX_UID..=MAX_SANDBOX_UID. Helm parses YAML +numbers as float64, so the integer conversion also avoids `2e+09` rendering. +Booleans are rejected because they would otherwise convert to 1 or 0. +*/}} +{{- define "openshell.sandboxId" -}} +{{- if not (or (kindIs "invalid" .value) (eq (toString .value) "")) -}} +{{- $id := int64 .value -}} +{{- if or (kindIs "bool" .value) (ne (float64 .value) (float64 $id)) (lt $id 1) (gt $id 4294967294) -}} +{{- fail (printf "%s must be an integer between 1 and 4294967294" .name) -}} +{{- end -}} +{{- $id -}} +{{- end -}} +{{- end }} + {{/* Validate chart values that Helm would otherwise accept silently. */}} diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index a3e0210a35..c6eea2b6a1 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -247,6 +247,12 @@ data: {{- if .Values.server.defaultRuntimeClassName }} default_runtime_class_name = {{ .Values.server.defaultRuntimeClassName | quote }} {{- end }} + {{- with include "openshell.sandboxId" (dict "name" "server.sandboxUid" "value" .Values.server.sandboxUid) }} + sandbox_uid = {{ . }} + {{- end }} + {{- with include "openshell.sandboxId" (dict "name" "server.sandboxGid" "value" .Values.server.sandboxGid) }} + sandbox_gid = {{ . }} + {{- end }} {{- if (.Values.supervisor.image.pullPolicy | default .Values.global.image.pullPolicy) }} supervisor_image_pull_policy = {{ include "openshell.canonicalImagePullPolicy" (.Values.supervisor.image.pullPolicy | default .Values.global.image.pullPolicy) | quote }} {{- end }} diff --git a/deploy/helm/openshell/tests/gateway_sandbox_identity_test.yaml b/deploy/helm/openshell/tests/gateway_sandbox_identity_test.yaml new file mode 100644 index 0000000000..0b8d4adfbd --- /dev/null +++ b/deploy/helm/openshell/tests/gateway_sandbox_identity_test.yaml @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: sandbox UID and GID +templates: + - templates/gateway-config.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: omits sandbox_uid and sandbox_gid by default + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'sandbox_uid|sandbox_gid' + + - it: renders sandbox_uid and sandbox_gid as integers + set: + server.sandboxUid: 1500 + server.sandboxGid: 2000 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\s*sandbox_uid\s*=\s*1500$' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\s*sandbox_gid\s*=\s*2000$' + + - it: renders sandbox_uid alone + set: + server.sandboxUid: 1500 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\s*sandbox_uid\s*=\s*1500$' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'sandbox_gid' + + - it: renders large IDs without scientific notation + set: + server.sandboxUid: 1000680000 + server.sandboxGid: 4294967294 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\s*sandbox_uid\s*=\s*1000680000$' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\s*sandbox_gid\s*=\s*4294967294$' + + - it: rejects a zero sandbox UID + set: + server.sandboxUid: 0 + asserts: + - failedTemplate: + errorPattern: "server.sandboxUid must be an integer between 1 and 4294967294" + + - it: rejects a sandbox GID above the allowed range + set: + server.sandboxGid: 4294967295 + asserts: + - failedTemplate: + errorPattern: "server.sandboxGid must be an integer between 1 and 4294967294" + + - it: rejects a non-numeric sandbox UID + set: + server.sandboxUid: abc + asserts: + - failedTemplate: + errorPattern: "server.sandboxUid must be an integer between 1 and 4294967294" + + - it: rejects a fractional sandbox UID + set: + server.sandboxUid: 1500.5 + asserts: + - failedTemplate: + errorPattern: "server.sandboxUid must be an integer between 1 and 4294967294" + + - it: rejects a boolean sandbox UID + set: + server.sandboxUid: true + asserts: + - failedTemplate: + errorPattern: "server.sandboxUid must be an integer between 1 and 4294967294" + + - it: rejects a boolean sandbox GID + set: + server.sandboxGid: true + asserts: + - failedTemplate: + errorPattern: "server.sandboxGid must be an integer between 1 and 4294967294" diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 4bf21b88a8..63e90a1e35 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -328,6 +328,13 @@ server: # Set to a RuntimeClass name (e.g. "kata-containers", "nvidia") to apply it # to all sandboxes that don't explicitly override it. defaultRuntimeClassName: "" + # -- UID for sandbox pods (`sandbox_uid`). Empty (default) = use the OpenShift + # SCC namespace annotation if present, otherwise the driver default. Must be + # an integer between 1 and 4294967294. + sandboxUid: "" + # -- GID for sandbox pods (`sandbox_gid`). Empty (default) = same as + # sandboxUid. Must be an integer between 1 and 4294967294. + sandboxGid: "" # -- gRPC endpoint sandboxes call back into the gateway. Leave empty to derive # it from the chart fullname, release namespace, service port, and # disableTls flag, for example https://openshell.openshell.svc.cluster.local:8080. diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 3b70608661..4cfd902daa 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -811,6 +811,7 @@ provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.soc # PVC init container. When unset, the driver auto-detects from OpenShift SCC # namespace annotations (openshift.io/sa.scc.uid-range) if present, falling # back to 1000 on non-OpenShift clusters. Any non-root Linux UID/GID is valid. +# Helm sets these with server.sandboxUid and server.sandboxGid. # sandbox_uid = 1500 # sandbox_gid = 1500 # Operator-mode namespace discovery. At least one must be set when diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index 505f2cc69b..c1090d1450 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -233,6 +233,7 @@ Only the OpenShell gateway and the Agent Sandbox controller should be able to ma | `supervisor_image` | `supervisor.image.*` | Override the supervisor image. | | `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Default workspace PVC size. | | `workspace_storage_class` | `server.workspaceStorageClass` | `StorageClass` for workspace PVCs. Set this if the cluster has no default `StorageClass`. | +| `sandbox_uid` / `sandbox_gid` | `server.sandboxUid` / `server.sandboxGid` | Sandbox pod UID and GID. Takes priority over OpenShift SCC namespace annotations. `sandbox_gid` defaults to the UID. | | `https_proxy` | `upstreamProxy.url` | Corporate proxy for sandbox egress. | | `no_proxy` | `upstreamProxy.noProxy` | Destinations that bypass the corporate proxy. | | `proxy_auth_secret_name` / `proxy_auth_secret_key` | `upstreamProxy.authSecret.name` / `.key` | Secret holding the proxy `user:pass` credential. | From aa8320940e8152669f5723ee64e8e9f5b673ac4c Mon Sep 17 00:00:00 2001 From: aipcc-bot Date: Fri, 2 Oct 2026 15:48:11 +0000 Subject: [PATCH 14/18] fix(konflux): remove unused gateway Z3 prefetch RHAI-4526: Remove the obsolete Z3 source artifact and build override now that the locked z3-sys uses z3-src. Signed-off-by: aipcc-bot --- deploy/docker/Dockerfile.konflux.gateway | 9 ++------- deploy/konflux/gateway/generic-fetcher.yaml | 5 ----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/deploy/docker/Dockerfile.konflux.gateway b/deploy/docker/Dockerfile.konflux.gateway index d462f16af8..005c3120ae 100644 --- a/deploy/docker/Dockerfile.konflux.gateway +++ b/deploy/docker/Dockerfile.konflux.gateway @@ -64,14 +64,9 @@ ARG OPENSHELL_VERSION="" # The alias re-adds `in-tree-compute-drivers`, which a bare # `--no-default-features` would drop, leaving a gateway with no compute driver. -# z3-sys bundled build needs prefetched Z3 source. -ARG Z3_VERSION=4.16.0 -RUN mkdir -p /tmp/z3-src \ - && tar xzf /cachi2/output/deps/generic/z3-${Z3_VERSION}.tar.gz -C /tmp/z3-src --strip-components=1 \ - && VER="${OPENSHELL_VERSION#v}" \ +RUN VER="${OPENSHELL_VERSION#v}" \ && if [ -n "$VER" ]; then export OPENSHELL_GIT_VERSION="$VER"; fi \ - && Z3_SYS_BUNDLED_DIR_OVERRIDE=/tmp/z3-src \ - cargo auditable build --release \ + && cargo auditable build --release \ --package openshell-gateway \ --no-default-features \ --features defaults-without-telemetry,vendored-z3 && \ diff --git a/deploy/konflux/gateway/generic-fetcher.yaml b/deploy/konflux/gateway/generic-fetcher.yaml index 84234767fd..f417c45dbf 100644 --- a/deploy/konflux/gateway/generic-fetcher.yaml +++ b/deploy/konflux/gateway/generic-fetcher.yaml @@ -25,8 +25,3 @@ artifacts: - download_url: https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-aarch64-unknown-linux-gnu.tar.xz checksum: "sha256:6d364879b516fa914f98504e551faddedf35b71543b4331a6d26e4e217778cfa" filename: cargo-auditable-0.7.5-aarch64-unknown-linux-gnu.tar.xz - - # Z3 source (z3-sys bundled build downloads from GitHub at build time) - - download_url: https://github.com/Z3Prover/z3/archive/refs/tags/z3-4.16.0.tar.gz - checksum: "sha256:c68c3e5e4810b16126b8cb4c47eee85c1ac3e24a81914c8e371b40de9dd33ac7" - filename: z3-4.16.0.tar.gz From 8e9136bbc388add62c4ae886b3cfdefe09a86f58 Mon Sep 17 00:00:00 2001 From: Florent BENOIT Date: Fri, 2 Oct 2026 16:09:09 +0000 Subject: [PATCH 15/18] fix(vm): codesign macOS driver-vm with hypervisor entitlement in CI (#3507) The release tarball ships an unsigned binary that fails at runtime when Hypervisor.framework rejects the caller. Sign with the existing entitlements plist during the build, before artifact upload. Closes #3506 Signed-off-by: Florent Benoit --- .github/actions/build-rust-binary/action.yml | 13 +++++++++++++ .github/workflows/build-vm-driver.yml | 2 ++ 2 files changed, 15 insertions(+) diff --git a/.github/actions/build-rust-binary/action.yml b/.github/actions/build-rust-binary/action.yml index 2bc4e3ce8c..1118e9e244 100644 --- a/.github/actions/build-rust-binary/action.yml +++ b/.github/actions/build-rust-binary/action.yml @@ -29,6 +29,10 @@ inputs: description: Additional flags passed to cargo build required: false default: "" + entitlements-file: + description: Path to an entitlements plist for macOS ad-hoc codesigning (skipped when empty or not on macOS) + required: false + default: "" runs: using: composite steps: @@ -66,6 +70,15 @@ runs: # Confirm Syft can decode the embedded cargo-auditable metadata. SYFT_CHECK_FOR_APP_UPDATE=false syft "file:${binary}" -o cyclonedx-json | grep 'pkg:cargo/' > /dev/null + - name: Codesign with entitlements + if: inputs.entitlements-file != '' && runner.os == 'macOS' + shell: bash + env: + INPUTS_ENTITLEMENTS: ${{ inputs.entitlements-file }} + INPUTS_TRIPLE: ${{ inputs.triple }} + INPUTS_BINARY: ${{ inputs.binary }} + run: /usr/bin/codesign --entitlements "${INPUTS_ENTITLEMENTS}" --force -s - "target/${INPUTS_TRIPLE}/release/${INPUTS_BINARY}" + - name: Upload ${{ inputs.binary }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml index 0893f57f75..a93f6928da 100644 --- a/.github/workflows/build-vm-driver.yml +++ b/.github/workflows/build-vm-driver.yml @@ -39,6 +39,7 @@ jobs: - arch: aarch64 triple: aarch64-apple-darwin runner: macos-15-xlarge + entitlements-file: crates/openshell-driver-vm/entitlements.plist runs-on: ${{ matrix.runner }} timeout-minutes: 60 defaults: @@ -109,3 +110,4 @@ jobs: triple: ${{ matrix.triple }} cargo-version: ${{ inputs['cargo-version'] }} supervisor-image-tag: ${{ inputs['supervisor-image-tag'] }} + entitlements-file: ${{ matrix.entitlements-file }} From 046fd2a0246d6765845b3abf6f9cb2c74005781f Mon Sep 17 00:00:00 2001 From: alangou Date: Fri, 2 Oct 2026 16:22:27 +0000 Subject: [PATCH 16/18] ci: pin CI images by digest and add native architecture smoke checks (#4114) * ci: pin CI images by digest and add native architecture smoke checks Signed-off-by: Adrien Langou * docs: resolve CI monitoring guidance conflict with main Signed-off-by: Adrien Langou --------- Signed-off-by: Adrien Langou --- .github/workflows/branch-checks.yml | 12 ++++++------ .github/workflows/cargo-deny.yml | 2 +- .github/workflows/deb-package.yml | 2 +- .github/workflows/e2e-docker-test.yml | 2 +- .github/workflows/e2e-gpu-test.yaml | 2 +- .github/workflows/helm-lint.yml | 2 +- .github/workflows/release-dev.yml | 4 ++-- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/release-vm-kernel.yml | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 780d6ddbd1..f60dedbe22 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -64,7 +64,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -89,7 +89,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -386,7 +386,7 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -422,7 +422,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -442,7 +442,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -462,7 +462,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index c85e9ac758..8f121551db 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -35,7 +35,7 @@ jobs: name: Cargo Deny runs-on: linux-amd64-cpu8 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 1ec52dc3a1..a710c45f0e 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -37,7 +37,7 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 20 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index 24eb4a54f7..f26c905d0a 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -50,7 +50,7 @@ jobs: matrix: include: ${{ fromJSON(inputs.suite-matrix) }} container: - image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70@sha256:ffa96b8009de6e28bbf157060440c3abb312d3bcda444e9571c539c8c6115615 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml index b363a0509b..dfc54598fe 100644 --- a/.github/workflows/e2e-gpu-test.yaml +++ b/.github/workflows/e2e-gpu-test.yaml @@ -38,7 +38,7 @@ jobs: runner: wsl-amd64-gpu-rtxpro6000-latest-1 experimental: true container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/helm-lint.yml b/.github/workflows/helm-lint.yml index 0ca4473d90..d60398e83b 100644 --- a/.github/workflows/helm-lint.yml +++ b/.github/workflows/helm-lint.yml @@ -81,7 +81,7 @@ jobs: if: needs.pr_metadata.outputs.should_run == 'true' && needs.helm_changes.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 961f61ecde..51ced81703 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -22,7 +22,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 5 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -201,7 +201,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 20 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index da0ef42df2..9f619ece33 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -34,7 +34,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 5 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -323,7 +323,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 20 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -805,7 +805,7 @@ jobs: contents: read packages: write container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-vm-kernel.yml b/.github/workflows/release-vm-kernel.yml index 8a31c3adbe..11518cadea 100644 --- a/.github/workflows/release-vm-kernel.yml +++ b/.github/workflows/release-vm-kernel.yml @@ -39,7 +39,7 @@ jobs: runs-on: linux-arm64-cpu8 timeout-minutes: 60 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -89,7 +89,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 60 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:9cb72baa2e61a1b5f12407e6e82da7fdba0aa722@sha256:67a9a0c32cb99825e6d3e9d9eec45d67149ea1ff7c11a1b1e1b3480d2d3df684 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} From ec49209da25be39840742df29b64ec694d159c2f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 2 Oct 2026 18:42:14 +0000 Subject: [PATCH 17/18] fix(providers): stabilize provider environment revisions (#4122) Signed-off-by: Drew Newberry --- crates/openshell-server/src/grpc/policy.rs | 42 ++++++++ .../src/provider_profile_sources.rs | 99 ++++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 639e6c88ed..ec65533bc5 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -13779,6 +13779,48 @@ mod tests { ); } + #[tokio::test] + async fn annotated_profile_has_stable_provider_environment_revision() { + let state = test_server_state().await; + let mut profile = openshell_providers::example_profiles::load("github").to_proto(); + profile.annotations = (0..8) + .map(|index| (format!("key-{index}"), format!("value-{index}"))) + .collect(); + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + profile, + )) + .await + .unwrap(); + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let sandbox = test_sandbox( + "sb-stable-provider-revision", + "stable-provider-revision", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + ); + state.store.put_message(&sandbox).await.unwrap(); + + let mut revisions = HashSet::new(); + for _ in 0..16 { + let config = load_sandbox_config(&state, &sandbox).await.unwrap(); + let environment = load_sandbox_provider_environment(&state, &sandbox, true) + .await + .unwrap(); + assert_eq!( + config.provider_env_revision, + environment.provider_env_revision + ); + revisions.insert(config.provider_env_revision); + } + assert_eq!(revisions.len(), 1); + } + #[tokio::test] async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() { use openshell_core::proto::{ diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 6728b2f0c1..d304efb2bc 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -9,7 +9,8 @@ use std::sync::Arc; use async_trait::async_trait; use openshell_core::GatewayProviderProfileSourceConfig; use openshell_core::mcp::normalize_provider_profile_mcp_fields; -use openshell_core::proto::ProviderProfile; +use openshell_core::policy_identity::canonical_rule_bytes; +use openshell_core::proto::{NetworkPolicyRule, ProviderProfile}; use openshell_gateway_interceptors::{ GatewayInterceptorProfileSource, GatewayInterceptorRuntime, ProviderProfileSourceSnapshot as InterceptorProfileSnapshot, @@ -102,7 +103,7 @@ impl ProviderProfileSource for UserProviderProfileSource { if let Some(profile) = stored.profile { let mut profile = profile_response_payload(profile, resource_version); normalize_provider_profile_mcp_fields(&mut profile); - hasher.update(profile.encode_to_vec()); + hasher.update(canonical_provider_profile_bytes(&profile)); profiles.push(ScopedSnapshotProfile { scope: ProfileScope::Platform, profile, @@ -123,7 +124,7 @@ impl ProviderProfileSource for UserProviderProfileSource { if let Some(profile) = stored.profile { let mut profile = profile_response_payload(profile, resource_version); normalize_provider_profile_mcp_fields(&mut profile); - hasher.update(profile.encode_to_vec()); + hasher.update(canonical_provider_profile_bytes(&profile)); profiles.push(ScopedSnapshotProfile { scope: ProfileScope::Workspace, profile, @@ -495,7 +496,38 @@ fn hash_scoped_profile_revision(entry: &ScopedProfileEntry, hasher: &mut Sha256) b"source-managed" }; hasher.update(ownership_tag); - hasher.update(entry.response.encode_to_vec()); + hasher.update(canonical_provider_profile_bytes(&entry.response)); +} + +/// Keep profile revision inputs stable across protobuf map iteration orders. +fn canonical_provider_profile_bytes(profile: &ProviderProfile) -> Vec { + let mut map_free = profile.clone(); + map_free.annotations.clear(); + map_free.endpoints.clear(); + + let mut out = Vec::new(); + append_canonical_bytes(&mut out, &map_free.encode_to_vec()); + let mut annotations = profile.annotations.iter().collect::>(); + annotations.sort_by_key(|(key, _)| key.as_str()); + out.extend_from_slice(&(annotations.len() as u64).to_le_bytes()); + for (key, value) in annotations { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, value.as_bytes()); + } + out.extend_from_slice(&(profile.endpoints.len() as u64).to_le_bytes()); + for endpoint in &profile.endpoints { + let rule = NetworkPolicyRule { + endpoints: vec![endpoint.clone()], + ..Default::default() + }; + append_canonical_bytes(&mut out, &canonical_rule_bytes(&rule)); + } + out +} + +fn append_canonical_bytes(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + out.extend_from_slice(bytes); } fn scope_to_string(scope: ProfileScope) -> &'static str { @@ -709,7 +741,7 @@ fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { let mut hasher = Sha256::new(); hasher.update(b"openshell-provider-profile-snapshot-v1"); for profile in profiles { - hasher.update(profile.encode_to_vec()); + hasher.update(canonical_provider_profile_bytes(&profile)); } format!("sha256:{:x}", hasher.finalize()) } @@ -1617,6 +1649,63 @@ mod tests { } } + #[tokio::test] + async fn unchanged_annotated_profile_has_stable_revisions() { + use std::collections::HashSet; + + let store = crate::persistence::test_store().await; + let mut stored = stored_profile_in_workspace("annotated-api", "default"); + stored.profile.as_mut().unwrap().annotations = (0..8) + .map(|index| (format!("key-{index}"), format!("value-{index}"))) + .collect(); + store.put_message(&stored).await.unwrap(); + + let sources = ProviderProfileSources::with_default_sources(); + let mut source_revisions = HashSet::new(); + let mut profile_revisions = HashSet::new(); + for _ in 0..32 { + let catalog = sources.snapshot_catalog(&store, "default").await.unwrap(); + source_revisions.insert(catalog.revision().to_string()); + let mut hasher = Sha256::new(); + catalog.hash_type_profile_revision_for_scope("annotated-api", "default", &mut hasher); + profile_revisions.insert(hasher.finalize().to_vec()); + } + + assert_eq!(source_revisions.len(), 1); + assert_eq!(profile_revisions.len(), 1); + } + + #[test] + fn canonical_profile_bytes_sort_nested_endpoint_maps() { + use openshell_core::proto::GraphqlOperation; + + let mut first = profile("mapped-endpoint"); + let endpoint = first.endpoints.first_mut().unwrap(); + endpoint.graphql_persisted_queries = (0..8) + .map(|index| (format!("query-{index}"), GraphqlOperation::default())) + .collect(); + let mut second = first.clone(); + second.endpoints[0].graphql_persisted_queries = (0..8) + .rev() + .map(|index| (format!("query-{index}"), GraphqlOperation::default())) + .collect(); + + assert_eq!( + canonical_provider_profile_bytes(&first), + canonical_provider_profile_bytes(&second) + ); + assert!( + second.endpoints[0] + .graphql_persisted_queries + .remove("query-0") + .is_some() + ); + assert_ne!( + canonical_provider_profile_bytes(&first), + canonical_provider_profile_bytes(&second) + ); + } + #[tokio::test] async fn cross_workspace_duplicate_profile_ids_do_not_collide() { let store = crate::persistence::test_store().await; From 7145dafce12b7263d1c84ecc13b04a6ada452431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Prpi=C4=8D?= Date: Fri, 2 Oct 2026 14:52:05 -0400 Subject: [PATCH 18/18] CARRY: fix(konflux): install Git in the CLI runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream issue 3957 (see commit 36819f4) makes uploads fail closed when Git repository discovery or filtering fails. CLI image installs git-core only in its builder. The separate runtime cannot run git rev-parse, so default uploads fail even for ordinary directories outside a Git work tree. Install git-core in the runtime using the existing prefetched RPM closure and clarify its build/runtime role in rpms.in.yaml. Run git --version as the final non-root user so image builds fail if Git is unavailable. Fixes: RHAI-4864 Co-authored-by: GPT-6.1 Sol Signed-off-by: Martin Prpič --- deploy/docker/Dockerfile.konflux.cli | 5 ++++- deploy/konflux/cli/rpms.in.yaml | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile.konflux.cli b/deploy/docker/Dockerfile.konflux.cli index 5114f513c2..815230169e 100644 --- a/deploy/docker/Dockerfile.konflux.cli +++ b/deploy/docker/Dockerfile.konflux.cli @@ -97,7 +97,7 @@ RUN . /tmp/build-env && \ # --------------------------------------------------------------------------- FROM registry.access.redhat.com/ubi9/ubi-minimal:9.8@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 -RUN microdnf install -y --nodocs ca-certificates crypto-policies-scripts \ +RUN microdnf install -y --nodocs ca-certificates crypto-policies-scripts git-core \ && microdnf clean all # Enable post-quantum cryptography support in the RHEL system policy. @@ -117,4 +117,7 @@ LABEL com.redhat.component="odh-openshell-cli-container" \ USER 1000:1000 +# Uploads need Git for repository discovery, even outside a Git work tree. +RUN git --version + ENTRYPOINT ["/usr/local/bin/openshell"] diff --git a/deploy/konflux/cli/rpms.in.yaml b/deploy/konflux/cli/rpms.in.yaml index 0ec7e8d2b3..e747a05608 100644 --- a/deploy/konflux/cli/rpms.in.yaml +++ b/deploy/konflux/cli/rpms.in.yaml @@ -39,6 +39,7 @@ packages: - gcc-c++ - cmake - openssl-devel + # Build and runtime: Git is required for CLI upload filtering. - git-core # Runtime stage - ca-certificates