Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/lxc-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list
sudo apt-get update
sudo apt-get install -y incus incus-client incus-base
sudo apt-get install -y incus-client incus-base
incus --version

- name: Initialise Incus
Expand All @@ -104,9 +104,9 @@ jobs:
uv tool install ./cmlxc
uv --color never tool dir --bin >> "$GITHUB_PATH"

- name: Cache Incus images
- name: Restore Incus image cache
id: cache-images
uses: actions/cache@v6
uses: actions/cache/restore@v6
with:
path: /tmp/incus-cache
key: incus-v6-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py', 'cmlxc/.github/workflows/lxc-test.yml') }}
Expand Down Expand Up @@ -196,6 +196,7 @@ jobs:
done

- name: Export images for cache
id: export-images
if: always() && steps.cache-images.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/incus-cache
Expand All @@ -204,13 +205,24 @@ jobs:
echo "Publishing builder container as image ..."
incus publish builder-localchat --alias localchat-builder --force || true
fi
exported=0
for alias in localchat-base localchat-builder; do
if incus image list --format csv -c l | grep -q "^$alias$"; then
echo "Exporting: $alias"
incus image export $alias /tmp/incus-cache/$alias || true
if [ -f /tmp/incus-cache/$alias ] && [ ! -f /tmp/incus-cache/$alias.tar.gz ]; then
mv /tmp/incus-cache/$alias /tmp/incus-cache/$alias.tar.gz
fi
exported=$((exported+1))
fi
done
echo "exported=$exported" >> "$GITHUB_OUTPUT"

# Split from the restore above so a FAILED run still saves its images.
- name: Save Incus image cache
if: always() && steps.export-images.outputs.exported > 0
uses: actions/cache/save@v6
with:
path: /tmp/incus-cache
key: incus-v6-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py', 'cmlxc/.github/workflows/lxc-test.yml') }}

2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list
sudo apt-get update
sudo apt-get install -y incus incus-client incus-base
sudo apt-get install -y incus-client incus-base
incus --version

- name: Initialise Incus
Expand Down
8 changes: 7 additions & 1 deletion src/cmlxc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import argparse
import os
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -677,13 +678,18 @@ def main(args=None):
if args.func is None:
return parser.parse_args(["-h"])

# Enable max verbosity when GitHub Actions debug logging is on
if not args.verbose and os.environ.get("RUNNER_DEBUG") == "1":
args.verbose = 3

out = Out(verbosity=args.verbose)
try:
res = args.func(args, out)
if res is None:
res = 0
return res
except SetupError as exc:
except (SetupError, ValueError) as exc:
# ValueError is the bad-user-input signal
out.red(str(exc))
return 1
except KeyboardInterrupt:
Expand Down
21 changes: 18 additions & 3 deletions src/cmlxc/driver_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ def description(self) -> str:
return f"ref {self.ref!r} from {self.url}"


# A ref is interpolated into `git fetch` / `git checkout` inside `bash -ec`
# on the builder, so restrict it to characters git actually allows in a ref
_REF_RE = re.compile(r"[\w./@+-]+")


def _remote_spec(url, ref):
"""Build a remote SourceSpec after validating *ref*."""
if not _REF_RE.fullmatch(ref or ""):
raise ValueError(
f"Invalid ref {ref!r}."
" Refs may contain letters, digits, and any of . / @ + - _"
)
return SourceSpec("remote", url=url, ref=ref)


def parse_source(value: str, default_url: str) -> SourceSpec:
"""Turn a SOURCE string into a typed spec.

Expand All @@ -52,15 +67,15 @@ def parse_source(value: str, default_url: str) -> SourceSpec:
if value.startswith(("/", ".")):
return SourceSpec("local", path=Path(value))
if value.startswith("@"):
return SourceSpec("remote", url=default_url, ref=value[1:])
return _remote_spec(default_url, value[1:])
if "://" in value:
if "@" in value:
url, _, ref = value.rpartition("@")
if url:
return SourceSpec("remote", url=url, ref=ref)
return _remote_spec(url, ref)
return SourceSpec("remote", url=value, ref="main")
if "/" in value:
return SourceSpec("remote", url=default_url, ref=value)
return _remote_spec(default_url, value)
raise ValueError(f"Invalid SOURCE: {value!r}. Use @ref, /path, ./path, or URL@ref.")


Expand Down
18 changes: 18 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ def test_parse_source_rejects_invalid(value):
parse_source(value, URL)


@pytest.mark.parametrize(
"value",
[
"@main; rm -rf /",
"@$(id)",
"@`id`",
"@main whoami",
"@main'",
"@",
"https://github.com/fork/relay.git@main;id",
],
)
def test_parse_source_rejects_unsafe_ref(value):
# refs reach `git checkout` inside `bash -ec` on the builder
with pytest.raises(ValueError, match="Invalid ref"):
parse_source(value, URL)


@pytest.mark.parametrize("bad", [".", "..", "../relay", "/path", "a/b", "a.b"])
def test_validate_relay_name_rejects_invalid(bad):
with pytest.raises(ValueError, match="Invalid relay name"):
Expand Down