solari-runloop-compat 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/BLUEPRINTS.md +2 -2
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/COMPAT.md +13 -7
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/PKG-INFO +3 -3
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/pyproject.toml +2 -2
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/__init__.py +1 -1
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_dockerfile.py +30 -82
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_gateway.py +30 -12
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_mapping.py +15 -2
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_context.py +4 -2
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_dockerfile.py +44 -32
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_gateway_wire.py +30 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_live_staging.py +16 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_mapping.py +41 -1
- solari_runloop_compat-0.1.0/solari_runloop/_guest/solari-run.sh +0 -14
- solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_extract.py +0 -101
- solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_sizecheck.py +0 -14
- solari_runloop_compat-0.1.0/tests/test_extract.py +0 -111
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/.gitignore +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/GATEWAY-ENDPOINTS.md +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/NOTICE +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/README.md +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_branding.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_client.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_config.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_context.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_exceptions.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_loop.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_models.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_resources.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_sync.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/lib/__init__.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/lib/polling.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/pagination.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/sdk.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/__init__.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_log.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_logs_list_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_parameters.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_async_execution_detail_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_execution_detail_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_snapshot_list_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_snapshot_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devboxes/__init__.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devboxes/devbox_logs_list_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/secret_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/__init__.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/after_idle.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/code_mount_parameters.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/launch_parameters.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/lifecycle_configuration.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/lifecycle_hooks.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/resume_triggers.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/tunnel_view.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/run_runloop_examples.sh +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_config.py +0 -0
- {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_surface.py +0 -0
|
@@ -42,7 +42,7 @@ pipeline) use `{op:"workdir", dir}`; `localFiles` is rejected (`LocalFilesUnsupp
|
|
|
42
42
|
`.dockerignore` is applied client-side when the SDK uploads the directory (`storage_object.upload_from_dir`).
|
|
43
43
|
Gateway work: `contextObjectId` validation (same org), the `copy` op in the build executor, ~2 days; tests for wrong-org objects.
|
|
44
44
|
|
|
45
|
-
## Level 3a: any public `FROM <image
|
|
45
|
+
## Level 3a: any public `FROM <image>` by chroot import (SUPERSEDED by 3b for new builds; Python compat now emits `fromImage`)
|
|
46
46
|
`FROM python:3.12-alpine`, `FROM node:20`, `FROM ghcr.io/org/img:tag`, and (with `build_args={"SOLARI_IMPORT": "1"}`) `FROM ubuntu`/`debian`
|
|
47
47
|
**as an imported image** instead of the native base. How: the build steps (a) install `crane` (pinned `v0.20.3`, tarball sha256 checked
|
|
48
48
|
at download, the binary's sha256 **re-checked before every use**, the build fails on a mismatch), (b) check the manifest size against
|
|
@@ -67,7 +67,7 @@ VM today (the builder disk size is not configurable yet, so big images fail on d
|
|
|
67
67
|
`/etc/solari-cmd` but **not started**; public registries only; amd64 only; absolute symlinks inside the image resolve against the host in the file API,
|
|
68
68
|
so reading such a path is refused (exec through `solari-run` resolves them correctly); processes from different `exec` calls share the chroot, but there is
|
|
69
69
|
no PID 1/init in the image (no systemd), and a `RUN` that needs a running daemon fails.
|
|
70
|
-
|
|
70
|
+
The Python compat layer no longer emits these steps (it sends `compiled.fromImage`, level 3b); the exec/fs marker-file handling stays for templates built earlier.
|
|
71
71
|
|
|
72
72
|
## Level 3b: native, for every SDK (assigned to bug1; archi-sandbox's block list applies)
|
|
73
73
|
guest-agent `rootDir` mode (exec chroots, fs ops use a symlink-safe join, rootDir immutable, capabilities dropped), `fromImage` on `POST /templates`
|
|
@@ -29,7 +29,7 @@ gateway endpoint (see `GATEWAY-ENDPOINTS.md`); **NO** raises `SolariCompatUnsupp
|
|
|
29
29
|
| `devbox.from_id(id)`, `devbox.list(**)` | OK, live | |
|
|
30
30
|
| `Devbox.get_info / await_running / await_suspended / shutdown / suspend / resume / keep_alive` | OK, live | `suspend`/`resume` = Solari pause/resume (RAM+disk). `keep_alive` re-arms the idle window the devbox was created with (default 30 min). |
|
|
31
31
|
| `with ... as devbox` (sync) / `async with` | OK | Shuts the devbox down on exit. |
|
|
32
|
-
| `devbox.cmd.exec(cmd, stdout=, stderr=, output=, last_n=)` | OK, live | Runs `bash -lc "<cmd>"` from `$HOME
|
|
32
|
+
| `devbox.cmd.exec(cmd, stdout=, stderr=, output=, last_n=)` | OK, live | Runs `bash -lc "<cmd>"` from `$HOME`; on a devbox built from an image (`FROM <image>`) it runs a non-login `bash -c` when the image has `/bin/bash` and `sh -c` when it does not (Alpine), so the image's own `PATH` survives (a login shell would reset it). As a consequence `~/.profile` and `/etc/profile.d` are not read on image devboxes. Callbacks receive lines. Output is never truncated (`*_truncated` is always false). `ExecutionResult.stdout(num_lines=)` etc. as in Runloop. |
|
|
33
33
|
| `devbox.cmd.exec_async(...)` -> `Execution` | DIFF | `result()`, `get_state()`, `kill()`, `send_std_in()`, `join()` work **inside the creating process** (an in-memory registry). On a gateway with durable executions (pinetree-desktop#570, detected by probing `GET /sandboxes/{id}/executions`) every command runs through it, so `executions.retrieve / await_completed / kill / send_std_in` also work by `(devbox_id, execution_id)` from **another process**; callbacks are fed by polling the execution (~0.4 s). Against a gateway without it, the in-process registry above is used. `close_std_in` NO. |
|
|
34
34
|
| `devbox.shell(name).exec / exec_async / delete`, `shell_name=` | DIFF | Wired to the gateway's named shells (pinetree-desktop#569): one persistent bash per name (cwd, env, functions, jobs persist). Output reaches `stdout=/stderr=/output=` callbacks when the command ends, not while it runs; a shell command cannot be killed or given stdin (end the shell with `shell.delete()`). Raises `SolariCompatUnsupported` on a gateway or sandbox image without shells (the guest image needs a new golden). Not yet run against staging: #569 is not deployed. |
|
|
35
35
|
| `devbox.file.read / write / upload / download` | OK, live | Relative paths resolve against `$HOME`; parent dirs are created on write/upload. `upload(file=)` takes bytes, `Path`, str path or a file object. |
|
|
@@ -38,7 +38,7 @@ gateway endpoint (see `GATEWAY-ENDPOINTS.md`); **NO** raises `SolariCompatUnsupp
|
|
|
38
38
|
| `devbox.get_tunnel()`, `get_tunnel_url(port)`, `net.enable_tunnel / get_tunnel / remove_tunnel`, create `tunnel={'auth_mode','wake_on_http','http_keep_alive'}` | DIFF, live | Real tunnel API (pinetree-desktop#575): one switch for every guest port, `https://<label>-<port>.<preview domain>`; `tunnel_key` is the `<label>`. `authenticated` (Solari's default) returns an `auth_token` that must be sent in the **`x-solari-preview-token`** header (Runloop uses `Authorization: Bearer`); `open` needs nothing. `get_tunnel()` returns `None` when no tunnel is enabled (as Runloop). **Difference:** Solari refuses `open` + `wake_on_http` (anonymous traffic could wake and bill a sandbox); the shim raises `SolariCompatUnsupported` for it, use `authenticated`. Verified on 2 gateway replicas: 40/40 requests succeed with the token, 20/20 are refused without it, revocation takes effect within 3 s. On a gateway without the tunnel API it falls back to an open per-port preview URL. |
|
|
39
39
|
| `devbox.logs(execution_id=, shell_name=)` | DIFF | Uses the gateway's `GET /sandboxes/{id}/logs` when it exists (feat2's work); until then it returns the output of commands run through **this client instance** (in-process). |
|
|
40
40
|
| `devbox.create_ssh_key()`, PTY tunnels, gateway/MCP tokens, `watch_evictions` | NO | Runloop platform features. |
|
|
41
|
-
| `blueprint.create(name, dockerfile=, system_setup_commands=, file_mounts=, base_blueprint_name=/id=, build_args=, launch_parameters=)` | DIFF, live | Builds a Solari template from a **Dockerfile subset** and waits for it (`FROM` ubuntu/debian = the native base; **any other public image** (e.g. `python:3.12-alpine`) is
|
|
41
|
+
| `blueprint.create(name, dockerfile=, system_setup_commands=, file_mounts=, base_blueprint_name=/id=, build_args=, launch_parameters=)` | DIFF, live | Builds a Solari template from a **Dockerfile subset** and waits for it (`FROM` ubuntu/debian = the native base; **any other public image** (e.g. `python:3.12-alpine`) is built natively by the gateway (`compiled.fromImage`), see the limits below; `RUN ENV ARG WORKDIR ENTRYPOINT CMD`; `COPY`/`ADD` with a `build_context` (see below); not multi-stage, `USER`). See `BLUEPRINTS.md`. `build_context`, `code_mounts`, `services`, `secrets` NO. |
|
|
42
42
|
| `blueprint.from_id / list`, `Blueprint.get_info / logs / delete` | OK, live | `list()` returns your org's custom templates only. `logs()` has no timestamps/levels (Solari returns plain lines). |
|
|
43
43
|
| `storage_object.upload_from_dir/bytes/text/file`, `as_build_context()`, `from_id`, `delete` | DIFF, live | Not real storage objects: the context is a deterministic `.tar.gz` **held by this client** and embedded in the build recipe, capped at ~700 KB compressed (the template body limit is 1 MiB). Honours `.dockerignore`. `COPY`/`ADD` (incl. `--chown`, `--chmod`, globs, `ADD` of a local tar) copy from it; `COPY --from`, URLs, `../` sources are refused. A context uploaded by another process is `SolariCompatUnsupported`. Everything else on `storage_object` (create, list, download, mounts) -> GATEWAY (objects). |
|
|
44
44
|
| `secret.create / from_name / from_id / update / list / delete`, `Secret.get_info / update / delete`, `client.secrets.*`, create `secrets={ENV: secret_name}` | DIFF | Solari account secrets (pinetree-desktop#583): **write-only** (no route returns a value), names `[A-Za-z][A-Za-z0-9_.-]{0,63}` double as ids, values up to 16 KiB, 200 per org, at most 20 per create, reserved env names (`LD_*`, `PATH`, `HOME`...) refused. Not exercised on staging yet: the gateway answers 501 until the org-secrets key exists, and the shim then raises `SolariCompatUnsupported`. |
|
|
@@ -86,16 +86,22 @@ figures are not forwarded). No size at all leaves Solari's defaults (2 vCPU / 2
|
|
|
86
86
|
| `XX_LARGE` | 8 vCPU, 32 GiB | 8 vCPU, **16 GiB (capped)** |
|
|
87
87
|
| `CUSTOM_SIZE` | `custom_cpu_cores`, `custom_gb_memory` | ceil(cpu) capped at 8; memory x 1024 MiB capped at 16384 |
|
|
88
88
|
|
|
89
|
-
###
|
|
90
|
-
The
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
### `FROM <image>` limits (native image build, BLUEPRINTS.md level 3b)
|
|
90
|
+
The gateway pulls the image (public registries from an allowlist only, amd64 only, no credentials), unpacks it, and runs `RUN` steps and `cmd.exec`
|
|
91
|
+
inside it; the root is a chroot in your own microVM (not a security boundary); no init/systemd inside the image. The image needs a shell for `RUN`.
|
|
92
|
+
Needs a gateway with native image builds (`POST /templates` accepting `compiled.fromImage`); until then the build fails instead of silently building a plain base.
|
|
93
|
+
|
|
94
|
+
**The image's own `ENV` is carried over**: `PATH`, `GOPATH`, `JAVA_HOME` and the like come from the image config, so `FROM golang:1.22` has `go` on `PATH`.
|
|
95
|
+
`ENV` values may refer to them (`ENV PATH=/opt/x/bin:$PATH` keeps the image's `/go/bin:/usr/local/go/bin`); the gateway expands `$NAME` against the
|
|
96
|
+
image's env and earlier `ENV` lines when it builds. `ARG` values are expanded by the client. Images from before this change (built with the old chroot
|
|
97
|
+
import) keep working but still lack the image's `ENV`: rebuild them.
|
|
93
98
|
|
|
94
99
|
## Status, errors, pagination
|
|
95
100
|
- Status (Solari state -> `DevboxView.status`): `starting`->`initializing`, `running`->`running`, `paused`->`suspended`,
|
|
96
|
-
`archived`->`suspended`, `releasing`->`shutdown`, `gone`->`shutdown`; anything else `failure`.
|
|
101
|
+
`archived`->`suspended`, `stopped`->`shutdown`, `releasing`->`shutdown`, `gone`->`shutdown`; anything else `failure`.
|
|
97
102
|
`DevboxView` fields Solari does not have are synthesized: `create_time_ms` from `createdAt`, one `state_transitions`
|
|
98
103
|
entry, `launch_parameters` from the real `cpu`/`memMb`, `capabilities=[]`.
|
|
104
|
+
- Solari's `stopped` state (guest powered itself off; `POST /sandboxes/:id/start`) has no Runloop equivalent: there is no `Devbox.start`, and a `stopped` sandbox reports status `shutdown` (same as the TypeScript package). Use `solari_sandbox`'s `start()` directly.
|
|
99
105
|
- Errors: 400 `BadRequestError`, 401 `AuthenticationError`, 402 and 403 `PermissionDeniedError` (`status_code` keeps 402),
|
|
100
106
|
404 `NotFoundError`, 409 `ConflictError`, 422 `UnprocessableEntityError`, 429 `RateLimitError`, 5xx `InternalServerError`;
|
|
101
107
|
network -> `APIConnectionError`, timeouts -> `APITimeoutError`. `.body` is Solari's `{code,error,message,retryable}`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: solari-runloop-compat
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop.
|
|
5
5
|
Project-URL: Homepage, https://getsolari.com
|
|
6
6
|
Author: Solari
|
|
@@ -9,8 +9,8 @@ License-File: NOTICE
|
|
|
9
9
|
Keywords: compatibility,runloop,sandbox,solari
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Requires-Dist: pydantic<3,>=2
|
|
12
|
-
Requires-Dist: solari-core>=0.
|
|
13
|
-
Requires-Dist: solari-sandbox>=0.
|
|
12
|
+
Requires-Dist: solari-core>=0.4.0
|
|
13
|
+
Requires-Dist: solari-sandbox>=0.3.0
|
|
14
14
|
Provides-Extra: test
|
|
15
15
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
16
16
|
Requires-Dist: pytest>=7; extra == 'test'
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "solari-runloop-compat"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = [{ name = "Solari" }]
|
|
13
13
|
keywords = ["solari", "sandbox", "runloop", "compatibility"]
|
|
14
|
-
dependencies = ["solari-sandbox>=0.
|
|
14
|
+
dependencies = ["solari-sandbox>=0.3.0", "solari-core>=0.4.0", "pydantic>=2,<3"]
|
|
15
15
|
|
|
16
16
|
[project.optional-dependencies]
|
|
17
17
|
test = ["pytest>=7", "pytest-asyncio>=0.23"]
|
|
@@ -5,7 +5,8 @@ result, so a Dockerfile maps onto that recipe rather than onto an OCI image buil
|
|
|
5
5
|
that model can do faithfully; everything else raises :class:`SolariCompatUnsupported` naming the instruction
|
|
6
6
|
(BLUEPRINTS.md describes the full design and the path to arbitrary base images).
|
|
7
7
|
|
|
8
|
-
Supported: ``FROM`` (one stage; ubuntu/debian-family or a Solari base/template
|
|
8
|
+
Supported: ``FROM`` (one stage; ubuntu/debian-family or a Solari base/template map to the native base, any other public
|
|
9
|
+
image becomes the gateway's native ``compiled.fromImage`` build, BLUEPRINTS.md level 3b), ``RUN``, ``ENV``, ``ARG`` (defaults and
|
|
9
10
|
``--build-arg``), ``WORKDIR``, ``ENTRYPOINT``, ``CMD``, ``LABEL``/``EXPOSE``/``MAINTAINER`` (ignored), ``SHELL`` (bash only).
|
|
10
11
|
Not yet: ``COPY``/``ADD`` (need build-context objects), multi-stage, ``USER``, ``VOLUME``, ``HEALTHCHECK``, ``ONBUILD``.
|
|
11
12
|
"""
|
|
@@ -18,7 +19,6 @@ import posixpath
|
|
|
18
19
|
import re
|
|
19
20
|
import logging
|
|
20
21
|
import shlex
|
|
21
|
-
from pathlib import Path
|
|
22
22
|
from typing import Any, Dict, List, Optional, Tuple
|
|
23
23
|
|
|
24
24
|
from . import _exceptions as ex
|
|
@@ -30,30 +30,12 @@ DEBIAN_FAMILY = {"ubuntu", "debian"}
|
|
|
30
30
|
#: Names that select a Solari golden base directly.
|
|
31
31
|
SOLARI_BASES = {"base", "workstation"}
|
|
32
32
|
|
|
33
|
-
#: Pinned build tool for image import (BLUEPRINTS.md, level 3a). The build FAILS if the checksum does not match.
|
|
34
|
-
CRANE_VERSION = "v0.20.3"
|
|
35
|
-
CRANE_SHA256 = "36c67a932f489b3f2724b64af90b599a8ef2aa7b004872597373c0ad694dc059"
|
|
36
|
-
CRANE_URL = f"https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz"
|
|
37
|
-
ROOT_DIR = "/srv/root"
|
|
38
33
|
CONTEXT_DIR = "/tmp/solari-context"
|
|
39
34
|
#: Registries the import accepts (public pulls only; there are no credentials in the build).
|
|
40
35
|
REGISTRY_ALLOWLIST = ("docker.io", "index.docker.io", "registry-1.docker.io", "ghcr.io", "public.ecr.aws", "gcr.io", "quay.io", "mcr.microsoft.com")
|
|
41
36
|
#: ``build_args`` switch that forces image import even for ubuntu/debian images (default: those use the native base).
|
|
42
37
|
IMPORT_ARG = "SOLARI_IMPORT"
|
|
43
38
|
|
|
44
|
-
_GUEST = Path(__file__).parent / "_guest"
|
|
45
|
-
#: Runs a command inside the imported root (ENV and WORKDIR applied); installed into the builder, so into every
|
|
46
|
-
#: devbox created from the template. The compat layer's exec wrapper calls it too.
|
|
47
|
-
SOLARI_RUN = (_GUEST / "solari-run.sh").read_text()
|
|
48
|
-
#: Validating tar extractor (see its docstring for the guards).
|
|
49
|
-
SOLARI_EXTRACT = (_GUEST / "solari_extract.py").read_text()
|
|
50
|
-
SOLARI_SIZECHECK = (_GUEST / "solari_sizecheck.py").read_text()
|
|
51
|
-
#: Caps for one import: compressed layers (checked from the manifest before downloading), extracted bytes, wall clock.
|
|
52
|
-
MAX_COMPRESSED_BYTES = 2 << 30
|
|
53
|
-
MAX_EXTRACTED_BYTES = 4 << 30
|
|
54
|
-
IMPORT_TIMEOUT_S = 900
|
|
55
|
-
CRANE_BIN_SHA256 = "675f3b2f1696c1f6bc55b1ef535163364119776999f3d1471e4558ed35bab548"
|
|
56
|
-
|
|
57
39
|
|
|
58
40
|
def logical_lines(text: str) -> List[Tuple[str, str]]:
|
|
59
41
|
"""Join ``\\`` continuations, drop comments/blank lines, split into ``(INSTRUCTION, rest)``."""
|
|
@@ -84,9 +66,14 @@ def logical_lines(text: str) -> List[Tuple[str, str]]:
|
|
|
84
66
|
DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
85
67
|
|
|
86
68
|
|
|
87
|
-
def _subst(s: str, args: Dict[str, str]) -> str:
|
|
69
|
+
def _subst(s: str, args: Dict[str, str], only: Optional[set] = None) -> str:
|
|
70
|
+
"""Expand ``$NAME`` / ``${NAME}``. With ``only``, names outside it stay literal (the gateway expands them against the
|
|
71
|
+
image's own config env when it builds an ``ENV`` step)."""
|
|
72
|
+
|
|
88
73
|
def rep(m: "re.Match[str]") -> str:
|
|
89
74
|
name = m.group(1) or m.group(2)
|
|
75
|
+
if only is not None and name not in only:
|
|
76
|
+
return m.group(0)
|
|
90
77
|
return args.get(name, "")
|
|
91
78
|
|
|
92
79
|
return re.sub(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))", rep, s)
|
|
@@ -118,7 +105,7 @@ def _registry_of(image: str) -> str:
|
|
|
118
105
|
|
|
119
106
|
|
|
120
107
|
def _resolve_from(rest: str, args: Dict[str, str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
121
|
-
"""-> ``(base, from_template,
|
|
108
|
+
"""-> ``(base, from_template, from_image)`` for the CompiledImage."""
|
|
122
109
|
tokens = [t for t in rest.split() if not t.startswith("--")]
|
|
123
110
|
if "AS" in [t.upper() for t in tokens[1:]]:
|
|
124
111
|
raise ex.SolariCompatUnsupported("Multi-stage Dockerfile (FROM ... AS)", "Single-stage builds only.")
|
|
@@ -143,7 +130,7 @@ def _resolve_from(rest: str, args: Dict[str, str]) -> Tuple[Optional[str], Optio
|
|
|
143
130
|
if registry not in REGISTRY_ALLOWLIST:
|
|
144
131
|
raise ex.SolariCompatUnsupported(
|
|
145
132
|
f"FROM {image}",
|
|
146
|
-
f"
|
|
133
|
+
f"FROM <image> only builds public images from {', '.join(REGISTRY_ALLOWLIST)} (no registry credentials in builds).",
|
|
147
134
|
)
|
|
148
135
|
return None, None, image
|
|
149
136
|
|
|
@@ -166,7 +153,8 @@ def compile_dockerfile(
|
|
|
166
153
|
args: Dict[str, str] = {"PATH": DEFAULT_PATH, **(build_args or {})} # ARG + ENV, for $VAR substitution
|
|
167
154
|
arg_only: Dict[str, str] = dict(build_args or {}) # ARGs, exported into each RUN
|
|
168
155
|
seen_from = False
|
|
169
|
-
|
|
156
|
+
from_image: Optional[str] = None
|
|
157
|
+
arg_names: set = set(build_args or {}) # ARG / --build-arg names: expanded here; ENV names are expanded by the gateway
|
|
170
158
|
workdir = "/"
|
|
171
159
|
context_loaded = False
|
|
172
160
|
|
|
@@ -179,47 +167,39 @@ def compile_dockerfile(
|
|
|
179
167
|
if base_blueprint_name:
|
|
180
168
|
log.warning("base_blueprint_name %r overrides the Dockerfile FROM", base_blueprint_name)
|
|
181
169
|
else:
|
|
182
|
-
base, from_template,
|
|
183
|
-
if
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
steps.extend(import_steps(import_image))
|
|
170
|
+
base, from_template, from_image = fb, ft, img
|
|
171
|
+
if from_image:
|
|
172
|
+
# Native image build (level 3b): RUN steps and exec run inside the unpacked image, and the gateway
|
|
173
|
+
# expands ENV values against the image's own config env, so the seed PATH is not a Dockerfile variable.
|
|
174
|
+
if "PATH" not in (build_args or {}):
|
|
175
|
+
args.pop("PATH", None)
|
|
190
176
|
elif instr == "ARG":
|
|
191
177
|
k, eq, v = rest.partition("=")
|
|
192
178
|
k = k.strip()
|
|
193
179
|
args.setdefault(k, v.strip().strip('"') if eq else "")
|
|
194
180
|
arg_only[k] = args[k]
|
|
181
|
+
arg_names.add(k)
|
|
195
182
|
elif instr == "ENV":
|
|
196
|
-
for k, v in _parse_env(_subst(rest, args)):
|
|
183
|
+
for k, v in _parse_env(_subst(rest, args, arg_names) if from_image else _subst(rest, args)):
|
|
197
184
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
|
|
198
185
|
raise ex.SolariCompatUnsupported(f"ENV name {k!r}", "Environment variable names must match [A-Za-z_][A-Za-z0-9_]*.")
|
|
199
186
|
args[k] = v
|
|
187
|
+
arg_names.discard(k)
|
|
200
188
|
steps.append({"op": "env", "key": k, "value": v})
|
|
201
|
-
if import_image:
|
|
202
|
-
steps.append(_run(f"printf 'export %s=%s\\n' {shlex.quote(k)} {shlex.quote(shlex.quote(v))} >> /etc/solari-env"))
|
|
203
189
|
elif instr == "RUN":
|
|
204
190
|
cmd = " ".join(shlex.quote(a) for a in json.loads(rest)) if rest.startswith("[") else _subst_run(rest, arg_only)
|
|
205
|
-
steps.append(_run(
|
|
191
|
+
steps.append(_run(cmd))
|
|
206
192
|
elif instr == "WORKDIR":
|
|
207
193
|
wd = _subst(rest, args)
|
|
208
194
|
wd = wd if wd.startswith("/") else posixpath.normpath(posixpath.join(workdir, wd))
|
|
209
195
|
workdir = wd
|
|
210
196
|
steps.append({"op": "workdir", "dir": wd})
|
|
211
|
-
if import_image:
|
|
212
|
-
steps.append(_run(f"echo {shlex.quote(wd)} > /etc/solari-workdir && solari-run {shlex.quote('mkdir -p ' + shlex.quote(wd))}"))
|
|
213
197
|
elif instr == "ENTRYPOINT":
|
|
214
198
|
cmdv = _json_or_shell(_subst(rest, args))
|
|
215
199
|
steps.append({"op": "entrypoint", "command": cmdv})
|
|
216
|
-
if import_image:
|
|
217
|
-
steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-entrypoint"))
|
|
218
200
|
elif instr == "CMD":
|
|
219
201
|
cmdv = _json_or_shell(_subst(rest, args))
|
|
220
202
|
steps.append({"op": "cmd", "command": cmdv})
|
|
221
|
-
if import_image:
|
|
222
|
-
steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-cmd"))
|
|
223
203
|
elif instr in ("LABEL", "EXPOSE", "MAINTAINER", "STOPSIGNAL"):
|
|
224
204
|
continue
|
|
225
205
|
elif instr == "SHELL":
|
|
@@ -231,15 +211,15 @@ def compile_dockerfile(
|
|
|
231
211
|
if not context_loaded:
|
|
232
212
|
steps.append(_context_step(context))
|
|
233
213
|
context_loaded = True
|
|
234
|
-
steps.append(_copy_step(instr, _subst(rest, args), workdir
|
|
214
|
+
steps.append(_copy_step(instr, _subst(rest, args), workdir))
|
|
235
215
|
else:
|
|
236
216
|
raise ex.SolariCompatUnsupported(instr)
|
|
237
217
|
|
|
238
218
|
for cmd in system_setup_commands or []:
|
|
239
|
-
steps.append(_run(
|
|
219
|
+
steps.append(_run(cmd))
|
|
240
220
|
for path, contents in (file_mounts or {}).items():
|
|
241
221
|
b64 = base64.b64encode(contents.encode()).decode()
|
|
242
|
-
dest =
|
|
222
|
+
dest = path
|
|
243
223
|
steps.append(_run(f"mkdir -p \"$(dirname {shlex.quote(dest)})\" && echo {b64} | base64 -d > {shlex.quote(dest)}"))
|
|
244
224
|
if not seen_from and not from_template:
|
|
245
225
|
base = "base"
|
|
@@ -248,6 +228,8 @@ def compile_dockerfile(
|
|
|
248
228
|
out: Dict[str, Any] = {"kind": "sandbox", "packages": [], "steps": steps, "fragment": "", "localFiles": []}
|
|
249
229
|
if from_template:
|
|
250
230
|
out["fromTemplate"] = from_template
|
|
231
|
+
elif from_image:
|
|
232
|
+
out["fromImage"] = from_image
|
|
251
233
|
else:
|
|
252
234
|
out["base"] = base or "base"
|
|
253
235
|
return out
|
|
@@ -265,40 +247,6 @@ def _run(command: str) -> Dict[str, Any]:
|
|
|
265
247
|
return {"op": "run", "command": command}
|
|
266
248
|
|
|
267
249
|
|
|
268
|
-
def import_steps(image: str) -> List[Dict[str, Any]]:
|
|
269
|
-
"""Build steps that pull ``image`` into ``/srv/root`` and install the ``solari-run`` wrapper."""
|
|
270
|
-
b64 = lambda text: base64.b64encode(text.encode()).decode() # noqa: E731
|
|
271
|
-
img = shlex.quote(image)
|
|
272
|
-
crane_ok = f"echo '{CRANE_BIN_SHA256} /usr/local/bin/crane' | sha256sum -c - >/dev/null || {{ echo 'solari: crane binary checksum mismatch' >&2; exit 1; }}"
|
|
273
|
-
size_check = f"python3 /usr/local/bin/solari-size-check \"$SOLARI_IMAGE\" {MAX_COMPRESSED_BYTES}"
|
|
274
|
-
return [
|
|
275
|
-
_run(
|
|
276
|
-
"set -e; mkdir -p /usr/local/bin; if [ ! -x /usr/local/bin/crane ]; then curl -fsSL " + shlex.quote(CRANE_URL) + " -o /tmp/crane.tgz && "
|
|
277
|
-
f"echo '{CRANE_SHA256} /tmp/crane.tgz' | sha256sum -c - && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane && rm -f /tmp/crane.tgz; fi; " + crane_ok
|
|
278
|
-
),
|
|
279
|
-
_run(
|
|
280
|
-
f"echo {b64(SOLARI_EXTRACT)} | base64 -d > /usr/local/bin/solari-extract && echo {b64(SOLARI_SIZECHECK)} | base64 -d > /usr/local/bin/solari-size-check && "
|
|
281
|
-
"chmod 0755 /usr/local/bin/solari-extract /usr/local/bin/solari-size-check"
|
|
282
|
-
),
|
|
283
|
-
# Build steps run under plain sh, so the pipeline (which needs pipefail) runs in an explicit bash.
|
|
284
|
-
_run(
|
|
285
|
-
"bash -c "
|
|
286
|
-
+ shlex.quote(
|
|
287
|
-
"set -eo pipefail; " + crane_ok + f"; export DOCKER_CONFIG=$(mktemp -d) SOLARI_IMAGE={img}; "
|
|
288
|
-
+ size_check
|
|
289
|
-
+ f"; rm -rf {ROOT_DIR}; mkdir -p {ROOT_DIR}; "
|
|
290
|
-
f"timeout {IMPORT_TIMEOUT_S} crane export --platform linux/amd64 {img} - | python3 /usr/local/bin/solari-extract {ROOT_DIR} {MAX_EXTRACTED_BYTES}; "
|
|
291
|
-
'rm -rf "$DOCKER_CONFIG"; '
|
|
292
|
-
f"chroot {ROOT_DIR} /bin/sh -c true 2>/dev/null || {{ echo {shlex.quote('solari: image ' + image + ' has no /bin/sh; image import needs a shell (distroless images are not supported)')} >&2; exit 1; }}"
|
|
293
|
-
)
|
|
294
|
-
),
|
|
295
|
-
_run(
|
|
296
|
-
f"echo {b64(SOLARI_RUN)} | base64 -d > /usr/local/bin/solari-run && chmod 0755 /usr/local/bin/solari-run && "
|
|
297
|
-
f"echo {ROOT_DIR} > /etc/solari-rootdir && : > /etc/solari-env && echo / > /etc/solari-workdir"
|
|
298
|
-
),
|
|
299
|
-
]
|
|
300
|
-
|
|
301
|
-
|
|
302
250
|
_GLOB_SAFE = re.compile(r"[A-Za-z0-9_./*?\[\]+@%-]+")
|
|
303
251
|
|
|
304
252
|
|
|
@@ -306,8 +254,8 @@ def _context_step(context: bytes) -> Dict[str, Any]:
|
|
|
306
254
|
return _run(f"rm -rf {CONTEXT_DIR} && mkdir -p {CONTEXT_DIR} && echo {base64.b64encode(context).decode()} | base64 -d | tar -xz -C {CONTEXT_DIR}")
|
|
307
255
|
|
|
308
256
|
|
|
309
|
-
def _copy_step(instr: str, rest: str, workdir: str
|
|
310
|
-
"""``COPY``/``ADD`` from the extracted build context into the image
|
|
257
|
+
def _copy_step(instr: str, rest: str, workdir: str) -> Dict[str, Any]:
|
|
258
|
+
"""``COPY``/``ADD`` from the extracted build context into the image."""
|
|
311
259
|
parts = shlex.split(rest) if not rest.startswith("[") else json.loads(rest)
|
|
312
260
|
flags = [p for p in parts if p.startswith("--")]
|
|
313
261
|
parts = [p for p in parts if not p.startswith("--")]
|
|
@@ -327,7 +275,7 @@ def _copy_step(instr: str, rest: str, workdir: str, imported: bool) -> Dict[str,
|
|
|
327
275
|
abs_dest = posixpath.normpath(dest if dest.startswith("/") else posixpath.join(workdir, dest))
|
|
328
276
|
if dest.endswith("/") or dest in (".", "./"):
|
|
329
277
|
abs_dest = abs_dest.rstrip("/") + "/"
|
|
330
|
-
real_dest =
|
|
278
|
+
real_dest = abs_dest
|
|
331
279
|
quoted = " ".join(s if _GLOB_SAFE.fullmatch(s) else shlex.quote(s) for s in srcs)
|
|
332
280
|
into_dir = dest.endswith("/") or len(srcs) > 1
|
|
333
281
|
q_dest = shlex.quote(real_dest)
|
|
@@ -117,6 +117,7 @@ class Gateway:
|
|
|
117
117
|
self._starting_after: Optional[bool] = None
|
|
118
118
|
self._objects: Optional[bool] = None # gateway has /objects (#574)
|
|
119
119
|
self._native: set = set() # devboxes whose guest agent chroots into an imported image itself
|
|
120
|
+
self._plain: set = set() # devboxes known NOT to be a native image build
|
|
120
121
|
self._tunnel_api: Optional[bool] = None # gateway has /sandboxes/{id}/tunnel (#575)
|
|
121
122
|
self._durable: Optional[bool] = None # gateway has /sandboxes/{id}/executions (pinetree-desktop#570)
|
|
122
123
|
self._remote: Dict[str, str] = {} # execution id -> devbox id, for executions started through the durable API
|
|
@@ -325,22 +326,31 @@ class Gateway:
|
|
|
325
326
|
raise mp.map_error(e) from e
|
|
326
327
|
raise mp.map_error(last) from last # type: ignore[arg-type]
|
|
327
328
|
|
|
329
|
+
async def _is_native(self, devbox_id: str) -> bool:
|
|
330
|
+
"""3b: the guest agent chroots natively, so exec/fs can't see the host markers. The gateway says so on the sandbox view
|
|
331
|
+
(`fromImage` / `rootDir`): then no wrapper, no path prefix, no cwd from us, and no bash (an Alpine image has none) or
|
|
332
|
+
login shell (it would reset the image's PATH)."""
|
|
333
|
+
if devbox_id in self._native:
|
|
334
|
+
return True
|
|
335
|
+
# One GET per new devbox id; `_plain` caches the negative so exec does not pay it again. Keep it.
|
|
336
|
+
if devbox_id not in self._plain:
|
|
337
|
+
view = await self.get_raw(devbox_id)
|
|
338
|
+
if view.get("fromImage") or view.get("rootDir"):
|
|
339
|
+
self._native.add(devbox_id)
|
|
340
|
+
return True
|
|
341
|
+
self._plain.add(devbox_id)
|
|
342
|
+
return False
|
|
343
|
+
|
|
328
344
|
async def _env(self, devbox_id: str) -> Tuple[str, Optional[str], str]:
|
|
329
345
|
"""``(home, image_root, workdir)``: ``image_root`` is set for devboxes made from an image-import blueprint."""
|
|
330
346
|
if devbox_id not in self._home:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
)
|
|
347
|
+
native = await self._is_native(devbox_id)
|
|
348
|
+
probe = 'printf "%s\\n%s\\n%s\\n%s\\n" "$HOME" "$(cat /etc/solari-rootdir 2>/dev/null)" "$(cat /etc/solari-workdir 2>/dev/null)" ""'
|
|
349
|
+
pcmd, pargs = mp.shell_wrap(probe, native=native)
|
|
350
|
+
r = await self._on_channel(devbox_id, lambda h: h.commands.run(pcmd, args=pargs))
|
|
335
351
|
lines = (r.stdout.split("\n") + ["", "", "", ""])[:4]
|
|
336
352
|
home = lines[0].strip() or "/root"
|
|
337
|
-
# 3b: the guest agent chroots natively, so exec/fs can't see the host markers. The gateway says so on the
|
|
338
|
-
# sandbox view (`fromImage` / `rootDir`): then no wrapper, no path prefix, no cwd from us.
|
|
339
|
-
view = await self.get_raw(devbox_id)
|
|
340
|
-
native = bool(view.get("fromImage") or view.get("rootDir"))
|
|
341
353
|
root = None if native else (lines[1].strip() or None)
|
|
342
|
-
if native:
|
|
343
|
-
self._native.add(devbox_id)
|
|
344
354
|
# Some guest images name a $HOME that does not exist yet.
|
|
345
355
|
await self._on_channel(devbox_id, lambda h: h.commands.run("/bin/mkdir", args=["-p", home]))
|
|
346
356
|
self._home[devbox_id] = (home, root, lines[2].strip() or "/")
|
|
@@ -388,7 +398,7 @@ class Gateway:
|
|
|
388
398
|
asyncio.get_running_loop().create_task(self._remote_watch(rec))
|
|
389
399
|
return rec
|
|
390
400
|
home, root, _wd = await self._env(devbox_id) # Runloop runs commands from the user's home directory.
|
|
391
|
-
cmd, args = mp.shell_wrap(f"solari-run {shlex.quote(command)}" if root else command)
|
|
401
|
+
cmd, args = mp.shell_wrap(f"solari-run {shlex.quote(command)}" if root else command, native=devbox_id in self._native)
|
|
392
402
|
cwd = None if devbox_id in self._native else home
|
|
393
403
|
ch = await self._on_channel(devbox_id, lambda h: h.commands.start(cmd, args=args, cwd=cwd))
|
|
394
404
|
rec = _Exec(execution_id=f"exec_{uuid.uuid4().hex}", devbox_id=devbox_id, command=command, handle=ch, callbacks=(stdout, stderr, output))
|
|
@@ -435,7 +445,8 @@ class Gateway:
|
|
|
435
445
|
)
|
|
436
446
|
|
|
437
447
|
async def remote_start(self, devbox_id: str, command: str, callbacks: Tuple[Optional[LogCallback], Optional[LogCallback], Optional[LogCallback]]) -> DevboxAsyncExecutionDetailView:
|
|
438
|
-
|
|
448
|
+
cmd, args = mp.shell_wrap(self._remote_script(), native=await self._is_native(devbox_id))
|
|
449
|
+
body = {"cmd": cmd, "args": args, "env": {"SOLARI_USER_CMD": command}}
|
|
439
450
|
d = await self._req("POST", f"/sandboxes/{_q(devbox_id)}/executions", body, idempotency_key=new_idempotency_key())
|
|
440
451
|
self._remote[d["executionId"]] = devbox_id
|
|
441
452
|
if any(callbacks):
|
|
@@ -786,6 +797,13 @@ class Gateway:
|
|
|
786
797
|
if mem_mb:
|
|
787
798
|
body["memMb"] = mem_mb
|
|
788
799
|
d = await self._req("POST", "/templates", body, idempotency_key=new_idempotency_key())
|
|
800
|
+
if compiled.get("fromImage") and not d.get("fromImage"):
|
|
801
|
+
# A gateway that predates native image builds ignores `fromImage` and would build a plain base template.
|
|
802
|
+
try:
|
|
803
|
+
await self.blueprint_delete(d["templateId"])
|
|
804
|
+
except Exception: # noqa: BLE001 - best effort: the build row is useless either way
|
|
805
|
+
log.warning("could not discard template %s after refusing a gateway without native image builds", d.get("templateId"))
|
|
806
|
+
raise ex.SolariCompatUnsupported("FROM <image>", "This gateway has no native image builds (compiled.fromImage) yet.")
|
|
789
807
|
return mp.to_blueprint_view(d)
|
|
790
808
|
|
|
791
809
|
async def blueprint_get(self, blueprint_id: str) -> BlueprintView:
|
|
@@ -73,6 +73,8 @@ STATUS_MAP: Dict[str, str] = {
|
|
|
73
73
|
"running": "running",
|
|
74
74
|
"paused": "suspended",
|
|
75
75
|
"archived": "suspended",
|
|
76
|
+
# Guest powered itself off; only Solari's start() can boot it again and Runloop has no such verb, so it reads as ended.
|
|
77
|
+
"stopped": "shutdown",
|
|
76
78
|
"releasing": "shutdown",
|
|
77
79
|
"gone": "shutdown",
|
|
78
80
|
}
|
|
@@ -287,8 +289,19 @@ def translate_errors() -> Iterator[None]:
|
|
|
287
289
|
# --- exec -----------------------------------------------------------------------------------
|
|
288
290
|
|
|
289
291
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
#: Native image devboxes: a NON-login bash when the image has one (Runloop semantics: `[[ ]]`, arrays, `{a,b}`, pipefail), else sh
|
|
293
|
+
#: (Alpine, busybox). The command is passed as "$1" so it is never re-parsed by the wrapper.
|
|
294
|
+
NATIVE_SHELL_SCRIPT = 'if [ -x /bin/bash ]; then exec /bin/bash -c "$1"; else exec /bin/sh -c "$1"; fi'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def shell_wrap(command: str, *, native: bool = False) -> Tuple[str, List[str]]:
|
|
298
|
+
"""Runloop takes a shell string; Solari exec is argv with no shell.
|
|
299
|
+
|
|
300
|
+
A devbox built from an image (``fromImage``) runs in that image: a login shell would re-source its /etc/profile, which
|
|
301
|
+
on Debian-family images resets ``PATH`` and drops the image's own (``/go/bin`` ...). Those get a plain (non-login) bash
|
|
302
|
+
when the image has one and ``sh -c`` otherwise (Alpine has no bash), with the environment the guest agent already applied."""
|
|
303
|
+
if native:
|
|
304
|
+
return "/bin/sh", ["-c", NATIVE_SHELL_SCRIPT, "sh", command]
|
|
292
305
|
return "/bin/bash", ["-lc", command]
|
|
293
306
|
|
|
294
307
|
|
|
@@ -60,9 +60,11 @@ def test_copy_steps(tmp_path):
|
|
|
60
60
|
assert "app/main.py" in names(base64.b64decode(blob))
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def test_copy_into_a_from_image_build(tmp_path):
|
|
64
64
|
c = compile_with(tmp_path, "FROM python:3.12-alpine\nCOPY README.md /srv/R.md")
|
|
65
|
-
assert
|
|
65
|
+
assert c["fromImage"] == "python:3.12-alpine"
|
|
66
|
+
assert any("cp -a -- README.md /srv/R.md" in s.get("command", "").replace("'", "") for s in c["steps"])
|
|
67
|
+
assert not any("/srv/root" in s.get("command", "") for s in c["steps"])
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def test_add_unpacks_a_local_archive(tmp_path):
|
|
@@ -68,31 +68,51 @@ def test_logical_lines_joins_continuations_and_skips_comments():
|
|
|
68
68
|
assert df.logical_lines("RUN a \\\n # note\n b\n\n# c\nENV x=1") == [("RUN", "a b"), ("ENV", "x=1")]
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
def
|
|
71
|
+
def test_other_images_use_the_native_from_image_build():
|
|
72
72
|
c = df.compile_dockerfile("FROM python:3.12-alpine\nENV A=1\nWORKDIR /app\nRUN pip --version\nENTRYPOINT [\"/e\"]")
|
|
73
|
-
|
|
74
|
-
assert c["
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
assert "
|
|
88
|
-
assert "
|
|
89
|
-
assert steps[
|
|
90
|
-
|
|
73
|
+
assert c["fromImage"] == "python:3.12-alpine" and "base" not in c and "fromTemplate" not in c
|
|
74
|
+
assert c["steps"] == [
|
|
75
|
+
{"op": "env", "key": "A", "value": "1"},
|
|
76
|
+
{"op": "workdir", "dir": "/app"},
|
|
77
|
+
{"op": "run", "command": "pip --version"},
|
|
78
|
+
{"op": "entrypoint", "command": ["/e"]},
|
|
79
|
+
]
|
|
80
|
+
blob = repr(c)
|
|
81
|
+
assert "crane" not in blob and "solari-run" not in blob and "/srv/root" not in blob # no chroot shim any more
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_from_image_env_keeps_the_images_own_variables_for_the_gateway():
|
|
85
|
+
# golang:1.22 has PATH=/go/bin:/usr/local/go/bin:...; the gateway expands $PATH against it, so it must not be pre-expanded here.
|
|
86
|
+
c = df.compile_dockerfile("FROM golang:1.22\nENV PATH=/opt/x/bin:$PATH\nENV GOFLAGS=-mod=mod:${PATH}")
|
|
87
|
+
assert c["fromImage"] == "golang:1.22"
|
|
88
|
+
assert c["steps"][0] == {"op": "env", "key": "PATH", "value": "/opt/x/bin:$PATH"}
|
|
89
|
+
assert c["steps"][1] == {"op": "env", "key": "GOFLAGS", "value": "-mod=mod:${PATH}"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_from_image_env_chain_is_left_to_the_gateway_and_args_are_expanded_here():
|
|
93
|
+
c = df.compile_dockerfile("FROM node:20\nARG V=7\nENV A=/opt/$V\nENV PATH=$A/bin:$PATH")
|
|
94
|
+
assert [(s["key"], s["value"]) for s in c["steps"]] == [("A", "/opt/7"), ("PATH", "$A/bin:$PATH")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_from_image_build_arg_path_is_a_real_argument():
|
|
98
|
+
c = df.compile_dockerfile("FROM node:20\nENV X=$PATH", build_args={"PATH": "/custom"})
|
|
99
|
+
assert c["steps"][0]["value"] == "/custom"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_from_image_build_arg_path_combined_with_an_env_that_references_it():
|
|
103
|
+
c = df.compile_dockerfile("FROM node:20\nENV PATH=/opt/x/bin:$PATH", build_args={"PATH": "/custom"})
|
|
104
|
+
assert c["steps"][0] == {"op": "env", "key": "PATH", "value": "/opt/x/bin:/custom"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_native_base_images_still_expand_path_against_dockers_default():
|
|
108
|
+
c = df.compile_dockerfile("FROM ubuntu:22.04\nENV PATH=/opt/x/bin:$PATH")
|
|
109
|
+
assert "fromImage" not in c and c["base"] == "base"
|
|
110
|
+
assert c["steps"][0]["value"] == "/opt/x/bin:" + df.DEFAULT_PATH
|
|
91
111
|
|
|
92
112
|
|
|
93
113
|
@pytest.mark.parametrize("image", ["ghcr.io/org/img:1", "quay.io/org/img", "public.ecr.aws/x/y:2", "python:3.12", "library/python:3.12", "docker.io/library/node:20"])
|
|
94
114
|
def test_allowlisted_registries(image):
|
|
95
|
-
assert df.compile_dockerfile(f"FROM {image}")["
|
|
115
|
+
assert df.compile_dockerfile(f"FROM {image}")["fromImage"] == image
|
|
96
116
|
|
|
97
117
|
|
|
98
118
|
@pytest.mark.parametrize("image", ["evil.example.com/a:1", "localhost:5000/a", "10.0.0.5/a", "registry.internal/a"])
|
|
@@ -101,15 +121,15 @@ def test_other_registries_refused(image):
|
|
|
101
121
|
df.compile_dockerfile(f"FROM {image}")
|
|
102
122
|
|
|
103
123
|
|
|
104
|
-
def
|
|
124
|
+
def test_import_switch_forces_from_image_for_debian_family():
|
|
105
125
|
assert df.compile_dockerfile("FROM ubuntu:22.04")["steps"] == []
|
|
106
126
|
forced = df.compile_dockerfile("FROM ubuntu:22.04", build_args={"SOLARI_IMPORT": "1"})
|
|
107
|
-
assert
|
|
127
|
+
assert forced["fromImage"] == "ubuntu:22.04" and "base" not in forced
|
|
108
128
|
|
|
109
129
|
|
|
110
|
-
def
|
|
130
|
+
def test_from_image_file_mounts_land_at_their_path():
|
|
111
131
|
c = df.compile_dockerfile("FROM alpine", file_mounts={"/etc/x": "1"})
|
|
112
|
-
assert "/
|
|
132
|
+
assert c["fromImage"] == "alpine" and "/etc/x" in c["steps"][-1]["command"] and "/srv/root" not in c["steps"][-1]["command"]
|
|
113
133
|
|
|
114
134
|
|
|
115
135
|
@pytest.mark.parametrize("bad", ["ENV A;B=1", "ENV $(id)=1", "ENV a-b=1"])
|
|
@@ -118,14 +138,6 @@ def test_env_names_are_validated_before_they_reach_the_env_file(bad):
|
|
|
118
138
|
df.compile_dockerfile("FROM python:3.12-alpine\n" + bad)
|
|
119
139
|
|
|
120
140
|
|
|
121
|
-
def test_the_no_shell_message_is_shell_quoted_for_odd_image_names():
|
|
122
|
-
import shlex
|
|
123
|
-
|
|
124
|
-
cmd = df.import_steps("ghcr.io/org/we'ird:1")[2]["command"]
|
|
125
|
-
inner = shlex.split(cmd)[2] # bash -c '<script>': the script survives one level of shell parsing intact
|
|
126
|
-
words = shlex.split(inner.replace("{", " ").replace("}", " ").replace(";", " ; "))
|
|
127
|
-
assert any("ird:1 has no /bin/sh" in w for w in words)
|
|
128
|
-
|
|
129
141
|
|
|
130
142
|
def test_env_path_prepend_expands_against_the_docker_default_path():
|
|
131
143
|
c = df.compile_dockerfile("FROM ubuntu:22.04\nENV PATH=/opt/x/bin:$PATH\nENV PATH=${PATH}:/opt/y")
|
|
@@ -179,6 +179,7 @@ async def test_durable_exec_start_wait_and_view():
|
|
|
179
179
|
("GET", "/sandboxes/sb%3A1/executions?limit=1"): {"executions": []},
|
|
180
180
|
("POST", "/sandboxes/sb%3A1/executions/ex_1/wait"): EXEC_DONE,
|
|
181
181
|
("POST", "/sandboxes/sb%3A1/executions"): EXEC_RUNNING,
|
|
182
|
+
("GET", "/sandboxes/sb%3A1"): RAW,
|
|
182
183
|
})
|
|
183
184
|
res = await g.execute("sb:1", "echo o; exit 7")
|
|
184
185
|
start = [c for c in s.calls if c[0] == "POST" and c[1].endswith("/executions")][0]
|
|
@@ -537,3 +538,32 @@ async def test_delete_snapshot_gives_up_after_the_bound_and_treats_404_as_done(m
|
|
|
537
538
|
|
|
538
539
|
g2, _ = gw({("DELETE", "/snapshots/s"): _ex.NotFoundError("gone")})
|
|
539
540
|
await g2.delete_snapshot("s")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@pytest.mark.asyncio
|
|
544
|
+
async def test_blueprint_from_image_refused_on_a_gateway_without_native_image_builds():
|
|
545
|
+
g, http = gw({("POST", "/templates"): {"templateId": "tpl_1", "name": "n", "status": "building"}, ("DELETE", "/templates/tpl_1"): {}})
|
|
546
|
+
with pytest.raises(sr.SolariCompatUnsupported, match="native image builds"):
|
|
547
|
+
await g.blueprint_create("n", {"kind": "sandbox", "fromImage": "golang:1.22", "packages": [], "steps": [], "localFiles": []}, None, None)
|
|
548
|
+
assert ("DELETE", "/templates/tpl_1", None) in http.calls
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@pytest.mark.asyncio
|
|
552
|
+
async def test_blueprint_from_image_accepted_when_the_gateway_echoes_it():
|
|
553
|
+
g, http = gw({("POST", "/templates"): {"templateId": "tpl_2", "name": "n", "status": "building", "fromImage": "golang:1.22"}})
|
|
554
|
+
view = await g.blueprint_create("n", {"kind": "sandbox", "fromImage": "golang:1.22", "packages": [], "steps": [], "localFiles": []}, None, None)
|
|
555
|
+
assert view.id == "tpl_2" and not any(c[0] == "DELETE" for c in http.calls)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def test_durable_exec_on_a_native_image_build_avoids_the_login_shell():
|
|
559
|
+
# A login shell re-sources /etc/profile, which resets PATH on Debian images; Alpine has no bash at all.
|
|
560
|
+
g, s = gw({
|
|
561
|
+
("GET", "/sandboxes/sb%3A1/executions?limit=1"): {"executions": []},
|
|
562
|
+
("POST", "/sandboxes/sb%3A1/executions/ex_1/wait"): EXEC_DONE,
|
|
563
|
+
("POST", "/sandboxes/sb%3A1/executions"): EXEC_RUNNING,
|
|
564
|
+
("GET", "/sandboxes/sb%3A1"): {**RAW, "fromImage": "docker.io/library/golang:1.22"},
|
|
565
|
+
})
|
|
566
|
+
await g.execute("sb:1", "echo $PATH")
|
|
567
|
+
start = [c for c in s.calls if c[0] == "POST" and c[1].endswith("/executions")][0]
|
|
568
|
+
assert start[2]["cmd"] == "/bin/sh" and start[2]["args"][0] == "-c" and "-l" not in start[2]["args"][0]
|
|
569
|
+
assert "/bin/bash -c" in start[2]["args"][1] and start[2]["args"][-1] == g._remote_script()
|
|
@@ -235,3 +235,19 @@ def test_phase2_name_update_paging_and_async_snapshot(sdk: sr.RunloopSDK, cleanu
|
|
|
235
235
|
assert done.status == "ready" and done.name == "p2-snap" and done.metadata == {"m": "1"}
|
|
236
236
|
up = snap.update(name="p2-snap2", metadata={"m": "2"})
|
|
237
237
|
assert up.name == "p2-snap2" and up.metadata == {"m": "2"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_from_image_keeps_the_images_own_path(sdk: sr.RunloopSDK, cleanup: List) -> None:
|
|
241
|
+
"""F30: FROM golang must keep /go/bin:/usr/local/go/bin on PATH, also behind an `ENV PATH=/opt/x/bin:$PATH`."""
|
|
242
|
+
bp = sdk.blueprint.create(
|
|
243
|
+
name=f"compat-go-{int(time.time())}",
|
|
244
|
+
dockerfile="FROM golang:1.22\nENV PATH=/opt/x/bin:$PATH\nRUN mkdir -p /opt/x/bin\n",
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
devbox = bp.create_devbox()
|
|
248
|
+
cleanup.append(("devbox", devbox.id))
|
|
249
|
+
out = devbox.cmd.exec("echo $PATH; which go").stdout()
|
|
250
|
+
assert out.splitlines()[0].startswith("/opt/x/bin:/go/bin:/usr/local/go/bin:")
|
|
251
|
+
assert out.splitlines()[1] == "/usr/local/go/bin/go"
|
|
252
|
+
finally:
|
|
253
|
+
bp.delete()
|
|
@@ -42,7 +42,7 @@ def test_unknown_size_is_a_400():
|
|
|
42
42
|
|
|
43
43
|
@pytest.mark.parametrize(
|
|
44
44
|
"state,status",
|
|
45
|
-
[("starting", "initializing"), ("running", "running"), ("paused", "suspended"), ("archived", "suspended"), ("releasing", "shutdown"), ("gone", "shutdown"), ("???", "failure")],
|
|
45
|
+
[("starting", "initializing"), ("running", "running"), ("paused", "suspended"), ("archived", "suspended"), ("stopped", "shutdown"), ("releasing", "shutdown"), ("gone", "shutdown"), ("???", "failure")],
|
|
46
46
|
)
|
|
47
47
|
def test_status_map(state, status):
|
|
48
48
|
assert mp.map_status(state) == status
|
|
@@ -117,3 +117,43 @@ def test_shell_wrap_and_tail():
|
|
|
117
117
|
|
|
118
118
|
def test_open_tunnel_at_create_is_accepted():
|
|
119
119
|
assert mp.create_body(tunnel={"auth_mode": "open"}) == {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_native(script_cmd, *, bash_path="/bin/bash"):
|
|
123
|
+
import subprocess
|
|
124
|
+
|
|
125
|
+
cmd, args = mp.shell_wrap(script_cmd, native=True)
|
|
126
|
+
args = [a.replace("/bin/bash", bash_path) for a in args[:2]] + args[2:]
|
|
127
|
+
return subprocess.run([cmd, *args], capture_output=True, text=True, timeout=20)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_shell_wrap_plain_devbox_keeps_the_login_bash():
|
|
131
|
+
assert mp.shell_wrap("echo hi") == ("/bin/bash", ["-lc", "echo hi"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_shell_wrap_native_runs_bash_when_the_image_has_it():
|
|
135
|
+
import os
|
|
136
|
+
|
|
137
|
+
if not os.access("/bin/bash", os.X_OK):
|
|
138
|
+
pytest.skip("no /bin/bash on this machine")
|
|
139
|
+
r = _run_native('[[ a == a ]] && echo "bash:$0:${BASH_VERSION:+yes}"; echo {x,y}')
|
|
140
|
+
assert r.returncode == 0 and "bash:" in r.stdout and "yes" in r.stdout and "x y" in r.stdout
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_shell_wrap_native_falls_back_to_sh_when_the_image_has_no_bash():
|
|
144
|
+
r = _run_native("echo fallback-ok; echo \"$0\"", bash_path="/nonexistent/bash")
|
|
145
|
+
assert r.returncode == 0 and "fallback-ok" in r.stdout
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_shell_wrap_native_is_not_a_login_shell():
|
|
149
|
+
import os
|
|
150
|
+
|
|
151
|
+
r = _run_native("shopt -q login_shell && echo login || echo notlogin") if os.access("/bin/bash", os.X_OK) else None
|
|
152
|
+
if r is not None:
|
|
153
|
+
assert "notlogin" in r.stdout
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_shell_wrap_native_passes_the_command_as_one_argument():
|
|
157
|
+
cmd, args = mp.shell_wrap("echo 'a b' \"$HOME\"; exit 3", native=True)
|
|
158
|
+
assert args[-1] == "echo 'a b' \"$HOME\"; exit 3" and args[-2] == "sh"
|
|
159
|
+
assert _run_native("exit 3").returncode == 3
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# usage: solari-run '<shell command>' runs inside the imported image root (/etc/solari-rootdir).
|
|
3
|
-
# Image import is a compatibility layer over a chroot in the customer's own microVM; it is not a security boundary.
|
|
4
|
-
set -e
|
|
5
|
-
R=$(cat /etc/solari-rootdir)
|
|
6
|
-
mkdir -p "$R/proc" "$R/dev" "$R/sys" "$R/tmp" "$R/etc"
|
|
7
|
-
mountpoint -q "$R/proc" || mount --bind /proc "$R/proc"
|
|
8
|
-
mountpoint -q "$R/dev" || mount --rbind /dev "$R/dev"
|
|
9
|
-
mountpoint -q "$R/sys" || mount --rbind /sys "$R/sys"
|
|
10
|
-
cp /etc/resolv.conf "$R/etc/resolv.conf" 2>/dev/null || true
|
|
11
|
-
set -a; [ -f /etc/solari-env ] && . /etc/solari-env; set +a
|
|
12
|
-
SOLARI_WD=$(cat /etc/solari-workdir 2>/dev/null || echo /); SOLARI_CMD="$1"; export SOLARI_WD SOLARI_CMD
|
|
13
|
-
if [ -x "$R/bin/bash" ]; then SH=/bin/bash; else SH=/bin/sh; fi
|
|
14
|
-
exec chroot "$R" "$SH" -c 'cd "$SOLARI_WD" 2>/dev/null || cd /; eval "$SOLARI_CMD"'
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Extract a tar stream (``crane export``) into a root directory, refusing anything that could write outside it.
|
|
3
|
-
|
|
4
|
-
usage: solari-extract <root> [max-bytes] < image.tar
|
|
5
|
-
Guards (each has a test in tests/test_extract.py that fails without it):
|
|
6
|
-
absolute member names, ``..`` components, writes through a symlink that leaves the root, hardlinks that leave the root,
|
|
7
|
-
device nodes / fifos (skipped), setuid/setgid/sticky bits (stripped), total size and member-count caps.
|
|
8
|
-
Runs as root inside the customer's own sandbox; this is defence in depth, the microVM is the boundary.
|
|
9
|
-
"""
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
import tarfile
|
|
13
|
-
|
|
14
|
-
MAX_MEMBERS = 400_000
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Refused(Exception):
|
|
18
|
-
pass
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def extract(stream, root, max_bytes=4 << 30):
|
|
22
|
-
root = os.path.realpath(root)
|
|
23
|
-
|
|
24
|
-
def inside(p):
|
|
25
|
-
return p == root or p.startswith(root + os.sep)
|
|
26
|
-
|
|
27
|
-
total = count = skipped = 0
|
|
28
|
-
with tarfile.open(fileobj=stream, mode="r|") as tf:
|
|
29
|
-
for m in tf:
|
|
30
|
-
count += 1
|
|
31
|
-
total += max(m.size, 0)
|
|
32
|
-
if count > MAX_MEMBERS:
|
|
33
|
-
raise Refused("too many archive members")
|
|
34
|
-
if total > max_bytes:
|
|
35
|
-
raise Refused(f"image larger than the {max_bytes} byte cap")
|
|
36
|
-
if m.name.startswith("/"):
|
|
37
|
-
raise Refused(f"absolute member name {m.name!r}")
|
|
38
|
-
name = os.path.normpath(m.name)
|
|
39
|
-
if name == ".":
|
|
40
|
-
continue
|
|
41
|
-
if name == ".." or name.startswith("../"):
|
|
42
|
-
raise Refused(f"member escapes the root: {m.name!r}")
|
|
43
|
-
dest = os.path.join(root, name)
|
|
44
|
-
parent = os.path.realpath(os.path.dirname(dest))
|
|
45
|
-
if not inside(parent):
|
|
46
|
-
raise Refused(f"member {m.name!r} resolves outside the root through a symlink")
|
|
47
|
-
dest = os.path.join(parent, os.path.basename(dest))
|
|
48
|
-
if m.isdir():
|
|
49
|
-
if os.path.islink(dest):
|
|
50
|
-
os.unlink(dest)
|
|
51
|
-
os.makedirs(dest, exist_ok=True)
|
|
52
|
-
os.chmod(dest, (m.mode & 0o777) | 0o700)
|
|
53
|
-
continue
|
|
54
|
-
if os.path.lexists(dest) and not os.path.isdir(dest) or os.path.islink(dest):
|
|
55
|
-
os.unlink(dest)
|
|
56
|
-
if m.isreg():
|
|
57
|
-
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, m.mode & 0o777)
|
|
58
|
-
with os.fdopen(fd, "wb") as out:
|
|
59
|
-
src = tf.extractfile(m)
|
|
60
|
-
while True:
|
|
61
|
-
chunk = src.read(1 << 20)
|
|
62
|
-
if not chunk:
|
|
63
|
-
break
|
|
64
|
-
out.write(chunk)
|
|
65
|
-
os.chmod(dest, m.mode & 0o777)
|
|
66
|
-
elif m.issym():
|
|
67
|
-
os.symlink(m.linkname, dest)
|
|
68
|
-
elif m.islnk():
|
|
69
|
-
tname = os.path.normpath(m.linkname)
|
|
70
|
-
if m.linkname.startswith("/") or tname == ".." or tname.startswith("../"):
|
|
71
|
-
raise Refused(f"hardlink target escapes the root: {m.linkname!r}")
|
|
72
|
-
target = os.path.realpath(os.path.join(root, tname))
|
|
73
|
-
if not inside(target) or not os.path.isfile(target):
|
|
74
|
-
raise Refused(f"hardlink target outside the root or missing: {m.linkname!r}")
|
|
75
|
-
os.link(target, dest)
|
|
76
|
-
continue
|
|
77
|
-
else:
|
|
78
|
-
skipped += 1 # device nodes, fifos
|
|
79
|
-
continue
|
|
80
|
-
try:
|
|
81
|
-
os.lchown(dest, m.uid, m.gid)
|
|
82
|
-
except OSError:
|
|
83
|
-
pass
|
|
84
|
-
return count, skipped
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def main(argv):
|
|
88
|
-
if len(argv) < 2:
|
|
89
|
-
print(__doc__, file=sys.stderr)
|
|
90
|
-
return 2
|
|
91
|
-
try:
|
|
92
|
-
n, skipped = extract(sys.stdin.buffer, argv[1], int(argv[2]) if len(argv) > 2 else 4 << 30)
|
|
93
|
-
except Refused as e:
|
|
94
|
-
print(f"solari-extract: refused: {e}", file=sys.stderr)
|
|
95
|
-
return 3
|
|
96
|
-
print(f"solari-extract: {n} members, {skipped} device nodes skipped", file=sys.stderr)
|
|
97
|
-
return 0
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
sys.exit(main(sys.argv))
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""usage: solari-size-check <image> <max-compressed-bytes>: refuse an image whose compressed layers exceed the cap
|
|
3
|
-
(read from the registry manifest before anything is downloaded)."""
|
|
4
|
-
import json
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
|
|
8
|
-
image, cap = sys.argv[1], int(sys.argv[2])
|
|
9
|
-
m = json.loads(subprocess.check_output(["crane", "manifest", "--platform", "linux/amd64", image]))
|
|
10
|
-
if "layers" not in m:
|
|
11
|
-
sys.exit("solari: image manifest has no layers (multi-arch index without linux/amd64?)")
|
|
12
|
-
size = sum(layer.get("size", 0) for layer in m["layers"])
|
|
13
|
-
if size > cap:
|
|
14
|
-
sys.exit(f"solari: image is {size} bytes compressed, the cap is {cap}")
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""The tar extractor's guards. Each test fails if the matching check in _guest/solari_extract.py is removed."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import os
|
|
8
|
-
import stat
|
|
9
|
-
import tarfile
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
spec = importlib.util.spec_from_file_location("solari_extract", Path(__file__).parent.parent / "solari_runloop" / "_guest" / "solari_extract.py")
|
|
15
|
-
se = importlib.util.module_from_spec(spec)
|
|
16
|
-
spec.loader.exec_module(se)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def tar_of(*members):
|
|
20
|
-
buf = io.BytesIO()
|
|
21
|
-
with tarfile.open(fileobj=buf, mode="w") as tf:
|
|
22
|
-
for info, data in members:
|
|
23
|
-
tf.addfile(info, io.BytesIO(data) if data is not None else None)
|
|
24
|
-
buf.seek(0)
|
|
25
|
-
return buf
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def f(name, data=b"x", mode=0o644):
|
|
29
|
-
i = tarfile.TarInfo(name)
|
|
30
|
-
i.size = len(data)
|
|
31
|
-
i.mode = mode
|
|
32
|
-
return i, data
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def link(name, target, hard=False):
|
|
36
|
-
i = tarfile.TarInfo(name)
|
|
37
|
-
i.type = tarfile.LNKTYPE if hard else tarfile.SYMTYPE
|
|
38
|
-
i.linkname = target
|
|
39
|
-
return i, None
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def d(name):
|
|
43
|
-
i = tarfile.TarInfo(name)
|
|
44
|
-
i.type = tarfile.DIRTYPE
|
|
45
|
-
i.mode = 0o755
|
|
46
|
-
return i, None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_normal_extraction(tmp_path):
|
|
50
|
-
se.extract(tar_of(d("etc"), f("etc/os-release", b"A"), link("bin", "usr/bin"), d("usr"), d("usr/bin")), tmp_path)
|
|
51
|
-
assert (tmp_path / "etc/os-release").read_bytes() == b"A" and os.path.islink(tmp_path / "bin")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_absolute_name_refused(tmp_path):
|
|
55
|
-
with pytest.raises(se.Refused, match="absolute"):
|
|
56
|
-
se.extract(tar_of(f("/etc/passwd")), tmp_path)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@pytest.mark.parametrize("name", ["../evil", "a/../../evil", ".."])
|
|
60
|
-
def test_dotdot_refused(tmp_path, name):
|
|
61
|
-
with pytest.raises(se.Refused, match="escapes"):
|
|
62
|
-
se.extract(tar_of(f(name)), tmp_path / "root")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_write_through_symlink_out_of_root_refused(tmp_path):
|
|
66
|
-
root = tmp_path / "root"
|
|
67
|
-
root.mkdir()
|
|
68
|
-
outside = tmp_path / "outside"
|
|
69
|
-
outside.mkdir()
|
|
70
|
-
with pytest.raises(se.Refused, match="symlink"):
|
|
71
|
-
se.extract(tar_of(link("evil", str(outside)), f("evil/pwned", b"x")), root)
|
|
72
|
-
assert not (outside / "pwned").exists()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_relative_symlink_escape_refused(tmp_path):
|
|
76
|
-
root = tmp_path / "root"
|
|
77
|
-
root.mkdir()
|
|
78
|
-
with pytest.raises(se.Refused):
|
|
79
|
-
se.extract(tar_of(link("up", "../.."), f("up/pwned")), root)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_hardlink_out_of_root_refused(tmp_path):
|
|
83
|
-
root = tmp_path / "root"
|
|
84
|
-
root.mkdir()
|
|
85
|
-
(tmp_path / "secret").write_text("s")
|
|
86
|
-
with pytest.raises(se.Refused, match="hardlink"):
|
|
87
|
-
se.extract(tar_of(link("h", "../secret", hard=True)), root)
|
|
88
|
-
with pytest.raises(se.Refused, match="hardlink"):
|
|
89
|
-
se.extract(tar_of(link("h", "/etc/passwd", hard=True)), root)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_hardlink_inside_root_ok(tmp_path):
|
|
93
|
-
se.extract(tar_of(f("a", b"1"), link("b", "a", hard=True)), tmp_path)
|
|
94
|
-
assert (tmp_path / "b").read_bytes() == b"1"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def test_device_nodes_skipped(tmp_path):
|
|
98
|
-
i = tarfile.TarInfo("dev-null")
|
|
99
|
-
i.type = tarfile.CHRTYPE
|
|
100
|
-
n, skipped = se.extract(tar_of((i, None), f("ok")), tmp_path)
|
|
101
|
-
assert skipped == 1 and not (tmp_path / "dev-null").exists() and (tmp_path / "ok").exists()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_setuid_bits_stripped(tmp_path):
|
|
105
|
-
se.extract(tar_of(f("su", b"x", mode=0o4755)), tmp_path)
|
|
106
|
-
assert not (os.stat(tmp_path / "su").st_mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX))
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def test_size_cap(tmp_path):
|
|
110
|
-
with pytest.raises(se.Refused, match="cap"):
|
|
111
|
-
se.extract(tar_of(f("big", b"x" * 2000)), tmp_path, max_bytes=1000)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_view.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_view.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/secret_view.py
RENAMED
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/tunnel_view.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|