Skip to content

Commit f296756

Browse files
authored
Merge pull request #87 from mikkovihonen/fix/packaging-fixes
FIX packaging and performance
2 parents 76a8b87 + b15a5e2 commit f296756

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+4190
-2701
lines changed

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,60 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
66
[docs/ways-of-working.md](docs/ways-of-working.md) for the version number scheme and
77
release process.
88

9+
## [0.5.5-beta] - 2026-04-13
10+
11+
### Fixed
12+
- RPM package upgrade failed when pip tried to install both the old and new
13+
quadletman wheels simultaneously — during RPM upgrade `%post` runs before old
14+
package files are removed, so the `quadletman-*.whl` glob matched two wheels;
15+
fixed by using a version-specific glob (`quadletman-%{pkg_version}-*.whl`)
16+
- All 9 volume file-operation API routes (browse, get, save, upload, delete,
17+
mkdir, chmod, archive, restore) operated on the current working directory
18+
instead of returning an error when called on a Podman-managed (quadlet) volume
19+
`qm_host_path` is empty for quadlet volumes and `resolve_safe_path("")`
20+
resolves to CWD; the UI hid the buttons but the API was unprotected
21+
- Archive restore (`volume_restore`) always chowned extracted files to the
22+
compartment root user, ignoring the volume's `qm_owner_uid` — now delegates
23+
to `volume_manager.chown_volume_dir` which respects the helper user
24+
- Deleting a quadlet-managed volume only tried to remove a host directory
25+
(which doesn't exist); now correctly removes the `.volume` unit file and
26+
reloads systemd
27+
- `sync_helper_users` only considered container UID maps when deciding which
28+
helper users to keep — deleting a container could delete a helper user still
29+
needed by a volume with `qm_owner_uid`; volume owner UIDs are now included
30+
- Updating the owner UID of a quadlet-managed volume failed because
31+
`update_volume_owner` unconditionally tried to `chown` a host directory that
32+
does not exist for Podman-managed named volumes; the `chown` is now skipped
33+
when `qm_use_quadlet` is true
34+
- Files uploaded, saved, or created in volumes with a custom owner UID were owned
35+
by the compartment root user (`qm-{id}`) instead of the volume's configured
36+
helper user (`qm-{id}-N`) — `save_file`, `upload_file`, and `mkdir_entry` now
37+
resolve the correct owner based on the volume's `qm_owner_uid`
38+
39+
### Changed
40+
- Compartment start/stop/restart/resync operations are now queued and executed
41+
in the background — the HTTP request returns 202 immediately instead of
42+
blocking for 1-60+ seconds; a per-compartment worker drains the queue
43+
sequentially; the existing ViewPoller picks up status changes automatically
44+
- Individual container start/stop operations are also queued — previously these
45+
blocked the HTTP request and timed out after 30s during image pulls
46+
- Slow container starts (image pulls, heavy init) no longer time out — queued
47+
operations use `lifecycle_operation_timeout` (default 10 min) instead of the
48+
30s `subprocess_timeout`
49+
- Dashboard and compartment detail polling now uses batched `systemctl show`
50+
and `systemctl status` calls — one subprocess per type per compartment
51+
instead of one per container; `_is_unit_enabled` filesystem checks replaced
52+
with the `UnitFileState` property already returned by `systemctl show`,
53+
eliminating 2 additional `sudo test` subprocess calls per container
54+
- Connection monitor and process monitor are now disabled by default for new
55+
compartments — existing compartments are not affected (migration 0013)
56+
57+
### Added
58+
- Compartment shell bottom sheet now shows a user selector with the compartment
59+
root user and all helper users; selecting a helper user opens a bash shell
60+
running as that user (e.g. `qm-{id}-1000`), matching the UID that owns the
61+
volume inside the container
62+
963
## [0.5.4-beta] - 2026-04-13
1064

1165
### Fixed

packaging/rpm/quadletman.spec

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,14 @@ systemctl stop %{name}.service 2>/dev/null || :
144144
# Recreate venv on every install/upgrade so the Python version always matches.
145145
rm -rf "$VENV"
146146
python3 -m venv "$VENV"
147+
# Use the version-specific glob — during RPM upgrade %%post runs before old
148+
# package files are removed, so a bare quadletman-*.whl glob would match both
149+
# the old and new wheels and pip would fail with a version conflict.
147150
if ! "$VENV/bin/pip" install --quiet --no-cache-dir --disable-pip-version-check \
148-
"$WHEEL_DIR"/quadletman-*.whl; then
151+
"$WHEEL_DIR"/quadletman-%{pkg_version}-*.whl; then
149152
echo "ERROR: pip install failed — quadletman will not start." >&2
150153
echo "Ensure internet access is available and retry with:" >&2
151-
echo " $VENV/bin/pip install $WHEEL_DIR/quadletman-*.whl" >&2
154+
echo " $VENV/bin/pip install $WHEEL_DIR/quadletman-%{pkg_version}-*.whl" >&2
152155
fi
153156

154157
# Restore SELinux contexts on compiled extensions so they can be dlopen'd.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""disable_monitors_by_default
2+
3+
Revision ID: 0013
4+
Revises: 0012
5+
Create Date: 2026-04-13
6+
7+
Change the server_default for connection_monitor_enabled and
8+
process_monitor_enabled from "1" (True) to "0" (False) so new
9+
compartments have monitoring disabled by default. Existing rows
10+
are not modified — only the default for future INSERTs changes.
11+
"""
12+
13+
from collections.abc import Sequence
14+
15+
from alembic import op
16+
17+
revision: str = "0013"
18+
down_revision: str | None = "0012"
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
23+
def upgrade() -> None:
24+
with op.batch_alter_table("compartments") as batch_op:
25+
batch_op.alter_column("connection_monitor_enabled", server_default="0")
26+
batch_op.alter_column("process_monitor_enabled", server_default="0")
27+
28+
29+
def downgrade() -> None:
30+
with op.batch_alter_table("compartments") as batch_op:
31+
batch_op.alter_column("connection_monitor_enabled", server_default="1")
32+
batch_op.alter_column("process_monitor_enabled", server_default="1")
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""add_operations_table
2+
3+
Revision ID: 0014
4+
Revises: 0013
5+
Create Date: 2026-04-13
6+
7+
Adds an operations table for queued lifecycle operations (start/stop/restart/resync).
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
revision: str = "0014"
16+
down_revision: str | None = "0013"
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"operations",
24+
sa.Column("id", sa.Text(), nullable=False),
25+
sa.Column("compartment_id", sa.Text(), nullable=False),
26+
sa.Column("op_type", sa.Text(), nullable=False),
27+
sa.Column("status", sa.Text(), nullable=False, server_default="pending"),
28+
sa.Column("payload", sa.Text(), nullable=False, server_default="{}"),
29+
sa.Column("result", sa.Text(), nullable=False, server_default="{}"),
30+
sa.Column("submitted_by", sa.Text(), nullable=False),
31+
sa.Column("session_id", sa.Text(), nullable=False),
32+
sa.Column(
33+
"submitted_at",
34+
sa.Text(),
35+
nullable=False,
36+
server_default=sa.text("(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))"),
37+
),
38+
sa.Column("started_at", sa.Text(), nullable=True),
39+
sa.Column("completed_at", sa.Text(), nullable=True),
40+
sa.PrimaryKeyConstraint("id"),
41+
sa.ForeignKeyConstraint(["compartment_id"], ["compartments.id"], ondelete="CASCADE"),
42+
)
43+
op.create_index("ix_operations_compartment", "operations", ["compartment_id"])
44+
op.create_index("ix_operations_status", "operations", ["status"])
45+
46+
47+
def downgrade() -> None:
48+
op.drop_index("ix_operations_status")
49+
op.drop_index("ix_operations_compartment")
50+
op.drop_table("operations")

quadletman/config/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Settings(BaseModel):
3737
image_update_check_interval: int = 21600 # seconds between image update checks (6 hours)
3838
subprocess_timeout: int = 30 # default timeout for systemctl/podman commands
3939
image_pull_timeout: int = 300 # timeout for image pull and auto-update
40+
lifecycle_operation_timeout: int = 600 # timeout for queued lifecycle ops (start/stop/restart)
4041
webhook_timeout: int = 10 # timeout for webhook HTTP POST delivery
4142
webhook_max_retries: int = 3 # max webhook delivery attempts (exponential backoff)
4243
poll_interval: int = 30 # seconds between container state polls
@@ -70,6 +71,7 @@ class Settings(BaseModel):
7071
_MINIMUM_BOUNDS: dict[str, int] = {
7172
"subprocess_timeout": 1,
7273
"image_pull_timeout": 1,
74+
"lifecycle_operation_timeout": 30,
7375
"webhook_timeout": 1,
7476
"webhook_max_retries": 1,
7577
"poll_interval": 5,

quadletman/db/orm.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ class CompartmentRow(Base):
6161
server_default=func.strftime("%Y-%m-%dT%H:%M:%SZ", "now"),
6262
)
6363
connection_monitor_enabled: Mapped[bool] = mapped_column(
64-
Boolean, nullable=False, default=True, server_default="1"
64+
Boolean, nullable=False, default=False, server_default="0"
6565
)
6666
process_monitor_enabled: Mapped[bool] = mapped_column(
67-
Boolean, nullable=False, default=True, server_default="1"
67+
Boolean, nullable=False, default=False, server_default="0"
6868
)
6969
connection_history_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
7070
agent_last_seen: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -763,6 +763,36 @@ class SystemEventRow(Base):
763763
)
764764

765765

766+
# ---------------------------------------------------------------------------
767+
# operations (lifecycle operation queue)
768+
# ---------------------------------------------------------------------------
769+
770+
771+
class OperationRow(Base):
772+
__tablename__ = "operations"
773+
774+
id: Mapped[str] = mapped_column(Text, primary_key=True)
775+
compartment_id: Mapped[str] = mapped_column(
776+
Text, ForeignKey("compartments.id", ondelete="CASCADE"), nullable=False, index=True
777+
)
778+
op_type: Mapped[str] = mapped_column(Text, nullable=False)
779+
status: Mapped[str] = mapped_column(
780+
Text, nullable=False, default="pending", server_default="pending"
781+
)
782+
payload: Mapped[str] = mapped_column(Text, nullable=False, default="{}", server_default="{}")
783+
result: Mapped[str] = mapped_column(Text, nullable=False, default="{}", server_default="{}")
784+
submitted_by: Mapped[str] = mapped_column(Text, nullable=False)
785+
session_id: Mapped[str] = mapped_column(Text, nullable=False)
786+
submitted_at: Mapped[str] = mapped_column(
787+
Text,
788+
nullable=False,
789+
default=_utcnow,
790+
server_default=func.strftime("%Y-%m-%dT%H:%M:%SZ", "now"),
791+
)
792+
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
793+
completed_at: Mapped[str | None] = mapped_column(Text, nullable=True)
794+
795+
766796
# ---------------------------------------------------------------------------
767797
# secrets
768798
# ---------------------------------------------------------------------------
0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)